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).
This commit is contained in:
jtricerolph 2026-08-25 14:25:46 +00:00
parent 807a27ca1b
commit d818bd1e7a
9 changed files with 212 additions and 7 deletions

View file

@ -183,6 +183,10 @@ export async function initDb() {
// back in mqtt.js's status handler. A mismatch = guest changed it via the dial.
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS last_commanded_target_t NUMERIC(4,1)`)
// Valve position (0-100, from thermostats[0].pos on shellies/<id>/info) — >0 already
// drives heating_active, but the modal wants the raw percentage too.
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS valve_position INT`)
// Default-template rollout: existing installs created occupied_temp/vacant_temp/
// heating_offset_min/cooling_offset_min as NOT NULL with a hardcoded default — relax
// that so NULL can mean "inherit the global hvac_defaults template" (see seedDefaults()

View file

@ -284,13 +284,14 @@ async function handleInfo(externalRef, payload) {
const roomTemp = thermostat.tmp?.value
const valvePos = thermostat.pos
const heatingActive = typeof valvePos === 'number' ? valvePos > 0 : null
const wifiRssi = payload.wifi_sta?.rssi
try {
await pool.query(
`UPDATE zone_devices
SET current_room_temp = $2, heating_active = $3, status_updated_at = NOW()
SET current_room_temp = $2, heating_active = $3, valve_position = $4, wifi_rssi = $5, status_updated_at = NOW()
WHERE device_type = 'shelly_trv' AND external_ref = $1`,
[externalRef, roomTemp ?? null, heatingActive]
[externalRef, roomTemp ?? null, heatingActive, valvePos ?? null, wifiRssi ?? null]
)
} catch (err) {
console.error('[mqtt] failed to record info update:', err.message)

View file

@ -1,6 +1,6 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js'
import { batchSetZoneTemperature } from '../lib/mqtt.js'
import { batchSetZoneTemperature, setTargetWithRetry } from '../lib/mqtt.js'
import { commandAcDevice } from '../lib/ac-control.js'
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
@ -140,4 +140,55 @@ export async function overrideRoutes(app) {
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 }
})
}