From 0bb0a6f2a16c78f7a662b70c1f416257ede7e74a Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:47:41 -0700 Subject: [PATCH] fix(matrix): recover target liveness --- docs/matrix-mqtt-protocol.md | 14 +++--- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 4 +- esphome/packages/coreview-matrix-base.yaml | 46 ++++++++++++------- server/server.js | 20 ++++++-- 4 files changed, 58 insertions(+), 26 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 3764e86..03bfd16 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -83,12 +83,14 @@ retained desired state. ## Target status and acknowledgement -Targets publish `coreview.matrix.status.v1` with `online`, `width`, `height`, -`colorDepth`, `rotation`, `features`, and optional firmware version. CoreView -uses it for observability; the server-side target configuration remains the -authority for compilation. After applying a state or event, targets publish -`coreview.matrix.ack.v1` with its `revision` or `eventId` and either `ok: true` -or a bounded error code. +Targets publish retained `coreview.matrix.status.v1` on MQTT connection and as +a periodic heartbeat (the reference firmware reports every 30 seconds). It +contains `online`, `width`, `height`, `colorDepth`, `rotation`, `features`, and +an optional firmware version. CoreView uses it for observability; the +server-side target configuration remains the authority for compilation. After +applying a state or event, targets publish `coreview.matrix.ack.v1` with its +`revision` or `eventId` and either `ok: true` or a bounded error code. A valid +acknowledgement also refreshes the target's liveness in CoreView. An unclaimed reference Beacon also includes a six-digit `claimCode` in its status payload. CoreView uses this only to confirm physical possession while an diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 7d64d8a..06f5067 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -190,7 +190,9 @@ display: for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { uint32_t hash = (uint32_t) x * 374761393UL + (uint32_t) y * 668265263UL + frame * 2246822519UL; hash = (hash ^ (hash >> 13)) * 1274126177UL; hash ^= hash >> 16; - int heat = (height - 1 - y) * 255 / std::max(1, height - 1) + (int) (hash & 0x3FU) - 70; + // ESPHome coordinates start at the top. Heat belongs at the + // bottom so the flame rises upward instead of hanging down. + int heat = y * 255 / std::max(1, height - 1) + (int) (hash & 0x3FU) - 70; heat = std::max(0, std::min(255, heat)); it.draw_pixel_at(x, y, Color((uint8_t) heat, (uint8_t) (heat / 3), 0)); } diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index b223d0f..19d359b 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -136,6 +136,35 @@ globals: restore_value: no initial_value: '64' +script: + - id: coreview_publish_status + then: + - mqtt.publish_json: + topic: ${coreview_status_topic} + retain: true + payload: |- + root["schema"] = "coreview.matrix.status.v1"; + root["online"] = true; + root["width"] = id(coreview_matrix).get_width(); + root["height"] = id(coreview_matrix).get_height(); + root["colorDepth"] = ${matrix_color_depth}; + root["rotation"] = ${matrix_rotation}; + root["firmwareVersion"] = "esphome"; + JsonArray features = root["features"].to(); + features.add("clock"); features.add("status"); features.add("notification"); + features.add("ticker"); features.add("icons"); features.add("music"); + root["claimCode"] = id(coreview_claim_code); + root["claimed"] = id(coreview_claimed); + +interval: + - interval: 30s + then: + - if: + condition: + mqtt.connected: + then: + - script.execute: coreview_publish_status + mqtt: broker: !secret mqtt_broker username: !secret mqtt_username @@ -149,22 +178,7 @@ mqtt: payload: '{"schema":"coreview.matrix.status.v1","online":false}' retain: true on_connect: - - mqtt.publish_json: - topic: ${coreview_status_topic} - retain: true - payload: |- - root["schema"] = "coreview.matrix.status.v1"; - root["online"] = true; - root["width"] = id(coreview_matrix).get_width(); - root["height"] = id(coreview_matrix).get_height(); - root["colorDepth"] = ${matrix_color_depth}; - root["rotation"] = ${matrix_rotation}; - root["firmwareVersion"] = "esphome"; - JsonArray features = root["features"].to(); - features.add("clock"); features.add("status"); features.add("notification"); - features.add("ticker"); features.add("icons"); features.add("music"); - root["claimCode"] = id(coreview_claim_code); - root["claimed"] = id(coreview_claimed); + - script.execute: coreview_publish_status on_json_message: topic: ${coreview_state_topic} then: diff --git a/server/server.js b/server/server.js index 8a7dab5..b3477aa 100644 --- a/server/server.js +++ b/server/server.js @@ -40,6 +40,10 @@ const CORS_ORIGINS = (process.env.CORS_ORIGINS || "") .map((origin) => origin.trim()) .filter(Boolean); const HEARTBEAT_STALE_MS = 45000; +// Matrix targets report retained MQTT status every 30 seconds. Allow two +// missed reports before declaring one stale so a short Wi-Fi/MQTT blip does +// not contradict a panel that is still receiving state. +const MATRIX_HEARTBEAT_STALE_MS = 90000; const RUNTIME_OVERRIDE_SWEEP_MS = 5000; const PENDING_DEVICE_TTL_MS = Math.max( 60 * 60 * 1000, @@ -4877,15 +4881,17 @@ async function refreshFrigateStatus() { } function screenStateFor(target, info = {}) { + const matrixTarget = getMatrixTarget(target); const matrixStatus = matrixTargetStatus.get(target); - const effectiveInfo = getMatrixTarget(target) && matrixStatus + const effectiveInfo = matrixTarget && matrixStatus ? { ...info, connected: Boolean(matrixStatus.online), lastSeen: matrixStatus.lastSeen, userAgent: matrixStatus.payload?.firmwareVersion || "matrix" } : info; const lastSeen = Number(effectiveInfo.lastSeen || 0); const ageMs = lastSeen > 0 ? Date.now() - lastSeen : null; + const staleAfterMs = matrixTarget ? MATRIX_HEARTBEAT_STALE_MS : HEARTBEAT_STALE_MS; let status = "offline"; if (effectiveInfo.connected) { - status = ageMs !== null && ageMs <= HEARTBEAT_STALE_MS ? "online" : "stale"; + status = ageMs !== null && ageMs <= staleAfterMs ? "online" : "stale"; } const screen = getScreenRecord(target); const assignedView = screen?.viewId ? getView(screen.viewId) : null; @@ -5397,7 +5403,15 @@ function attachMqttClientHandlers(client) { const target = String(topic.slice(matrixPrefix.length, -"/ack".length) || "").trim().toLowerCase(); const existing = matrixTargetStatus.get(target) || {}; if (getMatrixTarget(target)) { - matrixTargetStatus.set(target, { ...existing, lastAckAt: Date.now(), lastAck: parsed && typeof parsed === "object" ? parsed : {} }); + // An acknowledgement can only be emitted by a target that received + // and applied our state, so it is authoritative liveness evidence. + matrixTargetStatus.set(target, { + ...existing, + online: true, + lastSeen: Date.now(), + lastAckAt: Date.now(), + lastAck: parsed && typeof parsed === "object" ? parsed : {} + }); } return; }