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. // 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)`) 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/ // Default-template rollout: existing installs created occupied_temp/vacant_temp/
// heating_offset_min/cooling_offset_min as NOT NULL with a hardcoded default — relax // 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() // 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 roomTemp = thermostat.tmp?.value
const valvePos = thermostat.pos const valvePos = thermostat.pos
const heatingActive = typeof valvePos === 'number' ? valvePos > 0 : null const heatingActive = typeof valvePos === 'number' ? valvePos > 0 : null
const wifiRssi = payload.wifi_sta?.rssi
try { try {
await pool.query( await pool.query(
`UPDATE zone_devices `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`, WHERE device_type = 'shelly_trv' AND external_ref = $1`,
[externalRef, roomTemp ?? null, heatingActive] [externalRef, roomTemp ?? null, heatingActive, valvePos ?? null, wifiRssi ?? null]
) )
} catch (err) { } catch (err) {
console.error('[mqtt] failed to record info update:', err.message) console.error('[mqtt] failed to record info update:', err.message)

View file

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

View file

@ -1,6 +1,6 @@
import type { import type {
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig, Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, TrvStatus,
} from './types' } from './types'
const BASE = '/hvac/api' const BASE = '/hvac/api'
@ -152,6 +152,14 @@ export function sendDaikinControl(deviceId: number, body: { onOff?: boolean; mod
return request(`/devices/${deviceId}/daikin-control`, { method: 'POST', body: JSON.stringify(body) }) return request(`/devices/${deviceId}/daikin-control`, { method: 'POST', body: JSON.stringify(body) })
} }
// TRV manual control (device-level — shelly_trv rows only)
export function fetchTrvStatus(deviceId: number): Promise<TrvStatus> {
return request(`/devices/${deviceId}/trv-status`)
}
export function sendTrvControl(deviceId: number, body: { tempC: number }): Promise<{ ok: boolean }> {
return request(`/devices/${deviceId}/trv-control`, { method: 'POST', body: JSON.stringify(body) })
}
// Config / settings // Config / settings
export function fetchConfig(): Promise<AppConfig> { export function fetchConfig(): Promise<AppConfig> {
return request('/config') return request('/config')

View file

@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from 'react'
import { Minus, Plus, RefreshCw, Loader2, Wifi, Gauge, Clock } from 'lucide-react'
import { fetchTrvStatus, sendTrvControl } from '../api'
const DEBOUNCE_MS = 1000
const MIN_TEMP = 5
const MAX_TEMP = 30
function timeAgo(iso: string | null | undefined): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
if (ms < 0) return 'just now'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
// Manual setpoint control for one assigned Shelly TRV. +/- clicks update the
// displayed value immediately but the MQTT command is debounced 1s after the last
// click — these are battery/deep-sleep valves going through the full retry/backoff
// pipeline (setTargetWithRetry), so rapid-fire clicking shouldn't fire a command per
// click. Read side is push-populated (mqtt.js keeps the row fresh from
// shellies/+/status + shellies/+/info), same "read the row" shape as Mhi/Daikin panels.
export default function TrvControlPanel({ deviceId, canControl }: { deviceId: number; canControl: boolean }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [applying, setApplying] = useState(false)
const [setpoint, setSetpoint] = useState(18)
const [roomTemp, setRoomTemp] = useState<number | null>(null)
const [valvePosition, setValvePosition] = useState<number | null>(null)
const [wifiRssi, setWifiRssi] = useState<number | null>(null)
const [lastSeen, setLastSeen] = useState<string | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [, forceTick] = useState(0) // re-render periodically so "Xs ago" stays live
function load() {
setLoading(true); setError('')
fetchTrvStatus(deviceId)
.then(s => {
if (s.error) { setError(s.error); return }
if (s.setpoint != null) setSetpoint(s.setpoint)
setRoomTemp(s.roomTemp ?? null)
setValvePosition(s.valvePosition ?? null)
setWifiRssi(s.wifiRssi ?? null)
setLastSeen(s.statusUpdatedAt ?? s.lastSeen ?? null)
})
.catch(e => setError(e instanceof Error ? e.message : 'Failed to read device status'))
.finally(() => setLoading(false))
}
useEffect(load, [deviceId])
useEffect(() => {
const t = setInterval(() => forceTick(n => n + 1), 30000)
return () => clearInterval(t)
}, [])
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, [])
async function apply(tempC: number) {
setApplying(true); setError('')
try {
const res = await sendTrvControl(deviceId, { tempC })
if (!res.ok) setError('Device did not accept the command')
} catch (e) {
setError(e instanceof Error ? e.message : 'Command failed')
} finally {
setApplying(false)
}
}
function adjust(delta: number) {
const next = Math.round(Math.min(MAX_TEMP, Math.max(MIN_TEMP, setpoint + delta)) * 2) / 2
setSetpoint(next)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => apply(next), DEBOUNCE_MS)
}
if (loading) return <p className="muted" style={{ fontSize: 12.5 }}>Reading valve status…</p>
return (
<div style={{ marginTop: 8 }}>
{error && <div className="error-banner" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button className="btn btn-sm" disabled={!canControl} onClick={() => adjust(-0.5)}>
<Minus size={12} strokeWidth={1.75} />
</button>
<span style={{ minWidth: 48, textAlign: 'center', fontWeight: 600, fontSize: 13 }}>{setpoint.toFixed(1)}°C</span>
<button className="btn btn-sm" disabled={!canControl} onClick={() => adjust(0.5)}>
<Plus size={12} strokeWidth={1.75} />
</button>
</div>
{roomTemp != null && <span className="muted" style={{ fontSize: 12 }}>Room: {roomTemp.toFixed(1)}°C</span>}
{valvePosition != null && (
<span className="muted" style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Gauge size={13} strokeWidth={1.75} /> Valve {valvePosition}%
</span>
)}
{wifiRssi != null && (
<span className="muted" style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Wifi size={13} strokeWidth={1.75} /> {wifiRssi} dBm
</span>
)}
<span className="muted" style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Clock size={13} strokeWidth={1.75} /> {timeAgo(lastSeen)}
</span>
<button className="btn btn-sm" disabled={loading} onClick={load} title="Refresh live status">
{applying ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <RefreshCw size={13} strokeWidth={1.75} />}
</button>
</div>
</div>
)
}

View file

@ -104,7 +104,7 @@ export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick:
{trvs.slice(0, 3).map(d => ( {trvs.slice(0, 3).map(d => (
<div key={d.id} className={`rad-icon ${d.heating_active ? 'heating' : 'idle'}`}> <div key={d.id} className={`rad-icon ${d.heating_active ? 'heating' : 'idle'}`}>
<Heater size={16} strokeWidth={1.75} /> <Heater size={16} strokeWidth={1.75} />
<span>{fmtTemp(d.current_target_temp)}/{fmtTemp(d.current_room_temp)}</span> <span>{fmtTemp(d.current_target_temp)}</span>
</div> </div>
))} ))}
</div> </div>

View file

@ -6,6 +6,7 @@ import { useAuth } from './AuthGate'
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api' import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
import MhiControlPanel from './MhiControlPanel' import MhiControlPanel from './MhiControlPanel'
import DaikinControlPanel from './DaikinControlPanel' import DaikinControlPanel from './DaikinControlPanel'
import TrvControlPanel from './TrvControlPanel'
// The 12 fields a room can individually override — each is `null` on the zone when // The 12 fields a room can individually override — each is `null` on the zone when
// inheriting the matching field from the global hvac_defaults template (Settings page), // inheriting the matching field from the global hvac_defaults template (Settings page),
@ -175,6 +176,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
</div> </div>
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />} {(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
{d.device_type === 'daikin' && <DaikinControlPanel deviceId={d.id} canControl={canControl} />} {d.device_type === 'daikin' && <DaikinControlPanel deviceId={d.id} canControl={canControl} />}
{d.device_type === 'shelly_trv' && <TrvControlPanel deviceId={d.id} canControl={canControl} />}
</div> </div>
))} ))}

