Skip to content
← Public packages

@kentcdodds/hydrawise

Server-side Hunter Hydrawise controller and zone helpers backed by the hydrawiseApiKey secret.

src/index.js

320 lines · 8.6 KB · JavaScript
const API_BASE = 'https://api.hydrawise.com/api/v1/'
const API_KEY_PLACEHOLDER = '{{secret:hydrawiseApiKey}}'

function assertObject(value, name) {
	if (value === null || typeof value !== 'object' || Array.isArray(value)) {
		throw new TypeError(name + ' must be an object')
	}
}

function optionalInteger(value, name) {
	if (value === undefined || value === null || value === '') return undefined
	const number = Number(value)
	if (!Number.isInteger(number)) throw new TypeError(name + ' must be an integer')
	return number
}

function requiredInteger(value, name) {
	const number = optionalInteger(value, name)
	if (number === undefined) throw new TypeError(name + ' is required')
	return number
}

function requiredPositiveInteger(value, name) {
	const number = requiredInteger(value, name)
	if (number <= 0) throw new RangeError(name + ' must be greater than 0')
	return number
}

function encodeParams(params = {}) {
	const entries = []
	for (const [key, value] of Object.entries(params)) {
		if (value === undefined || value === null || value === '') continue
		entries.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(value)))
	}
	return entries.length ? '&' + entries.join('&') : ''
}

/**
 * Compact Hydrawise REST request helper.
 * Appends `api_key={{secret:hydrawiseApiKey}}` (resolved by Kody's fetch gateway),
 * builds query params, and returns parsed JSON. No OpenAPI scaffold — Hydrawise
 * publishes only a PDF REST guide, and query-param secrets are not a scaffold auth kind.
 *
 * Important: keep the secret placeholder unencoded. `URLSearchParams` percent-encodes
 * `{`/`}` and the fetch gateway will not resolve `%7B%7Bsecret:...%7D%7D`.
 */
