diff --git a/backend/src/db.js b/backend/src/db.js index f11ab9c..75792e0 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -210,6 +210,16 @@ export const DEFAULT_HVAC_TEMPLATE = { trv_vacant_temp: 16.0, ac_occupied: { power: 'on', mode: 'cool', setpoint: 22.0, fan_speed: 'medium' }, ac_vacant: { power: 'off', mode: 'cool', setpoint: 18.0, fan_speed: 'low' }, + // Weather compensation — ordered ascending by peak_above_c. At an occupied-state + // transition, the scheduler picks the HIGHEST tier whose threshold today's forecast + // peak clears and uses its template instead of ac_occupied above; below every + // threshold (or if no forecast is available), ac_occupied is used unchanged. See + // lib/weather.js and resolveAcTemplate() in lib/scheduler.js. + ac_occupied_tiers: [ + { name: 'warm', peak_above_c: 24, power: 'off', mode: 'cool', setpoint: 20.0, fan_speed: 'medium' }, + { name: 'hot', peak_above_c: 28, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'high' }, + { name: 'heatwave', peak_above_c: 32, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'powerful' }, + ], } async function seedDefaults() { diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index 5412793..a643787 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -9,6 +9,7 @@ import { pool, getConfig, logActivity, DEFAULT_HVAC_TEMPLATE } from '../db.js' import { fetchBookings } from './newbook.js' import { batchSetZoneTemperature } from './mqtt.js' import { commandAcDevice } from './ac-control.js' +import { getPeakTempC } from './weather.js' const TZ = 'Europe/London' const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived'] @@ -176,13 +177,34 @@ async function applyTarget(zone, devices, tempC) { return { ok: successful > 0, successful, total: trvs.length } } +// Weather compensation — pick the HIGHEST tier in hvacDefaults.ac_occupied_tiers whose +// peak_above_c today's forecast peak clears (tiers are stored ascending; see +// DEFAULT_HVAC_TEMPLATE in db.js). Returns null (→ use the plain ac_occupied template) +// if there's no forecast, tiers aren't configured, or peakTempC doesn't clear even the +// lowest threshold. Global only for now — no per-zone tier overrides, weather is the +// same for the whole building. +function resolveAcTier(hvacDefaults, peakTempC) { + if (peakTempC == null) return null + const tiers = hvacDefaults.ac_occupied_tiers || [] + let picked = null + for (const tier of tiers) { + if (peakTempC >= Number(tier.peak_above_c)) picked = tier + } + return picked +} + // Resolve the effective occupied/vacant aircon template for a zone: each field is the // zone's own override if set, else the matching field from the global hvac_defaults // template — same per-field inherit-if-NULL relationship as the TRV temps/offsets. -function resolveAcTemplate(zone, newState, hvacDefaults) { +// On the occupied branch, a weather tier (if one clears its threshold) overrides the +// global ac_occupied template wholesale, BEFORE the zone's own per-field overrides are +// applied on top — a zone override should still win over weather, same precedence a +// staff member would expect from "I set this room's aircon manually". +function resolveAcTemplate(zone, newState, hvacDefaults, peakTempC) { const occupied = HEAT_STATES.includes(newState) const prefix = occupied ? 'ac_occupied' : 'ac_vacant' - const globalTemplate = (occupied ? hvacDefaults.ac_occupied : hvacDefaults.ac_vacant) || {} + const tier = occupied ? resolveAcTier(hvacDefaults, peakTempC) : null + const globalTemplate = tier || (occupied ? hvacDefaults.ac_occupied : hvacDefaults.ac_vacant) || {} const power = zone[`${prefix}_power`] ?? globalTemplate.power const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint return { @@ -190,33 +212,34 @@ function resolveAcTemplate(zone, newState, hvacDefaults) { mode: zone[`${prefix}_mode`] ?? globalTemplate.mode ?? undefined, tempC: setpoint != null ? Number(setpoint) : undefined, fanSpeed: zone[`${prefix}_fan_speed`] ?? globalTemplate.fan_speed ?? undefined, + tierApplied: tier?.name ?? null, } } -async function applyAcTargets(zone, devices, newState, hvacDefaults) { +async function applyAcTargets(zone, devices, newState, hvacDefaults, peakTempC) { const acDevices = devices.filter(d => AC_DEVICE_TYPES.includes(d.device_type)) if (!acDevices.length) return { ok: true, successful: 0, total: 0 } - const patch = resolveAcTemplate(zone, newState, hvacDefaults) + const patch = resolveAcTemplate(zone, newState, hvacDefaults, peakTempC) let successful = 0 for (const device of acDevices) { const result = await commandAcDevice(device, patch, { origin: 'automation' }) if (result.ok) successful++ } - return { ok: successful > 0, successful, total: acDevices.length } + return { ok: successful > 0, successful, total: acDevices.length, tierApplied: patch.tierApplied } } // Apply heating logic ONLY at state transitions — never poll-and-reassert during // 'occupied', which would stomp a guest's button/WS adjustment made mid-stay (or a // staff member's manual aircon adjustment via the control panel). -async function applyStateTransition(zone, oldState, newState, hvacDefaults) { +async function applyStateTransition(zone, oldState, newState, hvacDefaults, peakTempC) { const devices = await getZoneDevices(zone.id) const target = HEAT_STATES.includes(newState) ? Number(zone.occupied_temp ?? hvacDefaults.trv_occupied_temp) : Number(zone.vacant_temp ?? hvacDefaults.trv_vacant_temp) const trvResult = await applyTarget(zone, devices, target) - const acResult = await applyAcTargets(zone, devices, newState, hvacDefaults) + const acResult = await applyAcTargets(zone, devices, newState, hvacDefaults, peakTempC) const notes = [] if (trvResult.total > 0) { @@ -225,9 +248,10 @@ async function applyStateTransition(zone, oldState, newState, hvacDefaults) { : `TRV failed: ${target}°C (${trvResult.successful || 0}/${trvResult.total})`) } if (acResult.total > 0) { + const tierNote = acResult.tierApplied ? ` [${acResult.tierApplied} weather tier]` : '' notes.push(acResult.ok - ? `AC ${acResult.successful}/${acResult.total} commanded` - : `AC failed: ${acResult.successful}/${acResult.total}`) + ? `AC ${acResult.successful}/${acResult.total} commanded${tierNote}` + : `AC failed: ${acResult.successful}/${acResult.total}${tierNote}`) } await logActivity(zone.id, 'transition', { @@ -278,6 +302,11 @@ export async function pollOnce() { hvacDefaults: await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE), } + // Today's forecast peak, for weather-compensated AC tiers (resolveAcTemplate()). + // getPeakTempC() internally TTL-caches for an hour and never throws — a weather + // outage just means no tier gets applied this cycle, never a blocked poll. + const peakTempC = await getPeakTempC() + const now = DateTime.now().setZone(TZ) const today = now.toISODate() @@ -336,7 +365,7 @@ export async function pollOnce() { if (!zone.auto_mode || !automationEnabled) continue // manual override or master switch off — don't touch setpoints if (newState === oldState) continue // no transition — never poll-and-reassert - await applyStateTransition(zone, oldState, newState, defaults.hvacDefaults) + await applyStateTransition(zone, oldState, newState, defaults.hvacDefaults, peakTempC) } catch (err) { console.error(`[scheduler] zone ${zone.id} update failed:`, err.message) await logActivity(zone.id, 'error', { note: err.message, source: 'scheduler' }) diff --git a/backend/src/lib/weather.js b/backend/src/lib/weather.js new file mode 100644 index 0000000..f9aba68 --- /dev/null +++ b/backend/src/lib/weather.js @@ -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 + } +} diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 9055a39..07d3783 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -1,6 +1,7 @@ import { requireAuth, requireCap } from '../auth.js' import { pool, getConfig, DEFAULT_HVAC_TEMPLATE } from '../db.js' import { isConnected as mqttConnected } from '../lib/mqtt.js' +import { getPeakTempC } from '../lib/weather.js' export async function statusRoutes(app) { app.addHook('preHandler', requireAuth) @@ -8,6 +9,17 @@ export async function statusRoutes(app) { // GET /api/status — live status per zone: room state, target temp, device health/battery app.get('/api/status', { preHandler: requireCap('view') }, async () => { const hvacDefaults = await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE) + // Today's forecast peak — informational only here (what a NEW occupied transition + // would use right now); doesn't retroactively affect already-applied AC targets. + // getPeakTempC() is TTL-cached internally so this is cheap to call on every status poll. + const weatherPeakC = await getPeakTempC() + const tiers = hvacDefaults.ac_occupied_tiers || [] + let weatherTier = null + if (weatherPeakC != null) { + for (const tier of tiers) { + if (weatherPeakC >= Number(tier.peak_above_c)) weatherTier = tier.name + } + } const { rows: zones } = await pool.query(` SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status, zs.booking_kind, zs.current_night, zs.total_nights, zs.has_next_booking @@ -32,6 +44,8 @@ export async function statusRoutes(app) { return { mqtt_connected: mqttConnected(), + weather_peak_c: weatherPeakC, + weather_tier: weatherTier, zones: zones.map(z => ({ ...z, room_state: z.room_state || 'vacant', diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 40d144c..2b4b43a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -46,7 +46,7 @@ export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number; } // Status / activity -export function fetchStatus(): Promise<{ mqtt_connected: boolean; zones: ZoneStatus[] }> { +export function fetchStatus(): Promise<{ mqtt_connected: boolean; weather_peak_c: number | null; weather_tier: string | null; zones: ZoneStatus[] }> { return request('/status') } export function fetchActivity(zoneId?: number, limit = 100): Promise { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ead75ba..2fb2013 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react' import { Wifi, WifiOff } from 'lucide-react' import { fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook, - fetchConfig, updateConfig, fetchMqttStatus, createZone, + fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone, } from '../api' -import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate } from '../types' +import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types' import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types' export default function Settings() { @@ -33,6 +33,9 @@ export default function Settings() { const [mqttConnected, setMqttConnected] = useState(null) const [mqttNote, setMqttNote] = useState('') + const [weatherPeakC, setWeatherPeakC] = useState(null) + const [weatherTier, setWeatherTier] = useState(null) + const [publicAreaName, setPublicAreaName] = useState('') const [creatingZone, setCreatingZone] = useState(false) @@ -53,9 +56,12 @@ export default function Settings() { setDefaultDeparture(cfg.default_departure_time ?? '10:00:00') setMaintUrl(cfg.maintenance_url ?? '') setMaintKey(cfg.maintenance_api_key ?? '') - setDefaults(cfg.hvac_defaults ?? DEFAULT_HVAC_TEMPLATE) + // Merge onto the shipped default rather than replacing outright — an hvac_defaults + // row saved before weather compensation existed won't have ac_occupied_tiers yet. + setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers }) }).catch(() => {}) fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {}) + fetchStatus().then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) }).catch(() => {}) }, []) function toggleExcluded(siteId: string) { @@ -133,6 +139,13 @@ export default function Settings() { setDefaults(d => ({ ...d, [which]: { ...d[which], [field]: value } })) } + function updateTier(index: number, field: keyof HvacWeatherTier, value: string | number) { + setDefaults(d => ({ + ...d, + ac_occupied_tiers: d.ac_occupied_tiers.map((t, i) => i === index ? { ...t, [field]: value } : t), + })) + } + async function saveDefaults() { setDefaultsSaving(true); setError(''); setMsg('') try { @@ -323,6 +336,61 @@ export default function Settings() { +
Weather Compensation
+

+ Overrides the Occupied aircon template above on a hot day. At check-in/arrival, the scheduler + checks today's forecast peak (from Settings → Integrations → Hotel Location / Met Office DataHub, + falls back to Open-Meteo with no key needed) and uses the highest tier below whose threshold it + clears — below every threshold, the Default Aircon "Occupied" row above still applies unchanged. + Only re-checked at the moment a room becomes occupied, never mid-stay, so it can't override a + guest's own adjustment later in their booking. + {weatherPeakC != null && ( + <> Today's forecast peak: {weatherPeakC}°C{weatherTier ? <> — currently in the {weatherTier} tier : ' — below every tier'}. + )} +

