homebridge-hunter-hydrawise
Version:
HomeKit integration for Hunter Hydrawise Irrigation Controllers.
246 lines • 12.2 kB
JavaScript
import { Pool, errors, interceptors, request, setGlobalDispatcher } from "undici";
import { FeatureOptions, retry } from "homebridge-plugin-utils";
import { HYDRAWISE_API_RETRY_INTERVAL, HYDRAWISE_API_TIMEOUT, HYDRAWISE_MQTT_TOPIC, PLATFORM_NAME, PLUGIN_NAME } from "./settings.js";
import { featureOptionCategories, featureOptions } from "./hydrawise-options.js";
import { MqttClient } from "homebridge-plugin-utils";
import { HydrawiseController } from "./hydrawise-controller.js";
import { STATUS_CODES } from "node:http";
import util from "node:util";
export class HydrawisePlatform {
accessories;
account;
api;
dispatcher;
featureOptions;
config;
configuredDevices;
hap;
log;
mqtt;
constructor(log, config, api) {
this.accessories = [];
this.account = {};
this.api = api;
this.configuredDevices = {};
this.featureOptions = new FeatureOptions(featureOptionCategories, featureOptions, config?.options ?? []);
this.hap = api.hap;
this.log = log;
this.log.debug = this.debug.bind(this);
this.mqtt = null;
this.config = {
apiKey: config?.apiKey ?? "",
debug: config?.debug === true,
mqttTopic: config?.mqttTopic ?? HYDRAWISE_MQTT_TOPIC,
mqttUrl: config?.mqttUrl,
options: config?.options ?? []
};
// No Hydrawise API key, we're done.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!this.config.apiKey?.length) {
this.log.error("Unable to startup: no Hunter Hydrawise API key has been configured. Please configure one and restart the plugin.");
return;
}
// Initialize our network connectivity.
this.initNetworking();
// Initialize MQTT, if needed.
if (this.config.mqttUrl) {
this.mqtt = new MqttClient(this.config.mqttUrl, this.config.mqttTopic, this.log);
}
this.log.debug("Debug logging on. Expect a lot of data.");
// Fire up the Hydrawise API once Homebridge has loaded all the cached accessories it knows about and called configureAccessory() on each.
api.on("didFinishLaunching" /* APIEvent.DID_FINISH_LAUNCHING */, () => void this.configureHydrawise());
}
// This gets called when homebridge restores cached accessories at startup. We intentionally avoid doing anything significant here, and save all that logic for
// Hydrawise API enumeration.
configureAccessory(accessory) {
// Add this to the accessory array so we can track it.
this.accessories.push(accessory);
}
// Configure and connect to the Hydrawise API.
async configureHydrawise() {
// Keep retrying until we're successful at regular intervals.
await retry(async () => {
// Get our list of controllers.
const response = await this.retrieve("customerdetails.php");
// Not found, let's retry again.
if (!response) {
return false;
}
try {
this.account = await response.body.json();
}
catch (error) {
this.log.error("Unable to retrieve the list of controllers: %s", util.inspect(error, { colors: true, depth: null, sorted: true }));
return false;
}
this.log.info("Successfully connected to the Hydrawise API.");
this.log.debug(util.inspect(this.account, { colors: true, depth: null, sorted: true }));
// Trim whitespace on irrigation controller names.
this.account.controllers = this.account.controllers.map(x => ({ ...x, name: x.name.trim() }));
for (const controller of this.account.controllers) {
this.log.info("Discovered irrigation controller: %s (serial: %s id: %s).", controller.name, controller.serial_number, controller.controller_id);
this.configureController(controller);
}
// Find all the orphaned irrigation controller accessories that aren't in the authoritative list provided by Hydrawise for this account and remove them.
this.accessories.filter(controller => !this.account.controllers.some(accessory => this.hap.uuid.generate(accessory.controller_id.toString()) === controller.UUID))
.map(accessory => this.removeAccessory(accessory));
return true;
}, HYDRAWISE_API_RETRY_INTERVAL * 1000);
}
// Configure a discovered irrigation controller.
configureController(controller) {
// Generate this controller's unique identifier.
const uuid = this.hap.uuid.generate(controller.controller_id.toString());
// See if we already know about this accessory or if it's truly new.
let accessory = this.accessories.find(x => x.UUID === uuid);
// Check to see if the user has disabled the device.
if (!this.featureOptions.test("Device", controller.controller_id.toString())) {
// If the accessory already exists, let's remove it.
if (accessory) {
this.removeAccessory(accessory);
}
// We're done.
return null;
}
// If we've already configured this device before, we're done.
if (this.configuredDevices[uuid]) {
return null;
}
// It's a new device - let's add it to HomeKit.
if (!accessory) {
accessory = new this.api.platformAccessory(controller.name, uuid);
// Register this accessory with Homebridge and add it to the accessory array so we can track it.
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.push(accessory);
}
// Inform the user.
this.log.info("Configuring irrigation controller: %s (serial: %s id: %s).", controller.name, controller.serial_number, controller.controller_id);
// Add it to our list of configured devices.
this.configuredDevices[uuid] = new HydrawiseController(this, accessory, controller);
// Refresh the accessory cache.
this.api.updatePlatformAccessories([accessory]);
return this.configuredDevices[uuid];
}
// Remove the accessory from HomeKit.
removeAccessory(accessory) {
// Inform the user.
this.log.info("%s: Removing device from HomeKit.", accessory.displayName);
// Unregister the accessory and delete it's remnants from HomeKit.
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.splice(this.accessories.indexOf(accessory), 1);
this.api.updatePlatformAccessories(this.accessories);
}
// Initialize our network stack.
initNetworking() {
// Create an interceptor that allows us to set the user agent to our liking.
const ua = (dispatch) => (opts, handler) => {
opts.headers ??= {};
opts.headers["user-agent"] = "homebridge-hunter-hydrawise";
return dispatch(opts, handler);
};
// Cleanup any existing dispatcher we may have.
void this.dispatcher?.destroy();
// We want to enable the use of HTTP/2, accept unauthorized SSL certificates and retry a request up to three times.
this.dispatcher = new Pool("https://api.hydrawise.com", { allowH2: true, clientTtl: 60 * 1000, connect: { rejectUnauthorized: false }, connections: 1 })
.compose(ua, interceptors.retry({ maxRetries: 3, maxTimeout: 5000, minTimeout: 1000, statusCodes: [400, 404, 429, 500, 502, 503, 504], timeoutFactor: 2 }));
setGlobalDispatcher(this.dispatcher);
}
// Communicate HTTP requests with the Hydrawise API.
async retrieve(endpoint, params) {
// Catch Hydrawise server-side issues:
//
// 400: Bad request.
// 404: Not found.
// 429: Too many requests.
// 500: Internal server error.
// 502: Bad gateway.
// 503: Service temporarily unavailable.
const serverErrors = new Set([400, 404, 429, 500, 502, 503]);
let response;
// Create a signal handler to deliver the abort operation.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HYDRAWISE_API_TIMEOUT * 1000);
const signal = controller.signal;
params ??= {};
// Set our API key.
// eslint-disable-next-line camelcase
params.api_key = this.config.apiKey;
const queryParams = new URLSearchParams(params);
// Construct our API call.
const url = "https://api.hydrawise.com/api/v1/" + endpoint + "?" + queryParams.toString();
try {
// Execute the API call.
response = await request(url, { signal: signal });
// Bad username and password.
if (response.statusCode === 404) {
this.log.error("Invalid API key. Please check your Hydrawise API key.");
return null;
}
// API rate limit exceeded.
if (response.statusCode === 429) {
this.log.error("Hydrawise API rate limit has been exceeded.");
return null;
}
// Some other unknown error occurred.
if (!(response.statusCode >= 200) && (response.statusCode < 300)) {
this.log.error(serverErrors.has(response.statusCode) ? "Hydrawise API is temporarily unavailable." : response.statusCode.toString() + ": " +
STATUS_CODES[response.statusCode]);
return null;
}
return response;
}
catch (error) {
// We aborted the connection.
if ((error instanceof DOMException) && (error.name === "AbortError")) {
this.log.error("The Hydrawise API is taking too long to respond to a request. This error can usually be safely ignored.");
this.log.debug("Original request was: %s", url);
// Reset our network stack, just in case.
this.initNetworking();
return null;
}
// Connection timed out.
if (error instanceof errors.ConnectTimeoutError) {
this.log.error("Connection timed out.");
return null;
}
// We destroyed the pool due to a reset event and our inflight connections are failing.
if (error instanceof errors.RequestRetryError) {
this.log.error("Unable to connect to the Hydrawise API. This is usually temporary and will retry automatically.");
return null;
}
if (error instanceof TypeError) {
const cause = error.cause;
switch (cause.code) {
case "ECONNREFUSED":
case "EHOSTDOWN":
this.log.error("Connection refused.");
break;
case "ECONNRESET":
this.log.error("Connection has been reset.");
break;
case "ENOTFOUND":
this.log.error("Hostname or IP address not found. Please ensure you're connected to the Internet.");
break;
default:
this.log.error("Error: %s | %s.", cause.code, cause.message);
this.log.error(util.inspect(error, { colors: true, depth: null, sorted: true }));
break;
}
return null;
}
this.log.error(util.inspect(error, { colors: true, depth: null, sorted: true }));
return null;
}
finally {
// Clear out our response timeout if needed.
clearTimeout(timeout);
}
}
// Utility for debug logging.
debug(message, ...parameters) {
if (this.config.debug) {
this.log.error(util.format(message, ...parameters));
}
}
}
//# sourceMappingURL=hydrawise-platform.js.map