Add weather compensation tiers for occupied aircon defaults

Warm/hot/heatwave tiers (ascending peak_above_c thresholds) override the
global ac_occupied template when today's forecast peak clears one, picked
once at the occupied-state transition so a guest's later manual adjustment
is never stomped. New lib/weather.js fetches the peak via Met Office
DataHub (if configured in settings) or Open-Meteo (no key needed) as a
fallback, TTL-cached with a stale-fallback window on fetch failure.
Zone-level per-field overrides still apply on top of a picked tier.

Thresholds/templates live in hvac_defaults.ac_occupied_tiers (existing
JSONB config blob, no migration) and are editable on the Settings page's
new Weather Compensation table. Hotel lat/lon and the optional Met Office
API key live centrally in the settings app (hotel_location/met_office
integrations), not hvac-local, for reuse by other apps later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 11:01:41 +00:00
parent 48ffebc08d
commit 915fc0039c
7 changed files with 236 additions and 14 deletions

View file

@ -0,0 +1,86 @@
// Outdoor peak-temperature forecast, for weather-compensated AC defaults
// (resolveAcTemplate()'s tier pick in lib/scheduler.js). Hotel location and the
// optional Met Office DataHub API key both live centrally in the shared `settings`
// app (`hotel_location` / `met_office` integrations, plain HTTP-fetchable config,
// same pattern as lib/newbook.js's getCredentials()) — deliberately not hvac-local,
// so any future app that also wants "how hot is it outside today" can reuse them.
//
// Provider selection: Met Office DataHub if an api_key is configured AND the
// integration is enabled in settings (the existing enable/disable toggle IS the
// "use Met Office" switch); otherwise Open-Meteo, which needs no key at all. Either
// way this only ever returns TODAY's forecast high — not live/current temperature —
// so a tier, once picked at an occupied-state transition, doesn't need to fight
// hour-to-hour swings (see scheduler.js's applyStateTransition()).
const CACHE_TTL_MS = 60 * 60 * 1000 // 1h — forecasts don't move fast enough to poll more often
const STALE_FALLBACK_MS = 6 * 60 * 60 * 1000 // still serve a cached value up to 6h old if a refetch fails
let cache = { peakC: null, fetchedAt: 0 }
async function getIntegration(slug) {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/${slug}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (res.status === 404 || res.status === 403) return null // not found / disabled — not an error, just "not configured"
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching ${slug}`)
return res.json()
}
// NOTE: field name taken from the published Met Office Site Specific Forecast API
// (Daily) schema — not yet exercised against a live key, verify on first real test.
async function fetchFromMetOffice(lat, lon, apiKey) {
const res = await fetch(
`https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily?latitude=${lat}&longitude=${lon}`,
{ headers: { apikey: apiKey }, signal: AbortSignal.timeout(8000) }
)
if (!res.ok) throw new Error(`Met Office DataHub returned ${res.status}`)
const data = await res.json()
const today = data?.features?.[0]?.properties?.timeSeries?.[0]
const peak = today?.dayMaxScreenTemperature
if (typeof peak !== 'number') throw new Error('Met Office response missing dayMaxScreenTemperature')
return peak
}
async function fetchFromOpenMeteo(lat, lon) {
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&daily=temperature_2m_max&timezone=Europe%2FLondon&forecast_days=1`,
{ signal: AbortSignal.timeout(8000) }
)
if (!res.ok) throw new Error(`Open-Meteo returned ${res.status}`)
const data = await res.json()
const peak = data?.daily?.temperature_2m_max?.[0]
if (typeof peak !== 'number') throw new Error('Open-Meteo response missing temperature_2m_max')
return peak
}
// getPeakTempC() — today's forecast high (°C) for the hotel's location, or null if
// unavailable (location not configured in settings yet, both providers failed, and
// no cache fresh enough to fall back on). null means "use the baseline ac_occupied
// template, no weather tier" — same fail-safe posture as scheduler.js's NewBook
// fetch-failure path: never guess, just don't escalate.
export async function getPeakTempC() {
const age = Date.now() - cache.fetchedAt
if (cache.fetchedAt && age < CACHE_TTL_MS) return cache.peakC
try {
const location = await getIntegration('hotel_location')
const lat = location?.latitude
const lon = location?.longitude
if (!lat || !lon) {
console.warn('[weather] hotel_location not configured in settings — weather compensation disabled')
return cache.fetchedAt && age < STALE_FALLBACK_MS ? cache.peakC : null
}
const metOffice = await getIntegration('met_office')
const peakC = metOffice?.api_key
? await fetchFromMetOffice(lat, lon, metOffice.api_key)
: await fetchFromOpenMeteo(lat, lon)
cache = { peakC, fetchedAt: Date.now() }
return peakC
} catch (err) {
console.error('[weather] forecast fetch failed:', err.message)
return cache.fetchedAt && age < STALE_FALLBACK_MS ? cache.peakC : null
}
}