From 807a27ca1bfd63b94e9ef435651c0bcbcb44feb7 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 25 Aug 2026 14:09:38 +0000 Subject: [PATCH] Fix TRV status ingestion: subscribe to live shellies/+/status topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware actually running on the deployed TRVs (SHTRV-01 v2.2.4, confirmed against live hardware) publishes a combined status blob on shellies//status, not the per-field shellies//thermostat/0/target_t topic handleStatus() was written against — so current_target_temp was permanently NULL and the dashboard modal always showed "—". Adds handleStatusBlob() to parse the real payload shape and populate current_target_temp/current_room_temp/battery_pct. This firmware also has no guest-vs-automation 'source' field, so guest detection is redesigned to diff an incoming target_t against a new last_commanded_target_t column, stamped by publishTarget() right before every command is sent. A mismatch means the guest changed it via the dial; a match means it's an echo of our own automation. Old handleStatus()/topicMatchesStatus() path left in place, dormant — harmless if some future firmware does use per-field topics. TRV command topic (shellies//thermostat/0/command/target_t) is still unverified against live hardware — flagged in code comments. --- backend/src/db.js | 7 ++++ backend/src/lib/mqtt.js | 71 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index a18fe57..d58fbee 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -176,6 +176,13 @@ export async function initDb() { await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS remote_locked BOOLEAN`) await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS status_updated_at TIMESTAMPTZ`) + // Live TRV firmware (SHTRV-01 v2.2.4, confirmed 2026-08-25) publishes a combined + // status blob with no guest-vs-automation 'source' field the old archived logic + // relied on. Guest detection is done by diffing an incoming target_t against the + // last value hvac itself commanded — stamped here right before every publish, read + // 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)`) + // 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 a7f02fa..af04576 100644 --- a/backend/src/lib/mqtt.js +++ b/backend/src/lib/mqtt.js @@ -224,6 +224,52 @@ function topicMatchesInfo(topic) { return /^shellies\/(.+)\/info$/.exec(topic) } +function topicMatchesStatusBlob(topic) { + return /^shellies\/(.+)\/status$/.exec(topic) +} + +// shellies//status -> the LIVE topic this firmware (SHTRV-01 v2.2.4, confirmed +// 2026-08-25) actually publishes: a combined blob, not the per-field topic +// handleStatus() above was written against ({"tmp":{"value":22.6,"units":"C", +// "is_valid":true},"target_t":{"enabled":true,"value":10.0,"units":"C"}, +// "temperature_offset":0.0,"bat":72}). No 'source' field exists on this firmware — +// guest-vs-automation is inferred by diffing target_t against last_commanded_target_t +// (stamped by publishTarget() right before every command we send). No prior command +// on record (fresh device, nothing commanded yet) -> 'unknown', not a guessed guess. +async function handleStatusBlob(externalRef, payload) { + const roomTemp = payload.tmp?.value + const targetTemp = payload.target_t?.value + const battery = payload.bat + + try { + const { rows } = await pool.query( + `SELECT last_commanded_target_t FROM zone_devices WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef] + ) + const lastCommanded = rows[0]?.last_commanded_target_t + let origin = 'unknown' + if (targetTemp != null && lastCommanded != null) { + origin = Math.abs(Number(lastCommanded) - targetTemp) < 0.1 ? 'automation' : 'guest' + } + + await pool.query( + `UPDATE zone_devices + SET current_target_temp = $2, current_room_temp = $3, battery_pct = $4, + target_origin = $5, last_seen = NOW(), status_updated_at = NOW(), updated_at = NOW() + WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef, targetTemp ?? null, roomTemp ?? null, battery ?? null, origin] + ) + + if (origin === 'guest' && targetTemp != null) { + await syncGuestAdjustmentToRoom(externalRef, targetTemp).catch(err => { + console.error('[mqtt] guest valve sync failed:', err.message) + }) + } + } catch (err) { + console.error('[mqtt] failed to record status blob update:', err.message) + } +} + // shellies//info -> full Gen1 status snapshot, republished on every state change. // For TRVs (SHTRV-01) this carries thermostats[0].tmp.value (actual sensed room temp) // and thermostats[0].pos (valve position 0-100, >0 = actively calling for heat) — the @@ -362,6 +408,7 @@ export async function connect() { console.log('[mqtt] connected to broker') c.subscribe([ 'shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t', 'shellies/+/info', + 'shellies/+/status', // live combined-blob topic (see handleStatusBlob) — the real path 'hvac/mhi/+/status/+', // MHI aircon status (retained per-signal JSON) ]) resolve() @@ -392,6 +439,11 @@ export async function connect() { handleStatus(statusMatch[1], payload).catch(() => {}) return } + const statusBlobMatch = topicMatchesStatusBlob(topic) + if (statusBlobMatch) { + handleStatusBlob(statusBlobMatch[1], payload).catch(() => {}) + return + } const infoMatch = topicMatchesInfo(topic) if (infoMatch) { handleInfo(infoMatch[1], payload).catch(() => {}) @@ -430,9 +482,20 @@ export function getDiscoveredDevices() { } // Publish a setpoint command to a single TRV. Does NOT wait for acknowledgment — -// use setTargetWithRetry() for the full reliability pipeline. -function publishTarget(externalRef, tempC) { +// use setTargetWithRetry() for the full reliability pipeline. Stamps +// last_commanded_target_t BEFORE publishing so handleStatusBlob() has something to +// diff the device's next status report against (this firmware has no 'source' field +// to read instead — see handleStatusBlob for the full explanation). +// UNVERIFIED against live hardware: this command topic is carried over from the old +// per-field scheme, which this firmware's STATUS side has already proven not to use — +// confirm this is actually what the device accepts before relying on it in the UI. +async function publishTarget(externalRef, tempC) { if (!client?.connected) throw new Error('MQTT broker not connected') + await pool.query( + `UPDATE zone_devices SET last_commanded_target_t = $2, target_origin = 'pending' + WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef, tempC] + ).catch(err => console.error('[mqtt] failed to stamp last_commanded_target_t:', err.message)) const topic = `shellies/${externalRef}/thermostat/0/command/target_t` client.publish(topic, String(tempC)) } @@ -477,7 +540,7 @@ export async function setTargetWithRetry(device, tempC) { for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) { try { - publishTarget(externalRef, tempC) + await publishTarget(externalRef, tempC) } catch (err) { console.error(`[mqtt] publish failed for ${externalRef}:`, err.message) } @@ -501,7 +564,7 @@ export async function setTargetWithRetry(device, tempC) { // All MQTT retries exhausted — try HTTP wake-up before giving up const wokeUp = await tryHttpWakeUp(deviceIp) if (wokeUp) { - try { publishTarget(externalRef, tempC) } catch { /* ignore */ } + try { await publishTarget(externalRef, tempC) } catch { /* ignore */ } const acked = await waitForAck(externalRef, tempC, 30000) if (acked) return true }