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
|
|
@ -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<ActivityEntry[]> {
|
||||
|
|
|
|||
|
|
@ -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<boolean | null>(null)
|
||||
const [mqttNote, setMqttNote] = useState('')
|
||||
|
||||
const [weatherPeakC, setWeatherPeakC] = useState<number | null>(null)
|
||||
const [weatherTier, setWeatherTier] = useState<string | null>(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() {
|
|||
</table>
|
||||
</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 className="field-row">
|
||||
<div className="field">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue