Skip to content
← Public packages

@kentcdodds/venstar

Typed Venstar thermostat helpers for climate summaries, comfort setpoints, away mode, schedules, sensors, and runtimes.

src/index.ts

127 lines · 8.1 KB · TypeScript
import { kody } from 'kody:runtime'

export type ThermostatMode = 'off' | 'heat' | 'cool' | 'auto'
export type FanMode = 'auto' | 'on'
export type ThermostatLookupParams = { /** Saved thermostat name or IP address. Omit only when the low-level connector has a default. */ thermostat?: string }
export type ClimateSummary = { ok: true; thermostats: unknown }
export type ThermostatInfoResult = { ok: true; thermostat?: string; info: unknown }
export type ThermostatDataResult = { ok: true; thermostat?: string; data: unknown }
export type SetComfortParams = ThermostatLookupParams & { /** Desired HVAC mode. */ mode?: ThermostatMode; /** Desired fan mode. */ fan?: FanMode; /** Heat setpoint in Fahrenheit. */ heattemp?: number; /** Cool setpoint in Fahrenheit. */ cooltemp?: number; /** Minimum gap required between heat and cool in auto mode. Defaults to 2. */ minAutoGap?: number }
export type SetAutoRangeParams = ThermostatLookupParams & { /** Heat setpoint in Fahrenheit. */ heattemp: number; /** Cool setpoint in Fahrenheit. */ cooltemp: number; /** Desired fan mode. */ fan?: FanMode; /** Minimum gap required between heat and cool. Defaults to 2. */ minAutoGap?: number }
export type SetAwayParams = ThermostatLookupParams & { /** true for away mode, false for home mode. */ away: boolean }
export type SetScheduleParams = ThermostatLookupParams & { /** true to enable the local thermostat schedule, false to disable it. */ schedule: boolean }
export type ThermostatMutationResult = { ok: true; thermostat?: string; request: Record<string, unknown>; result: unknown } | { ok: false; error: string }
export type VenstarThermostatsNamespace = { listThermostats: typeof listThermostats; summarizeClimate: typeof summarizeClimate; getInfo: typeof getInfo; getSensors: typeof getSensors; getRuntimes: typeof getRuntimes; setComfort: typeof setComfort; setAutoRange: typeof setAutoRange; setAway: typeof setAway; setSchedule: typeof setSchedule }

const MODE_TO_NUMBER: Record<ThermostatMode, number> = { off: 0, heat: 1, cool: 2, auto: 3 }
const FAN_TO_NUMBER: Record<FanMode, number> = { auto: 0, on: 1 }
function normalizeText(value: unknown): string { return typeof value === 'string' ? value.trim().toLowerCase() : '' }
function normalizeThermostat(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined }
function assertTemp(name: string, value: unknown): number | undefined {
  if (value === undefined) return undefined
  const numeric = Number(value)
  if (!Number.isFinite(numeric) || numeric < 40 || numeric > 99) throw new Error(name + ' must be a Fahrenheit setpoint between 40 and 99.')
  return numeric
}
function modeNumber(mode?: ThermostatMode): number | undefined { return mode ? MODE_TO_NUMBER[mode] : undefined }
function fanNumber(fan?: FanMode): number | undefined { return fan ? FAN_TO_NUMBER[fan] : undefined }
function validateAutoRange(heattemp: number, cooltemp: number, minAutoGap = 2): string | null { return cooltemp > heattemp + minAutoGap ? null : 'In auto mode, cooltemp must be greater than heattemp plus minAutoGap.' }

/** List managed Venstar thermostats with saved names/IPs and status summaries. */
export async function listThermostats(): Promise<unknown> { return await kody.mcp['home'].venstar_list_thermostats({}) }

/** Read all managed thermostats as a compact climate summary. */
export async function summarizeClimate(): Promise<ClimateSummary> { return { ok: true, thermostats: await listThermostats() } }

/** Fetch /query/info for one Venstar thermostat by saved name or IP. */
export async function getInfo(params: ThermostatLookupParams = {}): Promise<ThermostatInfoResult> {
  const thermostat = normalizeThermostat(params.thermostat)
  const info = await kody.mcp['home'].venstar_get_thermostat_info({ thermostat })
  return { ok: true, thermostat, info }
}

