Fix TRV status ingestion: subscribe to live shellies/+/status topic
The firmware actually running on the deployed TRVs (SHTRV-01 v2.2.4, confirmed against live hardware) publishes a combined status blob on shellies/<id>/status, not the per-field shellies/<id>/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/<id>/thermostat/0/command/target_t) is still unverified against live hardware — flagged in code comments.
This commit is contained in:
parent
1a06a860f5
commit
807a27ca1b
2 changed files with 74 additions and 4 deletions
|
|
@ -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 remote_locked BOOLEAN`)
|
||||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS status_updated_at TIMESTAMPTZ`)
|
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/
|
// Default-template rollout: existing installs created occupied_temp/vacant_temp/
|
||||||
// heating_offset_min/cooling_offset_min as NOT NULL with a hardcoded default — relax
|
// 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()
|
// that so NULL can mean "inherit the global hvac_defaults template" (see seedDefaults()
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,52 @@ function topicMatchesInfo(topic) {
|
||||||
return /^shellies\/(.+)\/info$/.exec(topic)
|
return /^shellies\/(.+)\/info$/.exec(topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function topicMatchesStatusBlob(topic) {
|
||||||
|
return /^shellies\/(.+)\/status$/.exec(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellies/<id>/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/<id>/info -> full Gen1 status snapshot, republished on every state change.
|
// shellies/<id>/info -> full Gen1 status snapshot, republished on every state change.
|
||||||
// For TRVs (SHTRV-01) this carries thermostats[0].tmp.value (actual sensed room temp)
|
// 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
|
// 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')
|
console.log('[mqtt] connected to broker')
|
||||||
c.subscribe([
|
c.subscribe([
|
||||||
'shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t', 'shellies/+/info',
|
'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)
|
'hvac/mhi/+/status/+', // MHI aircon status (retained per-signal JSON)
|
||||||
])
|
])
|
||||||
resolve()
|
resolve()
|
||||||
|
|
@ -392,6 +439,11 @@ export async function connect() {
|
||||||
handleStatus(statusMatch[1], payload).catch(() => {})
|
handleStatus(statusMatch[1], payload).catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const statusBlobMatch = topicMatchesStatusBlob(topic)
|
||||||
|
if (statusBlobMatch) {
|
||||||
|
handleStatusBlob(statusBlobMatch[1], payload).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
const infoMatch = topicMatchesInfo(topic)
|
const infoMatch = topicMatchesInfo(topic)
|
||||||
if (infoMatch) {
|
if (infoMatch) {
|
||||||
handleInfo(infoMatch[1], payload).catch(() => {})
|
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 —
|
// Publish a setpoint command to a single TRV. Does NOT wait for acknowledgment —
|
||||||
// use setTargetWithRetry() for the full reliability pipeline.
|
// use setTargetWithRetry() for the full reliability pipeline. Stamps
|
||||||
function publishTarget(externalRef, tempC) {
|
// 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')
|
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`
|
const topic = `shellies/${externalRef}/thermostat/0/command/target_t`
|
||||||
client.publish(topic, String(tempC))
|
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++) {
|
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
||||||
try {
|
try {
|
||||||
publishTarget(externalRef, tempC)
|
await publishTarget(externalRef, tempC)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[mqtt] publish failed for ${externalRef}:`, err.message)
|
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
|
// All MQTT retries exhausted — try HTTP wake-up before giving up
|
||||||
const wokeUp = await tryHttpWakeUp(deviceIp)
|
const wokeUp = await tryHttpWakeUp(deviceIp)
|
||||||
if (wokeUp) {
|
if (wokeUp) {
|
||||||
try { publishTarget(externalRef, tempC) } catch { /* ignore */ }
|
try { await publishTarget(externalRef, tempC) } catch { /* ignore */ }
|
||||||
const acked = await waitForAck(externalRef, tempC, 30000)
|
const acked = await waitForAck(externalRef, tempC, 30000)
|
||||||
if (acked) return true
|
if (acked) return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue