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
|
|
@ -210,6 +210,16 @@ export const DEFAULT_HVAC_TEMPLATE = {
|
||||||
trv_vacant_temp: 16.0,
|
trv_vacant_temp: 16.0,
|
||||||
ac_occupied: { power: 'on', mode: 'cool', setpoint: 22.0, fan_speed: 'medium' },
|
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_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() {
|
async function seedDefaults() {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { pool, getConfig, logActivity, DEFAULT_HVAC_TEMPLATE } from '../db.js'
|
||||||
import { fetchBookings } from './newbook.js'
|
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'
|
||||||
|
|
||||||
const TZ = 'Europe/London'
|
const TZ = 'Europe/London'
|
||||||
const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived']
|
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 }
|
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.
|
||||||
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 occupied = HEAT_STATES.includes(newState)
|
||||||
const prefix = occupied ? 'ac_occupied' : 'ac_vacant'
|
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 power = zone[`${prefix}_power`] ?? globalTemplate.power
|
||||||
const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint
|
const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint
|
||||||
return {
|
return {
|
||||||
|
|
@ -190,33 +212,34 @@ function resolveAcTemplate(zone, newState, hvacDefaults) {
|
||||||
mode: zone[`${prefix}_mode`] ?? globalTemplate.mode ?? undefined,
|
mode: zone[`${prefix}_mode`] ?? globalTemplate.mode ?? undefined,
|
||||||
tempC: setpoint != null ? Number(setpoint) : undefined,
|
tempC: setpoint != null ? Number(setpoint) : undefined,
|
||||||
fanSpeed: zone[`${prefix}_fan_speed`] ?? globalTemplate.fan_speed ?? 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))
|
const acDevices = devices.filter(d => AC_DEVICE_TYPES.includes(d.device_type))
|
||||||
if (!acDevices.length) return { ok: true, successful: 0, total: 0 }
|
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
|
let successful = 0
|
||||||
for (const device of acDevices) {
|
for (const device of acDevices) {
|
||||||
const result = await commandAcDevice(device, patch, { origin: 'automation' })
|
const result = await commandAcDevice(device, patch, { origin: 'automation' })
|
||||||
if (result.ok) successful++
|
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
|
// 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
|
// '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).
|
// 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 devices = await getZoneDevices(zone.id)
|
||||||
const target = HEAT_STATES.includes(newState)
|
const target = HEAT_STATES.includes(newState)
|
||||||
? Number(zone.occupied_temp ?? hvacDefaults.trv_occupied_temp)
|
? Number(zone.occupied_temp ?? hvacDefaults.trv_occupied_temp)
|
||||||
: Number(zone.vacant_temp ?? hvacDefaults.trv_vacant_temp)
|
: Number(zone.vacant_temp ?? hvacDefaults.trv_vacant_temp)
|
||||||
|
|
||||||
const trvResult = await applyTarget(zone, devices, target)
|
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 = []
|
const notes = []
|
||||||
if (trvResult.total > 0) {
|
if (trvResult.total > 0) {
|
||||||
|
|
@ -225,9 +248,10 @@ async function applyStateTransition(zone, oldState, newState, hvacDefaults) {
|
||||||
: `TRV failed: ${target}°C (${trvResult.successful || 0}/${trvResult.total})`)
|
: `TRV failed: ${target}°C (${trvResult.successful || 0}/${trvResult.total})`)
|
||||||
}
|
}
|
||||||
if (acResult.total > 0) {
|
if (acResult.total > 0) {
|
||||||
|
const tierNote = acResult.tierApplied ? ` [${acResult.tierApplied} weather tier]` : ''
|
||||||
notes.push(acResult.ok
|
notes.push(acResult.ok
|
||||||
? `AC ${acResult.successful}/${acResult.total} commanded`
|
? `AC ${acResult.successful}/${acResult.total} commanded${tierNote}`
|
||||||
: `AC failed: ${acResult.successful}/${acResult.total}`)
|
: `AC failed: ${acResult.successful}/${acResult.total}${tierNote}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await logActivity(zone.id, 'transition', {
|
await logActivity(zone.id, 'transition', {
|
||||||
|
|
@ -278,6 +302,11 @@ export async function pollOnce() {
|
||||||
hvacDefaults: await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE),
|
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 now = DateTime.now().setZone(TZ)
|
||||||
const today = now.toISODate()
|
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 (!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
|
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) {
|
} catch (err) {
|
||||||
console.error(`[scheduler] zone ${zone.id} update failed:`, err.message)
|
console.error(`[scheduler] zone ${zone.id} update failed:`, err.message)
|
||||||
await logActivity(zone.id, 'error', { note: err.message, source: 'scheduler' })
|
await logActivity(zone.id, 'error', { note: err.message, source: 'scheduler' })
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { requireAuth, requireCap } from '../auth.js'
|
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'
|
||||||
|
|
||||||
export async function statusRoutes(app) {
|
export async function statusRoutes(app) {
|
||||||
app.addHook('preHandler', requireAuth)
|
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
|
// GET /api/status — live status per zone: room state, target temp, device health/battery
|
||||||
app.get('/api/status', { preHandler: requireCap('view') }, async () => {
|
app.get('/api/status', { preHandler: requireCap('view') }, async () => {
|
||||||
const hvacDefaults = await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE)
|
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(`
|
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.booking_kind, zs.current_night, zs.total_nights, zs.has_next_booking
|
||||||
|
|
@ -32,6 +44,8 @@ export async function statusRoutes(app) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mqtt_connected: mqttConnected(),
|
mqtt_connected: mqttConnected(),
|
||||||
|
weather_peak_c: weatherPeakC,
|
||||||
|
weather_tier: weatherTier,
|
||||||
zones: zones.map(z => ({
|
zones: zones.map(z => ({
|
||||||
...z,
|
...z,
|
||||||
room_state: z.room_state || 'vacant',
|
room_state: z.room_state || 'vacant',
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status / activity
|
// 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')
|
return request('/status')
|
||||||
}
|
}
|
||||||
export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEntry[]> {
|
export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEntry[]> {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import { useEffect, useState } from 'react'
|
||||||
import { Wifi, WifiOff } from 'lucide-react'
|
import { Wifi, WifiOff } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
|
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
|
||||||
fetchConfig, updateConfig, fetchMqttStatus, createZone,
|
fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone,
|
||||||
} from '../api'
|
} 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'
|
import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
|
@ -33,6 +33,9 @@ export default function Settings() {
|
||||||
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
|
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
|
||||||
const [mqttNote, setMqttNote] = useState('')
|
const [mqttNote, setMqttNote] = useState('')
|
||||||
|
|
||||||
|
const [weatherPeakC, setWeatherPeakC] = useState<number | null>(null)
|
||||||
|
const [weatherTier, setWeatherTier] = useState<string | null>(null)
|
||||||
|
|
||||||
const [publicAreaName, setPublicAreaName] = useState('')
|
const [publicAreaName, setPublicAreaName] = useState('')
|
||||||
const [creatingZone, setCreatingZone] = useState(false)
|
const [creatingZone, setCreatingZone] = useState(false)
|
||||||
|
|
||||||
|
|
@ -53,9 +56,12 @@ export default function Settings() {
|
||||||
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
|
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
|
||||||
setMaintUrl(cfg.maintenance_url ?? '')
|
setMaintUrl(cfg.maintenance_url ?? '')
|
||||||
setMaintKey(cfg.maintenance_api_key ?? '')
|
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(() => {})
|
}).catch(() => {})
|
||||||
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).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) {
|
function toggleExcluded(siteId: string) {
|
||||||
|
|
@ -133,6 +139,13 @@ export default function Settings() {
|
||||||
setDefaults(d => ({ ...d, [which]: { ...d[which], [field]: value } }))
|
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() {
|
async function saveDefaults() {
|
||||||
setDefaultsSaving(true); setError(''); setMsg('')
|
setDefaultsSaving(true); setError(''); setMsg('')
|
||||||
try {
|
try {
|
||||||
|
|
@ -323,6 +336,61 @@ export default function Settings() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title">Weather Compensation</div>
|
||||||
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||||
|
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: <strong>{weatherPeakC}°C</strong>{weatherTier ? <> — currently in the <strong>{weatherTier}</strong> tier</> : ' — below every tier'}.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="table-wrap" style={{ marginBottom: 24 }}>
|
||||||
|
<table className="data">
|
||||||
|
<thead><tr><th>Tier</th><th>If peak above (°C)</th><th>On/Off</th><th>Mode</th><th>Setpoint (°C)</th><th>Fan speed</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{defaults.ac_occupied_tiers.map((tier, i) => (
|
||||||
|
<tr key={tier.name}>
|
||||||
|
<td style={{ fontWeight: 600, textTransform: 'capitalize' }}>{tier.name}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number" step="0.5" style={{ width: 80 }}
|
||||||
|
value={tier.peak_above_c}
|
||||||
|
onChange={e => updateTier(i, 'peak_above_c', parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={tier.power} onChange={e => updateTier(i, 'power', e.target.value)}>
|
||||||
|
<option value="on">On</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={tier.mode} onChange={e => updateTier(i, 'mode', e.target.value)}>
|
||||||
|
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number" step="0.5" style={{ width: 90 }}
|
||||||
|
value={tier.setpoint}
|
||||||
|
onChange={e => updateTier(i, 'setpoint', parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={tier.fan_speed} onChange={e => updateTier(i, 'fan_speed', e.target.value)}>
|
||||||
|
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Default TRVs</div>
|
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Default TRVs</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,15 @@ export interface HvacAcTemplate {
|
||||||
fan_speed: string
|
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 {
|
export interface HvacDefaults {
|
||||||
arrival_prep_offset_min: number
|
arrival_prep_offset_min: number
|
||||||
departure_prep_offset_min: number
|
departure_prep_offset_min: number
|
||||||
|
|
@ -216,6 +225,7 @@ export interface HvacDefaults {
|
||||||
trv_vacant_temp: number
|
trv_vacant_temp: number
|
||||||
ac_occupied: HvacAcTemplate
|
ac_occupied: HvacAcTemplate
|
||||||
ac_vacant: HvacAcTemplate
|
ac_vacant: HvacAcTemplate
|
||||||
|
ac_occupied_tiers: HvacWeatherTier[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = {
|
export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = {
|
||||||
|
|
@ -225,6 +235,11 @@ export const DEFAULT_HVAC_TEMPLATE: HvacDefaults = {
|
||||||
trv_vacant_temp: 16.0,
|
trv_vacant_temp: 16.0,
|
||||||
ac_occupied: { power: 'on', mode: 'cool', setpoint: 22.0, fan_speed: 'medium' },
|
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_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 {
|
export interface AppConfig {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue