garagedoor-accfactory
Version:
HomeKit garage door opener system using HAP-NodeJS library
1,239 lines (1,089 loc) • 116 kB
JavaScript
// 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