Skip to content
← Public packages

@kentcdodds/hydrawise

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

src/weekday-status.js

140 lines · 4.2 KB · JavaScript
import postMessage from 'kody:@kentcdodds/discord/post-message'
import { getControllers, getStatus, isSoftSkipError } from './index.js'

export { isSoftSkipError }

const REPORT_CHANNEL_ID = '1498718302237757654'

/**
 * Weekday Hydrawise status line for Discord #reporting. Read-only: controllers,
 * rain/sensors, and next zone run. Never runs or suspends zones. Soft-skips
 * Hydrawise outages and 429 rate limits instead of error-spamming.
 *
 * @param input
 * @param [input.dryRun] - Format the status without posting Discord.
 * @returns Status snapshot and optional Discord post.
 *
 * @example
 * import weekdayStatus from 'kody:@kentcdodds/hydrawise/weekday-status'
 * const result = await weekdayStatus({ dryRun: true })
 * // => { ok: true, dryRun: true, content: '🌱 **Hydrawise** · ...' }
 */
export default async function weekdayStatus(input = {}) {
	try {
		return await runWeekdayStatus(input)
	} catch (error) {
		if (isSoftSkipError(error)) {
			return {
				ok: true,
				skipped: true,
				reason: 'soft-skip',
				error: error instanceof Error ? error.message : String(error),
			}
		}
		throw error
	}
}

async function runWeekdayStatus(input = {}) {
	const [controllers, status] = await Promise.all([getControllers(), getStatus()])
	const content = formatStatus(controllers, status)
	if (input.dryRun) {
		return {
			ok: true,
			dryRun: true,
			content,
			controller: summarizeController(controllers),
			rain: rainSummary(status),
			nextRun: nextRunSummary(status),
			posted: false,
		}
	}
	const post = await postMessage({ channelId: REPORT_CHANNEL_ID, content })
	const messageId = post?.id || post?.message?.id || post?.messageId || null
	return {
		ok: true,
		posted: true,
		messageId,
		content,
		controller: summarizeController(controllers),
		rain: rainSummary(status),
		nextRun: nextRunSummary(status),
		post,
	}
}

function summarizeController(controllers) {
	const current =
		controllers.controllers?.find(
			(c) => c.id === controllers.currentControllerId,
		) || controllers.controllers?.[0]
	return {
		name: controllers.currentControllerName || current?.name || 'Hydrawise',
		id: controllers.currentControllerId || current?.id,
		lastContact: current?.lastContact,
		status: current?.status,
		count: controllers.controllers?.length || 0,
	}
}

function rainSummary(status) {
	const sensors = Array.isArray(status.sensors) ? status.sensors : []
	const rain = sensors.find((sensor) => {
		const name = String(sensor.name || sensor.type || '').toLowerCase()
		return name.includes('rain') || Number(sensor.type) === 1
	})
	if (!rain) {
		return { present: sensors.length > 0, wet: null, label: 'rain unknown' }
	}
	const input = rain.input ?? rain.active ?? rain.off
	const wet = input === 1 || input === true || String(rain.timer || '') === '1'
	return {
		present: true,
		wet,
		name: rain.name || 'Rain',
		label: wet ? 'rain: wet / delaying' : 'rain: dry',
		raw: { name: rain.name, type: rain.type, input: rain.input },
	}
}

function nextRunSummary(status) {
	const zones = Array.isArray(status.zones) ? status.zones : []
	const upcoming = zones
		.filter(
			(zone) =>
				typeof zone.nextRunInSeconds === 'number' &&
				zone.nextRunInSeconds >= 0 &&
				zone.nextRunInSeconds < 7 * 24 * 3600,
		)
		.sort((a, b) => a.nextRunInSeconds - b.nextRunInSeconds)
	const next = upcoming[0]
	if (!next) return { label: 'no run scheduled' }
	return {
		name: next.name,
		number: next.number,
		inSeconds: next.nextRunInSeconds,
		timeLabel: next.nextRunTimeLabel,
		durationSeconds: next.runDurationSeconds,
		label: next.nextRunTimeLabel
			? `${next.name} ${next.nextRunTimeLabel}`
			: `${next.name} in ${Math.round(next.nextRunInSeconds / 60)}m`,
	}
}

function formatStatus(controllers, status) {
	const controller = summarizeController(controllers)
	const rain = rainSummary(status)
	const next = nextRunSummary(status)
	const watering = (status.zones || []).filter(
		(zone) => zone.nextRunTimeLabel === 'Running now' || zone.nextRunInSeconds === 0,
	)
	const wateringBit = watering.length
		? `watering ${watering.map((z) => z.name).join(', ')}`
		: null
	return [
		`🌱 **Hydrawise** · ${controller.name} · ${rain.label} · next ${next.label}`,
		wateringBit ? `Now: ${wateringBit}` : null,
	]
		.filter(Boolean)
		.join('\n')
}