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 {