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:
parent
e7a8153990
commit
5fc576aabd
6 changed files with 88 additions and 32 deletions
32
backend/src/lib/ac-template.js
Normal file
32
backend/src/lib/ac-template.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { fetchBookings } from './newbook.js'
|
||||||
import { batchSetZoneTemperature } from './mqtt.js'
|
import { batchSetZoneTemperature } from './mqtt.js'
|
||||||
import { commandAcDevice } from './ac-control.js'
|
import { commandAcDevice } from './ac-control.js'
|
||||||
import { getPeakTempC } from './weather.js'
|
import { getPeakTempC } from './weather.js'
|
||||||
|
import { resolveOccupiedAcTier } from './ac-template.js'
|
||||||
|
|
||||||
const TZ = 'Europe/London'
|
const TZ = 'Europe/London'
|
||||||
const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived']
|
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 }
|
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
|
// 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
|
// 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.
|
// 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
|
// On the occupied branch, a weather tier (if one clears its threshold, see
|
||||||
// global ac_occupied template wholesale, BEFORE the zone's own per-field overrides are
|
// lib/ac-template.js) overrides the global ac_occupied template wholesale, BEFORE the
|
||||||
// applied on top — a zone override should still win over weather, same precedence a
|
// zone's own per-field overrides are applied on top — a zone override should still win
|
||||||
// staff member would expect from "I set this room's aircon manually".
|
// over weather, same precedence a staff member would expect from "I set this room's
|
||||||
|
// aircon manually".
|
||||||
function resolveAcTemplate(zone, newState, hvacDefaults, peakTempC) {
|
function resolveAcTemplate(zone, newState, hvacDefaults, peakTempC) {
|
||||||
const occupied = HEAT_STATES.includes(newState)
|
const occupied = HEAT_STATES.includes(newState)
|
||||||
const prefix = occupied ? 'ac_occupied' : 'ac_vacant'
|
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 globalTemplate = tier || (occupied ? hvacDefaults.ac_occupied : hvacDefaults.ac_vacant) || {}
|
||||||
const power = zone[`${prefix}_power`] ?? globalTemplate.power
|
const power = zone[`${prefix}_power`] ?? globalTemplate.power
|
||||||
const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint
|
const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { requireAuth, requireCap } from '../auth.js'
|
||||||
import { pool, getConfig, DEFAULT_HVAC_TEMPLATE } from '../db.js'
|
import { pool, getConfig, DEFAULT_HVAC_TEMPLATE } from '../db.js'
|
||||||
import { isConnected as mqttConnected } from '../lib/mqtt.js'
|
import { isConnected as mqttConnected } from '../lib/mqtt.js'
|
||||||
import { getPeakTempC } from '../lib/weather.js'
|
import { getPeakTempC } from '../lib/weather.js'
|
||||||
|
import { resolveGlobalOccupiedAcTemplate } from '../lib/ac-template.js'
|
||||||
|
|
||||||
export async function statusRoutes(app) {
|
export async function statusRoutes(app) {
|
||||||
app.addHook('preHandler', requireAuth)
|
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.
|
// 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.
|
// getPeakTempC() is TTL-cached internally so this is cheap to call on every status poll.
|
||||||
const weatherPeakC = await getPeakTempC()
|
const weatherPeakC = await getPeakTempC()
|
||||||
const tiers = hvacDefaults.ac_occupied_tiers || []
|
// What ANY room transitioning into occupied right now would get — the same
|
||||||
let weatherTier = null
|
// resolution the scheduler itself uses (lib/ac-template.js), shown here purely for
|
||||||
if (weatherPeakC != null) {
|
// visibility (Dashboard summary banner). Doesn't reflect per-zone overrides.
|
||||||
for (const tier of tiers) {
|
const activeAcTemplate = resolveGlobalOccupiedAcTemplate(hvacDefaults, weatherPeakC)
|
||||||
if (weatherPeakC >= Number(tier.peak_above_c)) weatherTier = tier.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { rows: zones } = await pool.query(`
|
const { rows: zones } = await pool.query(`
|
||||||
SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status,
|
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
|
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 {
|
return {
|
||||||
mqtt_connected: mqttConnected(),
|
mqtt_connected: mqttConnected(),
|
||||||
weather_peak_c: weatherPeakC,
|
weather_peak_c: weatherPeakC,
|
||||||
weather_tier: weatherTier,
|
weather_tier: activeAcTemplate.tierApplied,
|
||||||
|
active_ac_template: activeAcTemplate,
|
||||||
zones: zones.map(z => ({
|
zones: zones.map(z => ({
|
||||||
...z,
|
...z,
|
||||||
room_state: z.room_state || 'vacant',
|
room_state: z.room_state || 'vacant',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
||||||
MhiGateway, MhiStatus,
|
MhiGateway, MhiStatus, ActiveAcTemplate,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const BASE = '/hvac/api'
|
const BASE = '/hvac/api'
|
||||||
|
|
@ -46,7 +46,13 @@ export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status / activity
|
// Status / activity
|
||||||
export function fetchStatus(): Promise<{ mqtt_connected: boolean; weather_peak_c: number | null; weather_tier: string | null; zones: ZoneStatus[] }> {
|
export function fetchStatus(): Promise<{
|
||||||
|
mqtt_connected: boolean
|
||||||
|
weather_peak_c: number | null
|
||||||
|
weather_tier: string | null
|
||||||
|
active_ac_template: ActiveAcTemplate
|
||||||
|
zones: ZoneStatus[]
|
||||||
|
}> {
|
||||||
return request('/status')
|
return request('/status')
|
||||||
}
|
}
|
||||||
export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEntry[]> {
|
export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEntry[]> {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,32 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { WifiOff } from 'lucide-react'
|
import { WifiOff } from 'lucide-react'
|
||||||
import { fetchStatus } from '../api'
|
import { fetchStatus } from '../api'
|
||||||
import type { ZoneStatus } from '../types'
|
import type { ZoneStatus, ActiveAcTemplate } from '../types'
|
||||||
import ZoneCard from '../components/ZoneCard'
|
import ZoneCard from '../components/ZoneCard'
|
||||||
import ZoneDetailModal from '../components/ZoneDetailModal'
|
import ZoneDetailModal from '../components/ZoneDetailModal'
|
||||||
|
|
||||||
const POLL_MS = 30000
|
const POLL_MS = 30000
|
||||||
|
|
||||||
|
function cap(s: string) {
|
||||||
|
return s[0].toUpperCase() + s.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [zones, setZones] = useState<ZoneStatus[]>([])
|
const [zones, setZones] = useState<ZoneStatus[]>([])
|
||||||
const [mqttConnected, setMqttConnected] = useState(true)
|
const [mqttConnected, setMqttConnected] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [selected, setSelected] = useState<ZoneStatus | null>(null)
|
const [selected, setSelected] = useState<ZoneStatus | null>(null)
|
||||||
|
const [weatherPeakC, setWeatherPeakC] = useState<number | null>(null)
|
||||||
|
const [activeAcTemplate, setActiveAcTemplate] = useState<ActiveAcTemplate | null>(null)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchStatus()
|
fetchStatus()
|
||||||
.then(res => {
|
.then(res => {
|
||||||
setZones(res.zones)
|
setZones(res.zones)
|
||||||
setMqttConnected(res.mqtt_connected)
|
setMqttConnected(res.mqtt_connected)
|
||||||
|
setWeatherPeakC(res.weather_peak_c)
|
||||||
|
setActiveAcTemplate(res.active_ac_template)
|
||||||
setError('')
|
setError('')
|
||||||
})
|
})
|
||||||
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load status'))
|
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load status'))
|
||||||
|
|
@ -42,6 +50,23 @@ export default function Dashboard() {
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{weatherPeakC != null && activeAcTemplate && (
|
||||||
|
<div className="card" style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap',
|
||||||
|
marginBottom: 16, fontSize: 13,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
Forecast high today: <strong style={{ color: 'var(--gold)' }}>{weatherPeakC}°C</strong>
|
||||||
|
</div>
|
||||||
|
<div className="muted">
|
||||||
|
Aircon default: <strong style={{ color: 'var(--text-dark)' }}>{activeAcTemplate.power === 'on' ? 'ON' : 'OFF'}</strong>
|
||||||
|
{activeAcTemplate.setpoint != null && <>, {activeAcTemplate.setpoint}°C</>}
|
||||||
|
{activeAcTemplate.fan_speed && <>, {cap(activeAcTemplate.fan_speed)} fan</>}
|
||||||
|
{activeAcTemplate.tierApplied && <> — <strong>{cap(activeAcTemplate.tierApplied)}</strong> tier</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{!mqttConnected && (
|
{!mqttConnected && (
|
||||||
<div className="error-banner" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="error-banner" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,14 @@ export interface HvacWeatherTier extends HvacAcTemplate {
|
||||||
peak_above_c: number
|
peak_above_c: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What GET /api/status resolves as "the occupied AC template any room transitioning
|
||||||
|
// into occupied right now would get" — hvac_defaults.ac_occupied, or a weather tier
|
||||||
|
// override on top if one clears (tierApplied names it; null means the plain baseline
|
||||||
|
// applies). Doesn't reflect any individual room's own per-field overrides.
|
||||||
|
export interface ActiveAcTemplate extends HvacAcTemplate {
|
||||||
|
tierApplied: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface HvacDefaults {
|
export interface HvacDefaults {
|
||||||
arrival_prep_offset_min: number
|
arrival_prep_offset_min: number
|
||||||
departure_prep_offset_min: number
|
departure_prep_offset_min: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue