UNPKG

garagedoor-accfactory

Version:

HomeKit garage door opener system using HAP-NodeJS library

1,222 lines (1,084 loc) 117 kB
// HomeKit history service // Simple history service for HomeKit developed accessories with HAP-NodeJS // // 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 valve protection // -- Eve Degree/Weather2 history // -- Eve Water guard history // // Credit to https://github.com/simont77/fakegato-history for the work on starting the EveHome comms protocol decoding // // Version 15/10/2024 // Mark Hulskamp // Define nodejs module requirements import { setTimeout } from 'node:timers'; import { Buffer } from 'node:buffer'; import util from 'util'; import fs from 'fs'; // Define constants 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 const DAYSOFWEEK = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; // Create the history object export default class HomeKitHistory { accessory = undefined; // Accessory service for this history hap = undefined; // HomeKit Accessory Protocol API stub log = undefined; // Logging function object maxEntries = MAX_HISTORY_SIZE; // used for rolling history. if 0, means no rollover EveHome = undefined; constructor(accessory, log, api, options) { // Validate the passed in logging object. We are expecting certain functions to be present if ( typeof log?.info === 'function' && typeof log?.success === 'function' && typeof log?.warn === 'function' && typeof log?.error === 'function' && typeof log?.debug === 'function' ) { this.log = log; } if (typeof accessory !== 'undefined' && typeof accessory === 'object') { this.accessory = accessory; } if (typeof options === 'object') { if (typeof options?.maxEntries === 'number') { this.maxEntries = options.maxEntries; } } // Workout if we're running under HomeBridge or HAP-NodeJS library if (typeof api?.version === 'number' && typeof api?.hap === 'object' && typeof api?.HAPLibraryVersion === 'undefined') { // We have the HomeBridge version number and hap API object this.hap = api.hap; } if (typeof api?.HAPLibraryVersion === 'function' && typeof api?.version === 'undefined' && typeof api?.hap === 'undefined') { // As we're missing the HomeBridge entry points but have the HAP library version this.hap = api; } // Dynamically create the additional services and characteristics this.#createHomeKitServicesAndCharacteristics(); // Setup HomeKitHistory using HAP-NodeJS library if (typeof accessory?.username !== 'undefined') { // Since we have a username for the accessory, we'll assume this is not running under Homebridge // We'll use it's persist folder for storing history files this.storageKey = util.format('History.%s.json', accessory.username.replace(/:/g, '').toUpperCase()); } // Setup HomeKitHistory under Homebridge if (typeof accessory?.username === 'undefined') { this.storageKey = util.format('History.%s.json', accessory.UUID); } this.storage = this.hap.HAPStorage.storage(); 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 this.resetHistory(); // Start with blank history } this.restart = Math.floor(Date.now() / 1000); // time we restarted // perform rollover if needed when starting service if (this.maxEntries !== 0 && this.historyData.next >= this.maxEntries) { this.rolloverHistory(); } } // Class functions addHistory(service, entry, timegap) { // we'll use the service or characteristic UUID to determine the history entry time and data we'll add // reformat the entry object to order the fields consistantly in the output // Add new history types in the switch statement let historyEntry = {}; if (this.restart !== null && typeof entry.restart === 'undefined') { // Object recently created, so log the time restarted our history service entry.restart = this.restart; this.restart = null; } if (typeof entry.time === 'undefined') { // No logging time was passed in, so set entry.time = Math.floor(Date.now() / 1000); } if (typeof service.subtype === 'undefined') { service.subtype = 0; } if (typeof timegap === 'undefined') { timegap = 0; // Zero minimum time gap between entries } switch (service.UUID) { case this.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); break; } case this.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); break; } case this.hap.Service.Window.UUID: case this.hap.Service.WindowCovering.UUID: { // Window and Window Covering history // entry.time => unix time in seconds // entry.status => 0 = closed, 1 = open // entry.position => position in % 0% = closed 100% fully open 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); break; } case this.hap.Service.HeaterCooler.UUID: case this.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 // entry.temperature => current temperature in degress C // entry.target => {low, high} = cooling limit, heating limit // entry.humidity => current humidity historyEntry.status = entry.status; historyEntry.temperature = entry.temperature; 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); break; } case this.hap.Service.EveAirPressureSensor.UUID: case this.hap.Service.AirQualitySensor.UUID: case this.hap.Service.TemperatureSensor.UUID: { // Temperature sensor history // entry.time => unix time in seconds // entry.temperature => current temperature in degress C // entry.humidity => current humidity // optional (entry.ppm) // optional (entry.voc => current VOC measurement in ppb)\ // optional (entry.pressure -> in hpa) historyEntry.temperature = entry.temperature; if (typeof entry.humidity === 'undefined') { // fill out humidity if missing entry.humidity = 0; } if (typeof entry.ppm === 'undefined') { // fill out ppm if missing entry.ppm = 0; } if (typeof entry.voc === 'undefined') { // fill out voc if missing entry.voc = 0; } if (typeof entry.pressure === 'undefined') { // fill out pressure if missing entry.pressure = 0; } historyEntry.temperature = entry.temperature; historyEntry.humidity = entry.humidity; historyEntry.ppm = entry.ppm; 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); break; } case this.hap.Service.Valve.UUID: { // Water valve history // entry.time => unix time in seconds // entry.status => 0 = valve closed, 1 = valve opened // entry.water => amount of water in L's // entry.duration => time for water amount historyEntry.status = entry.status; 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); break; } case this.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 don't have sub type, so we'll use 0 for it break; } case this.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 don't have sub type, so we'll use 0 for it break; } case this.hap.Service.Outlet.UUID: { // Power outlet history // entry.time => unix time in seconds // entry.status => 0 = off, 1 = on // 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); break; } case this.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); break; } case this.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); break; } } } resetHistory() { // Reset history to nothing this.historyData = {}; this.historyData.reset = Math.floor(Date.now() / 1000); // time history was reset this.historyData.rollover = 0; // no last rollover time this.historyData.next = 0; // next entry for history is at start this.historyData.types = []; // no service types in history this.historyData.data = []; // no history data this.storage.setItem(this.storageKey, this.historyData); } rolloverHistory() { // Roll history over and start from zero. // We'll include an entry as to when the rollover took place // remove all history data after the rollover entry this.historyData.data.splice(this.maxEntries, this.historyData.data.length); this.historyData.rollover = Math.floor(Date.now() / 1000); this.historyData.next = 0; this.#updateHistoryTypes(); this.storage.setItem(this.storageKey, this.historyData); } #addEntry(type, sub, time, timegap, entry) { let historyEntry = {}; let recordEntry = true; // always record entry unless we don't need to historyEntry.time = time; historyEntry.type = type; historyEntry.sub = sub; Object.entries(entry).forEach(([key, value]) => { if (key !== 'time' || key !== 'type' || key !== 'sub') { // Filer out events we want to control historyEntry[key] = value; } }); // If we have a minimum time gap specified, find the last time entry for this type and if less than min gap, ignore if (timegap !== 0) { let typeIndex = this.historyData.types.findIndex((type) => type.type === historyEntry.type && type.sub === historyEntry.sub); if ( typeIndex >= 0 && time - this.historyData.data[this.historyData.types[typeIndex].lastEntry].time < timegap && typeof historyEntry.restart === 'undefined' ) { // time between last recorded entry and new entry is less than minimum gap and its not a 'restart' entry // so don't log it recordEntry = false; } } if (recordEntry === true) { // Work out where this goes in the history data array if (this.maxEntries !== 0 && this.historyData.next >= this.maxEntries) { // roll over history data as we've reached the defined max entry size this.rolloverHistory(); } this.historyData.data[this.historyData.next] = historyEntry; this.historyData.next++; // Update types we have in history. This will just be the main type and its latest location in history let typeIndex = this.historyData.types.findIndex((type) => type.type === historyEntry.type && type.sub === historyEntry.sub); if (typeIndex === -1) { this.historyData.types.push({ type: historyEntry.type, sub: historyEntry.sub, lastEntry: this.historyData.next - 1, }); } else { this.historyData.types[typeIndex].lastEntry = this.historyData.next - 1; } // Validate types last entries. Helps with rolled over data etc. If we cannot find the type anymore, remove from known types this.historyData.types.forEach((typeEntry, index) => { if (this.historyData.data[typeEntry.lastEntry].type !== typeEntry.type) { // not found, so remove from known types this.historyData.types.splice(index, 1); } }); this.storage.setItem(this.storageKey, this.historyData); // Save to persistent storage } } getHistory(service, subtype, specifickey) { // returns a JSON object of all history for this service and subtype // handles if we've rolled over history also let tempHistory = []; if (specifickey === undefined) { specifickey = {}; } if (service !== undefined && service !== '') { // passed in UUID byself, rather than service object specifickey.type = service; } if (service?.UUID !== undefined && service.UUID !== '') { specifickey.type = service.UUID; } if (service?.subtype === undefined && typeof subtype === undefined) { specifickey.sub = subtype; } if (subtype !== undefined && subtype !== null) { specifickey.sub = subtype; } tempHistory = tempHistory .concat( this.historyData.data.slice(this.historyData.next, this.historyData.data.length), this.historyData.data.slice(0, this.historyData.next), ) .filter((historyEntry) => { return Object.entries(specifickey).every(([key, value]) => historyEntry[key] === value); }); return tempHistory; } generateCSV(service, csvfile) { // Generates a CSV file for use in applications such as Numbers/Excel for graphing // we get all the data for the service, ignoring the specific subtypes let tempHistory = this.getHistory(service, null); // all history if (tempHistory.length !== 0) { let writer = fs.createWriteStream(csvfile, { flags: 'w', autoClose: 'true', }); if (writer !== null) { // write header, we'll use the first record keys for the header keys let header = 'time,subtype'; Object.keys(tempHistory[0]).forEach((key) => { if (key !== 'time' && key !== 'type' && key !== 'sub' && key !== 'restart') { header = header + ',' + key; } }); writer.write(header + '\n'); // write data // Date/Time converted into local timezone tempHistory.forEach((historyEntry) => { let csvline = new Date(historyEntry.time * 1000).toLocaleString().replace(',', '') + ',' + historyEntry.sub; Object.entries(historyEntry).forEach(([key, value]) => { if (key !== 'time' && key !== 'type' && key !== 'sub' && key !== 'restart') { csvline = csvline + ',' + value; } }); writer.write(csvline + '\n'); }); writer.end(); } } } lastHistory(service, subtype) { // returns the last history event for this service type and subtype let lastHistory = this.getHistory(service, subtype); return lastHistory.length > 0 ? lastHistory?.[lastHistory.length - 1] : undefined; } entryCount(service, subtype, specifickey) { // returns the number of history entries for this service type and subtype // can can also be limited to a specific key value let tempHistory = this.getHistory(service, subtype, specifickey); return tempHistory.length; } #updateHistoryTypes() { // Builds the known history types and last entry in current history data // Might be time consuming..... this.historyData.types = []; for (let index = this.historyData.data.length - 1; index > 0; index--) { if ( this.historyData.types.findIndex( (type) => (typeof type.sub !== 'undefined' && type.type === this.historyData.data[index].type && type.sub === this.historyData.data[index].sub) || (typeof type.sub === 'undefined' && type.type === this.historyData.data[index].type), ) === -1 ) { this.historyData.types.push({ type: this.historyData.data[index].type, sub: this.historyData.data[index].sub, lastEntry: index, }); } } } // Overlay EveHome service, characteristics and functions // Alot of code taken from fakegato https://github.com/simont77/fakegato-history // references from https://github.com/ebaauw/homebridge-lib/blob/master/lib/EveHomeKitTypes.js // // Overlay our history into EveHome. Can only have one service history exposed to EveHome (ATM... see if can work around) // Returns object created for our EveHome accessory if successfull linkToEveHome(service, options) { if (typeof service !== 'object' || typeof this?.EveHome?.service !== 'undefined') { return; } if (typeof options !== 'object') { options = {}; } switch (service.UUID) { case this.hap.Service.ContactSensor.UUID: case this.hap.Service.Door.UUID: case this.hap.Service.Window.UUID: case this.hap.Service.GarageDoorOpener.UUID: { // treat these as EveHome Door // Inverse status used for all UUID types except this.hap.Service.ContactSensor.UUID // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [ this.hap.Characteristic.EveLastActivation, this.hap.Characteristic.EveOpenedDuration, this.hap.Characteristic.EveTimesOpened, ]); let tempHistory = this.getHistory(service.UUID, service.subtype); let historyreftime = this.historyData.reset - EPOCH_OFFSET; if (tempHistory.length !== 0) { historyreftime = tempHistory[0].time - EPOCH_OFFSET; } this.EveHome = { service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: service.UUID === this.hap.Service.ContactSensor.UUID ? 'contact' : 'door', fields: '0601', entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0, }; // Setup initial values and callbacks for charateristics we are using service.updateCharacteristic( this.hap.Characteristic.EveTimesOpened, this.entryCount(this.EveHome.type, this.EveHome.sub, { status: 1 }), ); service.updateCharacteristic(this.hap.Characteristic.EveLastActivation, this.#EveLastEventTime()); // Setup callbacks for characteristics service.getCharacteristic(this.hap.Characteristic.EveTimesOpened).onGet(() => { // Count of entries based upon status = 1, opened return this.entryCount(this.EveHome.type, this.EveHome.sub, { status: 1 }); }); service.getCharacteristic(this.hap.Characteristic.EveLastActivation).onGet(() => { return this.#EveLastEventTime(); // time of last event in seconds since first event }); break; } case this.hap.Service.WindowCovering.UUID: { // Treat as Eve MotionBlinds // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [ this.hap.Characteristic.EveGetConfiguration, this.hap.Characteristic.EveSetConfiguration, ]); let tempHistory = this.getHistory(service.UUID, service.subtype); let historyreftime = this.historyData.reset - EPOCH_OFFSET; if (tempHistory.length !== 0) { historyreftime = tempHistory[0].time - EPOCH_OFFSET; } 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, }; //17 CurrentPosition //18 TargetPosition //19 PositionState service.getCharacteristic(this.hap.Characteristic.EveGetConfiguration).onGet(() => { let value = util.format( '0002 5500 0302 %s 9b04 %s 1e02 5500 0c', numberToEveHexString(2979, 4), // firmware version (build xxxx) numberToEveHexString(Math.floor(Date.now() / 1000), 8), ); // 'now' time return encodeEveData(value); }); service.getCharacteristic(this.hap.Characteristic.EveSetConfiguration).onSet((value) => { //let processedData = {}; let valHex = decodeEveData(value); let index = 0; //console.log('EveSetConfiguration', valHex); while (index < valHex.length) { // first byte is command in this data stream // second byte is size of data for command let command = valHex.substr(index, 2); let size = parseInt(valHex.substr(index + 2, 2), 16) * 2; let data = valHex.substr(index + 4, parseInt(valHex.substr(index + 2, 2), 16) * 2); switch (command) { case '00': { // end of command? break; } case 'f0': { // set limits // data // 02 bottom position set // 01 top position set // 04 favourite position set break; } case 'f1': { // orientation set?? break; } case 'f3': { // move window covering to set limits // xxyyyy - xx = move command (01 = up, 02 = down, 03 = stop), yyyy - distance/time/ticks/increment to move?? //let moveCommand = data.substring(0, 2); //let moveAmount = EveHexStringToNumber(data.substring(2)); //console.log('move', moveCommand, moveAmount); let currentPosition = service.getCharacteristic(this.hap.Characteristic.CurrentPosition).value; if (data === '015802') { currentPosition = currentPosition + 1; } if (data === '025802') { currentPosition = currentPosition - 1; } //console.log('move', currentPosition, data); service.updateCharacteristic(this.hap.Characteristic.CurrentPosition, currentPosition); service.updateCharacteristic(this.hap.Characteristic.TargetPosition, currentPosition); break; } default: { this?.log?.debug && this.log.debug('Unknown Eve MotionBlinds command "%s" with data "%s"', command, data); break; } } index += 4 + size; // Move to next command accounting for header size of 4 bytes } }); break; } case this.hap.Service.HeaterCooler.UUID: case this.hap.Service.Thermostat.UUID: { // treat these as EveHome Thermo // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [ this.hap.Characteristic.EveValvePosition, this.hap.Characteristic.EveFirmware, this.hap.Characteristic.EveProgramData, this.hap.Characteristic.EveProgramCommand, this.hap.Characteristic.StatusActive, this.hap.Characteristic.CurrentTemperature, this.hap.Characteristic.TemperatureDisplayUnits, this.hap.Characteristic.LockPhysicalControls, ]); let tempHistory = this.getHistory(service.UUID, service.subtype); let historyreftime = this.historyData.reset - EPOCH_OFFSET; if (tempHistory.length !== 0) { historyreftime = tempHistory[0].time - EPOCH_OFFSET; } this.EveHome = { service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: 'thermo', fields: '0102 0202 1102 1001 1201 1d01', entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0, }; // Need some internal storage to track Eve Thermo configuration from EveHome app this.EveThermoPersist = { firmware: typeof options?.EveThermo_firmware === 'number' ? options.EveThermo_firmware : 1251, // 1251 (2015), 2834 (2020) thermo attached: options?.EveThermo_attached === true, // attached to base? tempoffset: typeof options?.EveThermo_tempoffset === 'number' ? options.EveThermo_tempoffset : -2.5, // Temperature offset enableschedule: options?.EveThermo_enableschedule === true, // Schedules on/off pause: options?.EveThermo_pause === true, // Paused on/off vacation: options?.EveThermo_vacation === true, // Vacation status - disabled ie: Home vacationtemp: typeof options?.EveThermo_vacationtemp === 'number' ? options.EveThermo_vactiontemp : null, // Vacation temp programs: typeof options?.EveThermo_programs === 'object' ? options.EveThermo_programs : [], }; // Setup initial values and callbacks for charateristics we are using service.updateCharacteristic( this.hap.Characteristic.EveFirmware, encodeEveData(util.format('2c %s be', numberToEveHexString(this.EveThermoPersist.firmware, 4))), ); // firmware version (build xxxx))); service.updateCharacteristic(this.hap.Characteristic.EveProgramData, this.#EveThermoGetDetails(options.getcommand)); service.getCharacteristic(this.hap.Characteristic.EveProgramData).onGet(() => { return this.#EveThermoGetDetails(options.getcommand); }); service.getCharacteristic(this.hap.Characteristic.EveProgramCommand).onSet((value) => { let programs = []; let processedData = {}; let valHex = decodeEveData(value); let index = 0; while (index < valHex.length) { let command = valHex.substr(index, 2); index += 2; // skip over command value, and this is where data starts. switch (command) { case '00': { // start of command string ?? break; } case '06': { // end of command string ?? break; } case '7f': { // end of command string ?? break; } case '11': { // valve calibration/protection?? //0011ff00f22076 // 00f22076 - 111100100010000001110110 // 15868022 // 7620f2 - 011101100010000011110010 // 7741682 //console.log(Math.floor(Date.now() / 1000)); index += 10; break; } case '10': { // OK to remove break; } case '12': { // temperature offset // 8bit signed value. Divide by 10 to get float value this.EveThermoPersist.tempoffset = EveHexStringToNumber(valHex.substr(index, 2)) / 10; processedData.tempoffset = this.EveThermoPersist.tempoffset; index += 2; break; } case '13': { // schedules enabled/disable this.EveThermoPersist.enableschedule = valHex.substr(index, 2) === '01' ? true : false; processedData.enableschedule = this.EveThermoPersist.enableschedule; index += 2; break; } case '14': { // Installed status index += 2; break; } case '18': { // Pause/resume via HomeKit automation/scene // 20 - pause thermostat operation // 10 - resume thermostat operation this.EveThermoPersist.pause = valHex.substr(index, 2) === '20' ? true : false; processedData.pause = this.EveThermoPersist.pause; index += 2; break; } case '19': { // Vacation on/off, vacation temperature via HomeKit automation/scene 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 //let nowTemp = valHex.substr(index, 2) === '80' ? null : parseInt(valHex.substr(index, 2), 16) * 0.5; let ecoTemp = valHex.substr(index + 2, 2) === '80' ? null : parseInt(valHex.substr(index + 2, 2), 16) * 0.5; let comfortTemp = valHex.substr(index + 4, 2) === '80' ? null : parseInt(valHex.substr(index + 4, 2), 16) * 0.5; processedData.scheduleTemps = { eco: ecoTemp, comfort: comfortTemp, }; index += 6; break; } case 'fc': { // Date/Time mmhhDDMMYY index += 10; break; } case 'fa': { // Programs (week - mon, tue, wed, thu, fri, sat, sun) // index += 112; for (let index2 = 0; index2 < 7; index2++) { let times = []; for (let index3 = 0; index3 < 4; index3++) { // decode start time let start = parseInt(valHex.substr(index, 2), 16); //let start_min = null; //let start_hr = null; let start_offset = null; if (start !== 0xff) { //start_min = (start * 10) % 60; // Start minute //start_hr = ((start * 10) - start_min) / 60; // Start hour start_offset = start * 10 * 60; // Seconds since 00:00 } // decode end time let end = parseInt(valHex.substr(index + 2, 2), 16); //let end_min = null; //let end_hr = null; let end_offset = null; if (end !== 0xff) { //end_min = (end * 10) % 60; // End minute //end_hr = ((end * 10) - end_min) / 60; // End hour end_offset = end * 10 * 60; // Seconds since 00:00 } if (start_offset !== null && end_offset !== null) { times.push({ start: start_offset, duration: end_offset - start_offset, ecotemp: processedData.scheduleTemps.eco, comforttemp: processedData.scheduleTemps.comfort, }); } index += 4; } programs.push({ id: programs.length + 1, days: DAYSOFWEEK[index2], schedule: times, }); } this.EveThermoPersist.programs = programs; processedData.programs = this.EveThermoPersist.programs; break; } case '1a': { // Program (day) index += 16; break; } case 'f2': { // ?? index += 2; break; } case 'f6': { //?? index += 6; break; } case 'ff': { // ?? index += 4; break; } default: { this?.log?.debug && this.log.debug('Unknown Eve Thermo command "%s"', command); break; } } } // Send complete processed command data if configured to our callback if (typeof options?.setcommand === 'function' && Object.keys(processedData).length !== 0) { options.setcommand(processedData); } }); break; } case this.hap.Service.EveAirPressureSensor.UUID: { // treat these as EveHome Weather (2015) // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [this.hap.Characteristic.EveFirmware]); let tempHistory = this.getHistory(service.UUID, service.subtype); let 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: 'weather', fields: '0102 0202 0302', entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0, }; service.updateCharacteristic( this.hap.Characteristic.EveFirmware, encodeEveData(util.format('01 %s be', numberToEveHexString(809, 4))), ); break; } case this.hap.Service.AirQualitySensor.UUID: case this.hap.Service.TemperatureSensor.UUID: { // treat these as EveHome Room(s) // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [ this.hap.Characteristic.EveFirmware, service.UUID === this.hap.Service.AirQualitySensor.UUID ? this.hap.Characteristic.VOCDensity : this.hap.Characteristic.TemperatureDisplayUnits, ]); let tempHistory = this.getHistory(service.UUID, service.subtype); let historyreftime = this.historyData.reset - EPOCH_OFFSET; if (tempHistory.length !== 0) { historyreftime = tempHistory[0].time - EPOCH_OFFSET; } if (service.UUID === this.hap.Service.AirQualitySensor.UUID) { // Eve Room 2 (2018) 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, }; service.updateCharacteristic( this.hap.Characteristic.EveFirmware, encodeEveData(util.format('27 %s be', numberToEveHexString(1416, 4))), ); // firmware version (build xxxx))); // Need to ensure HomeKit accessory which has Air Quality service also has temperature & humidity services. // Temperature service needs characteristic this.hap.Characteristic.TemperatureDisplayUnits set to CELSIUS } if (service.UUID === this.hap.Service.TemperatureSensor.UUID) { // Eve Room (2015) 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, }; service.updateCharacteristic( this.hap.Characteristic.EveFirmware, encodeEveData(util.format('02 %s be', numberToEveHexString(1151, 4))), ); // firmware version (build xxxx))); // Temperature needs to be in Celsius service.updateCharacteristic( this.hap.Characteristic.TemperatureDisplayUnits, this.hap.Characteristic.TemperatureDisplayUnits.CELSIUS, ); } break; } case this.hap.Service.MotionSensor.UUID: { // treat these as EveHome Motion // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [ this.hap.Characteristic.EveMotionSensitivity, this.hap.Characteristic.EveMotionDuration, this.hap.Characteristic.EveLastActivation, // this.hap.Characteristic.EveGetConfiguration, // this.hap.Characteristic.EveSetConfiguration, ]); let tempHistory = this.getHistory(service.UUID, service.subtype); let historyreftime = this.historyData.reset - EPOCH_OFFSET; if (tempHistory.length !== 0) { historyreftime = tempHistory[0].time - EPOCH_OFFSET; } 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, }; // Need some internal storage to track Eve Motion configuration from EveHome app this.EveMotionPersist = { duration: typeof options?.EveMotion_duration === 'number' ? options.EveMotion_duration : 5, // default 5 seconds sensitivity: typeof options?.EveMotion_sensitivity === 'number' ? options.EveMotion_sensivity : this.hap.Characteristic.EveMotionSensitivity.HIGH, // default sensitivity ledmotion: options?.EveMotion_ledmotion === true, // off }; // Setup initial values and callbacks for charateristics we are using service.updateCharacteristic(this.hap.Characteristic.EveLastActivation, this.#EveLastEventTime()); service.getCharacteristic(this.hap.Characteristic.EveLastActivation).onGet(() => { return this.#EveLastEventTime(); // time of last event in seconds since first event }); service.updateCharacteristic(this.hap.Characteristic.EveMotionSensitivity, this.EveMotionPersist.sensitivity); service.getCharacteristic(this.hap.Characteristic.EveMotionSensitivity).onGet(() => { return this.EveMotionPersist.sensitivity; }); service.getCharacteristic(this.hap.Characteristic.EveMotionSensitivity).onSet((value) => { this.EveMotionPersist.sensitivity = value; }); service.updateCharacteristic(this.hap.Characteristic.EveMotionDuration, this.EveMotionPersist.duration); service.getCharacteristic(this.hap.Characteristic.EveMotionDuration).onGet(() => { return this.EveMotionPersist.duration; }); service.getCharacteristic(this.hap.Characteristic.EveMotionDuration).onSet((value) => { this.EveMotionPersist.duration = value; }); /*service.updateCharacteristic(this.hap.Characteristic.EveGetConfiguration, encodeEveData('300100')); service.getCharacteristic(this.hap.Characteristic.EveGetConfiguration).onGet(() => { let value = util.format( '0002 2500 0302 %s 9b04 %s 8002 ffff 1e02 2500 0c', numberToEveHexString(1144, 4), // firmware version (build xxxx) numberToEveHexString(Math.floor(Date.now() / 1000), 8), // 'now' time ); // Not sure why 64bit value??? console.log('Motion set', value) return encodeEveData(value)); }); service.getCharacteristic(this.hap.Characteristic.EveSetConfiguration).onSet((value) => { let valHex = decodeEveData(value); let index = 0; while (index < valHex.length) { // first byte is command in this data stream // second byte is size of data for command let command = valHex.substr(index, 2); let size = parseInt(valHex.substr(index + 2, 2), 16) * 2; let data = valHex.substr(index + 4, parseInt(valHex.substr(index + 2, 2), 16) * 2); switch(command) { case '30' : { this.EveMotionPersist.ledmotion = (data === '01' ? true : false); break; } case '80' : { //0000 0400 (mostly) and sometimes 300103 and 80040000 ffff break; } default : { this?.log?.debug && this.log.debug('Unknown Eve Motion command "%s" with data "%s"', command, data); break; } } index += (4 + size); // Move to next command accounting for header size of 4 bytes } }); */ break; } case this.hap.Service.SmokeSensor.UUID: { // treat these as EveHome Smoke // Setup the history service and the required characteristics for this service UUID type // Callbacks setup below after this is created let historyService = this.#createHistoryService(service, [ this.hap.Characteristic.EveGetConfiguration, this.hap.Characteristic.EveSetConfiguration, this.hap.Characteristic.EveDeviceStatus, ]); let tempHistory = this.getHistory(service.UUID, service.subtype); let historyreftime = this.historyData.reset - EPOCH_OFFSET; if (tempHistory.length !== 0) { historyreftime = tempHistory[0].time - EPOCH_OFFSET; } this.EveHome = { service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: 'smoke', fields: '1601 1b02 0f03 2302', entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0, }; // TODO = work out what the 'signatures' need to be for an Eve Smoke // Also, how to make alarm test button active in Eve app and not say 'Eve Smoke is not mounted correctly' // Need some internal storage to track Eve Smoke configuration from EveHome app this.EveSmokePersist = { firmware: typeof options?.EveSmoke_firmware === 'number' ? options.EveSmoke_firmware : 1208, // Firmware version lastalarmtest: typeof options?.EveSmoke_lastalarmtest === 'number' ? options.EveSmoke_lastalarmtest : 0, // Seconds of alarm test alarmtest: options?.EveSmoke_alarmtest === true, // Is alarmtest running heatstatus: typeof options?.EveSmoke_heatstatus === 'number' ? options.EveSmoke_heatstatus : 0, // Heat sensor status statusled: options?.EveSmoke_statusled === false, // Status LED flash/enabled smoketestpassed: options?.EveSmoke_smoketestpassed === false, // Passed smoke test? heattestpassed: options?.EveSmoke_heattestpassed === false, // Passed smoke test? hushedstate: options.EveSmoke_hushedstate === true, // Alarms muted }; // Setup initial values and callbacks for charateristics we are using service.updateCharacteristic( this.hap.Characteristic.EveDeviceStatus, this.#EveSmokeGetDetails(options.getcommand, this.hap.Characteristic.EveDeviceStatus), ); service.getCharacteristic(this.hap.Characteristic.EveDeviceStatus).onGet(() => { return this.#EveSmokeGetDetails(options.getcommand, this.hap.Characteristic.EveDeviceStatus); }); service.updateCharacteristic( this.hap.Characteristic.EveGetConfiguration, this.#EveSmokeGetDetails(options.getcommand, this.hap.Characteristic.EveGetConfiguration), ); service.getCharacteristic(this.hap.Characteristic.EveGetConfiguration).onGet(() => { return this.#EveSmokeGetDetails(options.getcommand, this.hap.Characteristic.EveGetConfiguration); }); service.getCharacteristic(this.hap.Characteristic.EveSetConfiguration).onSet((value) => { // Loop through set commands passed to us let processedData = {}; let valHex = decodeEveData(value); let index = 0; while (index < valHex.length) { // first byte is command in this data stream // second byte is size of data for command let command = valHex.substr(index, 2); let size = parseInt(valHex.substr(index + 2, 2), 16) * 2; let data = valHex.substr(index + 4, parseInt(valHex.substr(index + 2, 2), 16) * 2); switch (command) { case '40': { let subCommand = EveHexStringToNumber(data.substr(0, 2)); if (subCommand === 0x02) { // Alarm test start/stop this.EveSmokePersist.alarmtest = data === '0201' ? true : false; processedData.alarmtest = this.EveSmokePersist.alarmtest; } if (subCommand === 0x05) { // Flash status Led on/off this.EveSmokePersist.statusled = data === '0501' ? true : false; processedData.statusled = this.EveSmokePersist.statusled; } if (subCommand !== 0x02 && subCommand !== 0x05) { this?.log?.debug && this.log.debug('Unknown Eve Smoke command "%s" with data "%s"', command, data);