irrigationsystem
Version:
HomeKit irrigation system using HAP-NodeJS library
536 lines (467 loc) • 19.5 kB
JavaScript
// Irrigation System (HAP-NodeJS)
//
// Standalone HomeKit irrigation controller built on HAP-NodeJS.
// Provides control of irrigation zones, water tanks, flow monitoring,
// and optional leak detection with EveHome history support.
//
// Features:
// - Multi-zone irrigation control (single active zone)
// - Water tank level monitoring (ultrasonic sensors)
// - Flow sensor integration for usage tracking and leak detection
// - Optional virtual power switch for system control
// - Configurable via built-in HomeKitUI web interface
//
// Requirements:
// - Raspberry Pi with GPIO access
// - /boot/config.txt must include: dtoverlay=gpio-no-irq
//
// References:
// - https://github.com/sfeakes/SprinklerD
// - https://github.com/geoffreypetri/water-flow-sensor
//
// TODO:
// - Detect valve current draw and prevent overload conditions
// - Scheduling via Eve Home (Aqua)
// - Integrate Apple WeatherKit for weather-aware irrigation
//
// Code version: 2026.05.10
// Mark Hulskamp
;
// Define HAP-NodeJS module requirements
import HAP from '@homebridge/hap-nodejs';
// Define nodejs module requirements
import process from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
// Import our modules
import IrrigationSystem from './system.js';
import HomeKitDevice from './HomeKitDevice.js';
HomeKitDevice.PLUGIN_NAME = 'irrigationsystem';
HomeKitDevice.PLATFORM_NAME = 'IrrigationSystem';
import HomeKitHistory from './HomeKitHistory.js';
HomeKitDevice.EVEHOME = HomeKitHistory;
import HomeKitUI from './HomeKitUI.js';
import Logger from './logger.js';
const log = Logger.withPrefix(HomeKitDevice.PLATFORM_NAME);
HomeKitDevice.LOGGER = log; // Setup a reference to our logger for use in our HomeKitDevice module
import { crc32 } from './utils.js';
// Define constants
const { version } = createRequire(import.meta.url)('../package.json'); // Import the package.json file to get the version number
const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
const ACCESSORY_PINCODE = '031-45-154'; // Default HomeKit pairing code
const CONFIGURATION_FILE = 'irrigationsystem_config.json'; // Default configuration file name
const CONFIG_SCHEMA_FILE = path.join(__dirname, './config.schema.json');
// General helper functions
function loadConfiguration(filename) {
if (typeof filename !== 'string' || filename === '' || fs.existsSync(filename) === false) {
return;
}
let config = undefined;
try {
let loadedConfig = JSON.parse(fs.readFileSync(filename, 'utf8').trim());
config = {
power: false,
pauseTimeout: 0,
serialNumber: crc32(crypto.randomUUID().toUpperCase()).toString(),
hkUsername: crypto
.randomBytes(6)
.toString('hex')
.toUpperCase()
.split(/(..)/)
.filter((s) => s)
.join(':'),
tanks: [],
zones: [],
programs: {
enabled: false,
schedules: [],
},
options: {
waterLeakAlert: false,
sensorFlowPin: undefined,
flowRate: 0.0,
maxRuntime: 7200,
latitude: 0.0,
longitude: 0.0,
leakSensor: false,
elevation: 0,
eveHistory: true,
powerSwitch: false,
usonicBinary: '',
debug: false,
hkPairingCode: ACCESSORY_PINCODE,
webUIPort: 0,
webUIBearerToken: '',
},
};
Object.entries(loadedConfig).forEach(([key, value]) => {
if (key === 'power') {
config.power = (typeof value === 'string' && value.toUpperCase() === 'ON') || value === true;
}
if (key === 'pauseTimeout') {
config.pauseTimeout = Number.isFinite(Number(value)) === true && Number(value) > 0 ? Number(value) : 0;
}
if (key === 'serialNumber' && typeof value === 'string' && value !== '') {
config.serialNumber = value.trim();
}
if (key === 'hkUsername' && typeof value === 'string' && HomeKitDevice.MAC_ADDR.test(value) === true) {
config.hkUsername = value;
}
if (key === 'tanks' && Array.isArray(value) === true) {
// Validate tanks section
let unnamedCount = 1;
value.forEach((tank) => {
let tempTank = {
uuid: typeof tank?.uuid === 'string' && tank.uuid !== '' ? tank.uuid.trim() : crypto.randomUUID(),
name:
typeof tank?.name === 'string' && tank.name !== ''
? HomeKitDevice.makeValidHKName(tank.name.trim())
: 'Tank ' + unnamedCount++,
enabled: tank?.enabled === true,
capacity: Number.isFinite(Number(tank?.capacity)) === true && Number(tank.capacity) > 0 ? Number(tank.capacity) : 0,
sensorTrigPin:
Number.isFinite(Number(tank?.sensorTrigPin)) === true &&
Number.isFinite(Number(tank?.sensorEchoPin)) === true &&
Number(tank.sensorTrigPin) >= 0 &&
Number(tank.sensorTrigPin) <= 26
? Number(tank.sensorTrigPin)
: undefined,
sensorEchoPin:
Number.isFinite(Number(tank?.sensorEchoPin)) === true &&
Number.isFinite(Number(tank?.sensorTrigPin)) === true &&
Number(tank.sensorEchoPin) >= 0 &&
Number(tank.sensorEchoPin) <= 26
? Number(tank.sensorEchoPin)
: undefined,
sensorHeight:
Number.isFinite(Number(tank?.sensorHeight)) === true && Number(tank.sensorHeight) > 0 ? Number(tank.sensorHeight) : 0,
minimumLevel:
Number.isFinite(Number(tank?.minimumLevel)) === true && Number(tank.minimumLevel) > 0 ? Number(tank.minimumLevel) : 0,
};
if (tempTank.minimumLevel > tempTank.sensorHeight) {
tempTank.minimumLevel = tempTank.sensorHeight;
}
config.tanks.push(tempTank);
});
}
if (key === 'zones' && Array.isArray(value) === true) {
// Validate zones section
let unnamedCount = 1;
value.forEach((zone) => {
let tempZone = {
uuid: typeof zone?.uuid === 'string' && zone.uuid !== '' ? zone.uuid.trim() : crypto.randomUUID(),
name:
typeof zone?.name === 'string' && zone.name !== ''
? HomeKitDevice.makeValidHKName(zone.name.trim())
: 'Zone ' + unnamedCount++,
enabled: zone?.enabled === true,
runtime: Number.isFinite(Number(zone?.runtime)) === true ? Number(zone.runtime) : 300, // 5mins by default
relayPin:
Number.isFinite(Number(zone?.relayPin)) === true && Number(zone.relayPin) >= 0 && Number(zone.relayPin) <= 26
? Number(zone.relayPin)
: undefined,
};
if (tempZone.relayPin === undefined && Array.isArray(zone?.relayPin) === true) {
// Since multiple relay pins, we'll assume a relaypin group. validate it though
tempZone.relayPin = [];
zone.relayPin.forEach((pin) => {
if (Number.isFinite(Number(pin)) === true && Number(pin) >= 0 && Number(pin) <= 26) {
tempZone.relayPin.push(Number(pin));
}
});
}
config.zones.push(tempZone);
});
}
if (key === 'programs' && value !== null && Array.isArray(value) === false && typeof value === 'object') {
// Validate programs section
config.programs.enabled = value?.enabled === true;
config.programs.schedules = Array.isArray(value?.schedules) === true ? value.schedules : [];
}
if (key === 'options' && value !== null && Array.isArray(value) === false && typeof value === 'object') {
config.options.leakSensor = value?.leakSensor === true;
config.options.waterLeakAlert = value?.waterLeakAlert === true;
config.options.sensorFlowPin =
Number.isFinite(Number(value?.sensorFlowPin)) === true && Number(value.sensorFlowPin) >= 0 && Number(value.sensorFlowPin) <= 26
? Number(value.sensorFlowPin)
: undefined;
config.options.flowRate = Number.isFinite(Number(value?.flowRate)) === true ? Number(value.flowRate) : 0.0;
config.options.maxRuntime =
Number.isFinite(Number(value?.maxRuntime)) === true && Number(value.maxRuntime) > 0 ? Number(value.maxRuntime) : 7200;
config.options.latitude =
Number.isFinite(Number(value?.latitude)) === true && Number(value.latitude) >= -90 && Number(value.latitude) <= 90
? Number(value.latitude)
: 0.0;
config.options.longitude =
Number.isFinite(Number(value?.longitude)) === true && Number(value.longitude) >= -180 && Number(value.longitude) <= 180
? Number(value.longitude)
: 0.0;
config.options.elevation = Number.isFinite(Number(value?.elevation)) === true ? Number(value.elevation) : 0;
config.options.eveHistory = value?.eveHistory === true;
config.options.powerSwitch = value?.powerSwitch === true;
config.options.usonicBinary =
typeof value?.usonicBinary === 'string' && value.usonicBinary.trim() !== '' ? value.usonicBinary.trim() : undefined;
config.options.debug = value?.debug === true;
config.options.hkPairingCode =
HomeKitDevice.HK_PIN_3_2_3.test(value?.hkPairingCode) === true || HomeKitDevice.HK_PIN_4_4.test(value?.hkPairingCode) === true
? value.hkPairingCode
: ACCESSORY_PINCODE;
config.options.webUIPort =
Number.isFinite(Number(value?.webUIPort)) === true && Number(value.webUIPort) > 0 && Number(value.webUIPort) <= 65535
? Number(value.webUIPort)
: 0;
config.options.webUIBearerToken = typeof value?.webUIBearerToken === 'string' ? value.webUIBearerToken.trim() : '';
}
});
// Clamp each zone runtime to maxRuntime
config.zones.forEach((zone) => {
if (zone.runtime > config.options.maxRuntime) {
zone.runtime = config.options.maxRuntime;
}
});
// Fix up power state after processing config. If we have a paused timeout set, system will be off
config.power = config.power === true && config.pauseTimeout === 0;
// Write config backout!!
fs.writeFileSync(filename, JSON.stringify(config, null, 2) + '\n');
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
return config;
}
// Startup code
log.success(HomeKitDevice.PLUGIN_NAME + ' v' + version + ' (HAP v' + HAP.HAPLibraryVersion() + ') (Node v' + process.versions.node + ')');
// Check to see if a configuration file was passed into use and validate if present
let configurationFile = path.resolve(process.cwd(), CONFIGURATION_FILE);
let argFile = process.argv[2];
if (typeof argFile === 'string') {
configurationFile = path.isAbsolute(argFile) ? argFile : path.resolve(process.cwd(), argFile);
}
if (fs.existsSync(configurationFile) === false) {
// Configuration file, either by default name or specified on commandline is missing
log.error('Specified configuration "%s" cannot be found', configurationFile);
process.exit(1);
}
// Have a configuration file, now load the configuration options
let config = loadConfiguration(configurationFile);
if (config === undefined) {
log.info('Configuration file contains invalid JSON options');
process.exit(1);
}
log.info('Loaded configuration from "%s"', configurationFile);
// Enable debugging if configured
if (config?.options?.debug === true) {
Logger.setDebugEnabled();
log.warn('Debugging has been enabled');
}
// Create the main irrigation system accessory
let deviceData = {
hkPairingCode: config.options.hkPairingCode,
hkUsername: config.hkUsername,
serialNumber: config.serialNumber,
softwareVersion: version,
manufacturer: 'n0rt0nthec4t',
description: 'Irrigation System',
model: 'Irrigation System',
power: config.power === true && config.pauseTimeout === 0,
pauseTimeout: config.pauseTimeout,
tanks: config.tanks,
zones: config.zones,
eveHistory: config.options.eveHistory,
elevation: config.options.elevation,
latitude: config.options.latitude,
longitude: config.options.longitude,
leakSensor: config.options.leakSensor,
waterLeakAlert: config.options.waterLeakAlert,
sensorFlowPin: config.options.sensorFlowPin,
flowRate: config.options.flowRate,
maxRuntime: config.options.maxRuntime,
maxRunningZones: 1, // One zone at a time running only
usonicBinary: config.options.usonicBinary,
powerSwitch: config.options.powerSwitch,
programs: config.programs,
};
let tempDevice = new IrrigationSystem(undefined, HAP, deviceData);
let accessory = await tempDevice.add('Irrigation System', HAP.Categories.SPRINKLER, true);
// Start HomeKit Web UI if configured to do so
let ui = undefined;
if (config.options.webUIPort > 0) {
ui = new HomeKitUI({
name: 'Irrigation System',
version,
host: '0.0.0.0',
port: config.options.webUIPort,
auth: {
enabled: typeof config.options.webUIBearerToken === 'string' && config.options.webUIBearerToken !== '',
bearerToken: config.options.webUIBearerToken,
},
configFile: configurationFile,
schemaFile: CONFIG_SCHEMA_FILE,
accessory,
hap: HAP,
log,
pages: [
{
id: 'dashboard',
title: 'Dashboard',
svg: '<svg viewBox="0 0 24 24"><path d="M4 13h6V4H4z"/><path d="M14 20h6V4h-6z"/><path d="M4 20h6v-3H4z"/></svg>',
refreshInterval: 1000,
trustedHTML: true,
},
{
id: 'zones',
title: 'Zones',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="6" height="6"/><rect x="14" y="4" width="6" height="6"/><rect x="4" y="14" width="6" height="6"/><rect x="14" y="14" width="6" height="6"/></svg>',
schemaPath: 'zones',
},
{
id: 'tanks',
title: 'Tanks',
svg: '<svg viewBox="0 0 24 24"><path d="M12 3C12 3 6 10 6 14a6 6 0 0 0 12 0c0-4-6-11-6-11z"/></svg>',
schemaPath: 'tanks',
},
{
id: 'options',
title: 'Options',
icon: 'settings',
schemaPath: 'options',
},
],
onSaveConfig: async (savedConfig) => {
fs.writeFileSync(configurationFile, JSON.stringify(savedConfig, null, 2) + '\n');
let newConfig = loadConfiguration(configurationFile);
// Tanks changed
if (JSON.stringify(config.tanks) !== JSON.stringify(newConfig.tanks)) {
log.debug?.('Tank configuration updated from Web UI');
await HomeKitDevice.message(accessory.UUID, HomeKitDevice.UPDATE, {
tanks: newConfig.tanks,
});
}
// Zones changed
if (JSON.stringify(config.zones) !== JSON.stringify(newConfig.zones)) {
log.debug?.('Zone configuration updated from Web UI');
await HomeKitDevice.message(accessory.UUID, HomeKitDevice.UPDATE, {
zones: newConfig.zones,
});
}
// Options changed
if (JSON.stringify(config.options) !== JSON.stringify(newConfig.options)) {
log.debug?.('Options configuration updated from Web UI');
await HomeKitDevice.message(accessory.UUID, HomeKitDevice.UPDATE, {
eveHistory: newConfig.options.eveHistory,
elevation: newConfig.options.elevation,
latitude: newConfig.options.latitude,
longitude: newConfig.options.longitude,
leakSensor: newConfig.options.leakSensor,
waterLeakAlert: newConfig.options.waterLeakAlert,
sensorFlowPin: newConfig.options.sensorFlowPin,
flowRate: newConfig.options.flowRate,
maxRuntime: newConfig.options.maxRuntime,
powerSwitch: newConfig.options.powerSwitch,
});
}
// Debug changed
if (config.options.debug !== newConfig.options.debug) {
if (newConfig.options.debug === true) {
Logger.setDebugEnabled(true);
log.warn('Debugging has been enabled');
} else {
Logger.setDebugEnabled(false);
log.warn('Debugging has been disabled');
}
}
config = newConfig;
log.debug?.('Configuration updated');
},
onRestart: async () => {
await shutdown('restart', 1);
},
onGetPage: async (id) => {
if (id === 'dashboard') {
// Return has html and css for dashboard page. We'll get this from the device which can provide dynamic data for display
return tempDevice.getDashboard();
}
return {};
},
onAction: async (action, data, page) => {
if (page === 'dashboard' && action === 'power') {
// Action to turn system power on/off virtually
tempDevice.setPower(data?.power === true);
}
if (page === 'dashboard' && action === 'zone') {
// Action to turn on/off a zone
let zone = config.zones.find((item) => item.uuid === data.uuid);
if (zone !== undefined) {
tempDevice.setZoneActive(zone, data.active === true);
}
}
},
});
await ui.start();
}
// Setup message listener for set calls from the irrigation system
// Allows us to set and save back to configuration file
HomeKitDevice.message(tempDevice.uuid, HomeKitDevice.SET, async (values) => {
if (values?.zone !== undefined && values?.zone?.uuid !== undefined) {
// Setting a zone's details. We'll use the enclosed uuid to match into our configuration file
// We'll also only update details if present
config.zones
.filter((zone) => zone.uuid === values.zone.uuid)
.forEach((zone) => {
Object.entries(values.zone).forEach(([key, value]) => {
if (zone?.[key] !== undefined) {
zone[key] = value;
}
});
});
}
if (typeof values?.options === 'object' && values.options !== null && Array.isArray(values.options) === false) {
// Setting an 'option'
// We'll also only update details if present
Object.entries(values.options).forEach(([key, value]) => {
if (config?.options?.[key] !== undefined) {
config.options[key] = value;
}
});
}
if (typeof values?.power === 'boolean') {
// Setting 'power' status
config.power = values.power;
}
if (Number.isFinite(Number(values?.pauseTimeout)) === true) {
// Setting 'pause timeout' status
config.pauseTimeout = Number(values.pauseTimeout);
}
if (values?.programs !== undefined) {
// Setting 'program/schedules'
config.programs = values.programs;
}
// Write config backout!!
fs.writeFileSync(configurationFile, JSON.stringify(config, null, 2) + '\n');
log.debug?.('Configuration updated');
});
// Handle process shutdown
let shuttingDown = false;
async function shutdown(signal, exitCode = 0) {
if (shuttingDown === true) {
return;
}
shuttingDown = true;
log.warn('Received %s, shutting down gracefully...', signal);
await HomeKitDevice.shutdown();
if (ui !== undefined) {
ui.stop().catch(() => {
// Empty
});
}
process.exit(exitCode);
}
// Register process signal handlers for graceful shutdown
['SIGINT', 'SIGTERM'].forEach((signal) => {
process.once(signal, () => {
shutdown(signal, 0);
});
});