export async function request(endpoint, query = {}) {
	const path = String(endpoint || '').replace(/^\//, '')
	if (!path || path.includes('://') || path.includes('..') || path.includes('?')) {
		throw new Error('Hydrawise endpoint must be a relative API path (e.g. statusschedule.php).')
	}
	const href = API_BASE + path + '?api_key=' + API_KEY_PLACEHOLDER + encodeParams(query)
	const url = new URL(href)
	if (url.origin !== 'https://api.hydrawise.com' || !url.pathname.startsWith('/api/v1/')) {
		throw new Error('Hydrawise requests must stay on https://api.hydrawise.com/api/v1/.')
	}
	const response = await fetch(href, { headers: { accept: 'application/json' } })
	const text = await response.text()
	let data
	try {
		data = text ? JSON.parse(text) : null
	} catch {
		data = undefined
	}
	if (data === undefined) {
		const preview = text.slice(0, 300)
		if (!response.ok) {
			throw hydrawiseHttpError(
				response.status,
				preview || response.statusText || 'Hydrawise request failed',
			)
		}
		throw hydrawiseHttpError(response.status, 'non-JSON: ' + preview)
	}
	if (!response.ok) {
		const message = data?.message || data?.error || response.statusText || 'Hydrawise request failed'
		throw hydrawiseHttpError(response.status, message)
	}
	if (data?.message_type === 'error') {
		throw new Error('Hydrawise command failed: ' + (data.message || 'unknown error'))
	}
	return data
}

function hydrawiseHttpError(status, message) {
	const error = new Error('Hydrawise request failed with status ' + status + ': ' + message)
	error.status = status
	return error
}

const SOFT_SKIP_STATUSES = new Set([429, 502, 503, 504])
const SOFT_SKIP_NEEDLES = [
	'timeout',
	'etimedout',
	'unavailable',
	' 429',
	' 502',
	' 503',
	' 504',
	'network',
	'fetch failed',
	'durable object',
	'overload',
	'entitlement',
	'rate limit',
	'too many requests',
	'exceeded maximum',
]

/**
 * True for Hydrawise / transport blips that scheduled status should skip
 * instead of recording an Activity error (rate limits, gateway outages).
 */
export function isSoftSkipError(error) {
	const status = error?.status ?? error?.statusCode
	if (typeof status === 'number' && SOFT_SKIP_STATUSES.has(status)) return true
	const text = [error?.message, error?.name, error?.code, String(error ?? '')]
		.filter(Boolean)
		.join(' ')
		.toLowerCase()
	return SOFT_SKIP_NEEDLES.some((needle) => text.includes(needle))
}

function normalizeController(controller) {
	return {
		id: controller.controller_id,
		name: controller.name,
		lastContact: controller.last_contact,
		hasSerialNumber: Boolean(controller.serial_number),
		status: controller.status,
	}
}

function normalizeZone(zone) {
	return {
		id: zone.relay_id,
		number: zone.relay,
		name: zone.name,
		nextRunInSeconds: zone.time,
		nextRunTimeLabel: zone.timestr,
		runDurationSeconds: zone.run,
		type: zone.type,
		periodSeconds: zone.period,
		masterZone: zone.master,
		masterTimerSeconds: zone.master_timer,
	}
}

function controllerParam(input = {}) {
	assertObject(input, 'input')
	return { controller_id: optionalInteger(input.controllerId, 'controllerId') }
}

export async function getControllers() {
	const data = await request('customerdetails.php')
	return {
		customerId: data.customer_id,
		currentControllerId: data.controller_id,
		currentControllerName: data.current_controller,
		controllers: Array.isArray(data.controllers) ? data.controllers.map(normalizeController) : [],
		raw: data,
	}
}

export async function getStatus(input = {}) {
	const data = await request('statusschedule.php', controllerParam(input))
	return {
		time: data.time,
		nextPollSeconds: data.nextpoll,
		message: data.message,
		zones: Array.isArray(data.relays) ? data.relays.map(normalizeZone) : [],
		sensors: Array.isArray(data.sensors) ? data.sensors : [],
		raw: data,
	}
}

export async function listZones(input = {}) {
	const status = await getStatus(input)
	return { nextPollSeconds: status.nextPollSeconds, zones: status.zones }
}

async function setZone(action, params = {}, input = {}) {
	assertObject(input, 'input')
	const data = await request('setzone.php', { action, ...controllerParam(input), ...params })
	return {
		ok: data.message_type === 'info' || Boolean(data.message),
		message: data.message,
		messageType: data.message_type,
		raw: data,
	}
}

export async function runZone(input) {
	assertObject(input, 'input')
	const errors = []
	let relayId
	let seconds
	try {
		relayId = requiredInteger(input.relayId, 'relayId')
	} catch (error) {
		errors.push(error instanceof Error ? error.message : String(error))
	}
	try {
		seconds = requiredPositiveInteger(input.seconds, 'seconds')
	} catch (error) {
		errors.push(error instanceof Error ? error.message : String(error))
	}
	if (errors.length) throw new TypeError(errors.join('; '))
	return setZone(
		'run',
		{
			relay_id: relayId,
			period_id: 999,
			custom: seconds,
		},
		input,
	)
}

export async function runAllZones(input) {
	assertObject(input, 'input')
	return setZone(
		'runall',
		{
			period_id: 999,
			custom: requiredPositiveInteger(input.seconds, 'seconds'),
		},
		input,
	)
}

export async function stopZone(input) {
	assertObject(input, 'input')
	return setZone('stop', { relay_id: requiredInteger(input.relayId, 'relayId') }, input)
}

export async function stopAllZones(input = {}) {
	return setZone('stopall', {}, input)
}

export async function suspendZone(input) {
	assertObject(input, 'input')
	return setZone(
		'suspend',
		{
			relay_id: requiredInteger(input.relayId, 'relayId'),
			period_id: 999,
			custom: requiredInteger(input.untilEpochSeconds, 'untilEpochSeconds'),
		},
		input,
	)
}

export async function suspendAllZones(input) {
	assertObject(input, 'input')
	return setZone(
		'suspendall',
		{
			period_id: 999,
			custom: requiredInteger(input.untilEpochSeconds, 'untilEpochSeconds'),
		},
		input,
	)
}

export async function resumeZoneSchedule(input) {
	assertObject(input, 'input')
	return setZone(
		'suspend',
		{
			relay_id: requiredInteger(input.relayId, 'relayId'),
			period_id: 999,
			custom: Math.floor(Date.now() / 1e3) - 60,
		},
		input,
	)
}

export async function resumeAllZoneSchedules(input = {}) {
	return setZone(
		'suspendall',
		{
			period_id: 999,
			custom: Math.floor(Date.now() / 1e3) - 60,
		},
		input,
	)
}

export default async function dispatch(input = {}) {
	if (input == null) return getControllers()
	assertObject(input, 'input')
	switch (input.action) {
		case undefined:
		case 'get-controllers':
			return getControllers()
		case 'get-status':
			return getStatus(input)
		case 'list-zones':
			return listZones(input)
		case 'run-zone':
			return runZone(input)
		case 'run-all-zones':
			return runAllZones(input)
		case 'stop-zone':
			return stopZone(input)
		case 'stop-all-zones':
			return stopAllZones(input)
		case 'suspend-zone':
			return suspendZone(input)
		case 'suspend-all-zones':
			return suspendAllZones(input)
		case 'resume-zone-schedule':
			return resumeZoneSchedule(input)
		case 'resume-all-zone-schedules':
			return resumeAllZoneSchedules(input)
		default:
			throw new Error('Unknown Hydrawise action: ' + String(input.action || ''))
	}
}