garagedoor-accfactory
Version:
HomeKit garage door opener system using HAP-NodeJS library
1,222 lines (1,084 loc) • 117 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 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);