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

@ -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',