// 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 } }