UNPKG

irrigationsystem

Version:

HomeKit irrigation system using HAP-NodeJS library

488 lines (396 loc) 15 kB
// WaterTank // Part of irrigationsystem // // Handles ultrasonic-based water level measurement. // Uses an external binary for accurate sensor timing when available, // with optional built-in Node.js GPIO fallback when no binary is configured. // // Responsibilities: // - Read distance from ultrasonic sensor via external binary or GPIO fallback // - Use a per-sample median to reduce one-off ultrasonic noise // - Convert distance to water level and percentage // - Emit WATERLEVEL events via HomeKitDevice message bus // - Handle lifecycle cleanup (shutdown) // // Architecture: // - No direct references to parent system // - Emits events via HomeKitDevice.message(...) // - Polled at fixed interval // // Flow: // - Execute external binary (usonic_measure), or use GPIO fallback // - Parse/normalise distance output // - Use the current sample median as the measured distance // - Convert to usable tank height // - Emit updated level + percentage // // Lifecycle hooks used: // - onShutdown() -> clears polling timer // // Requirements: // - Valid trigger/echo GPIO pins // - External ultrasonic binary, or assigned WaterTank.GPIO library fallback // // Code version 2026.06.26 // Mark Hulskamp 'use strict'; // Define nodejs module requirements import process from 'node:process'; import child_process from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers'; // Import our modules import HomeKitDevice from './HomeKitDevice.js'; import { validGPIOPin, LOG_LEVELS } from './utils.js'; // Define constants const USONIC_READINGS = 5; // Number of readings per sensor sample const USONIC_MIN_RANGE = 200; // mm const USONIC_MAX_RANGE = 4500; // mm const USONIC_TIMEOUT = 5000; // ms const REFRESH_INTERVAL = 60 * 1000; // ms export default class WaterTank { static GPIO = undefined; // GPIO library override static WATERLEVEL_EVENT = 'WATERLEVEL'; uuid = undefined; log = undefined; waterlevel = undefined; percentage = undefined; // Internal data only for this class #HomeKitDeviceUUID = undefined; #readTimer = undefined; #reading = false; #sensorHeight = undefined; #minimumLevel = undefined; #triggerPin = undefined; #echoPin = undefined; #usonicBinary = undefined; constructor(log = undefined, uuid = undefined, deviceData = {}) { // Validate the passed in logging object. We are expecting certain functions to be present if (Object.values(LOG_LEVELS).every((fn) => typeof log?.[fn] === 'function')) { this.log = log; } // Store HomeKitDevice UUID for message routing this.#HomeKitDeviceUUID = typeof uuid === 'string' && uuid !== '' ? uuid : undefined; if (this.#HomeKitDeviceUUID !== undefined) { HomeKitDevice.message(this.#HomeKitDeviceUUID, HomeKitDevice.SHUTDOWN, this); } this.uuid = typeof deviceData?.uuid === 'string' && deviceData.uuid !== '' ? deviceData.uuid : crypto.randomUUID(); this.#sensorHeight = Number.isFinite(Number(deviceData?.sensorHeight)) === true && Number(deviceData.sensorHeight) > 0 ? Number(deviceData.sensorHeight) : undefined; this.#minimumLevel = Number.isFinite(Number(deviceData?.minimumLevel)) === true && Number(deviceData.minimumLevel) >= 0 ? Number(deviceData.minimumLevel) : 0; this.#triggerPin = validGPIOPin(deviceData?.sensorTrigPin) === true ? Number(deviceData.sensorTrigPin) : undefined; this.#echoPin = validGPIOPin(deviceData?.sensorEchoPin) === true ? Number(deviceData.sensorEchoPin) : undefined; this.#usonicBinary = path.resolve( process.cwd(), typeof deviceData?.usonicBinary === 'string' && deviceData.usonicBinary.trim() !== '' ? deviceData.usonicBinary.trim().startsWith('~') ? path.join(os.homedir(), deviceData.usonicBinary.trim().slice(1)) : deviceData.usonicBinary.trim() : './usonic_measure', ); // Validate binary if (fs.existsSync(this.#usonicBinary) === false) { this?.log?.warn?.('Unable to find "%s" used to perform ultrasonic measurements for tank uuid "%s"', this.#usonicBinary, this.uuid); if (WaterTank.GPIO !== undefined && this.#triggerPin !== undefined && this.#echoPin !== undefined) { this?.log?.warn?.('Falling back to Node.js GPIO measurements (reduced accuracy) for tank uuid "%s"', this.uuid); this.#usonicBinary = undefined; } else { this?.log?.warn?.('No valid ultrasonic measurement method available for tank uuid "%s"', this.uuid); return; } } // Validate pins if (this.#echoPin === undefined || this.#triggerPin === undefined) { this?.log?.error?.('No GPIO pins are defined for ultrasonic readings for tank uuid "%s"', this.uuid); return; } // Validate tank dimensions if (this.#sensorHeight === undefined || this.#sensorHeight - this.#minimumLevel <= 0) { this?.log?.error?.('Invalid tank dimensions for tank uuid "%s"', this.uuid); return; } this?.log?.debug?.( 'Using GPIO pins "%s" echo and "%s" trigger with "%s" for ultrasonic measurements on tank uuid "%s"', this.#echoPin, this.#triggerPin, this.#usonicBinary ?? 'Node.js GPIO fallback', this.uuid, ); // Initial read this.#readUsonicSensor(); // Start polling loop this.#readTimer = setInterval(() => { this.#readUsonicSensor(); }, REFRESH_INTERVAL); } onUpdate(deviceData = {}) { if (typeof deviceData !== 'object' || deviceData === null) { return; } if (Object.hasOwn(deviceData, 'sensorHeight') === true) { if (Number.isFinite(Number(deviceData.sensorHeight)) === true && Number(deviceData.sensorHeight) > 0) { this.#sensorHeight = Number(deviceData.sensorHeight); } } if (Object.hasOwn(deviceData, 'minimumLevel') === true) { if (Number.isFinite(Number(deviceData.minimumLevel)) === true && Number(deviceData.minimumLevel) >= 0) { this.#minimumLevel = Number(deviceData.minimumLevel); } } if (Object.hasOwn(deviceData, 'sensorTrigPin') === true) { this.#triggerPin = validGPIOPin(deviceData.sensorTrigPin) === true ? Number(deviceData.sensorTrigPin) : undefined; } if (Object.hasOwn(deviceData, 'sensorEchoPin') === true) { this.#echoPin = validGPIOPin(deviceData.sensorEchoPin) === true ? Number(deviceData.sensorEchoPin) : undefined; } if (this.#echoPin === undefined || this.#triggerPin === undefined) { this?.log?.error?.('No GPIO pins are defined for ultrasonic readings for tank uuid "%s"', this.uuid); return; } if (this.#sensorHeight === undefined || this.#sensorHeight - this.#minimumLevel <= 0) { this?.log?.error?.('Invalid tank dimensions for tank uuid "%s"', this.uuid); return; } this.#readUsonicSensor(); } async onShutdown() { if (this.#readTimer !== undefined) { clearInterval(this.#readTimer); this.#readTimer = undefined; } } getLevel() { return { uuid: this.uuid, waterlevel: this.waterlevel, percentage: this.percentage, }; } async #readUsonicSensor() { if (this.#reading === true) { return; } if (this.#triggerPin === undefined || this.#echoPin === undefined || this.#sensorHeight === undefined) { return; } this.#reading = true; try { let median = (values = []) => { let sorted = [...values].sort((a, b) => a - b); let middle = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; let readings = []; for (let i = 0; i < USONIC_READINGS; i++) { let reading = await this.#readDistance(); if (Number.isFinite(Number(reading)) === true && Number(reading) > 0) { readings.push(Number(reading)); } } if (readings.length === 0) { return; } // Median per-sample reading, robust against one-off ultrasonic spikes. let distance = median(readings); distance = Math.max(USONIC_MIN_RANGE, Math.min(USONIC_MAX_RANGE, distance)); if (distance > this.#sensorHeight) { distance = this.#sensorHeight; } let usableHeight = this.#sensorHeight - this.#minimumLevel; if (usableHeight <= 0) { return; } // Distance is measured from the sensor down to the water surface. // Convert to usable water height, clamped between empty and full. this.waterlevel = usableHeight - Math.max(0, distance - USONIC_MIN_RANGE); this.waterlevel = Math.max(0, Math.min(usableHeight, this.waterlevel)); this.percentage = (this.waterlevel / usableHeight) * 100; this.percentage = Math.max(0, Math.min(100, this.percentage)); // Emit event via HomeKitDevice. if (this.#HomeKitDeviceUUID !== undefined) { HomeKitDevice.message(this.#HomeKitDeviceUUID, WaterTank.WATERLEVEL_EVENT, { uuid: this.uuid, waterlevel: this.waterlevel, percentage: this.percentage, }); } } finally { this.#reading = false; } } async #readDistance() { // If no binary is available, use built-in GPIO fallback if (this.#usonicBinary === undefined) { let distance = await this.#measureDistanceGPIO(); if (Number.isFinite(Number(distance)) === true) { return Math.max(USONIC_MIN_RANGE, Math.min(USONIC_MAX_RANGE, Number(distance) * 10)); } return undefined; } return await new Promise((resolve) => { let output = ''; let timeout = undefined; let proc = undefined; let resolved = false; let finish = (value) => { if (resolved === true) { return; } resolved = true; if (timeout !== undefined) { clearTimeout(timeout); timeout = undefined; } resolve(value); }; // Validate binary exists and is executable try { fs.accessSync(this.#usonicBinary, fs.constants.X_OK); } catch { this?.log?.debug?.('usonic binary "%s" not executable for tank uuid "%s"', this.#usonicBinary, this.uuid); this.#usonicBinary = undefined; finish(undefined); return; } // Spawn process try { proc = child_process.spawn(this.#usonicBinary, [this.#triggerPin, this.#echoPin]); } catch (error) { this?.log?.debug?.('Failed to start usonic measurement for tank uuid "%s": %s', this.uuid, String(error)); this.#usonicBinary = undefined; finish(undefined); return; } timeout = setTimeout(() => { this?.log?.debug?.('usonic measurement timeout for tank uuid "%s"', this.uuid); try { proc.kill('SIGKILL'); } catch { // Empty } finish(undefined); }, USONIC_TIMEOUT); proc.stdout.on('data', (data) => { output += data.toString(); }); proc.stderr.on('data', (data) => { this?.log?.debug?.('usonic measurement stderr for tank uuid "%s": %s', this.uuid, data.toString().trim()); }); proc.on('error', (error) => { this?.log?.debug?.('usonic measurement error for tank uuid "%s": %s', this.uuid, String(error)); finish(undefined); }); proc.on('close', () => { let line = output.trim().toUpperCase(); if (line === '') { finish(undefined); return; } if (line === 'OUT OF RANGE') { this?.log?.debug?.('usonic measurement returned "out of range" for tank uuid "%s"', this.uuid); finish(undefined); return; } if (line.includes('DISTANCE') === true) { let match = line.match(/([0-9]+(\.[0-9]+)?)/); if (match !== null) { let value = Number(match[1]) * 10; if (Number.isFinite(value) === true) { finish(Math.max(USONIC_MIN_RANGE, Math.min(USONIC_MAX_RANGE, value))); return; } } } finish(undefined); }); }); } async #measureDistanceGPIO() { // If no GPIO library assigned, we cannot measure if (WaterTank.GPIO === undefined) { return undefined; } // Pins must be valid if (this.#triggerPin === undefined || this.#echoPin === undefined) { return undefined; } let self = this; return new Promise(async (resolve) => { const TIMEOUT_US = 500000; // 0.5s (~171m max range) let riseTime = undefined; let timeoutTimer = undefined; let completed = false; function cleanup() { if (timeoutTimer !== undefined) { clearTimeout(timeoutTimer); timeoutTimer = undefined; } try { WaterTank.GPIO.poll(self.#echoPin, null); // eslint-disable-next-line no-unused-vars } catch (error) { // Ignore cleanup errors } } function finish(distanceCm) { if (completed === true) { return; } completed = true; cleanup(); resolve(distanceCm); } try { // Configure pins (safe to call repeatedly) WaterTank.GPIO.open(self.#triggerPin, WaterTank.GPIO.OUTPUT, WaterTank.GPIO.LOW); WaterTank.GPIO.open(self.#echoPin, WaterTank.GPIO.INPUT); // Interrupt handler for BOTH edges WaterTank.GPIO.poll( self.#echoPin, (pin) => { if (completed === true) { return; } // Rising edge → start timing if (WaterTank.GPIO.read(pin) === WaterTank.GPIO.HIGH) { riseTime = process.hrtime.bigint(); return; } // Falling edge → end timing if (riseTime === undefined) { return; } let durationUs = Number((process.hrtime.bigint() - riseTime) / 1000n); let distanceCm = durationUs * 0.01715; finish(distanceCm); }, WaterTank.GPIO.POLL_BOTH, ); // Timeout guard timeoutTimer = setTimeout( () => { finish(undefined); }, Math.ceil(TIMEOUT_US / 1000), ); // Allow sensor to settle await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); // Trigger pulse (10µs) WaterTank.GPIO.write(self.#triggerPin, WaterTank.GPIO.HIGH); WaterTank.GPIO.usleep(10); WaterTank.GPIO.write(self.#triggerPin, WaterTank.GPIO.LOW); // eslint-disable-next-line no-unused-vars } catch (error) { finish(undefined); } }); } }