/** Fetch /query/sensors for one Venstar thermostat by saved name or IP. */
export async function getSensors(params: ThermostatLookupParams = {}): Promise<ThermostatDataResult> {
  const thermostat = normalizeThermostat(params.thermostat)
  const data = await kody.mcp['home'].venstar_get_thermostat_sensors({ thermostat })
  return { ok: true, thermostat, data }
}

/** Fetch /query/runtimes for one Venstar thermostat by saved name or IP. */
export async function getRuntimes(params: ThermostatLookupParams = {}): Promise<ThermostatDataResult> {
  const thermostat = normalizeThermostat(params.thermostat)
  const data = await kody.mcp['home'].venstar_get_thermostat_runtimes({ thermostat })
  return { ok: true, thermostat, data }
}

/** Set Venstar mode, fan, heat setpoint, and/or cool setpoint with typed strings. */
export async function setComfort(params: SetComfortParams): Promise<ThermostatMutationResult> {
  try {
    const thermostat = normalizeThermostat(params.thermostat)
    const heattemp = assertTemp('heattemp', params.heattemp)
    const cooltemp = assertTemp('cooltemp', params.cooltemp)
    if (params.mode === 'auto' && heattemp !== undefined && cooltemp !== undefined) {
      const error = validateAutoRange(heattemp, cooltemp, params.minAutoGap)
      if (error) return { ok: false, error }
    }
    const request: Record<string, unknown> = { thermostat }
    const mode = modeNumber(params.mode)
    const fan = fanNumber(params.fan)
    if (mode !== undefined) request.mode = mode
    if (fan !== undefined) request.fan = fan
    if (heattemp !== undefined) request.heattemp = heattemp
    if (cooltemp !== undefined) request.cooltemp = cooltemp
    const result = await kody.mcp['home'].venstar_control_thermostat(request)
    return { ok: true, thermostat, request, result }
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : String(error) }
  }
}

/** Set auto mode with validated heat/cool setpoints. */
export async function setAutoRange(params: SetAutoRangeParams): Promise<ThermostatMutationResult> {
  const missing: Array<string> = []
  if (params.heattemp === undefined) missing.push('heattemp')
  if (params.cooltemp === undefined) missing.push('cooltemp')
  if (missing.length) return { ok: false, error: missing.join(' and ') + (missing.length > 1 ? ' are' : ' is') + ' required for setAutoRange.' }
  return await setComfort({ ...params, mode: 'auto' })
}

/** Toggle Venstar away/home mode. */
export async function setAway(params: SetAwayParams): Promise<ThermostatMutationResult> {
  try {
    if (typeof params.away !== 'boolean') throw new Error('away must be an explicit boolean: true for away mode, false for home mode.')
    const thermostat = normalizeThermostat(params.thermostat)
    const request = { thermostat, away: params.away ? 1 : 0 }
    const result = await kody.mcp['home'].venstar_set_thermostat_settings(request)
    return { ok: true, thermostat, request, result }
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : String(error) }
  }
}

/** Enable or disable the thermostat's local schedule. */
export async function setSchedule(params: SetScheduleParams): Promise<ThermostatMutationResult> {
  try {
    if (typeof params.schedule !== 'boolean') throw new Error('schedule must be an explicit boolean: true to enable the local schedule, false to disable it.')
    const thermostat = normalizeThermostat(params.thermostat)
    const request = { thermostat, schedule: params.schedule ? 1 : 0 }
    const result = await kody.mcp['home'].venstar_set_thermostat_settings(request)
    return { ok: true, thermostat, request, result }
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : String(error) }
  }
}

/**
 * Default callable for `kody:@kentcdodds/venstar`; returns the typed Venstar helper namespace.
 *
 * @example
 * import venstarThermostats from 'kody:@kentcdodds/venstar'
 * const venstar = await venstarThermostats()
 * const info = await venstar.getInfo({ thermostat: 'apartment' })
 * // => { ok: true, thermostat: 'apartment', info: { mode: 3, heattemp: 68, cooltemp: 74, ... } }
 */
export default async function venstarThermostats(): Promise<VenstarThermostatsNamespace> {
  return { listThermostats, summarizeClimate, getInfo, getSensors, getRuntimes, setComfort, setAutoRange, setAway, setSchedule }
}