← 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 · JavaScriptconst 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 || ''))
}
}