UNPKG

homebridge-hunter-hydrawise

Version:

HomeKit integration for Hunter Hydrawise Irrigation Controllers.

450 lines 27.7 kB
import { HYDRAWISE_ACTIVE_ZONE_INDICATOR, HYDRAWISE_API_JITTER, HYDRAWISE_API_RETRY_INTERVAL } from "./settings.js"; import { acquireService, getServiceName, retry, sleep, validService } from "homebridge-plugin-utils"; import { HydrawiseReservedNames } from "./hydrawise-types.js"; import util from "node:util"; export class HydrawiseController { accessory; api; config; controller; hap; hints; log; platform; status; zoneHints; // The constructor initializes key variables and calls configureDevice(). constructor(platform, accessory, controller) { this.accessory = accessory; this.api = platform.api; this.status = {}; this.config = platform.config; this.hap = this.api.hap; this.hints = {}; this.controller = controller; this.platform = platform; this.zoneHints = {}; this.log = { debug: (message, ...parameters) => platform.debug(util.format(this.name + ": " + message, ...parameters)), error: (message, ...parameters) => platform.log.error(util.format(this.name + ": " + message, ...parameters)), info: (message, ...parameters) => platform.log.info(util.format(this.name + ": " + message, ...parameters)), warn: (message, ...parameters) => platform.log.warn(util.format(this.name + ": " + message, ...parameters)) }; this.configureDevice(); } // Configure an irrigation system accessory for HomeKit. configureDevice() { // Clean out the context object. this.accessory.context = {}; // Configure ourselves. this.configureHints(); this.configureInfo(); this.configureIrrigationSystem(); this.configureSuspendSwitches(); this.configureMqtt(); // Kickoff our state updates. void this.updateState(); } // Configure controller-specific settings. configureHints() { this.hints.logZone = this.hasFeature("Log.Zone"); this.hints.suspendAll = this.hasFeature("Device.Suspend"); return true; } // Configure the controller information for HomeKit. configureInfo() { // Update the manufacturer information for this controller. this.accessory.getService(this.hap.Service.AccessoryInformation)?.updateCharacteristic(this.hap.Characteristic.Manufacturer, "Hunter"); // Update the model information for this controller. this.accessory.getService(this.hap.Service.AccessoryInformation)?.updateCharacteristic(this.hap.Characteristic.Model, "Hydrawise"); // Update the serial number for this controller. this.accessory.getService(this.hap.Service.AccessoryInformation)?.updateCharacteristic(this.hap.Characteristic.SerialNumber, this.controller.serial_number); return true; } // Configure MQTT services. configureMqtt() { // Return our irrigation controller state. this.platform.mqtt?.subscribeGet(this.controller.serial_number, "controller", "Irrigation controller", () => { return this.statusJson; }, this.log); // Set the state of a given irrigation zone. this.platform.mqtt?.subscribeSet(this.controller.serial_number, "controller", "Irrigiation controller", async (value) => { // Parse the command. const action = value.split(" "); // Parse the zone number. const zoneValue = parseInt(action[1]); // Let's find the zone, if it exists. const zone = this.status.relays.find(x => x.relay === zoneValue); // No zone, we're done. if (!zone) { this.log.error("MQTT: Invalid zone specified."); return; } switch (action[0]) { case "start": await this.sendCommand(zone, "run", parseInt(action[2])); return; case "stop": await this.sendCommand(zone, "stop"); return; default: this.log.error("Invalid command."); return; } }, this.log); return true; } // Configure the irrigation system service for HomeKit. configureIrrigationSystem() { // Acquire the service and if needed, add a service label service in order to be able to properly enumerate and name the individual zone valves. const service = acquireService(this.accessory, this.hap.Service.IrrigationSystem, this.name, undefined, () => acquireService(this.accessory, this.hap.Service.ServiceLabel, this.name) ?.updateCharacteristic(this.hap.Characteristic.ServiceLabelNamespace, this.hap.Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS)); if (!service) { this.log.error("Unable to add the irrigation controller."); return false; } // Initialize the service. service.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.ACTIVE); service.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE); service.updateCharacteristic(this.hap.Characteristic.ProgramMode, this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED); return true; } // Configure suspend switch services for HomeKit. configureSuspendSwitches() { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.Switch, this.hints.suspendAll, HydrawiseReservedNames.SWITCH_SUSPEND_ALL)) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.Switch, getServiceName(this.accessory.getServiceById(this.hap.Service.Switch, HydrawiseReservedNames.SWITCH_SUSPEND_ALL)) ?? this.accessoryName + " Suspend All Zones", HydrawiseReservedNames.SWITCH_SUSPEND_ALL); // Fail gracefully. if (!service) { this.log.error("Unable to add suspend all zones switch."); return false; } // Suspend or resume the irrigation schedule. service.getCharacteristic(this.hap.Characteristic.On)?.onGet(() => this.isAllSuspended); service.getCharacteristic(this.hap.Characteristic.On)?.onSet(async (value) => { // We either set the timestamp to the current time, to resume irrigation, or to a year from now to suspend irrigation. const timestamp = (Date.now() / 1000) + (value ? 31556926 : 0); const response = await this.sendCommand("suspendall", timestamp); let status; try { status = await response?.body.json(); } catch (error) { this.log.error("Unable to retrieve the result of the %s request.", value ? "suspend" : "resume"); } if (status?.message_type === "error") { this.log.error("Unable to complete the %s request.", value ? "suspend" : "resume"); } if (!status || (status.message_type === "error")) { setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.On, !value), 50); return; } this.log.info("%s scheduled watering for all zones.", value ? "Suspending" : "Resuming"); }); service.updateCharacteristic(this.hap.Characteristic.On, this.isAllSuspended); this.log.info("Enabling suspend all zones switch."); return true; } // Update the irrigation system state from the Hydrawise API to HomeKit. async updateState() { // We loop forever, updating our irrigation system state at regular intervals. for (;;) { const isFirstRun = this.status?.nextpoll === undefined; // Update our status. If it's our first run through, we use our internal defaults. // eslint-disable-next-line no-await-in-loop await retry(async () => this.getStatus(), (isFirstRun ? HYDRAWISE_API_RETRY_INTERVAL : (this.status.nextpoll + HYDRAWISE_API_JITTER)) * 1000); // Let's get the list of current valves on this irrigation controller. const currentValves = this.status.relays.map(x => x.relay_id.toString()); // Remove valves that no longer exist. this.accessory.services.filter(x => (x.UUID === this.hap.Service.Valve.UUID) && !currentValves.includes(x.subtype ?? "")).map(x => this.accessory.removeService(x)); // Trim whitespace on zone names. this.status.relays = this.status.relays.map(x => ({ ...x, name: x.name.trim() })); let irrigationRemaining = 0; // Find the irrigation system service. const irrigationSystemService = this.accessory.getService(this.hap.Service.IrrigationSystem); // Discover any new zones and update our zone state. for (const zone of this.status.relays) { // Acquire the valve service. let isNewValve = false; const valveService = acquireService(this.accessory, this.hap.Service.Valve, this.accessory.getServiceById(this.hap.Service.Valve, zone.relay_id.toString())?.getCharacteristic(this.hap.Characteristic.ConfiguredName).value ?? zone.name, zone.relay_id.toString(), (newService) => { // Enumerate the valve service to align with the irrigation controller's zone numbering. newService.updateCharacteristic(this.hap.Characteristic.ServiceLabelIndex, zone.relay); // This allows users to enable or disable the zone from within HomeKit. We could exclude it, but the extra optionality for end users can be useful. newService.updateCharacteristic(this.hap.Characteristic.IsConfigured, this.hap.Characteristic.IsConfigured.CONFIGURED); // All valves attached to an irrigation system must have their type set accordingly. newService.updateCharacteristic(this.hap.Characteristic.ValveType, this.hap.Characteristic.ValveType.IRRIGATION); // Ensure that we inform the user of the new valve. isNewValve = true; }); if (!valveService) { this.log.error("Unable to create a valve service for zone: %s (%s).", zone.name, zone.relay_id); continue; } // See if the zone has been stopped due to a rain sensor event. const isStopped = this.isStoppedBySensor(zone); // Inform the user. if (isFirstRun || isNewValve) { // Create our zone hints, if needed. if (!this.zoneHints[zone.relay_id]) { this.zoneHints[zone.relay_id] = {}; } // Initialize our stopped state. this.zoneHints[zone.relay_id].isStopped = isStopped; this.log.info("%s: %s", this.getValveName(valveService, zone), this.zoneStatus(zone)); // Manually control the zone valve. valveService.getCharacteristic(this.hap.Characteristic.Active).onSet(async (value) => { const setOn = value === this.hap.Characteristic.Active.ACTIVE; const duration = valveService.getCharacteristic(this.hap.Characteristic.SetDuration).value?.toString() ?? "0"; let response; // Request the change in zone state. if (setOn) { response = await this.sendCommand(zone, "run", parseInt(duration)); } else { response = await this.sendCommand(zone, "stop"); } // Something went wrong in communicating with the Hydrawise API. if (!response) { // Revert our state for this zone. setTimeout(() => valveService.updateCharacteristic(this.hap.Characteristic.Active, setOn ? this.hap.Characteristic.Active.INACTIVE : this.hap.Characteristic.Active.ACTIVE), 50); return; } // Update our valve state accordingly. if (setOn) { valveService.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.IN_USE); valveService.updateCharacteristic(this.hap.Characteristic.RemainingDuration, duration); irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.ProgramMode, ("PROGRAM_SCHEDULED_MANUAL_MODE" in this.hap.Characteristic.ProgramMode) ? this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED_MANUAL_MODE : (("PROGRAM_SCHEDULED_MANUAL_MODE_" in this.hap.Characteristic.ProgramMode) ? this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED_MANUAL_MODE_ : 2)); irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.IN_USE); // Mark this zone as manually activated. this.zoneHints[zone.relay_id].isManual = true; } else { valveService.updateCharacteristic(this.hap.Characteristic.RemainingDuration, 0); valveService.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE); // Clear out the manual activation tracker for this zone. this.zoneHints[zone.relay_id].isManual = false; // No more manually activated zones, we can resume our schedule. if (!Object.values(this.zoneHints).some(x => x.isManual)) { irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.ProgramMode, this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED); } // If this was the only zone currently running on the irrigation controller, let's set the system to state to no longer in use. if (!this.status.relays.some(x => (x.time === 1) && (x.relay_id !== zone.relay_id))) { irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.InUse, this.hap.Characteristic.InUse.NOT_IN_USE); } } this.log.info("%s: Manually %s%s.", this.getValveName(valveService, zone), setOn ? "started" : "stopped", setOn ? " (duration: " + this.getMinutes(duration) + ")" : ""); }); } // Determine whether the zone is currently running from the Hydrawise API. this.zoneHints[zone.relay_id].isOn = zone.time === 1; // Retrieve whether the valve service is in use from HomeKit's perspective. const isValveInUse = valveService.getCharacteristic(this.hap.Characteristic.InUse).value === this.hap.Characteristic.InUse.IN_USE; // Get the duration of the next run time (if we aren't running currently) or the time remaining in this run if we're running. const duration = parseInt(zone.run); // If a zone is on, then our irrigation system is in use and we update the remaining runtime duration. if (this.zoneHints[zone.relay_id].isOn) { irrigationRemaining += duration; // Update the duration of the remaining runtime of this valve, in seconds. valveService.updateCharacteristic(this.hap.Characteristic.RemainingDuration, Math.min(duration, 3600)); } else { // Clear out the manual activation tracker for this zone. this.zoneHints[zone.relay_id].isManual = false; // Set the duration of the next run of this valve, in seconds, in HomeKit based on the Hydrawise scheduled runtime. valveService.updateCharacteristic(this.hap.Characteristic.SetDuration, Math.min(duration, 3600)); } // Active represents whether the zone is ready to be activated - meaning it's queued to turn on imminently or is currently on. if ((zone.time > 0) && (zone.time <= HYDRAWISE_ACTIVE_ZONE_INDICATOR)) { valveService.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.ACTIVE); this.log.debug("Setting %s as active.", this.getValveName(valveService, zone)); } else { valveService.updateCharacteristic(this.hap.Characteristic.Active, this.hap.Characteristic.Active.INACTIVE); } // InUse represents whether there is water flowing through the valve currently. valveService.updateCharacteristic(this.hap.Characteristic.InUse, this.zoneHints[zone.relay_id].isOn ? this.hap.Characteristic.InUse.IN_USE : this.hap.Characteristic.InUse.NOT_IN_USE); // Log our activity, if configured to do so. if (this.hints.logZone) { // Inform the user if the zone has been started or stopped. if (isValveInUse !== this.zoneHints[zone.relay_id].isOn) { this.log.info("%s: %s %s", this.getValveName(valveService, zone), this.zoneHints[zone.relay_id].isOn ? "Started" : "Stopped.", this.zoneHints[zone.relay_id].isOn ? "(duration: " + this.getMinutes(zone.run) + ")." : this.zoneStatus(zone)); } // Inform the user if the zone has been stopped due to a rain sensor. if (isStopped !== this.zoneHints[zone.relay_id].isStopped) { this.log.info("%s: Rain sensor is %s irrigation.", this.getValveName(valveService, zone), isStopped ? "stopping" : "allowing"); } } // Save the new setting. this.zoneHints[zone.relay_id].isStopped = isStopped; } // Update the irrigation system state. irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.InUse, (irrigationRemaining > 0) ? this.hap.Characteristic.InUse.IN_USE : this.hap.Characteristic.InUse.NOT_IN_USE); irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.RemainingDuration, Math.min(irrigationRemaining, 3600)); // No more manually activated zones, we can resume our schedule. if (!Object.values(this.zoneHints).some(x => x.isManual)) { if (Object.values(this.zoneHints).filter(x => x.isStopped).length === this.status.relays.length) { irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.ProgramMode, this.hap.Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED); } else { irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.ProgramMode, this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED); } } // Update the irrigation system's program mode if we're not manually running on any zones. if (!Object.values(this.zoneHints).some(x => x.isManual)) { // If all our zones have been stopped by a rain sensor, we indicate that no program is currently scheduled, otherwise, we're on our normal scheduled program. irrigationSystemService?.updateCharacteristic(this.hap.Characteristic.ProgramMode, (Object.values(this.zoneHints).filter(x => x.isStopped).length === this.status.relays.length) ? this.hap.Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED : this.hap.Characteristic.ProgramMode.PROGRAM_SCHEDULED); } // Publish our status to MQTT if configured to do so. this.platform.mqtt?.publish(this.controller.serial_number, "controller", this.statusJson); // Update our suspend status. this.accessory.getServiceById(this.hap.Service.Switch, HydrawiseReservedNames.SWITCH_SUSPEND_ALL)?.updateCharacteristic(this.hap.Characteristic.On, this.isAllSuspended); // Sleep until our next polling interval due to the Hydrawise API being rate-limited. // eslint-disable-next-line no-await-in-loop await sleep((this.status.nextpoll + HYDRAWISE_API_JITTER) * 1000); } } async sendCommand(zoneOrCmd, cmdOrDur, duration) { let command, zone; // We've been called as sendCommand("suspendall", duration) if (typeof zoneOrCmd === "string") { command = zoneOrCmd; duration = cmdOrDur; } else { // We've been called as sendCommand(zone, "run" | "stop", [duration]) zone = zoneOrCmd; command = cmdOrDur; } // User has queued us up...send the command to Hydrawise. // eslint-disable-next-line camelcase const params = { controller_id: this.controller.controller_id.toString() }; // If we've specified the zone, add it to our parameters. if (zone?.relay_id) { // eslint-disable-next-line camelcase params.relay_id = zone.relay_id.toString(); } switch (command) { case "run": params.action = "run"; if ((duration === undefined) || (duration <= 0)) { return null; } params.custom = duration.toString(); // eslint-disable-next-line camelcase params.period_id = "999"; break; case "stop": params.action = "stop"; break; case "suspendall": params.action = "suspendall"; if ((duration === undefined) || (duration <= 0)) { return null; } params.custom = duration.toString(); // eslint-disable-next-line camelcase params.period_id = "999"; delete params.relay_id; break; default: return null; } // Request the change in zone state. return this.platform.retrieve("setzone.php", params); } // Utility to return the status of a zone to a user. zoneStatus(zone) { if (this.isStoppedBySensor(zone)) { return "Rain sensor is preventing irrigation."; } // If we're currently running, inform the user of the remaining duration. Otherwise, inform the user of the next runtime. if (zone.time === 1) { return "Currently running with " + this.getMinutes(zone.run) + " remaining."; } else { return "Next run will be " + (zone.timestr.includes(":") ? "at " + this.formatStartTime(zone.timestr) : "on " + zone.timestr) + " for " + this.getMinutes(zone.run) + "."; } } // Retrieve the current status from the Hydrawise API. async getStatus() { // Get our schedule for this controller. // eslint-disable-next-line camelcase const response = await this.platform.retrieve("statusschedule.php", { controller_id: this.controller.controller_id.toString() }); // Not found, let's retry again. if (!response) { return false; } try { this.status = await response.body.json(); this.log.debug("Status updated."); this.log.debug(util.inspect(this.status, { colors: true, depth: null, sorted: true })); } catch (error) { this.log.error("Unable to retrieve the current status of the irrigation controller: --%s--", util.inspect(error, { colors: true, depth: null, sorted: true })); } return true; } // Utility to test for whether a zone has been stopped due to a rain sensor. isStoppedBySensor(zone) { return !zone.run && !zone.timestr && (zone.time === 1576800000) && this.status.sensors.filter(sensor => sensor.type === 1).some(sensor => sensor.relays.some(relay => relay.id === zone.relay_id)); } // Utility to conver the duration from seconds to minutes, with the correct plural marker. getMinutes(duration) { const minutes = Math.round(parseInt(duration) / 60); return minutes.toString() + " minute" + (minutes !== 1 ? "s" : ""); } // Utility to format the time strings returned by Hydrawise. formatStartTime(time) { // Split it into hours and minutes and ensure we convert it in the process. const [hours, minutes] = time.split(":").map(Number); // Return our user-friendly time string. return ((hours % 12) || 12).toString() + ":" + minutes.toString().padStart(2, "0") + " " + (hours >= 12 ? "PM" : "AM"); } // Utility for checking feature options on a device. hasFeature(option) { return this.platform.featureOptions.test(option, this.controller.serial_number); } // Utility function to get the configured name of a valve, if set. getValveName(service, zone) { return (service.getCharacteristic(this.hap.Characteristic.ConfiguredName).value ?? zone.name) + " [Zone " + zone.relay + "]"; } // Utility to return whether all zones are suspended or not. get isAllSuspended() { return !this.status.relays?.some(zone => zone.run || zone.timestr || (zone.time !== 1576800000) || this.isStoppedBySensor(zone)); } // Utility to return our status as a JSON for MQTT. get statusJson() { return JSON.stringify(this.status.relays.map(x => ({ name: x.name, relay: x.relay, run: x.run, time: x.time, timestr: x.timestr }))); } // Utility function to return the name of this controller. get name() { // We use the irrigation system service as the natural proxy for the name. const name = this.accessory.getService(this.hap.Service.IrrigationSystem)?.getCharacteristic(this.hap.Characteristic.Name).value; // If we don't have a name for the irrigation system service, return the controller name from Hydrawise. return name?.length ? name : this.controller.name; } // Utility function to return the current accessory name of this device. get accessoryName() { return (this.accessory.getService(this.hap.Service.AccessoryInformation)?.getCharacteristic(this.hap.Characteristic.Name).value ?? this.controller.name); } // Utility function to set the current accessory name of this device. set accessoryName(name) { // Set all the internally managed names within Homebridge to the new accessory name. this.accessory.displayName = name; this.accessory._associatedHAPAccessory.displayName = name; // Set all the HomeKit-visible names. this.accessory.getService(this.hap.Service.AccessoryInformation)?.updateCharacteristic(this.hap.Characteristic.Name, name); } } //# sourceMappingURL=hydrawise-controller.js.map