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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-26 15:09:29 +00:00
parent f3a73ad273
commit 55afcaa786
5 changed files with 50 additions and 22 deletions

View file

@ -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 }
})
}

View file

@ -63,7 +63,12 @@ export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEnt
}
// Override
export function overrideZone(id: number, tempC: number): Promise<{ ok: boolean; successful: number; total: number; auto_mode: boolean; error?: string }> {
// 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 }) })
}

View file

@ -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() {

View file

@ -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('')

View file

@ -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 {