UNPKG

garagedoor-accfactory

Version:

HomeKit garage door opener system using HAP-NodeJS library

1,239 lines (1,089 loc) 116 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 2025/06/16 // 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 DAYS_OF_WEEK = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const EMPTY_SCHEDULE = 'ffffffffffffffff'; const LOG_LEVELS = { INFO: 'info', SUCCESS: 'success', WARN: 'warn', ERROR: 'error', DEBUG: 'debug', }; // Create the history object export default class HomeKitHistory { historyData = {}; // Tracked history data via persistant storage restart = Math.floor(Date.now() / 1000); // time we restarted object or created EveHome = undefined; accessory = undefined; // Accessory service for this history hap = undefined; // HomeKit Accessory Protocol API stub log = undefined; // Logging function object // Internal data only for this class #persistStorage = undefined; #persistKey = undefined; #maxEntries = MAX_HISTORY_SIZE; // used for rolling history. if 0, means no rollover constructor(accessory = undefined, api = undefined, log = undefined, options = {}) { // Validate the passed in logging object. We are expecting certain functions to be present if (Object.values(LOG_LEVELS).every((fn) => typeof log?.[fn] === 'function')) { this.log = log; } // Get the actual HAP entry point from passed in api object, either Homebridge or HAP-NodeJS this.hap = isNaN(api?.version) === false && typeof api?.hap === 'object' && api?.HAPLibraryVersion === undefined ? api.hap : typeof api?.HAPLibraryVersion === 'function' && api?.version === undefined && api?.hap === undefined ? api : undefined; if (this.hap === undefined) { this?.log?.error?.('Missing HAP library API, cannot use class'); return; } if (typeof accessory !== 'undefined' && typeof accessory === 'object') { this.accessory = accessory; } if (isNaN(options?.maxEntries) === false) { this.#maxEntries = options.maxEntries; } // Determine the peristant storage file name 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.#persistKey = util.format('History.%s.json', accessory.username.replace(/:/g, '').toUpperCase()); } // Setup HomeKitHistory under Homebridge if (typeof accessory?.username === 'undefined') { this.#persistKey = util.format('History.%s.json', accessory.UUID); } // Setup persistant storage and load any data we have already this.#persistStorage = this.hap.HAPStorage.storage(); this.historyData = this.#persistStorage.getItem(this.#persistKey); 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 } // perform rollover if needed when starting service if (this.#maxEntries !== 0 && this.historyData.next >= this.#maxEntries) { this.rolloverHistory(); } // Dynamically create the additional services and characteristics this.#createHomeKitServicesAndCharacteristics(); } // Class functions addHistory(target, entry, timegap) { // Validate that target is a Service or Characteristic with a UUID string, // entry is an object, and hap.Service exists (class/function) if ( typeof target !== 'object' || typeof target.UUID !== 'string' || typeof entry !== 'object' || typeof this.hap?.Service !== 'function' || typeof this.hap?.Characteristic !== 'function' ) { return; } // Metadata map keyed by Service or Characteristic UUID let SERVICE_HISTORY_META = { [this.hap.Service.GarageDoorOpener.UUID]: { required: ['status'], comment: 'status => 0 = closed, 1 = open', }, [this.hap.Service.Fan.UUID]: { required: ['status'], comment: 'status => 0 = off, 1 = on; optional: temperature, humidity', }, [this.hap.Service.Fan.Fanv2.UUID]: { required: ['status'], comment: 'status => 0 = off, 1 = on; optional: temperature, humidity', }, [this.hap.Service.HumidifierDehumidifier.UUID]: { required: ['status'], comment: 'status => 0 = off, 1 = on; optional: temperature, humidity', }, [this.hap.Service.MotionSensor.UUID]: { required: ['status'], comment: 'status => 0 = motion cleared, 1 = motion detected', }, [this.hap.Service.Window.UUID]: { required: ['status', 'position'], comment: 'status => 0 = closed, 1 = open; position => % open (0–100)', }, [this.hap.Service.WindowCovering.UUID]: { required: ['status', 'position'], comment: 'status => 0 = closed, 1 = open; position => % open (0–100)', }, [this.hap.Service.HeaterCooler.UUID]: { required: ['status', 'temperature', 'target', 'humidity'], comment: 'status => 0 = off, 1 = cooling, 2 = heating; includes temperature, target {low/high}, humidity', }, [this.hap.Service.Thermostat.UUID]: { required: ['status', 'temperature', 'target', 'humidity'], comment: 'status => 0 = off, 1 = cooling, 2 = heating; includes temperature, target {low/high}, humidity', }, [this.hap.Service.TemperatureSensor.UUID]: { required: ['temperature'], defaults: { humidity: 0, ppm: 0, voc: 0, pressure: 0 }, comment: 'temperature required; humidity, ppm, voc, pressure default to 0', }, [this.hap.Service.AirQualitySensor.UUID]: { required: ['temperature'], defaults: { humidity: 0, ppm: 0, voc: 0, pressure: 0 }, comment: 'temperature required; humidity, ppm, voc, pressure default to 0', }, [this.hap.Service.EveAirPressureSensor.UUID]: { required: ['temperature'], defaults: { humidity: 0, ppm: 0, voc: 0, pressure: 0 }, comment: 'temperature required; humidity, ppm, voc, pressure default to 0', }, [this.hap.Service.Valve.UUID]: { required: ['status', 'water', 'duration'], comment: 'status => 0 = valve closed, 1 = open; includes water (L) and duration (s)', }, [this.hap.Characteristic.WaterLevel.UUID]: { required: ['level'], comment: 'level => water level percentage (0–100)', }, [this.hap.Service.LeakSensor.UUID]: { required: ['status'], comment: 'status => 0 = no leak, 1 = leak detected', }, [this.hap.Service.Outlet.UUID]: { required: ['status', 'volts', 'watts', 'amps'], comment: 'status => 0 = off, 1 = on; includes volts, watts, amps', }, [this.hap.Service.Doorbell.UUID]: { required: ['status'], comment: 'status => 0 = not pressed, 1 = pressed', }, [this.hap.Service.SmokeSensor.UUID]: { required: ['status'], comment: 'status => 0 = smoke cleared, 1 = smoke detected', }, }; // Lookup metadata for this target UUID (service or characteristic) let meta = SERVICE_HISTORY_META[target.UUID]; if (typeof meta !== 'object') { return; } // Set restart flag if applicable if (isNaN(this.restart) === false && typeof entry?.restart === 'undefined') { entry.restart = this.restart; this.restart = undefined; } // Ensure time is set if (isNaN(entry?.time) === true) { entry.time = Math.floor(Date.now() / 1000); } // Provide default subtype if missing if (typeof target.subtype === 'undefined') { target.subtype = 0; } // Default timegap if invalid if (isNaN(timegap) === true) { timegap = 0; } // Validate required keys exist on entry let required = [].concat(meta.required || []); for (let i = 0; i < required.length; i++) { if (typeof entry[required[i]] === 'undefined') { return; } } // Fill in default values if specified if (typeof meta.defaults === 'object') { let defaults = meta.defaults; for (let key in defaults) { if (typeof entry[key] === 'undefined') { entry[key] = defaults[key]; } } } // Compose list of keys to include in history entry (required + defaults) let keys = [].concat(required); if (typeof meta.defaults === 'object') { for (let key in meta.defaults) { if (keys.indexOf(key) === -1) { keys.push(key); } } } // Build the filtered history entry let historyEntry = {}; for (let i = 0; i < keys.length; i++) { let key = keys[i]; if (typeof entry[key] !== 'undefined') { historyEntry[key] = entry[key]; } } // Include restart if set if (isNaN(entry?.restart) === false) { historyEntry.restart = entry.restart; } // Use subtype 0 for characteristics like WaterLevel or LeakSensor, else service subtype let subtype = target.UUID === this.hap.Characteristic.WaterLevel.UUID || target.UUID === this.hap.Service.LeakSensor.UUID ? 0 : target.subtype; // Call internal add entry handler this.#addEntry(target.UUID, subtype, entry.time, timegap, historyEntry); } 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.#persistStorage.setItem(this.#persistKey, 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.#persistStorage.setItem(this.#persistKey, 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; // Filter out reserved keys Object.entries(entry).forEach(([key, value]) => { if (key !== 'time' && key !== 'type' && key !== 'sub') { 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((t) => t.type === type && t.sub === sub); let entryTime = this.historyData.data?.[this.historyData.types?.[typeIndex]?.lastEntry]?.time; if (typeIndex >= 0 && typeof entryTime === 'number' && time - entryTime < timegap && typeof historyEntry.restart === 'undefined') { // time between last recorded entry and new entry is less than minimum gap and it's 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(); } let entryIndex = this.historyData.next; this.historyData.data[entryIndex] = 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((t) => t.type === type && t.sub === sub); if (typeIndex === -1) { this.historyData.types.push({ type: type, sub: sub, lastEntry: entryIndex }); } else { this.historyData.types[typeIndex].lastEntry = entryIndex; } // Validate types last entries. Helps with rolled over data etc. If we cannot find the type anymore, remove from known types this.historyData.types = this.historyData.types.filter((typeEntry) => { return this.historyData.data[typeEntry?.lastEntry]?.type === typeEntry.type; }); // Save to persistent storage this.#persistStorage.setItem(this.#persistKey, this.historyData); } } 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?.('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: DAYS_OF_WEEK[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?.('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?.('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: options.EveSmoke_heatstatus === true, // 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?.('Unknown Eve Smoke command "%s" with data "%s"', command, data); } break; } default: { this?.log?.debug?.('Unknown Eve Smoke command "%s" with data "%s"', command, data); break; } } index += 4 + size; // Move to next command accounting for header size of 4 bytes } // 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.Valve.UUID: case this.hap.Service.IrrigationSystem.UUID: { // treat an irrigation system as EveHome Aqua // Under this, any valve history will be presented under this. We don't 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??? // 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.LockPhysicalControls, ]); let tempHistory = this.getHistory( this.hap.Service.Valve.UUID, service.UUID === this.hap.Service.IrrigationSystem.UUID ? null : 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: this.hap.Service.Valve.UUID, sub: service.UUID === this.hap.Service.IrrigationSystem.UUID ? null : servi