From 55afcaa7865d59f9dda62e191edc98cf08277c35 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 26 Aug 2026 15:09:29 +0000 Subject: [PATCH] Fix TRV control routes hanging up to ~93min, causing Gateway Time-out Both POST /api/devices/:id/trv-control and POST /api/zones/:id/override awaited the full setTargetWithRetry()/batchSetZoneTemperature() retry/backoff pipeline before responding -- up to ~93 minutes (RETRY_DELAYS_MS sums to 5610s across 10 attempts). Any reverse proxy times out its own connection long before that finishes, surfacing as a real Gateway Time-out (hit 2026-08-26 testing a TRV setpoint change -- confirmed not kiosk-specific, same code path every TRV control UI goes through, including kiosk's bulk edit which compounded it per selected device). Fix: both routes now fire the retry pipeline in the background and respond immediately (ok:true, pending:true) -- same eventually-consistent pattern the MHI/Daikin panels already use (fire command, poll status separately for the real outcome). Updated callers that read the old synchronous successful/total counts (ZoneDetailModal's override message, KioskBulkPanel's TRV result message) to reflect 'sent, applying in the background' instead. Co-Authored-By: Claude Sonnet 5 --- backend/src/routes/override.js | 49 ++++++++++++++------- frontend/src/api.ts | 7 ++- frontend/src/components/KioskBulkPanel.tsx | 6 ++- frontend/src/components/TrvControlPanel.tsx | 8 +++- frontend/src/components/ZoneDetailModal.tsx | 2 +- 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js index 4ac9dca..a020583 100644 --- a/backend/src/routes/override.js +++ b/backend/src/routes/override.js @@ -29,16 +29,26 @@ export async function overrideRoutes(app) { 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 + // Don't block the HTTP response on the full per-device retry/backoff pipeline — + // setTargetWithRetry() (called per-device inside batchSetZoneTemperature, plus + // a ~10s stagger between devices) can run up to ~93 minutes before giving up + // (RETRY_DELAYS_MS in lib/mqtt.js: 30s->30min x10 attempts), which any reverse + // proxy will time out on long before that finishes — hit as a real Gateway + // Time-out 2026-08-26. Fire it in the background instead; the frontend already + // reads the eventual outcome back via GET .../trv-status (setpoint/health_state) + // per device, same eventually-consistent pattern the MHI/Daikin panels use. + batchSetZoneTemperature(devices, tempC) + .then(results => { + const successful = Object.values(results).filter(Boolean).length + return logActivity(zone.id, 'override', { + note: `Manual override to ${tempC}°C (${successful}/${devices.length} devices, auto mode disabled)`, + source: 'manual', + userEmail: req.user.email, + }) + }) + .catch(err => req.log.error(`[override] background retry failed: ${err.message}`)) - 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 } + return { ok: true, pending: true, total: devices.length, auto_mode: false } }) // GET /api/devices/:id/mhi-status — live status for one assigned MHI aircon unit. @@ -181,14 +191,19 @@ export async function overrideRoutes(app) { 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) + // Don't block the HTTP response on the full retry/backoff pipeline — it can run + // up to ~93 minutes (see the same note on /api/zones/:id/override above) before + // giving up, which any reverse proxy will time out on long before that finishes. + // Fire it in the background; GET .../trv-status already reflects the eventual + // setpoint/health_state once it resolves, same as every other device panel. + setTargetWithRetry({ external_ref: device.external_ref, device_ip: device.device_ip }, tempC) + .then(ok => logActivity(device.zone_id, 'device_command', { + note: `TRV manual setpoint on ${device.location || device.discovered_name || device.external_ref}: ${tempC}°C${ok ? '' : ' — FAILED to acknowledge'}`, + source: 'manual', + userEmail: req.user.email, + })) + .catch(err => req.log.error(`[trv-control] background retry failed: ${err.message}`)) - 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 } + return { ok: true, pending: true } }) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index aa934e1..3d8de7b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -63,7 +63,12 @@ export function fetchActivity(zoneId?: number, limit = 100): Promise { +// successful/total confirmation isn't available synchronously any more — the +// backend fires the retry/backoff pipeline in the background rather than +// blocking the response on it (up to ~93 minutes, guaranteed proxy timeout). +// `pending: true` means "commands sent, applying" — read GET .../trv-status +// per device for the eventual real outcome. +export function overrideZone(id: number, tempC: number): Promise<{ ok: boolean; pending?: boolean; total: number; auto_mode: boolean; error?: string }> { return request(`/zones/${id}/override`, { method: 'POST', body: JSON.stringify({ temp_c: tempC }) }) } diff --git a/frontend/src/components/KioskBulkPanel.tsx b/frontend/src/components/KioskBulkPanel.tsx index 51920d2..164cbe8 100644 --- a/frontend/src/components/KioskBulkPanel.tsx +++ b/frontend/src/components/KioskBulkPanel.tsx @@ -50,7 +50,11 @@ export default function KioskBulkPanel({ devices, onDone }: Props) { await new Promise(r => setTimeout(r, TRV_STAGGER_MS)) } setApplyingTrv(false) - setResults(r => [...r, `TRVs: ${trvIds.length - failed}/${trvIds.length} updated${failed ? ' — some failed' : ''}`]) + // The backend now fires each TRV's retry/backoff pipeline in the background + // rather than blocking on it (see routes/override.js) — this only confirms the + // command was SENT, not that the valve has actually acknowledged it yet. Check + // the zone tiles' setpoint a little later for the real outcome. + setResults(r => [...r, `TRVs: sent to ${trvIds.length - failed}/${trvIds.length}${failed ? ' — some failed to send' : ''} — applying in the background`]) } async function applyAc() { diff --git a/frontend/src/components/TrvControlPanel.tsx b/frontend/src/components/TrvControlPanel.tsx index ed32a7d..decb450 100644 --- a/frontend/src/components/TrvControlPanel.tsx +++ b/frontend/src/components/TrvControlPanel.tsx @@ -23,8 +23,12 @@ function timeAgo(iso: string | null | undefined): string { // 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. +// click. That retry pipeline runs in the backend background now (routes/override.js), +// not awaited by the HTTP response — sendTrvControl() resolving ok:true only means +// the command was sent, not that the valve has acknowledged it yet. 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 — refresh (the +// button below) to see the real outcome once the valve responds. export default function TrvControlPanel({ deviceId, canControl }: { deviceId: number; canControl: boolean }) { const [loading, setLoading] = useState(true) const [error, setError] = useState('') diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx index 01a8ae9..02090a0 100644 --- a/frontend/src/components/ZoneDetailModal.tsx +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -134,7 +134,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: { try { const res = await overrideZone(zone.id, Number(overrideTemp)) if (res.ok) { - setMsg(`Set to ${overrideTemp}°C (${res.successful}/${res.total} devices) — auto mode disabled`) + setMsg(`Sent ${overrideTemp}°C to ${res.total} device${res.total === 1 ? '' : 's'} — applying in the background, auto mode disabled`) setAutoMode(false) onSaved() } else {