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:
parent
807a27ca1b
commit
d818bd1e7a
9 changed files with 212 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
||||
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus,
|
||||
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, TrvStatus,
|
||||
} from './types'
|
||||
|
||||
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) })
|
||||
}
|
||||
|
||||
// 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
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
|
|
|
|||
124
frontend/src/components/TrvControlPanel.tsx
Normal file
124
frontend/src/components/TrvControlPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -104,7 +104,7 @@ export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick:
|
|||
{trvs.slice(0, 3).map(d => (
|
||||
<div key={d.id} className={`rad-icon ${d.heating_active ? 'heating' : 'idle'}`}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useAuth } from './AuthGate'
|
|||
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
|
||||
import MhiControlPanel from './MhiControlPanel'
|
||||
import DaikinControlPanel from './DaikinControlPanel'
|
||||
import TrvControlPanel from './TrvControlPanel'
|
||||
|
||||
// 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),
|
||||
|
|
@ -175,6 +176,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
|||
</div>
|
||||
{(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 === 'shelly_trv' && <TrvControlPanel deviceId={d.id} canControl={canControl} />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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-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.idle { color: var(--st-cooling-down); }
|
||||
.rad-icon.heating { color: var(--danger); }
|
||||
.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.off { color: var(--text-mid); opacity: .55; }
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export interface DeviceSummary {
|
|||
// via MQTT (mqtt.js); mhi_modbus populates the rest via the AC poller (ac-poller.js).
|
||||
current_room_temp: string | null
|
||||
heating_active: boolean | null
|
||||
valve_position: number | null
|
||||
power_state: 'on' | 'off' | null
|
||||
current_mode: string | null
|
||||
fan_speed: string | null
|
||||
|
|
@ -189,6 +190,20 @@ export interface DaikinStatus {
|
|||
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 {
|
||||
id: number
|
||||
device_id: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue