irrigationsystem
Version:
HomeKit irrigation system using HAP-NodeJS library
880 lines (771 loc) • 42.2 kB
JavaScript
// Code version 17/10/2024
// Mark Hulskamp
'use strict';
// Define external module requirements
import GPIO from 'rpio';
// Define nodejs module requirements
import process from 'node:process';
import { Buffer } from 'node:buffer';
import EventEmitter from 'node:events';
import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers';
// Import our modules
import HomeKitDevice from './HomeKitDevice.js';
import WaterTank from './watertank.js';
import Valve from './valve.js';
Valve.GPIO = GPIO; // Setup the GPIO library for the valve class
const WATERLEAKTIMEOUT = 10000; // Milliseconds after a water valve is closed before we can report on any water leak
const FLOWDATABUFFER = 30000; // Milliseconds of water flow data to store. Used to determine constant leak
export default class IrrigationSystem extends HomeKitDevice {
irrigationService = undefined; // HomeKit service for this irrigation system
leakSensorService = undefined; // HomeKit service for a "leak" sensor
switchService = undefined;
lastValveClose = 0; // Last time a valve was closed
flowData = []; // Water flow readings buffer
activeCheck = [];
activeCheckTimer = undefined;
leakDetected = false; // No Water leak detected yet
// Internal data only for this class
#eventEmitter = undefined;
#lastFlowTime = undefined; // Time of last flow pulse recieved
#flowPulseCounter = undefined; // Flow sensor pulse counter
#flowTimer = undefined;
#pauseTimer = undefined;
#tanks = {}; // Object for tanks we actually created
#zones = {}; // Object for zones we actually created
constructor(accessory, api, log, eventEmitter, deviceData) {
super(accessory, api, log, eventEmitter, deviceData);
// Fix 'mis-named' characteristic option until changed in hap-nodejs based code (v1.1.x has fix)
if (this?.hap?.Characteristic?.ProgramMode?.PROGRAM_SCHEDULED_MANUAL_MODE_ !== undefined) {
this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED_MANUAL_MODE = 2;
}
// Init the GPIO (rpio) library. This only needs to be done once before using library functions
GPIO.init({ gpiomem: true });
GPIO.init({ mapping: 'gpio' });
// Validate if eventEmitter object passed to us is an instance of EventEmitter
if (eventEmitter instanceof EventEmitter === true) {
this.#eventEmitter = eventEmitter;
}
// Setup to close any opened valves if the process is stopped
let signals = { SIGINT: 2, SIGTERM: 15 };
Object.keys(signals).forEach((signal) => {
process.on(signal, () => {
this?.log?.debug && this.log.debug('Received signal to terminate process. Closing any opened values');
Object.values(this.#zones).forEach((zone) => {
zone.valves.forEach((valve) => {
if (valve.isOpen() === true) {
valve.close();
}
});
});
process.exit(128 + signals[signal]);
});
});
}
// Class functions
addServices() {
// Create extra details for output
let postSetupDetails = [];
// Setup the irrigation service if not already present on the accessory
this.irrigationService = this.accessory.getService(this.hap.Service.IrrigationSystem);
if (this.irrigationService === undefined) {
this.irrigationService = this.accessory.addService(this.hap.Service.IrrigationSystem, '', 1);
}
this.irrigationService.updateCharacteristic(
this.hap.Characteristic.Active,
this.deviceData.power === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE,
);
this.irrigationService.setPrimaryService();
// Setup callbacks for characteristics
this.irrigationService.getCharacteristic(this.hap.Characteristic.Active).onSet((value) => {
this.#processActiveCharacteristic(this.irrigationService, value, 'system');
});
this.irrigationService.getCharacteristic(this.hap.Characteristic.Active).onGet(() => {
return this.deviceData.power === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE;
});
// Add an optional "virtual" power switch if configured
this.switchService = this.accessory.getService(this.hap.Service.Switch);
if (this.deviceData?.powerSwitch === true) {
if (this.switchService === undefined) {
this.switchService = this.accessory.addService(this.hap.Service.Switch, '', 1);
}
// Setup set callback for this switch service
this.switchService.getCharacteristic(this.hap.Characteristic.On).onSet((value) => {
if (value !== this.deviceData.power) {
this.setPower(value);
this.set({ power: value });
}
});
this.switchService.getCharacteristic(this.hap.Characteristic.On).onGet(() => {
return this.deviceData.power === true;
});
}
if (this.switchService !== undefined && this.deviceData?.powerSwitch !== true) {
// No longer required to have the switch service
// This is to handle Homebridge cached restored accessories and if configuration options have changed
this?.log?.debug && this.log.debug('Configuration has changed to no-longer have virtual power switch');
this.accessory.removeService(this.switchService);
this.switchService = undefined;
}
// Add in any defined water tanks
if (Array.isArray(this.deviceData?.tanks) === true && this.deviceData.tanks.length > 0) {
this?.log?.debug('Creating defined watertanks from configuration');
this.deviceData.tanks.forEach((tank) => {
if (tank?.enabled === true && tank?.sensorEchoPin !== undefined && tank?.sensorTrigPin !== undefined) {
if (this.irrigationService.testCharacteristic(this.hap.Characteristic.WaterLevel) === false) {
// We haven't added the water level characteristic yet to the irringation service
// This added once we have the first 'enabled' water tank
this.irrigationService.addCharacteristic(this.hap.Characteristic.WaterLevel);
}
this.#tanks[tank.uuid] = new WaterTank(
this.log,
tank.uuid,
tank.sensorHeight,
tank.minimumLevel,
tank.sensorTrigPin,
tank.sensorEchoPin,
this.#eventEmitter,
);
postSetupDetails.push('Watertank "' + tank.name + '" with "' + tank.capacity + '" Litres');
}
});
}
// Setup any defined "physical" and/or "virtual" irrigation zones
if (Array.isArray(this.deviceData?.zones) === true && this.deviceData.zones.length > 0) {
this?.log?.debug('Creating defined irrigation zones from configuration');
this.deviceData.zones.forEach((zone, index) => {
let tempService = this.accessory.addService(this.hap.Service.Valve, 'Valve ' + (index + 1), index + 1);
tempService.addCharacteristic(this.hap.Characteristic.IsConfigured);
tempService.addCharacteristic(this.hap.Characteristic.RemainingDuration);
tempService.addCharacteristic(this.hap.Characteristic.SetDuration);
tempService.addCharacteristic(this.hap.Characteristic.ConfiguredName);
tempService.addCharacteristic(this.hap.Characteristic.Identifier);
// Setup characteristic property ranges
tempService.getCharacteristic(this.hap.Characteristic.SetDuration).setProps({ maxValue: this.deviceData.maxRuntime });
tempService.getCharacteristic(this.hap.Characteristic.RemainingDuration).setProps({ maxValue: this.deviceData.maxRuntime });
tempService.updateCharacteristic(this.hap.Characteristic.ValveType, this.hap.Characteristic.ValveType.IRRIGATION);
tempService.updateCharacteristic(this.hap.Characteristic.ConfiguredName, zone.name);
tempService.updateCharacteristic(
this.hap.Characteristic.IsConfigured,
zone.enabled === true ? this.hap.Characteristic.IsConfigured.CONFIGURED : this.hap.Characteristic.IsConfigured.NOT_CONFIGURED,
);
tempService.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
tempService.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
tempService.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
tempService.updateCharacteristic(this.hap.Characteristic.SetDuration, zone.runtime);
tempService.updateCharacteristic(this.hap.Characteristic.Identifier, crc32(zone.uuid.toUpperCase()));
if (zone?.relayPin !== undefined && Array.isArray(zone.relayPin) === false) {
// Since single relay pin, this is a "physical" zone
this.#zones[zone.uuid] = {
service: tempService,
valves: [new Valve(this.log, zone.name, zone.relayPin, this.#eventEmitter)],
timer: undefined,
totalwater: 0,
totalduration: 0,
};
}
if (Array.isArray(zone?.relayPin) === true) {
// Since relay pin in is an array, we'll treat this as a "virtual" zone
let valveArray = [];
zone.relayPin.forEach((relayPin) => {
valveArray.push(new Valve(this.log, zone.name, relayPin, this.#eventEmitter));
});
this.#zones[zone.uuid] = {
service: tempService,
valves: valveArray,
timer: undefined,
totalwater: 0,
totalduration: 0,
};
}
// Setup callbacks for characteristics
tempService.getCharacteristic(this.hap.Characteristic.Active).onSet((value) => {
this.#processActiveCharacteristic(this.deviceData.zones[index], value, 'valve');
});
tempService.getCharacteristic(this.hap.Characteristic.ConfiguredName).onSet((value) => {
this.setZoneName(this.deviceData.zones[index], value);
});
tempService.getCharacteristic(this.hap.Characteristic.IsConfigured).onSet((value) => {
this.setZoneEnabled(this.deviceData.zones[index], value);
});
tempService.getCharacteristic(this.hap.Characteristic.SetDuration).onSet((value) => {
this.setZoneRuntime(this.deviceData.zones[index], value);
});
tempService.getCharacteristic(this.hap.Characteristic.Active).onGet(() => {
return this.#zones[this.deviceData.zones[index].uuid].timer === undefined
? this.hap.Characteristic.Active.INACTIVE
: this.hap.Characteristic.Active.ACTIVE;
});
tempService.getCharacteristic(this.hap.Characteristic.ConfiguredName).onGet(() => {
return this.deviceData.zones[index].name;
});
tempService.getCharacteristic(this.hap.Characteristic.SetDuration).onGet(() => {
return this.deviceData.zones[index].runtime;
});
tempService.getCharacteristic(this.hap.Characteristic.IsConfigured).onGet(() => {
return this.deviceData.zones[index].enabled === true
? this.hap.Characteristic.IsConfigured.CONFIGURED
: this.hap.Characteristic.IsConfigured.NOT_CONFIGURED;
});
this.irrigationService.addLinkedService(tempService); // Link to main irrigation accesssory
postSetupDetails.push('Zone "' + zone.name + '" ' + (zone.enabled === false ? 'but disabled' : ''));
});
}
// Create flow/leak sensor if configured
if (this.deviceData?.sensorFlowPin !== undefined) {
this?.log?.debug('Setting up water flow sensor on GPIO pin "%s"', this.deviceData.sensorFlowPin);
// Initialise the GPIO output PINs for the flow sensor and setup a GPIO polling interrupt to count the pulses
this.#flowPulseCounter = 0; // Reset pulse counter
GPIO.open(this.deviceData.sensorFlowPin, GPIO.INPUT, GPIO.PULL_UP);
GPIO.poll(
this.deviceData.sensorFlowPin,
() => {
this.#flowPulseCounter++;
},
GPIO.POLL_HIGH,
);
// Setup interval to check the water flow every 1 second, which is 1Hz
this.#lastFlowTime = Date.now(); // Start of interval
this.#flowTimer = setInterval(() => {
// We've got the number of pulses over a set period of time, so calculate flow rate and volume used in this period
// Q (L/min) = (F (Hz) / 1000) * factor (L/min)
// V (L) = Q (L/Min) * (duration (min) )
let intervalDuration = Date.now() - this.#lastFlowTime;
let flowRate = (this.#flowPulseCounter / (intervalDuration / 1000)) * this.deviceData.flowRate;
let flowVolume = flowRate * (intervalDuration / 60000);
// Determine if flow rate calculated is "within" bounds. We use this to filter out extremes and random responses
// <--- TODO
// Send out an event with current water flow data
if (this.#eventEmitter !== undefined) {
this.#eventEmitter.emit(Valve.FLOWEVENT, {
time: Date.now(),
rate: flowRate,
volume: flowVolume,
});
}
this.#lastFlowTime = Date.now(); // Update process time
this.#flowPulseCounter = 0; // Reset pulse counter
}, 1000);
}
// Add an optional water leak sensor if configured
this.leakSensorService = this.accessory.getService(this.hap.Service.LeakSensor);
if (this.deviceData?.leakSensor === true && this.deviceData?.sensorFlowPin !== undefined) {
// Create the HomeKit service for the leak sensor
if (this.leakSensorService === undefined) {
this.leakSensorService = this.accessory.addService(this.hap.Service.LeakSensor, '', 1);
}
this.leakSensorService.updateCharacteristic(
this.hap.Characteristic.LeakDetected,
this.hap.Characteristic.LeakDetected.LEAK_NOT_DETECTED,
);
}
if (this.leakSensorService !== undefined && (this.deviceData?.leakSensor !== true || this.deviceData?.sensorFlowPin === undefined)) {
// No longer required to have the leak sensor service
// This is to handle Homebridge cached restored accessories and if configuration options have changed
this?.log?.debug && this.log.debug('Configuration has changed to no-longer have leak sensor');
this.accessory.removeService(this.leakSensorService);
this.leakSensorService = undefined;
}
// Setup timer to manage pausing and unpausing of watering
this.#pauseTimer = setInterval(() => {
if (this.deviceData.pauseTimeout !== 0 && Math.floor(Date.now() / 1000) >= this.deviceData.pauseTimeout) {
// Pause timeout expired, so turn system back on
this.setPower(true);
this?.log?.success && this.log.success('Watering has resumed after being paused for a period');
}
}, 5000); // Every 5 seconds. maybe every second??
// Setup event listeners for various events we'll want to process
if (this.#eventEmitter !== undefined) {
this.#eventEmitter.addListener(WaterTank.WATERLEVEL, (waterLevelData) => {
this.messageServices(WaterTank.WATERLEVEL, waterLevelData);
});
this.#eventEmitter.addListener(Valve.VALVEEVENT, (valveData) => {
this.messageServices(Valve.VALVEEVENT, valveData);
});
this.#eventEmitter.addListener(Valve.FLOWEVENT, (flowData) => {
this.messageServices(Valve.FLOWEVENT, flowData);
});
}
// Setup linkage to EveHome app if configured todo so
if (
this.deviceData?.eveHistory === true &&
this.irrigationService !== undefined &&
typeof this.historyService?.linkToEveHome === 'function'
) {
this.historyService.linkToEveHome(this.irrigationService, {
description: this.deviceData.description,
getcommand: this.#EveHomeGetCommand.bind(this),
setcommand: this.#EveHomeSetCommand.bind(this),
});
}
// Create extra details for output
this.switchService !== undefined && postSetupDetails.push('Virtual power switch');
this.deviceData?.sensorFlowPin !== undefined && postSetupDetails.push('Water flow sensor');
this.leakSensorService !== undefined &&
postSetupDetails.push('Leak sensor' + (this.deviceData?.waterLeakAlert === true ? 'with alerting' : ''));
return postSetupDetails;
}
setPower(value) {
// Turns the irrigation system "virtually" on or off
this.irrigationService.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE); // Not in use until we start a valve??
if (value === 'off' || value === 'OFF' || value === false || value === this.hap.Characteristic.Active.INACTIVE) {
// For any valves that are opened, finish them running gracefully
this.deviceData.zones.forEach((zone) => {
if (this.#zones?.[zone?.uuid]?.service !== undefined && this.#zones?.[zone?.uuid]?.timer !== undefined) {
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
});
this.irrigationService.updateCharacteristic(
this.hap.Characteristic.ProgramMode,
this.hap.Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED,
);
this.irrigationService.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
this.deviceData.power = false;
}
if (value === 'on' || value === 'ON' || value === true || value === this.hap.Characteristic.Active.ACTIVE) {
this.irrigationService.updateCharacteristic(
this.hap.Characteristic.ProgramMode,
this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED_MANUAL_MODE,
);
this.irrigationService.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.ACTIVE);
this.deviceData.power = true;
}
this.set({ power: this.deviceData.power });
this?.log?.info && this.log.info('Irrigation system was turned "%s"', this.deviceData.power === true ? 'on' : 'off');
}
setZoneName(zone, value) {
if (typeof zone !== 'object' || typeof value !== 'string' || value === '' || typeof this.#zones?.[zone?.uuid] !== 'object') {
return;
}
this?.log?.debug && this.log.debug('Setting irrigation zone name from "%s" to "%s"', zone.name, value);
zone.name = value;
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, value);
// Save updated current configuration
this.set({ zone: zone });
}
setZoneEnabled(zone, value) {
if (
typeof zone !== 'object' ||
(typeof value !== 'boolean' &&
value !== this.hap.Characteristic.IsConfigured.CONFIGURED &&
value !== this.hap.Characteristic.IsConfigured.NOT_CONFIGURED) ||
typeof this.#zones?.[zone?.uuid] !== 'object'
) {
return;
}
this?.log?.debug &&
this.log.debug(
'Setting irrigation zone "%s" status from "%s" to "%s"',
zone.name,
zone.enabled === true ? 'Enabled' : 'Disabled',
value === this.hap.Characteristic.IsConfigured.CONFIGURED || value === true ? 'Enabled' : 'Disabled',
);
// If we're making the zone 'disabled' and if the zone is currently active, stop it first
if (value === this.hap.Characteristic.IsConfigured.NOT_CONFIGURED || value === false) {
if (this.#zones?.[zone?.uuid]?.service !== undefined && this.#zones?.[zone?.uuid]?.timer !== undefined) {
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
}
zone.enabled = value === this.hap.Characteristic.IsConfigured.CONFIGURED || value === true ? true : false;
this.#zones[zone.uuid].service.updateCharacteristic(
this.hap.Characteristic.IsConfigured,
value === this.hap.Characteristic.IsConfigured.CONFIGURED || value === true
? this.hap.Characteristic.IsConfigured.CONFIGURED
: this.hap.Characteristic.IsConfigured.NOT_CONFIGURED,
);
// Save updated current configuration
this.set({ zone: zone });
}
setZoneRuntime(zone, value) {
if (typeof zone !== 'object' || isNaN(value) === true || typeof this.#zones?.[zone?.uuid] !== 'object') {
return;
}
this?.log?.debug && this.log.debug('Setting irrigation zone "%s", runtime from "%s" to "%s" seconds', zone.name, zone.runtime, value);
zone.runtime = value;
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.SetDuration, value);
// Save updated current configuration
this.set({ zone: zone });
}
setZoneActive(zone, value) {
if (
typeof zone !== 'object' ||
(typeof value !== 'boolean' &&
value !== this.hap.Characteristic.Active.ACTIVE &&
value !== this.hap.Characteristic.Active.INACTIVE) ||
typeof this.#zones?.[zone?.uuid] !== 'object' ||
zone?.enabled !== true
) {
return;
}
if (this.deviceData.power === true && (value === this.hap.Characteristic.Active.ACTIVE || value === true)) {
// Request to turn on sprinkler and the irrigation system is 'powered on'
// If there are any zones currently running, cancel them first
if (this.#numberRunningZones() !== 0) {
this.deviceData.zones.forEach((zone) => {
if (this.#zones?.[zone?.uuid]?.service !== undefined) {
if (
this.#zones[zone?.uuid].service.getCharacteristic(this.hap.Characteristic.Active).value ===
this.hap.Characteristic.Active.ACTIVE
) {
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
}
});
}
this.#zones[zone.uuid].totalwater = 0; // No water usage yet for the zone this time
this.#zones[zone.uuid].totalduration = 0; // No run duration for the water amount yet
if (Array.isArray(this.#zones[zone.uuid].valves) === true && this.#zones[zone.uuid].valves.length > 0) {
// Whether we have a "physical" or "virtual" zone, we'll open the first valve in the list
this.#zones[zone.uuid].valves[0].open();
}
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.ACTIVE);
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.IN_USE);
this.#zones[zone.uuid].service.updateCharacteristic(
this.hap.Characteristic.RemainingDuration,
this.#zones[zone.uuid].service.getCharacteristic(this.hap.Characteristic.SetDuration).value,
);
// Calculate the end time for the zone running, then sen
let endTime =
Math.floor(Date.now() / 1000) + this.#zones[zone.uuid].service.getCharacteristic(this.hap.Characteristic.SetDuration).value;
this.#zones[zone.uuid].timer = setInterval(() => {
if (Math.floor(Date.now() / 1000) < endTime) {
// Update HomeKit with remaining duration for zone
this.#zones[zone.uuid].service.updateCharacteristic(
this.hap.Characteristic.RemainingDuration,
endTime - Math.floor(Date.now() / 1000),
);
this.#zones[zone.uuid].valves.forEach((valve, index) => {
if (valve.isOpen() === true) {
// Calculate the remaining time for this valve
let zoneEndTime =
endTime -
(this.#zones[zone.uuid].service.getCharacteristic(this.hap.Characteristic.SetDuration).value /
this.#zones[zone.uuid].valves.length) *
(this.#zones[zone.uuid].valves.length - index - 1);
if (Math.floor(Date.now() / 1000) >= zoneEndTime && index < this.#zones[zone.uuid].valves.length - 1) {
// Reached end of the time for this valve, so stop it and start the next in line
valve.close();
this.#zones[zone.uuid].valves[index + 1].open(); // Open the next valve in the list
}
}
});
}
if (Math.floor(Date.now() / 1000) > endTime) {
// Zone runtime has finished, so make zone inactive
// Call back into this function to turn off the zone, as it'll do all the valve closing etc
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
}, 1000);
}
if (this.deviceData.power === false && (value === this.hap.Characteristic.Active.ACTIVE || value === true)) {
// Request to turn on sprinkler but the irrigation system is 'powered off'
// Work around is set state of the requested valve back to off after a short duration (500ms)
setTimeout(() => {
clearInterval(this.#zones[zone.uuid].timer); // Cancel any zone running timer
this.#zones[zone.uuid].timer = undefined;
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}, 500);
}
if (this.deviceData.power === true && (value === this.hap.Characteristic.Active.INACTIVE || value === false)) {
// Request to turn off sprinkler and the irrigation system is 'powered on'
clearInterval(this.#zones[zone.uuid].timer); // Cancel any zone running timer
this.#zones[zone.uuid].timer = undefined;
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
// Work out which "valve(s)" associated with the zone are opened, and close them
this.#zones[zone.uuid].valves.forEach((valve) => {
if (valve.isOpen() === true) {
valve.close();
}
});
}
}
messageServices(type, message) {
if (type === WaterTank.WATERLEVEL) {
// Water tank level event, so update total water percentage
// <---- TODO Need to "smooth" our readings to eliminate random reading
let totalPercentage = 0;
Object.values(this.#tanks).forEach((tank) => {
totalPercentage = totalPercentage + tank.percentage;
});
if (totalPercentage > 100) {
totalPercentage = 100;
}
this.irrigationService.updateCharacteristic(this.hap.Characteristic.WaterLevel, totalPercentage);
if (typeof this.historyService?.addHistory === 'function') {
this.historyService.addHistory(
this.hap.Characteristic.WaterLevel,
{ time: Math.floor(Date.now() / 1000), level: totalPercentage },
600,
);
}
}
if (type === Valve.FLOWEVENT && message?.time !== undefined) {
// Water flow data, we can use this to determine if have a leaking system
this.flowData.push(message);
if (isNaN(this.flowData?.[0]?.time) === false && message.time - this.flowData[0].time > FLOWDATABUFFER) {
// Stored the maximum time period in our flow data buffer, so remove the first element
this.flowData.shift();
}
if (this.leakSensorService !== undefined) {
let nonZeroVolume = this.flowData.filter((flow) => flow.volume !== 0).length;
if (this.#numberRunningZones() === 0 && (nonZeroVolume / this.flowData.length) * 100 > 80) {
if (
this.lastValveClose === 0 ||
(this.lastValveClose !== 0 && Math.floor(Date.now() / 1000) - this.lastValveClose > WATERLEAKTIMEOUT)
) {
// There are no valves opened and we're calculated that the flow data buffer for the period
// has logged over 80% water volume figures which are not zero
if (this.leakDetected === false) {
this.leakDetected = true; // Suspected water leak
if (typeof this.historyService?.addHistory === 'function') {
this.historyService.addHistory(this.leakSensorService, {
time: Math.floor(Date.now() / 1000),
status: 1,
}); // Leak detected
}
if (this.deviceData.waterLeakAlert === true) {
// Trigger HomeKit leak sensor if configured todo so
this.leakSensorService.updateCharacteristic(
this.hap.Characteristic.LeakDetected,
this.hap.Characteristic.LeakDetected.LEAK_DETECTED,
);
}
this?.log?.warn && this.log.warn('Detected suspected water leak on irrigation system');
}
}
}
if (this.#numberRunningZones() === 0 && nonZeroVolume === 0) {
// We've previously flagged a leak and it now looks like we're no longer reporting one, so clear the leak sensor status
if (this.leakDetected === true) {
this.leakDetected = false; // No longer detected water leak
if (typeof this.historyService?.addHistory === 'function') {
this.historyService.addHistory(this.leakSensorService, {
time: Math.floor(Date.now() / 1000),
status: 0,
}); // Leak not detected
}
this.leakSensorService.updateCharacteristic(
this.hap.Characteristic.LeakDetected,
this.hap.Characteristic.LeakDetected.LEAK_NOT_DETECTED,
);
this?.log?.success && this.log.success('Suspected water leak no longer detected on irrigation system');
}
}
}
}
if (type === Valve.VALVEEVENT && message?.uuid !== undefined) {
// Map our valve message uuid, back to a zone uuid
let associatedZone = undefined;
this.deviceData.zones.forEach((zone) => {
if (this.#zones[zone.uuid].valves.filter((valve) => valve?.uuid === message.uuid).length !== 0) {
associatedZone = zone;
}
});
if (message.status === Valve.Status.OPENED && this.#zones?.[associatedZone?.uuid] !== undefined) {
if (this.#zones[associatedZone.uuid].timer === undefined) {
if (typeof this.historyService?.addHistory === 'function') {
// Log zone opened to history service if present
this.historyService.addHistory(this.#zones[associatedZone.uuid].service, {
time: message.time,
status: 1,
water: 0,
duration: 0,
});
}
this?.log?.info && this.log.info('Zone "%s" was turned "on"', associatedZone.name);
}
}
if (message.status === Valve.Status.CLOSED && this.#zones?.[associatedZone?.uuid] !== undefined) {
this.lastValveClose = message.time;
this.#zones[associatedZone.uuid].totalwater = this.#zones[associatedZone.uuid].totalwater + message.water; // Add to running total for water usage
this.#zones[associatedZone.uuid].totalduration = this.#zones[associatedZone.uuid].totalduration + message.duration; // Add to running total for time
if (this.#zones[associatedZone.uuid].timer === undefined) {
// Since the zone doesn't not have an active runnign timer, we assume all valves associated with the zone have finished
if (typeof this.historyService?.addHistory === 'function') {
// Log zone closed to history service if present
this.historyService.addHistory(this.#zones[associatedZone.uuid].service, {
time: message.time,
status: 0,
water: this.#zones[associatedZone.uuid].totalwater,
duration: this.#zones[associatedZone.uuid].totalduration,
});
}
this?.log?.info && this.log.info('Zone "%s" was turned "off"', associatedZone.name);
}
}
}
}
#numberRunningZones() {
let activeZoneCount = 0;
Object.values(this.#zones).forEach((zone) => {
if (zone.service.getCharacteristic(this.hap.Characteristic.Active).value === this.hap.Characteristic.Active.ACTIVE) {
activeZoneCount++;
}
});
return activeZoneCount;
}
#numberEnabledZones() {
let enabledZoneCount = 0;
Object.values(this.#zones).forEach((zone) => {
if (zone.service.getCharacteristic(this.hap.Characteristic.IsConfigured).value === this.hap.Characteristic.IsConfigured.CONFIGURED) {
enabledZoneCount++;
}
});
return enabledZoneCount;
}
#processActiveCharacteristic(context, value, type) {
// workaround for using hey siri to turn on/off system and valves triggering active avents along with system active event
// Seems we get a system active event and valve events for all configured "enabled" valves when we ask siri to turn on/off
// the irrigation system. If we just turn on a valve and the system is not active, we get a "system" and "valve" event
this.activeCheck.push({
context: context,
value: value,
type: type,
takeaction: true,
});
if (this.activeCheckTimer === undefined) {
this.activeCheckTimer = setTimeout(() => {
let systemCount = this.activeCheck.filter(({ type }) => type === 'system' || type === 'switch').length;
let valveCount = this.activeCheck.filter(({ type }) => type === 'valve').length;
this.activeCheck.forEach((activeCheck) => {
// Filter out events we don't want to action
if (activeCheck.type === 'system' && valveCount === 1) {
// Turned on valve when system was off (inactive)
activeCheck.takeaction = false;
}
if (activeCheck.type === 'valve' && systemCount === 1 && this.#numberEnabledZones() === valveCount) {
// Siri action to turn on/off irrigation system, so make all valve actions as false
activeCheck.takeaction = false;
}
// Process callbacks
if ((activeCheck.type === 'system' || activeCheck.type === 'switch') && activeCheck.takeaction === true) {
this.setPower(activeCheck.value, activeCheck.callback);
} else if ((activeCheck.type === 'system' || activeCheck.type === 'switch') && activeCheck.takeaction === false) {
//activeCheck.callback(null); // process HomeKit callback without taking action
}
if (activeCheck.type === 'valve' && activeCheck.takeaction === true) {
this.setZoneActive(activeCheck.context, activeCheck.value);
} else if (activeCheck.type === 'valve' && activeCheck.takeaction === false) {
// Workaround for active state of valves going to "waiting" when we don't want them to straight after the callback to HomeKit
setTimeout(() => {
activeCheck.context.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
activeCheck.context.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
activeCheck.context.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}, 500); // Maybe able to reduce this from 500ms???
}
});
clearTimeout(this.activeCheckTimer);
this.activeCheck = [];
this.activeCheckTimer = undefined;
}, 500);
}
}
#EveHomeGetCommand(EveHomeGetData) {
// Pass back extra data for Eve Aqua onGet() to process command
// Data will already be an object, our only job is to add/modify it
EveHomeGetData.flowrate = this.deviceData.flowrate;
EveHomeGetData.programs = this.deviceData.programs.schedules;
EveHomeGetData.enableschedule = this.deviceData.programs.enabled === true;
EveHomeGetData.latitude = this.deviceData.latitude;
EveHomeGetData.longitude = this.deviceData.longitude;
EveHomeGetData.pause =
this.deviceData.pauseTimeout !== 0 ? Math.round((this.deviceData.pauseTimeout - Math.floor(Date.now() / 1000)) / 86400) : 0;
return EveHomeGetData;
}
#EveHomeSetCommand(EveHomeSetData) {
if (typeof EveHomeSetData !== 'object') {
return;
}
if (EveHomeSetData?.pause !== undefined) {
// EveHome suspension scene triggered from HomeKit
// 1 day = pause for today
// 2 day = pause for today and tomorrow
// get remaining seconds to midnight in our timezone (as date.now() is GMT time), then work out delay
this.deviceData.pauseTimeout = Math.floor(
Math.floor(Date.now() / 1000) +
((8.64e7 - ((Date.now() - new Date().getTimezoneOffset() * 6e4) % 8.64e7)) / 6e4) * 60 +
(EveHomeSetData.pause - 1) * 86400,
);
this?.log?.warn && this.log.warn('Watering has been paused for "%s"', EveHomeSetData.pause === 1 ? 'today' : 'today and tomorrow');
if (this.irrigationService.getCharacteristic(this.hap.Characteristic.Active).value === true) {
this.setPower(false); // Turn off irrigation system
}
}
if (EveHomeSetData?.flowrate !== undefined) {
// Updated flowrate from Eve Home app
this.deviceData.flowrate = EveHomeSetData.flowrate;
}
if (EveHomeSetData?.enable !== undefined) {
// Schedules enabled or not
this.deviceData.programs.enabled = EveHomeSetData.enabled;
}
if (EveHomeSetData?.programs !== undefined) {
// Watering schedules
this.deviceData.programs.schedules = EveHomeSetData.programs;
}
if (EveHomeSetData?.latitude !== undefined) {
// Latitude information
this.deviceData.latitude = EveHomeSetData.latitude;
}
if (EveHomeSetData?.longitude !== undefined) {
// Longitude information
this.deviceData.longitude = EveHomeSetData.longitude;
}
// Save any updated configurations
this.set({ pauseTimeout: this.deviceData.pauseTimeout });
this.set({
options: {
flowRate: this.deviceData.flowRate,
latitude: this.deviceData.latitude,
longitude: this.deviceData.longitude,
},
});
this.set({
programs: {
enabled: this.deviceData.programs.enabled,
schedules: this.deviceData.programs.schedules,
},
});
}
}
// General helper functions which don't need to be part of an object class
function crc32(valueToHash) {
let crc32HashTable = [
0x000000000, 0x077073096, -0x11f19ed4, -0x66f6ae46, 0x0076dc419, 0x0706af48f, -0x169c5acb, -0x619b6a5d, 0x00edb8832, 0x079dcb8a4,
-0x1f2a16e2, -0x682d2678, 0x009b64c2b, 0x07eb17cbd, -0x1847d2f9, -0x6f40e26f, 0x01db71064, 0x06ab020f2, -0xc468eb8, -0x7b41be22,
0x01adad47d, 0x06ddde4eb, -0xb2b4aaf, -0x7c2c7a39, 0x0136c9856, 0x0646ba8c0, -0x29d0686, -0x759a3614, 0x014015c4f, 0x063066cd9,
-0x5f0c29d, -0x72f7f20b, 0x03b6e20c8, 0x04c69105e, -0x2a9fbe1c, -0x5d988e8e, 0x03c03e4d1, 0x04b04d447, -0x2df27a03, -0x5af54a95,
0x035b5a8fa, 0x042b2986c, -0x2444362a, -0x534306c0, 0x032d86ce3, 0x045df5c75, -0x2329f231, -0x542ec2a7, 0x026d930ac, 0x051de003a,
-0x3728ae80, -0x402f9eea, 0x021b4f4b5, 0x056b3c423, -0x30456a67, -0x47425af1, 0x02802b89e, 0x05f058808, -0x39f3264e, -0x4ef416dc,
0x02f6f7c87, 0x058684c11, -0x3e9ee255, -0x4999d2c3, 0x076dc4190, 0x001db7106, -0x672ddf44, -0x102aefd6, 0x071b18589, 0x006b6b51f,
-0x60401b5b, -0x17472bcd, 0x07807c9a2, 0x00f00f934, -0x69f65772, -0x1ef167e8, 0x07f6a0dbb, 0x0086d3d2d, -0x6e9b9369, -0x199ca3ff,
0x06b6b51f4, 0x01c6c6162, -0x7a9acf28, -0xd9dffb2, 0x06c0695ed, 0x01b01a57b, -0x7df70b3f, -0xaf03ba9, 0x065b0d9c6, 0x012b7e950,
-0x74414716, -0x3467784, 0x062dd1ddf, 0x015da2d49, -0x732c830d, -0x42bb39b, 0x04db26158, 0x03ab551ce, -0x5c43ff8c, -0x2b44cf1e,
0x04adfa541, 0x03dd895d7, -0x5b2e3b93, -0x2c290b05, 0x04369e96a, 0x0346ed9fc, -0x529877ba, -0x259f4730, 0x044042d73, 0x033031de5,
-0x55f5b3a1, -0x22f28337, 0x05005713c, 0x0270241aa, -0x41f4eff0, -0x36f3df7a, 0x05768b525, 0x0206f85b3, -0x46992bf7, -0x319e1b61,
0x05edef90e, 0x029d9c998, -0x4f2f67de, -0x3828574c, 0x059b33d17, 0x02eb40d81, -0x4842a3c5, -0x3f459353, -0x12477ce0, -0x65404c4a,
0x003b6e20c, 0x074b1d29a, -0x152ab8c7, -0x622d8851, 0x004db2615, 0x073dc1683, -0x1c9cf4ee, -0x6b9bc47c, 0x00d6d6a3e, 0x07a6a5aa8,
-0x1bf130f5, -0x6cf60063, 0x00a00ae27, 0x07d079eb1, -0xff06cbc, -0x78f75c2e, 0x01e01f268, 0x06906c2fe, -0x89da8a3, -0x7f9a9835,
0x0196c3671, 0x06e6b06e7, -0x12be48a, -0x762cd420, 0x010da7a5a, 0x067dd4acc, -0x6462091, -0x71411007, 0x017b7be43, 0x060b08ed5,
-0x29295c18, -0x5e2e6c82, 0x038d8c2c4, 0x04fdff252, -0x2e44980f, -0x5943a899, 0x03fb506dd, 0x048b2364b, -0x27f2d426, -0x50f5e4b4,
0x036034af6, 0x041047a60, -0x209f103d, -0x579820ab, 0x0316e8eef, 0x04669be79, -0x349e4c74, -0x43997ce6, 0x0256fd2a0, 0x05268e236,
-0x33f3886b, -0x44f4b8fd, 0x0220216b9, 0x05505262f, -0x3a45c442, -0x4d42f4d8, 0x02bb45a92, 0x05cb36a04, -0x3d280059, -0x4a2f30cf,
0x02cd99e8b, 0x05bdeae1d, -0x649b3d50, -0x139c0dda, 0x0756aa39c, 0x0026d930a, -0x63f6f957, -0x14f1c9c1, 0x072076785, 0x005005713,
-0x6a40b57e, -0x1d4785ec, 0x07bb12bae, 0x00cb61b38, -0x6d2d7165, -0x1a2a41f3, 0x07cdcefb7, 0x00bdbdf21, -0x792c2d2c, -0xe2b1dbe,
0x068ddb3f8, 0x01fda836e, -0x7e41e933, -0x946d9a5, 0x06fb077e1, 0x018b74777, -0x77f7a51a, -0xf09590, 0x066063bca, 0x011010b5c,
-0x709a6101, -0x79d5197, 0x0616bffd3, 0x0166ccf45, -0x5ff51d88, -0x28f22d12, 0x04e048354, 0x03903b3c2, -0x5898d99f, -0x2f9fe909,
0x04969474d, 0x03e6e77db, -0x512e95b6, -0x2629a524, 0x040df0b66, 0x037d83bf0, -0x564351ad, -0x2144613b, 0x047b2cf7f, 0x030b5ffe9,
-0x42420de4, -0x35453d76, 0x053b39330, 0x024b4a3a6, -0x452fc9fb, -0x3228f96d, 0x054de5729, 0x023d967bf, -0x4c9985d2, -0x3b9eb548,
0x05d681b02, 0x02a6f2b94, -0x4bf441c9, -0x3cf3715f, 0x05a05df1b, 0x02d02ef8d,
];
let crc32 = 0xffffffff; // init crc32 hash;
valueToHash = Buffer.from(valueToHash); // convert value into buffer for processing
for (let index = 0; index < valueToHash.length; index++) {
crc32 = (crc32HashTable[(crc32 ^ valueToHash[index]) & 0xff] ^ (crc32 >>> 8)) & 0xffffffff;
}
crc32 ^= 0xffffffff;
return crc32 >>> 0; // return crc32
}