diff --git a/backend/src/db.js b/backend/src/db.js index d58fbee..c003e43 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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//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() diff --git a/backend/src/lib/mqtt.js b/backend/src/lib/mqtt.js index af04576..f2a99fd 100644 --- a/backend/src/lib/mqtt.js +++ b/backend/src/lib/mqtt.js @@ -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) diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js index ff1ff38..4ac9dca 100644 --- a/backend/src/routes/override.js +++ b/backend/src/routes/override.js @@ -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 } + }) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5c5611b..c4975f7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { + 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 { return request('/config') diff --git a/frontend/src/components/TrvControlPanel.tsx b/frontend/src/components/TrvControlPanel.tsx new file mode 100644 index 0000000..ed32a7d --- /dev/null +++ b/frontend/src/components/TrvControlPanel.tsx @@ -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(null) + const [valvePosition, setValvePosition] = useState(null) + const [wifiRssi, setWifiRssi] = useState(null) + const [lastSeen, setLastSeen] = useState(null) + + const debounceRef = useRef | 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

Reading valve status…

+ + return ( +
+ {error &&
{error}
} +
+
+ + {setpoint.toFixed(1)}°C + +
+ + {roomTemp != null && Room: {roomTemp.toFixed(1)}°C} + + {valvePosition != null && ( + + Valve {valvePosition}% + + )} + + {wifiRssi != null && ( + + {wifiRssi} dBm + + )} + + + {timeAgo(lastSeen)} + + + +
+
+ ) +} diff --git a/frontend/src/components/ZoneCard.tsx b/frontend/src/components/ZoneCard.tsx index faef9fd..9a81000 100644 --- a/frontend/src/components/ZoneCard.tsx +++ b/frontend/src/components/ZoneCard.tsx @@ -104,7 +104,7 @@ export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: {trvs.slice(0, 3).map(d => (
- {fmtTemp(d.current_target_temp)}/{fmtTemp(d.current_room_temp)} + {fmtTemp(d.current_target_temp)}
))} diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx index 841802c..146ca9c 100644 --- a/frontend/src/components/ZoneDetailModal.tsx +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -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 }: { {(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && } {d.device_type === 'daikin' && } + {d.device_type === 'shelly_trv' && } ))} diff --git a/frontend/src/index.css b/frontend/src/index.css index 9ae0818..f6f0608 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b0948eb..493318b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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