hvac/backend/src/lib/weather.js
jtricerolph 0321f0fc80 Fall back to Open-Meteo when a configured Met Office call fails
getPeakTempC() previously only tried Open-Meteo when NO Met Office api_key
was set at all — if a key was set but the call itself failed (wrong
DataHub product subscription, outage, etc.) the whole fetch went dark
with no fallback, so weather compensation silently stopped surfacing
anywhere. Root cause hit live: fetchFromMetOffice() calls the "Global
Spot" deterministic product, which is a separate DataHub subscription
from "Site-Specific Blended Probabilistic Forecast" — subscribing to the
wrong one 403s. Now any Met Office failure degrades to Open-Meteo instead
of returning nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:19:30 +00:00

107 lines
5.4 KiB
JavaScript

// 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, OR if the Met Office call fails for any reason
// (see the catch below), 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()).
//
// IMPORTANT: fetchFromMetOffice() below calls the "Global Spot" deterministic product
// (`sitespecific/v0/point/daily`) — a DIFFERENT Met Office DataHub subscription from
// the "Site-Specific Blended Probabilistic Forecast" product (percentile/probability
// bands, no plain max-temp field). Subscribing to the wrong one on the DataHub side
// gives a 403 here, which the catch below quietly degrades to Open-Meteo — confirmed
// live 2026-08-25.
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')
let peakC
if (metOffice?.api_key) {
try {
peakC = await fetchFromMetOffice(lat, lon, metOffice.api_key)
} catch (err) {
// Met Office configured but the call failed (wrong product subscription, key not
// yet propagated, outage, ...) — degrade to Open-Meteo rather than going dark.
// Common cause: the key is only subscribed to the Site-Specific Blended
// Probabilistic Forecast product, not the deterministic Global Spot one this
// fetches from — they're separate subscriptions on Met Office's side.
console.error('[weather] Met Office fetch failed, falling back to Open-Meteo:', err.message)
peakC = await fetchFromOpenMeteo(lat, lon)
}
} else {
peakC = 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
}
}