Show weather compensation summary banner on the Dashboard

New lib/ac-template.js factors the "which occupied AC template applies
right now" resolution out of scheduler.js (dedupes the tier-pick loop
that used to live separately in scheduler.js and status.js) into a
shared resolveOccupiedAcTier()/resolveGlobalOccupiedAcTemplate() pair.
GET /api/status now returns active_ac_template alongside weather_peak_c.

Dashboard shows a small banner above the zone grid: "Forecast high
today: XC" + "Aircon default: ON, 20C, High fan — Warm tier" — so staff
don't have to visit Settings to see what today's weather tier resolved
to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 11:42:01 +00:00
parent e7a8153990
commit 5fc576aabd
6 changed files with 88 additions and 32 deletions

View file

@ -0,0 +1,32 @@
// "Which occupied AC template applies right now" — shared by lib/scheduler.js (to
// actually command devices at a state transition) and routes/status.js (to show what's
// currently in effect on the Dashboard), so the weather-tier pick logic only lives once.
// Highest tier in hvacDefaults.ac_occupied_tiers whose peak_above_c today's forecast
// peak clears (tiers stored ascending — see DEFAULT_HVAC_TEMPLATE in db.js). null if
// there's no forecast, no tiers configured, or peakTempC doesn't clear even the lowest.
export function resolveOccupiedAcTier(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
}
// The GLOBAL template that would apply to any room transitioning into occupied right
// now — hvacDefaults.ac_occupied, or a weather tier override on top if one clears. Does
// NOT include per-zone field overrides (those are zone-specific and applied on top of
// this in scheduler.js's resolveAcTemplate()).
export function resolveGlobalOccupiedAcTemplate(hvacDefaults, peakTempC) {
const tier = resolveOccupiedAcTier(hvacDefaults, peakTempC)
const base = tier || hvacDefaults.ac_occupied || {}
return {
power: base.power,
mode: base.mode,
setpoint: base.setpoint,
fan_speed: base.fan_speed,
tierApplied: tier?.name ?? null,
}
}

View file

@ -10,6 +10,7 @@ import { fetchBookings } from './newbook.js'
import { batchSetZoneTemperature } from './mqtt.js'
import { commandAcDevice } from './ac-control.js'
import { getPeakTempC } from './weather.js'
import { resolveOccupiedAcTier } from './ac-template.js'
const TZ = 'Europe/London'
const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived']
@ -181,33 +182,18 @@ 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.
// 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".
// On the occupied branch, a weather tier (if one clears its threshold, see
// lib/ac-template.js) 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 tier = occupied ? resolveAcTier(hvacDefaults, peakTempC) : null
const tier = occupied ? resolveOccupiedAcTier(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

View file

@ -2,6 +2,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'
import { resolveGlobalOccupiedAcTemplate } from '../lib/ac-template.js'
export async function statusRoutes(app) {
app.addHook('preHandler', requireAuth)
@ -13,13 +14,10 @@ export async function statusRoutes(app) {
// 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
}
}
// What ANY room transitioning into occupied right now would get — the same
// resolution the scheduler itself uses (lib/ac-template.js), shown here purely for
// visibility (Dashboard summary banner). Doesn't reflect per-zone overrides.
const activeAcTemplate = resolveGlobalOccupiedAcTemplate(hvacDefaults, weatherPeakC)
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, zs.vacant_nights
@ -45,7 +43,8 @@ export async function statusRoutes(app) {
return {
mqtt_connected: mqttConnected(),
weather_peak_c: weatherPeakC,
weather_tier: weatherTier,
weather_tier: activeAcTemplate.tierApplied,
active_ac_template: activeAcTemplate,
zones: zones.map(z => ({
...z,
room_state: z.room_state || 'vacant',