+
+ + + + {defaults.ac_occupied_tiers.map((tier, i) => ( + + + + + + + + + ))} + +
TierIf peak above (°C)On/OffModeSetpoint (°C)Fan speed
{tier.name} + updateTier(i, 'peak_above_c', parseFloat(e.target.value) || 0)} + /> + + + + + + updateTier(i, 'setpoint', parseFloat(e.target.value) || 0)} + /> + + +
+
+
Default TRVs
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2249aa9..58dc2e5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -209,6 +209,15 @@ export interface HvacAcTemplate { fan_speed: string } +// Weather compensation tier — same shape as HvacAcTemplate plus the forecast-peak +// threshold that activates it. Tiers are stored ascending by peak_above_c; the +// scheduler picks the highest one today's forecast peak clears (see resolveAcTier() +// in backend/src/lib/scheduler.js), falling back to plain ac_occupied below all of them. +export interface HvacWeatherTier extends HvacAcTemplate { + name: string + peak_above_c: number +} + export interface HvacDefaults { arrival_prep_offset_min: number departure_prep_offset_min: number @@ -216,6 +225,7 @@ export interface HvacDefaults { trv_vacant_temp: number ac_occupied: HvacAcTemplate ac_vacant: HvacAcTemplate + ac_occupied_tiers: HvacWeatherTier[] } export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = { @@ -225,6 +235,11 @@ export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = { trv_vacant_temp: 16.0, ac_occupied: { power: 'on', mode: 'cool', setpoint: 22.0, fan_speed: 'medium' }, ac_vacant: { power: 'off', mode: 'cool', setpoint: 18.0, fan_speed: 'low' }, + ac_occupied_tiers: [ + { name: 'warm', peak_above_c: 24, power: 'off', mode: 'cool', setpoint: 20.0, fan_speed: 'medium' }, + { name: 'hot', peak_above_c: 28, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'high' }, + { name: 'heatwave', peak_above_c: 32, power: 'on', mode: 'cool', setpoint: 20.0, fan_speed: 'powerful' }, + ], } export interface AppConfig {