View file

@ -227,8 +227,8 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.rad-row { display: flex; gap: 10px; margin-top: 8px; flex-wrap: wrap; } .rad-row { display: flex; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.rad-icon { display: flex; align-items: center; gap: 3px; font-size: 10.5px; font-weight: 600; color: var(--text-mid); } .rad-icon { display: flex; align-items: center; gap: 3px; font-size: 10.5px; font-weight: 600; color: var(--text-mid); }
.rad-icon.heating { color: var(--st-heating-up); } .rad-icon.heating { color: var(--danger); }
.rad-icon.idle { color: var(--st-cooling-down); } .rad-icon.idle { color: var(--text-mid); }
.ac-block { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; padding-top: 4px; color: var(--text-dark); } .ac-block { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; padding-top: 4px; color: var(--text-dark); }
.ac-block.off { color: var(--text-mid); opacity: .55; } .ac-block.off { color: var(--text-mid); opacity: .55; }

View file

@ -93,6 +93,7 @@ export interface DeviceSummary {
// via MQTT (mqtt.js); mhi_modbus populates the rest via the AC poller (ac-poller.js). // via MQTT (mqtt.js); mhi_modbus populates the rest via the AC poller (ac-poller.js).
current_room_temp: string | null current_room_temp: string | null
heating_active: boolean | null heating_active: boolean | null
valve_position: number | null
power_state: 'on' | 'off' | null power_state: 'on' | 'off' | null
current_mode: string | null current_mode: string | null
fan_speed: string | null fan_speed: string | null
@ -189,6 +190,20 @@ export interface DaikinStatus {
error?: string error?: string
} }
export interface TrvStatus {
external_ref: string
setpoint?: number
roomTemp?: number
heatingActive?: boolean | null
valvePosition?: number | null
wifiRssi?: number | null
batteryPct?: number | null
targetOrigin?: string | null
lastSeen?: string | null
statusUpdatedAt?: string | null
error?: string
}
export interface DevicePhoto { export interface DevicePhoto {
id: number id: number
device_id: number device_id: number