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

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