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>
80 lines
3.3 KiB
JavaScript
80 lines
3.3 KiB
JavaScript
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)
|
|
|
|
// 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
|
|
FROM zones z
|
|
LEFT JOIN zone_state zs ON zs.zone_id = z.id
|
|
WHERE z.active = TRUE
|
|
ORDER BY z.zone_type, z.name
|
|
`)
|
|
|
|
const { rows: devices } = await pool.query(`
|
|
SELECT id, zone_id, device_type, external_ref, discovered_name, location,
|
|
health_state, battery_pct, wifi_rssi, current_target_temp, target_origin, last_seen,
|
|
current_room_temp, heating_active, power_state, current_mode, fan_speed,
|
|
remote_locked, status_updated_at
|
|
FROM zone_devices WHERE zone_id IS NOT NULL
|
|
`)
|
|
const devicesByZone = new Map()
|
|
for (const d of devices) {
|
|
if (!devicesByZone.has(d.zone_id)) devicesByZone.set(d.zone_id, [])
|
|
devicesByZone.get(d.zone_id).push(d)
|
|
}
|
|
|
|
return {
|
|
mqtt_connected: mqttConnected(),
|
|
weather_peak_c: weatherPeakC,
|
|
weather_tier: weatherTier,
|
|
zones: zones.map(z => ({
|
|
...z,
|
|
room_state: z.room_state || 'vacant',
|
|
has_next_booking: z.has_next_booking ?? false,
|
|
target_temp: ['heating_up', 'occupied'].includes(z.room_state)
|
|
? (z.occupied_temp ?? hvacDefaults.trv_occupied_temp)
|
|
: (z.vacant_temp ?? hvacDefaults.trv_vacant_temp),
|
|
devices: devicesByZone.get(z.id) || [],
|
|
})),
|
|
}
|
|
})
|
|
|
|
// GET /api/status/activity — recent activity log, optionally filtered by zone
|
|
app.get('/api/status/activity', { preHandler: requireCap('view') }, async (req) => {
|
|
const { zone_id, limit } = req.query || {}
|
|
const lim = Math.min(parseInt(limit) || 100, 500)
|
|
if (zone_id) {
|
|
const { rows } = await pool.query(
|
|
`SELECT al.*, z.name AS zone_name FROM activity_log al LEFT JOIN zones z ON z.id = al.zone_id
|
|
WHERE al.zone_id = $1 ORDER BY al.created_at DESC LIMIT $2`,
|
|
[zone_id, lim]
|
|
)
|
|
return rows
|
|
}
|
|
const { rows } = await pool.query(
|
|
`SELECT al.*, z.name AS zone_name FROM activity_log al LEFT JOIN zones z ON z.id = al.zone_id
|
|
ORDER BY al.created_at DESC LIMIT $1`,
|
|
[lim]
|
|
)
|
|
return rows
|
|
})
|
|
}
|