hvac/backend/src/routes/override.js
jtricerolph d818bd1e7a Add per-TRV control panel + valve/wifi telemetry, restyle dashboard radiator icon
Backend:
- New valve_position column, populated from shellies/<id>/info alongside
  wifi_rssi (was already a column, never actually written to).
- New GET/POST /api/devices/:id/trv-status and /trv-control routes, mirroring
  the mhi-status/mhi-control and daikin-status/daikin-control pattern. Control
  goes through the existing setTargetWithRetry() reliability pipeline.

Frontend:
- New TrvControlPanel.tsx: +/- setpoint control debounced 1s after the last
  click before firing a command (these are battery/deep-sleep valves going
  through a real retry/backoff pipeline, not a cheap register write like MHI/
  Daikin), plus room temp, valve position %, wifi signal, and a live "time
  since last update" readout. Wired into ZoneDetailModal for shelly_trv rows.
- ZoneCard dashboard tile: radiator icon now just shows red when heating_active
  (valve open) / grey when idle, with only the target setpoint alongside it
  (dropped the room-temp half, redundant with the zone-level temp line above).
2026-08-25 14:25:46 +00:00

194 lines
9.2 KiB
JavaScript

import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js'
import { batchSetZoneTemperature, setTargetWithRetry } from '../lib/mqtt.js'
import { commandAcDevice } from '../lib/ac-control.js'
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
// Manual force-temperature — the equivalent of the old integration's
// newbook.force_room_temperature service. Disables auto mode for the zone so the
// scheduler leaves it alone until staff switch auto mode back on (zones.js PATCH).
export async function overrideRoutes(app) {
app.addHook('preHandler', requireAuth)
app.post('/api/zones/:id/override', { preHandler: requireCap('control') }, async (req, reply) => {
const { temp_c } = req.body || {}
const tempC = Number(temp_c)
if (!Number.isFinite(tempC)) return reply.status(400).send({ error: 'temp_c must be a number' })
const { rows } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Zone not found' })
const zone = rows[0]
await pool.query('UPDATE zones SET auto_mode = FALSE WHERE id = $1', [zone.id])
const { rows: devices } = await pool.query(
`SELECT external_ref, device_ip FROM zone_devices WHERE zone_id = $1 AND device_type = 'shelly_trv'`,
[zone.id]
)
if (!devices.length) {
return reply.status(200).send({ ok: false, error: 'No TRVs mapped to this zone', auto_mode: false })
}
const results = await batchSetZoneTemperature(devices, tempC)
const successful = Object.values(results).filter(Boolean).length
await logActivity(zone.id, 'override', {
note: `Manual override to ${tempC}°C (${successful}/${devices.length} devices, auto mode disabled)`,
source: 'manual',
userEmail: req.user.email,
})
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
})
// GET /api/devices/:id/mhi-status — live status for one assigned MHI aircon unit.
// mhi_mqtt: served from the push-populated status cache (mqtt.js keeps it fresh
// from the gateway's retained topics). mhi_modbus: live Modbus round-trip read.
app.get('/api/devices/:id/mhi-status', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
const device = rows[0]
if (device.device_type === 'mhi_mqtt') {
return {
external_ref: device.external_ref,
onOff: device.power_state ?? undefined,
mode: device.current_mode ?? undefined,
setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined,
fanSpeed: device.fan_speed ?? undefined,
roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined,
}
}
const status = await mhiDriver.getStatus(device.external_ref)
if (!status || status.error) {
return reply.status(502).send({ error: status?.error || 'Failed to read device status' })
}
return status
})
// POST /api/devices/:id/mhi-control — explicit staff control from the room modal's
// MhiControlPanel. Body: { onOff?, mode?, tempC?, fanSpeed? } — only the fields present
// are written. The actual write path (mhi_mqtt publish vs mhi_modbus register write,
// plus optimistic cache update) lives in lib/ac-control.js's commandAcDevice(), shared
// with the scheduler's automatic occupied/vacant AC control (lib/scheduler.js).
app.post('/api/devices/:id/mhi-control', { preHandler: requireCap('control') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
const device = rows[0]
const { onOff, mode, tempC, fanSpeed } = req.body || {}
const { ok } = await commandAcDevice(device, { onOff, mode, tempC, fanSpeed }, { origin: 'manual', log: req.log })
await logActivity(device.zone_id, 'device_command', {
note: `MHI manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`,
source: 'manual',
userEmail: req.user.email,
})
return { ok }
})
// GET /api/devices/:id/daikin-status — served from the poll-populated cache
// (lib/daikin-poller.js), same "read the row, don't do a live round trip" shape as
// the mhi_mqtt branch above — Daikin has no push mechanism either, but a live HTTP
// call on every panel open would be slower and the cache is at most POLL_MS stale.
app.get('/api/devices/:id/daikin-status', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'daikin'`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Daikin device not found' })
const device = rows[0]
return {
external_ref: device.external_ref,
onOff: device.power_state ?? undefined,
mode: device.current_mode ?? undefined,
setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined,
fanSpeed: device.fan_speed ?? undefined,
roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined,
status_updated_at: device.status_updated_at,
}
})
// POST /api/devices/:id/daikin-control — explicit staff control from DaikinControlPanel.
// Body: { onOff?, mode?, tempC?, fanSpeed? }. Shares commandAcDevice() with the mhi
// routes above and with the scheduler's automatic occupied/vacant AC control.
app.post('/api/devices/:id/daikin-control', { preHandler: requireCap('control') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'daikin'`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Daikin device not found' })
const device = rows[0]
const { onOff, mode, tempC, fanSpeed } = req.body || {}
const { ok } = await commandAcDevice(device, { onOff, mode, tempC, fanSpeed }, { origin: 'manual', log: req.log })
await logActivity(device.zone_id, 'device_command', {
note: `Daikin manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`,
source: 'manual',
userEmail: req.user.email,
})
return { ok }
})
// GET /api/devices/:id/trv-status — live status for one assigned Shelly TRV, served
// from the push-populated cache (mqtt.js keeps current_target_temp/current_room_temp/
// battery_pct fresh from shellies/+/status, valve_position/wifi_rssi from
// shellies/+/info) — same "read the row, don't round-trip" shape as mhi_mqtt/daikin.
app.get('/api/devices/:id/trv-status', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'shelly_trv'`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'TRV not found' })
const device = rows[0]
return {
external_ref: device.external_ref,
setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined,
roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined,
heatingActive: device.heating_active,
valvePosition: device.valve_position,
wifiRssi: device.wifi_rssi,
batteryPct: device.battery_pct,
targetOrigin: device.target_origin,
lastSeen: device.last_seen,
statusUpdatedAt: device.status_updated_at,
}
})
// POST /api/devices/:id/trv-control — explicit staff setpoint change from the room
// modal's TrvControlPanel. Body: { tempC }. Goes through setTargetWithRetry() for the
// full reliability pipeline (30s->30min backoff, HTTP wake-up fallback) — same
// function the zone-level /override route above uses per-device, just for one TRV.
app.post('/api/devices/:id/trv-control', { preHandler: requireCap('control') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'shelly_trv'`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'TRV not found' })
const device = rows[0]
const tempC = Number(req.body?.tempC)
if (!Number.isFinite(tempC)) return reply.status(400).send({ error: 'tempC must be a number' })
const ok = await setTargetWithRetry({ external_ref: device.external_ref, device_ip: device.device_ip }, tempC)
await logActivity(device.zone_id, 'device_command', {
note: `TRV manual setpoint on ${device.location || device.discovered_name || device.external_ref}: ${tempC}°C${ok ? '' : ' — FAILED'}`,
source: 'manual',
userEmail: req.user.email,
})
return { ok }
})
}