irrigationsystem
Version:
HomeKit irrigation system using HAP-NodeJS library
1,356 lines (1,105 loc) • 92.2 kB
JavaScript
// IrrigationSystem
// Part of irrigationsystem project
//
// Implements a HomeKit-enabled irrigation controller using HAP-NodeJS.
// Manages irrigation zones, water tanks, flow sensing, leak detection,
// and dashboard rendering for HomeKitUI.
//
// Responsibilities:
// - Create and manage HomeKit IrrigationSystem service and linked Valve services
// - Manage irrigation zones (single or multi-relay valves)
// - Control zone runtime, sequencing, and scheduling via central timer loop
// - Track real-time flow rate and water usage per active zone
// - Aggregate historical water usage and duration per zone
// - Integrate ultrasonic water tank level sensors and expose combined level
// - Detect potential water leaks using rolling flow analysis
// - Maintain HomeKit state synchronisation for system and zones
// - Provide EveHome-compatible data via message bus (GET/SET)
// - Render dashboard UI (Active Zone + Tanks) for HomeKitUI module
//
// Architecture:
// - Extends HomeKitDevice base class (message-driven lifecycle)
// - Uses Valve class for GPIO control and flow sensing
// - Uses WaterTank class for ultrasonic level measurement
// - Uses centralised timers (pauseCheck, zoneCheck) instead of per-zone intervals
// - Uses internal zone registry (#zones) for runtime state tracking
//
// Runtime Model:
// - Only one zone can be active at a time
// - Zone runtime handled via shared 1Hz timer (#processZoneTimer)
// - Multi-relay zones split runtime evenly across valves
// - Flow sensor events drive live flowRate and waterUsed tracking
// - Valve events used for state sync, totals, and leak timing
//
// Leak Detection:
// - Maintains rolling flow buffer (FLOW_DATA_BUFFER)
// - Detects sustained flow when no zones are running
// - Applies grace period after valve close (WATER_LEAK_TIMEOUT)
// - Updates HomeKit LeakSensor and history accordingly
//
// Notes:
// - HomeKit state must be updated from ALL paths (timer, valve events, manual)
// to avoid desynchronisation ("waiting" state issues)
// - Flow-based tracking is authoritative during timed runs
// - Valve-reported totals only used for manual runs to avoid double counting
//
// Mark Hulskamp
'use strict';
// Define external module requirements
import GPIO from 'rpio';
// Define nodejs module requirements
import { setTimeout, clearTimeout } from 'node:timers';
// Import our modules
import HomeKitDevice from './HomeKitDevice.js';
import WaterTank from './watertank.js';
import Valve from './valve.js';
import FlowSensor from './flowsensor.js';
import { crc32 } from './utils.js';
// Define constants
const WATER_LEAK_TIMEOUT = 10000; // Milliseconds after a water valve is closed before we can report on any water leak
// Setup GPIO library for our device classes that require it
// This allows the classes to use GPIO functions without needing to import or initialise the library themselves.
// The library is initialised once in the main system class and then assigned to the static GPIO property of each class that needs it.
Valve.GPIO = GPIO;
FlowSensor.GPIO = GPIO;
WaterTank.GPIO = GPIO;
export default class IrrigationSystem extends HomeKitDevice {
static TYPE = 'IrrigationSystem';
static VERSION = '2026.05.13';
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
activeCheck = [];
activeCheckTimer = undefined;
leakDetected = false; // No Water leak detected yet
// Internal data only for this class
#tanks = {}; // Object for tanks we actually created
#zones = {}; // Object for zones we actually created
#flowSensor = undefined; // Flow sensor instance
#lastFlowRate = 0; // Last water flow rate
#lastFlowTime = 0; // Last time water flowed
#unassignedWaterUsed = 0; // Track water
constructor(accessory, api, deviceData) {
super(accessory, api, 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' });
}
// Class functions
onAdd() {
// Create / get primary irrigation system service and register message handler
this.irrigationService = this.addService(this.hap.Service.IrrigationSystem, '', 1, {
messages: this.message.bind(this),
});
this.irrigationService.setPrimaryService();
// Sync initial power state to HomeKit
this.irrigationService.updateCharacteristic(
this.hap.Characteristic.Active,
this.deviceData.power === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE,
);
// Active characteristic (system on/off)
this.addCharacteristic(this.irrigationService, this.hap.Characteristic.Active, {
onSet: (value) => {
this.#processActiveCharacteristic(this.irrigationService, value, 'system');
},
onGet: () => {
return this.deviceData.power === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE;
},
});
// Optional virtual power switch (linked service)
if (this.deviceData?.powerSwitch === true) {
this.#setupPowerSwitch(this.deviceData, true);
}
// Create water tanks (if configured)
this.#setupWaterTanks(this.deviceData, true);
Object.values(this.#tanks).forEach((tank) => {
this.postSetupDetail('Watertank "%s" with "%s" Litres', tank.config.name, tank.config.capacity);
});
// Create irrigation zones
this.#setupZones(this.deviceData, true);
Object.values(this.#zones).forEach((zone) => {
this.postSetupDetail('Zone "%s"', zone.config.name, zone.config.enabled === false ? '(disabled)' : '');
});
// Create flow sensor if configured.
// FlowSensor owns GPIO polling, flow calculation, and leak detection.
// IrrigationSystem only consumes FLOW_EVENT and LEAK_EVENT messages.
if (this.deviceData?.sensorFlowPin !== undefined) {
this.#setupFlowSensor(this.deviceData, true);
}
// Setup optional HomeKit leak sensor
if (this.#flowSensor !== undefined && this.deviceData?.leakSensor === true) {
this.#setupLeakSensor(this.deviceData, true);
}
// Register timers
this.addTimer('pauseCheck', { interval: 5000 }); // Resume watering after pause
this.addTimer('zoneCheck', { interval: 1000 }); // Centralised zone runtime handler
// Setup summary logging
this.switchService !== undefined && this.postSetupDetail('Virtual power switch');
this.#flowSensor !== undefined && this.postSetupDetail('Water flow sensor');
this.leakSensorService !== undefined &&
this.postSetupDetail('Leak sensor', this.deviceData?.waterLeakAlert === true ? 'with alerting' : 'with no alerting');
}
onUpdate(deviceData = {}) {
if (typeof deviceData !== 'object' || deviceData === null) {
return;
}
this.#setupPowerSwitch(deviceData);
this.#setupWaterTanks(deviceData);
this.#setupZones(deviceData);
this.#setupFlowSensor(deviceData);
this.#setupLeakSensor(deviceData);
// Updated changes to HomeKit water leak alerting
if (this.#flowSensor !== undefined && Object.hasOwn(deviceData, 'waterLeakAlert') === true) {
let waterLeakAlert = deviceData.waterLeakAlert === true;
if (this.leakSensorService !== undefined && waterLeakAlert !== this.deviceData.waterLeakAlert) {
this?.log?.info?.('Leak sensor', waterLeakAlert === true ? 'with alerting' : 'with no alerting');
}
}
}
async onTimer(message = {}) {
if (message?.timer === 'pauseCheck') {
if (this.deviceData.pauseTimeout !== 0 && Math.floor(Date.now() / 1000) >= this.deviceData.pauseTimeout) {
this.setPower(true);
this.log?.success?.('Watering has resumed after being paused for a period');
}
return;
}
if (message?.timer === 'zoneCheck') {
// Shared 1Hz zone runtime handler.
// Each active zone stores its own endTime and valve state; this timer only
// advances those zones and avoids creating one setInterval per zone run.
this.deviceData.zones.forEach((zone) => {
if (this.#zones?.[zone?.uuid]?.endTime !== undefined) {
this.#processZoneTimer(zone);
}
});
}
}
async onMessage(type, message = {}) {
if (typeof type !== 'string' || type === '') {
return;
}
if (type === WaterTank.WATERLEVEL_EVENT) {
this.#handleWaterLevelEvent(message);
}
if (type === FlowSensor.FLOW_EVENT) {
this.#handleFlowEvent(message);
}
if (type === FlowSensor.LEAK_EVENT) {
this.#handleLeakEvent(message);
}
if (type === Valve.VALVE_EVENT) {
this.#handleValveEvent(message);
}
if (HomeKitDevice.EVEHOME !== undefined && type === HomeKitDevice.EVEHOME.GET && typeof message === 'object' && message !== null) {
// 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
message.flowrate = this.deviceData.flowRate;
message.programs = this.deviceData.programs.schedules;
message.enableschedule = this.deviceData.programs.enabled === true;
message.latitude = this.deviceData.latitude;
message.longitude = this.deviceData.longitude;
message.pause =
this.deviceData.pauseTimeout !== 0 ? Math.round((this.deviceData.pauseTimeout - Math.floor(Date.now() / 1000)) / 86400) : 0;
return message;
}
if (HomeKitDevice.EVEHOME !== undefined && type === HomeKitDevice.EVEHOME.SET && typeof message === 'object' && message !== null) {
if (message?.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 +
(message.pause - 1) * 86400,
);
this?.log?.warn && this.log.warn('Watering has been paused for "%s"', message.pause === 1 ? 'today' : 'today and tomorrow');
if (this.irrigationService.getCharacteristic(this.hap.Characteristic.Active).value === true) {
this.setPower(false); // Turn off irrigation system
}
}
if (message?.flowrate !== undefined) {
// Updated flowrate from Eve Home app
this.deviceData.flowRate = message.flowrate;
}
if (message?.enabled !== undefined) {
// Schedules enabled or not
this.deviceData.programs.enabled = message.enabled === true;
}
if (message?.programs !== undefined) {
// Watering schedules
this.deviceData.programs.schedules = message.programs;
}
if (message?.latitude !== undefined) {
// Latitude information
this.deviceData.latitude = message.latitude;
}
if (message?.longitude !== undefined) {
// Longitude information
this.deviceData.longitude = message.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,
},
});
}
}
setPower(value) {
// Normalise incoming power state.
// Supports HomeKit Active enum, booleans, and legacy string values.
let power =
value === 'on' || value === 'ON' || value === true || value === this.hap.Characteristic.Active.ACTIVE
? true
: value === 'off' || value === 'OFF' || value === false || value === this.hap.Characteristic.Active.INACTIVE
? false
: undefined;
if (power === undefined) {
return;
}
// Turning the system off should gracefully stop any active zones first.
if (power === false && Array.isArray(this.deviceData?.zones) === true) {
this.deviceData.zones.forEach((zone) => {
if (this.#zones?.[zone?.uuid]?.run !== undefined) {
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
});
}
// Update internal state.
this.deviceData.power = power;
// Reflect system power state in HomeKit.
this.irrigationService.updateCharacteristic(
this.hap.Characteristic.Active,
power === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE,
);
this.irrigationService.updateCharacteristic(
this.hap.Characteristic.ProgramMode,
power === true
? this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED_MANUAL_MODE
: this.hap.Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED,
);
this.irrigationService.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
// Keep optional virtual power switch in sync.
this.switchService?.updateCharacteristic?.(this.hap.Characteristic.On, power === true);
// Save updated current configuration.
this.set({ power: this.deviceData.power });
this?.log?.info?.('Irrigation system was turned "%s"', power === true ? 'on' : 'off');
}
setZoneName(zone, value) {
if (
typeof zone !== 'object' ||
typeof value !== 'string' ||
value.trim() === '' ||
typeof this.#zones?.[zone?.uuid]?.service !== 'object'
) {
return;
}
// Normalise name (trim whitespace)
let name = value.trim();
// Nothing changed
if (name === zone.name) {
return;
}
this?.log?.debug?.('Setting irrigation zone name from "%s" to "%s"', zone.name, name);
// Persist internal name
zone.name = name;
// Keep stored zone config in sync
if (this.#zones?.[zone.uuid]?.config !== undefined) {
this.#zones[zone.uuid].config.name = name;
}
// Reflect updated name in HomeKit
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, name);
// Save updated 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;
}
// Normalise incoming value to a boolean for internal use
let enabled = value === true || value === this.hap.Characteristic.IsConfigured.CONFIGURED;
this?.log?.debug &&
this.log.debug(
'Setting irrigation zone "%s" status from "%s" to "%s"',
zone.name,
zone.enabled === true ? 'Enabled' : 'Disabled',
enabled === true ? 'Enabled' : 'Disabled',
);
// If disabling a zone while it is currently active, stop it first
if (enabled === false) {
if (this.#zones?.[zone?.uuid]?.run !== undefined) {
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
}
// Persist internal enabled state
zone.enabled = enabled;
// Reflect the new state back into HomeKit
this.#zones[zone.uuid].service.updateCharacteristic(
this.hap.Characteristic.IsConfigured,
enabled === true ? this.hap.Characteristic.IsConfigured.CONFIGURED : this.hap.Characteristic.IsConfigured.NOT_CONFIGURED,
);
// Persist updated configuration
this.set({ zone: zone });
}
setZoneRuntime(zone, value) {
if (typeof zone !== 'object' || Number.isFinite(Number(value)) !== true || typeof this.#zones?.[zone?.uuid] !== 'object') {
return;
}
// Clamp runtime to configured maximum before storing or reflecting it.
let runtime = Math.min(Number(value), this.deviceData.maxRuntime);
this?.log?.debug?.('Setting irrigation zone "%s", runtime from "%s" to "%s" seconds', zone.name, zone.runtime, runtime);
// Persist internal runtime.
zone.runtime = runtime;
// Reflect updated runtime in HomeKit.
this.#zones[zone.uuid].service.updateCharacteristic(this.hap.Characteristic.SetDuration, runtime);
// 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'
) {
return;
}
let zoneData = this.#zones[zone.uuid];
let currentZone = this.deviceData?.zones?.find((item) => item?.uuid === zone.uuid);
let activeZone = currentZone ?? zoneData.config ?? zone;
let wantsStart = value === this.hap.Characteristic.Active.ACTIVE || value === true;
let wantsStop = value === this.hap.Characteristic.Active.INACTIVE || value === false;
let zoneEnabled = activeZone?.enabled === true;
if (wantsStart === true && zoneEnabled !== true) {
this?.log?.warn?.('Ignored request to start disabled zone "%s"', activeZone.name);
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
return;
}
if (this.deviceData.power === true && wantsStart === true) {
// Request to turn on sprinkler and the irrigation system is powered on.
// Already running -> ignore duplicate start
if (zoneData.run !== undefined) {
return;
}
// Only one zone is allowed to run at a time, so stop any currently active zone first.
let runningZone = Object.entries(this.#zones).find(([uuid, data]) => {
return uuid !== zone.uuid && data.run !== undefined;
});
if (runningZone !== undefined) {
let runningZoneUUID = runningZone[0];
if (runningZoneUUID !== zone.uuid) {
let activeZone = this.deviceData.zones.find((z) => z?.uuid === runningZoneUUID);
if (activeZone !== undefined) {
this.setZoneActive(activeZone, this.hap.Characteristic.Active.INACTIVE);
}
}
}
// Reset sequencing state
zoneData.activeValveIndex = 0;
let duration = Number(zoneData.service.getCharacteristic(this.hap.Characteristic.SetDuration).value);
// Set timer FIRST
zoneData.endTime = Date.now() + duration * 1000;
// Create run immediately (removes race condition)
zoneData.run = {
startTime: Date.now(),
duration: duration,
lastValveCloseTime: 0,
flowRate: 0,
waterUsed: 0,
type: 'timed',
};
// Flow is now expected, so suppress leak detection while watering.
this.#flowSensor?.markExpectedFlow?.(true);
this.history(zoneData.service, {
time: Math.floor(zoneData.run.startTime / 1000),
status: 1,
water: 0,
duration: 0,
});
this?.log?.info?.('Zone "%s" was turned "on"', activeZone.name);
// Update HomeKit BEFORE opening valves
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.ACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, duration);
// Open first valve only (force clean state)
if (Array.isArray(zoneData.valves) === true && zoneData.valves.length > 0) {
zoneData.valves.forEach((valve, index) => {
if (index === 0) {
valve.open();
} else if (valve.isOpen() === true) {
valve.close();
}
});
}
return;
}
if (this.deviceData.power === false && wantsStart === true) {
// Request to turn on sprinkler while irrigation system is powered off.
setTimeout(() => {
zoneData.run = undefined;
zoneData.endTime = undefined;
zoneData.activeValveIndex = 0;
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}, 500);
return;
}
if (wantsStop === true) {
// Request to turn off sprinkler.
zoneData.endTime = undefined;
zoneData.activeValveIndex = 0;
zoneData.valves.forEach((valve) => {
if (valve.isOpen() === true) {
valve.close();
}
});
// Safety fallback
setTimeout(() => {
if (zoneData.run === undefined) {
return;
}
let anyOpen = zoneData.valves.some((v) => v.isOpen() === true);
if (anyOpen === true) {
this?.log?.warn?.('Zone "%s" did not report all valves closed after stop request; forcing HomeKit state idle', activeZone.name);
}
// If the valve close event did not complete cleanup, end the logical run
// so continued flow is treated as unexpected/leak flow instead of watering.
zoneData.run = undefined;
zoneData.endTime = undefined;
zoneData.activeValveIndex = 0;
this.#flowSensor?.markExpectedFlow?.(false);
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}, 2000);
}
}
#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);
} else if ((activeCheck.type === 'system' || activeCheck.type === 'switch') && activeCheck.takeaction === false) {
// Empty. HomeKit has already been updated, but no action is required.
}
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(() => {
let zoneData = this.#zones?.[activeCheck.context?.uuid];
if (zoneData?.service !== undefined) {
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}
}, 500); // Maybe able to reduce this from 500ms???
}
});
clearTimeout(this.activeCheckTimer);
this.activeCheck = [];
this.activeCheckTimer = undefined;
}, 500);
}
}
#processZoneTimer(zone) {
let zoneData = this.#zones?.[zone?.uuid];
// Must have an active run AND a valid endTime (timed run only).
if (typeof zoneData !== 'object' || zoneData.run === undefined || Number.isFinite(Number(zoneData.endTime)) !== true) {
return;
}
let now = Date.now();
let runtime =
Number.isFinite(Number(zoneData.run?.duration)) === true
? Number(zoneData.run.duration)
: Number(zoneData.service.getCharacteristic(this.hap.Characteristic.SetDuration).value);
let valveCount = Array.isArray(zoneData.valves) === true ? zoneData.valves.length : 0;
if (now < zoneData.endTime) {
zoneData.service.updateCharacteristic(
this.hap.Characteristic.RemainingDuration,
Math.max(0, Math.floor((zoneData.endTime - now) / 1000)),
);
if (valveCount > 1 && runtime > 0) {
if (Number.isFinite(Number(zoneData.activeValveIndex)) !== true) {
zoneData.activeValveIndex = 0;
}
let runtimeMS = runtime * 1000;
let elapsed = now - zoneData.run.startTime;
if (elapsed < 0) {
elapsed = 0;
}
if (elapsed > runtimeMS) {
elapsed = runtimeMS;
}
let slice = runtimeMS / valveCount;
if (slice <= 0) {
return;
}
let valveIndex = Math.min(valveCount - 1, Math.floor(elapsed / slice));
if (valveIndex !== zoneData.activeValveIndex) {
let previousIndex = zoneData.activeValveIndex;
zoneData.activeValveIndex = valveIndex;
// Close previous valve FIRST. Only one valve can ever be open.
if (zoneData.valves[previousIndex]?.isOpen() === true) {
zoneData.valves[previousIndex].close();
}
// Then open target valve.
if (zoneData.valves[valveIndex]?.isOpen() === false) {
zoneData.valves[valveIndex].open();
}
}
// Hard safety sync.
zoneData.valves.forEach((valve, index) => {
if (index !== zoneData.activeValveIndex && valve.isOpen() === true) {
valve.close();
}
});
if (zoneData.valves[zoneData.activeValveIndex]?.isOpen() === false) {
zoneData.valves[zoneData.activeValveIndex].open();
}
}
return;
}
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
#handleWaterLevelEvent(message = {}) {
if (
typeof message !== 'object' ||
message === null ||
typeof message?.uuid !== 'string' ||
typeof this.#tanks?.[message.uuid] !== 'object'
) {
return;
}
// Water tank level event.
// WaterTank already smooths the reading before emitting this event, so store
// the latest reading from the message and then aggregate all known tank levels.
if (Number.isFinite(Number(message?.waterlevel)) === true) {
this.#tanks[message.uuid].waterlevel = Number(message.waterlevel);
}
if (Number.isFinite(Number(message?.percentage)) === true) {
this.#tanks[message.uuid].percentage = Number(message.percentage);
}
this.#tanks[message.uuid].lastUpdated = Date.now();
let totalPercentage = 0;
Object.values(this.#tanks).forEach((tank) => {
if (Number.isFinite(Number(tank?.percentage)) === true) {
totalPercentage = totalPercentage + Number(tank.percentage);
}
});
// HomeKit WaterLevel is a single percentage value, so clamp the combined level
// to 0-100 even when multiple tanks are configured.
totalPercentage = Math.max(0, Math.min(100, totalPercentage));
this.irrigationService.updateCharacteristic(this.hap.Characteristic.WaterLevel, totalPercentage);
// Log water level history against the WaterLevel characteristic type.
this.history(this.irrigationService, { time: Math.floor(Date.now() / 1000), level: totalPercentage }, { timegap: 600 });
}
#handleFlowEvent(message = {}) {
if (typeof message !== 'object' || message === null || Number.isFinite(Number(message?.time)) !== true) {
return;
}
// Store latest system-wide flow so the dashboard can show flow even when
// no zone is active, such as during leak detection/manual valve opening.
let flowRate = Number.isFinite(Number(message?.rate)) === true ? Number(message.rate) : 0;
let flowVolume = Number.isFinite(Number(message?.volume)) === true ? Number(message.volume) : 0;
let activeRun = false;
let isLeakGracePeriod =
Number.isFinite(Number(this.lastValveClose)) === true &&
this.lastValveClose > 0 &&
Date.now() - Number(this.lastValveClose) <= WATER_LEAK_TIMEOUT;
this.#lastFlowRate = flowRate;
this.#lastFlowTime = Number(message.time);
// Track live flow against ACTIVE runs.
Object.values(this.#zones || {}).forEach((zone) => {
if (zone?.run !== undefined) {
activeRun = true;
zone.run.flowRate = flowRate;
zone.run.waterUsed = zone.run.waterUsed + flowVolume;
}
});
// Track flow that is not assigned to a zone.
// This covers leak/manual valve flow while no zone is running.
// Do not count residual flow immediately after a normal zone closes.
if (activeRun === false && isLeakGracePeriod === false && flowRate > 0 && flowVolume > 0) {
this.#unassignedWaterUsed = this.#unassignedWaterUsed + flowVolume;
}
// Reset stale unassigned usage after flow has stopped.
if (activeRun === false && flowRate === 0) {
this.#unassignedWaterUsed = 0;
}
}
#handleLeakEvent(message = {}) {
if (
typeof message !== 'object' ||
message === null ||
Number.isFinite(Number(message?.time)) !== true ||
(message.status !== 0 && message.status !== 1)
) {
return;
}
if (this.leakSensorService === undefined) {
return;
}
if (message.status === 1 && this.leakDetected === false) {
this.leakDetected = true;
this.history(this.leakSensorService, { time: Math.floor(Number(message.time) / 1000), status: 1 });
if (this.deviceData.waterLeakAlert === true) {
this.leakSensorService.updateCharacteristic(
this.hap.Characteristic.LeakDetected,
this.hap.Characteristic.LeakDetected.LEAK_DETECTED,
);
}
this?.log?.warn?.('Detected suspected water leak on irrigation system');
}
if (message.status === 0 && this.leakDetected === true) {
this.leakDetected = false;
this.history(this.leakSensorService, { time: Math.floor(Number(message.time) / 1000), status: 0 });
this.leakSensorService.updateCharacteristic(
this.hap.Characteristic.LeakDetected,
this.hap.Characteristic.LeakDetected.LEAK_NOT_DETECTED,
);
this?.log?.success?.('Suspected water leak no longer detected on irrigation system');
}
}
#handleValveEvent(message = {}) {
if (
typeof message !== 'object' ||
message === null ||
typeof message?.uuid !== 'string' ||
message.uuid === '' ||
Number.isFinite(Number(message?.time)) !== true ||
(message.status !== Valve.OPENED && message.status !== Valve.CLOSED)
) {
return;
}
let associatedZone = undefined;
this.deviceData.zones.forEach((zone) => {
if (this.#zones?.[zone.uuid]?.valves?.some((valve) => valve?.uuid === message.uuid) === true) {
associatedZone = zone;
}
});
if (this.#zones?.[associatedZone?.uuid] === undefined) {
return;
}
let zoneData = this.#zones[associatedZone.uuid];
if (message.status === Valve.OPENED) {
if (zoneData.run === undefined && associatedZone?.enabled !== true) {
let openedValve = zoneData.valves.find((valve) => valve?.uuid === message.uuid);
this?.log?.warn?.('Closing disabled zone "%s" after unexpected valve open event', associatedZone.name);
openedValve?.close?.();
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
return;
}
// First valve opening starts the run.
// Timed runs are normally created by setZoneActive() before the valve is opened.
// If no run exists here, treat it as a manual/physical valve activation fallback.
if (zoneData.run === undefined) {
// Determine run type ONLY based on whether a timer exists
let isTimed = Number.isFinite(Number(zoneData.endTime)) === true;
zoneData.run = {
startTime: message.time,
lastValveCloseTime: 0,
flowRate: 0,
waterUsed: 0,
type: isTimed === true ? 'timed' : 'manual',
};
this.history(zoneData.service, {
time: Math.floor(message.time / 1000),
status: 1,
water: 0,
duration: 0,
});
this?.log?.info?.('Zone "%s" was turned "on"', associatedZone.name);
}
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.ACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.IN_USE);
}
if (message.status === Valve.CLOSED) {
if (zoneData.run === undefined) {
return;
}
zoneData.run.lastValveCloseTime = message.time;
this.lastValveClose = message.time;
// If another valve is still open, this is only a relay change.
let anotherValveOpen = zoneData.valves.some((valve) => {
return valve?.uuid !== message.uuid && valve.isOpen() === true;
});
if (anotherValveOpen === true) {
return;
}
// TIMED RUN -> timer owns lifecycle while endTime is still in the future.
if (zoneData.run.type === 'timed' && Number.isFinite(Number(zoneData.endTime)) === true && Date.now() < zoneData.endTime) {
return;
}
let totalDuration = Math.max(0, Number(message.time) - Number(zoneData.run.startTime)) / 1000;
let totalWater = zoneData.run.waterUsed;
// Format duration (mm:ss)
let minutes = Math.floor(totalDuration / 60);
let seconds = Math.floor(totalDuration % 60);
let durationText = minutes + 'm ' + seconds + 's';
// Format water (litres)
let waterText =
Number.isFinite(Number(totalWater)) === true && totalWater > 0 ? Math.round(totalWater).toLocaleString() + 'L' : undefined;
this.history(zoneData.service, {
time: Math.floor(message.time / 1000),
status: 0,
water: totalWater,
duration: totalDuration,
});
// Enhanced logging
this?.log?.info?.(
waterText !== undefined ? 'Zone "%s" was turned "off" after %s using %s' : 'Zone "%s" was turned "off" after %s',
associatedZone.name,
durationText,
...(waterText !== undefined ? [waterText] : []),
);
// Flow is no longer expected, so start leak detection grace window.
this.#flowSensor?.markExpectedFlow?.(false);
zoneData.run = undefined;
zoneData.endTime = undefined;
zoneData.activeValveIndex = 0;
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}
}
#setupPowerSwitch(deviceData = {}, atStart = false) {
if (deviceData?.powerSwitch === true) {
if (this.switchService === undefined) {
this.switchService = this.addService(this.hap.Service.Switch, '', 1);
// HomeKit service name.
this.addCharacteristic(this.switchService, this.hap.Characteristic.Name);
this.switchService.updateCharacteristic(this.hap.Characteristic.Name, '');
this.addCharacteristic(this.switchService, this.hap.Characteristic.On, {
onSet: (value) => {
if (value !== this.deviceData.power) {
this.setPower(value);
}
},
onGet: () => {
return this.deviceData.power === true;
},
});
if (atStart === false) {
this?.log?.info?.('Virtual power switch has been added');
}
}
// Always keep state in sync.
this.switchService.updateCharacteristic(this.hap.Characteristic.On, this.deviceData.power === true);
return;
}
// Remove if no longer configured.
if (this.switchService !== undefined) {
this.removeService(this.switchService);
this.switchService = undefined;
if (atStart === false) {
this?.log?.info?.('Virtual power switch has been removed');
}
}
}
#setupWaterTanks(deviceData = {}) {
if (Array.isArray(deviceData?.tanks) !== true) {
deviceData.tanks = [];
}
let activeTanks = deviceData.tanks.filter((tank) => {
return (
typeof tank === 'object' &&
tank !== null &&
tank.enabled === true &&
tank.sensorEchoPin !== undefined &&
tank.sensorTrigPin !== undefined &&
typeof tank.uuid === 'string' &&
tank.uuid !== ''
);
});
let activeUUIDs = activeTanks.map((tank) => tank.uuid);
// Remove tanks no longer configured, disabled, or no longer valid
Object.keys(this.#tanks || {}).forEach((uuid) => {
if (activeUUIDs.includes(uuid) === false) {
this.#tanks[uuid]?.instance?.onShutdown?.();
this?.log?.debug?.('Watertank "%s" has been removed', this.#tanks[uuid]?.config?.name ?? uuid);
delete this.#tanks[uuid];
}
});
// Add / update active tanks
activeTanks.forEach((tank) => {
this.addCharacteristic(this.irrigationService, this.hap.Characteristic.WaterLevel);
if (this.#tanks?.[tank.uuid] === undefined) {
this.#tanks[tank.uuid] = {
instance: new WaterTank(this.log, this.uuid, {
...tank,
usonicBinary: this.deviceData?.usonicBinary,
}),
config: tank,
waterlevel: undefined,
percentage: undefined,
lastUpdated: undefined,
};
this?.log?.debug?.(
'Setting up watertank "%s" using trigger pin "%s" and echo pin "%s"',
tank.name,
tank.sensorTrigPin,
tank.sensorEchoPin,
);
return;
}
let existing = this.#tanks[tank.uuid];
if (JSON.stringify(existing.config) !== JSON.stringify(tank)) {
this?.log?.debug?.('Updating watertank "%s"', tank.name);
existing.instance?.onUpdate?.(tank);
existing.config = tank;
}
});
if (Object.keys(this.#tanks).length === 0) {
if (this.irrigationService.testCharacteristic(this.hap.Characteristic.WaterLevel) === true) {
this.removeCharacteristic(this.irrigationService, this.hap.Characteristic.WaterLevel);
}
}
}
#setupZones(deviceData = {}, atStart = false) {
if (Array.isArray(deviceData?.zones) !== true) {
deviceData.zones = [];
}
// HomeKit grouping support.
// Required when multiple Valve services exist on the same accessory.
if (deviceData.zones.length > 1) {
this.serviceLabelService = this.accessory.getService(this.hap.Service.ServiceLabel);
if (this.serviceLabelService === undefined) {
this.serviceLabelService = this.accessory.addService(this.hap.Service.ServiceLabel, '', 1);
}
this.serviceLabelService.updateCharacteristic(
this.hap.Characteristic.ServiceLabelNamespace,
this.hap.Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS,
);
}
// Remove stale HomeKit valve services no longer present in configuration.
this.accessory.services
.filter((service) => service.UUID === this.hap.Service.Valve.UUID)
.forEach((service) => {
let exists = deviceData.zones.some((zone) => {
if (typeof zone?.uuid !== 'string' || zone.uuid === '') {
return false;
}
return Number(service.subtype) === crc32(zone.uuid.toUpperCase());
});
if (exists === false) {
this.removeService(service);
}
});
// Remove internal zones no longer configured.
Object.keys(this.#zones || {}).forEach((uuid) => {
let exists = deviceData.zones.some((zone) => zone?.uuid === uuid);
if (exists === false) {
if (this.#zones[uuid]?.run !== undefined) {
this.#zones[uuid].endTime = undefined;
}
this.#zones[uuid]?.valves?.forEach((valve) => {
if (valve.isOpen() === true) {
valve.close();
}
});
if (atStart === false) {
this?.log?.info?.('Zone "%s" has been removed', this.#zones[uuid]?.config?.name ?? uuid);
}
delete this.#zones[uuid];
}
});
// Create / update configured zones.
deviceData.zones.forEach((zone, index) => {
if (typeof zone !== 'object' || zone === null || typeof zone.uuid !== 'string' || zone.uuid === '') {
return;
}
let uuidHash = crc32(zone.uuid.toUpperCase());
let service = this.addService(this.hap.Service.Valve, zone.name, uuidHash);
// Create new internal zone state if needed.
if (this.#zones?.[zone.uuid] === undefined) {
// HomeKit service name.
this.addCharacteristic(service, this.hap.Characteristic.Name);
service.updateCharacteristic(this.hap.Characteristic.Name, zone.name);
// Enable / disable zone.
this.addCharacteristic(service, this.hap.Characteristic.IsConfigured, {
onSet: (value) => this.setZoneEnabled(zone, value),
onGet: () =>
zone.enabled === true ? this.hap.Characteristic.IsConfigured.CONFIGURED : this.hap.Characteristic.IsConfigured.NOT_CONFIGURED,
});
// Remaining duration.
this.addCharacteristic(service, this.hap.Characteristic.RemainingDuration, {
props: { maxValue: this.deviceData.maxRuntime },
});
// Runtime.
this.addCharacteristic(service, this.hap.Characteristic.SetDuration, {
onSet: (value) => this.setZoneRuntime(zone, value),
onGet: () => zone.runtime,
props: { maxValue: this.deviceData.maxRuntime },
});
// Zone name.
this.addCharacteristic(service, this.hap.Characteristic.ConfiguredName, {
onSet: (value) => this.setZoneName(zone, value),
onGet: () => zone.name,
});
// Active state.
this.addCharacteristic(service, this.hap.Characteristic.Active, {
onSet: (value) => this.#processActiveCharacteristic(zone, value, 'valve'),
onGet: () =>
this.#zones?.[zone.uuid]?.run !== undefined ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE,
});
// Identifier is stable for the lifetime of this HomeKit service.
// Set once only when the service is first created.
this.addCharacteristic(service, this.hap.Characteristic.Identifier);
service.updateCharacteristic(this.hap.Characteristic.Identifier, uuidHash);
// ServiceLabelIndex is stable for the lifetime of this HomeKit service.
// Set once only when the service is first created.
this.addCharacteristic(service, this.hap.Characteristic.ServiceLabelIndex, {
onGet: () => index + 1,
});
service.updateCharacteristic(this.hap.Characteristic.ServiceLabelIndex, index + 1);
this.#zones[zone.uuid] = {
service,
valves: [],
config: undefined,
// Unified runtime state.
// When undefined -> zone is idle.
// When object -> zone is actively running.
run: undefined,
// Timer control.
endTime: undefined,
// Multi-valve sequencing support.
activeValveIndex: 0,
};
this.irrigationService.addLinkedService(service);
if (atStart === false) {
this?.log?.info?.('Zone "%s" has been added', zone.name);
}
}
let zoneData = this.#zones[zone.uuid];
if (zone.enabled !== true && zoneData.run !== undefined) {
this.setZoneActive(zone, this.hap.Characteristic.Active.INACTIVE);
}
// Relay layout changes require rebuilding the valve objects.
if (JSON.stringify(zoneData.config?.relayPin) !== JSON.stringify(zone.relayPin)) {
if (zoneData.run !== undefined) {
this.setZoneActive(zoneData.config, this.hap.Characteristic.Active.INACTIVE);
}
zoneData.valves.forEach((valve) => {
if (valve.isOpen() === true) {
valve.close();
}
});
let relayArray = Array.isArray(zone.relayPin) === true ? zone.relayPin : [zone.relayPin];
zoneData.valves = relayArray
.filter((relayPin) => Number.isFinite(Number(relayPin)) === true)
.map(
(relayPin) =>
new Valve(this.log, this.uuid, {
uuid: zone.uuid + '-' + relayPin,
name: zone.name,
relayPin,
}),
);
}
// Update stored config snapshot for later change detection.
zoneData.config = structuredClone(zone);
// Keep service display name and HomeKit name in sync.
if (zoneData.service.displayName !== zone.name) {
zoneData.service.displayName = zone.name;
}
// Update HomeKit state that can legitimately change.
zoneData.service.updateCharacteristic(this.hap.Characteristic.Name, zone.name);
zoneData.service.updateCharacteristic(this.hap.Characteristic.ValveType, this.hap.Characteristic.ValveType.IRRIGATION);
zoneData.service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, zone.name);
zoneData.service.updateCharacteristic(
this.hap.Characteristic.IsConfigured,
zone.enabled === true ? this.hap.Characteristic.IsConfigured.CONFIGURED : this.hap.Characteristic.IsConfigured.NOT_CONFIGURED,
);
zoneData.service.updateCharacteristic(this.hap.Characteristic.SetDuration, zone.runtime);
// If the zone is not actively running, keep HomeKit in a clean idle state.
if (zoneData.run === undefined) {
zoneData.service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE);
zoneData.service.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0);
}
});
}
#setupFlowSensor(deviceData = {}, atStart = false) {
// No flow pin configured, so remove any existing flow sensor
if (deviceData.sensorFlowPin === undefined) {
if (this.#flowSensor !== undefined) {
this.#flowSensor.onShutdown();
this.#flowSensor = undefined;
if (atStart === false) {
this?.log?.info?.('Flow sensor on "%s" has been removed', this.deviceData.sensorFlowPin);
}
}
return;
}
// We do not have an existing flow sensor, so set one up
if (this.#flowSensor === undefined) {
this.#flowSensor = new FlowSensor(this.log, this.uuid, {
sensorPin: deviceData.sensorFlowPin,
flowRate: deviceData.flowRate,
leakDetection: deviceData.leakSensor === true,
leakTimeout: WATER_LEAK_TIMEOUT,
});
if (atStart === false) {
this?.log?.info?.('Flow sensor on "%s" has been added', this.deviceData.sensorFlowPin);
}
r