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:
parent
48ffebc08d
commit
915fc0039c
7 changed files with 236 additions and 14 deletions
86
backend/src/lib/weather.js
Normal file
86
backend/src/lib/weather.js
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue