From 2d52a10afeac22a813b5621d2f4d71ae5644c71a Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:31:48 -0700 Subject: [PATCH 01/25] feat(matrix): add MQTT matrix targets and ESPHome client --- README.md | 5 + docs/matrix-mqtt-protocol.md | 68 +++++ esphome/README.md | 28 ++ esphome/coreview-matrix-base.yaml | 123 +++++++++ esphome/examples/coreview-beacon.yaml | 11 + esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 76 ++++++ server/server.js | 242 +++++++++++++++++- 7 files changed, 542 insertions(+), 11 deletions(-) create mode 100644 docs/matrix-mqtt-protocol.md create mode 100644 esphome/README.md create mode 100644 esphome/coreview-matrix-base.yaml create mode 100644 esphome/examples/coreview-beacon.yaml create mode 100644 esphome/hardware/esp32s3-hub75-3x2-32x16.yaml diff --git a/README.md b/README.md index 8c12f46..5400e44 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ Live entity state and automation data. MQTT Messaging and event distribution. +Pixel matrices +Capability-aware MQTT targets for constrained displays such as ESPHome/HUB75. +Matrix targets use a retained desired-state protocol instead of the browser +WebSocket runtime; see [the Matrix MQTT protocol](docs/matrix-mqtt-protocol.md). + Frigate Object detection events and camera triggers. diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md new file mode 100644 index 0000000..622156a --- /dev/null +++ b/docs/matrix-mqtt-protocol.md @@ -0,0 +1,68 @@ +# CoreView Matrix MQTT protocol + +Status: draft v1. This is the public transport contract for constrained pixel +matrix targets such as ESPHome/HUB75 displays. It is intentionally separate +from CoreView's browser/WebSocket screen protocol. + +## Topics + +With `MQTT_TOPIC_ROOT=home/signage` and a target ID of `kitchen-matrix`: + +| Direction | Topic | Retained | +| --- | --- | --- | +| CoreView → target | `home/signage/matrix/kitchen-matrix/state` | yes | +| CoreView → target | `home/signage/matrix/kitchen-matrix/event` | no | +| target → CoreView | `home/signage/matrix/kitchen-matrix/status` | yes | +| target → CoreView | `home/signage/matrix/kitchen-matrix/ack` | no | + +The target must only subscribe to its own `state` and `event` topics. Broker +ACLs should likewise limit its publish rights to its own `status` and `ack` +topics. + +## Desired state + +CoreView publishes this payload whenever the assigned View or its effective +runtime override changes. `revision` strictly increases per target; a client +must ignore an older revision. A retained state lets a restarted target render +without waiting for another CoreView change. + +```json +{ + "schema": "coreview.matrix.state.v1", + "target": "kitchen-matrix", + "revision": 42, + "issuedAt": "2026-07-25T19:30:00.000Z", + "display": { "width": 96, "height": 32, "colorDepth": 4, "rotation": 0 }, + "scene": { + "kind": "clock", + "title": "Kitchen", + "ticker": { "message": "Trash night" } + } +} +``` + +`scene.kind` is one of `clock`, `status`, `notification`, or `unsupported`. +`unsupported` is an explicit, renderable fallback for browser-only View types +(for example maps and photo slideshows); it is never a silent failure. + +Transient events use `coreview.matrix.event.v1`, carry a UUID `eventId`, a +`kind` of `notification` or `clear`, an optional RFC 3339 `expiresAt`, and a +severity (`info`, `success`, `warning`, `critical`). Events do not replace the +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. + +## Capability policy + +Matrix dimensions are per target, never hard-coded in CoreView. Target records +also declare color depth, rotation, and supported features. The reference +firmware may render a richer local idle animation, but CoreView scenes must use +only this protocol's portable primitives: text, short status fields, ticker, +severity, and the standard icon names. diff --git a/esphome/README.md b/esphome/README.md new file mode 100644 index 0000000..ce9f059 --- /dev/null +++ b/esphome/README.md @@ -0,0 +1,28 @@ +# CoreView Matrix for ESPHome + +This directory is the reference client for CoreView's Matrix MQTT transport. +It is intended for RGB HUB75 panels, but CoreView itself does not assume a +specific panel dimension. + +## Layout + +- `coreview-matrix-base.yaml` — portable MQTT state consumer and renderer. +- `hardware/` — board and panel wiring profiles. +- `examples/` — complete starting configurations. + +The base package reads the retained state topic documented in +[`docs/matrix-mqtt-protocol.md`](../docs/matrix-mqtt-protocol.md), publishes +availability/status, and acknowledges applied revisions. It deliberately does +not require Home Assistant's native API. + +## Quick start + +Use the example in place, or copy the complete `esphome/` directory into your +ESPHome configuration directory so its relative package paths remain valid. +Set `wifi_ssid`, `wifi_password`, `mqtt_broker`, `mqtt_username`, and +`mqtt_password` in ESPHome secrets, then change the target ID and hardware +profile as appropriate. Create a Matrix target in CoreView with the same ID. + +The included hardware profile matches the draft Beacon: an ESP32-S3 and six +32×16 HUB75 panels arranged 3×2 (96×32). It is an example, not a requirement. +Create a profile for other boards, pinouts, panel chains, or geometries. diff --git a/esphome/coreview-matrix-base.yaml b/esphome/coreview-matrix-base.yaml new file mode 100644 index 0000000..c4500af --- /dev/null +++ b/esphome/coreview-matrix-base.yaml @@ -0,0 +1,123 @@ +# Reusable CoreView Matrix MQTT client. Hardware setup belongs in hardware/. +substitutions: + coreview_topic_root: home/signage + coreview_target_id: replace-me + coreview_state_topic: ${coreview_topic_root}/matrix/${coreview_target_id}/state + coreview_status_topic: ${coreview_topic_root}/matrix/${coreview_target_id}/status + coreview_ack_topic: ${coreview_topic_root}/matrix/${coreview_target_id}/ack + +esphome: + on_boot: + priority: -100 + then: + - lambda: |- + id(coreview_boot_until) = millis() + 2500U; + +logger: + +ota: + - platform: esphome + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: ${coreview_target_id} Fallback + +captive_portal: + +time: + - platform: sntp + id: coreview_time + +globals: + - id: coreview_revision + type: uint32_t + restore_value: yes + initial_value: '0' + - id: coreview_scene + type: std::string + restore_value: no + initial_value: '"clock"' + - id: coreview_title + type: std::string + restore_value: no + initial_value: '"COREVIEW"' + - id: coreview_detail + type: std::string + restore_value: no + initial_value: '""' + - id: coreview_ticker + type: std::string + restore_value: no + initial_value: '""' + - id: coreview_severity + type: std::string + restore_value: no + initial_value: '"info"' + - id: coreview_boot_until + type: uint32_t + restore_value: no + initial_value: '0' + +mqtt: + broker: !secret mqtt_broker + username: !secret mqtt_username + password: !secret mqtt_password + birth_message: + topic: ${coreview_status_topic} + payload: '{"schema":"coreview.matrix.status.v1","online":true}' + retain: true + will_message: + topic: ${coreview_status_topic} + 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"; + on_json_message: + topic: ${coreview_state_topic} + then: + - lambda: |- + const char *schema = x["schema"] | ""; + const uint32_t revision = x["revision"] | 0; + if (std::string(schema) != "coreview.matrix.state.v1" || revision <= id(coreview_revision)) return; + JsonObjectConst scene = x["scene"].as(); + if (scene.isNull()) return; + id(coreview_revision) = revision; + id(coreview_scene) = scene["kind"] | "unsupported"; + id(coreview_title) = scene["title"] | "CoreView"; + id(coreview_detail) = scene["detail"] | ""; + id(coreview_severity) = scene["severity"] | "info"; + JsonArrayConst fields = scene["fields"].as(); + if (id(coreview_detail).empty() && !fields.isNull() && fields.size() > 0) { + JsonObjectConst first = fields[0].as(); + const char *label = first["label"] | ""; + const char *value = first["value"] | ""; + id(coreview_detail) = std::string(label) + " " + value; + } + JsonObjectConst ticker = scene["ticker"].as(); + id(coreview_ticker) = ticker.isNull() ? "" : (ticker["message"] | ""); + - mqtt.publish_json: + topic: ${coreview_ack_topic} + payload: |- + root["schema"] = "coreview.matrix.ack.v1"; + root["revision"] = id(coreview_revision); + root["ok"] = true; + +font: + - file: "gfonts://Roboto Mono" + id: coreview_font_small + size: 9 + - file: "gfonts://Roboto Mono" + id: coreview_font_large + size: 14 diff --git a/esphome/examples/coreview-beacon.yaml b/esphome/examples/coreview-beacon.yaml new file mode 100644 index 0000000..2dd03f3 --- /dev/null +++ b/esphome/examples/coreview-beacon.yaml @@ -0,0 +1,11 @@ +# Copy this file into your ESPHome configuration directory before compiling. +substitutions: + coreview_target_id: coreview-beacon + +esphome: + name: coreview-beacon + friendly_name: CoreView Beacon + +packages: + coreview_base: !include coreview-matrix-base.yaml + beacon_hardware: !include hardware/esp32s3-hub75-3x2-32x16.yaml diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml new file mode 100644 index 0000000..cf2e224 --- /dev/null +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -0,0 +1,76 @@ +# Reference profile for CoreView Beacon's ESP32-S3 and six 32x16 panels. +substitutions: + matrix_color_depth: '4' + matrix_rotation: '0' + +esp32: + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHZ + +display: + - platform: hub75 + id: coreview_matrix + panel_width: 32 + panel_height: 16 + layout_cols: 3 + layout_rows: 2 + layout: TOP_RIGHT_DOWN + r1_pin: GPIO6 + g1_pin: GPIO4 + b1_pin: GPIO5 + r2_pin: GPIO17 + g2_pin: GPIO7 + b2_pin: GPIO15 + a_pin: GPIO8 + b_pin: GPIO18 + c_pin: GPIO10 + d_pin: GPIO9 + e_pin: GPIO16 + clk_pin: GPIO12 + lat_pin: GPIO11 + oe_pin: GPIO13 + brightness: 64 + bit_depth: ${matrix_color_depth} + clock_speed: 10MHZ + clock_phase: true + latch_blanking: 1 + double_buffer: true + update_interval: 50ms + lambda: |- + const int width = it.get_width(); + const int height = it.get_height(); + const uint32_t now_ms = millis(); + it.fill(Color::BLACK); + if ((int32_t) (now_ms - id(coreview_boot_until)) < 0) { + it.print(width / 2, height / 2, id(coreview_font_small), Color(0, 255, 180), TextAlign::CENTER, "COREVIEW"); + return; + } + const Color color = id(coreview_severity) == "critical" ? Color(255, 32, 0) + : id(coreview_severity) == "warning" ? Color(255, 160, 0) + : id(coreview_severity) == "success" ? Color(0, 255, 120) : Color(0, 200, 255); + if (id(coreview_scene) == "clock") { + auto now = id(coreview_time).now(); + if (now.is_valid()) { + std::string stamp = now.strftime("%I:%M %p"); + if (!stamp.empty() && stamp[0] == '0') stamp.erase(0, 1); + it.print(width / 2, 1, id(coreview_font_large), color, TextAlign::TOP_CENTER, stamp.c_str()); + it.print(width / 2, height - 10, id(coreview_font_small), Color(0, 120, 255), TextAlign::TOP_CENTER, id(coreview_title).c_str()); + } else { + it.print(width / 2, height / 2, id(coreview_font_small), color, TextAlign::CENTER, "WAITING FOR TIME"); + } + } else { + if (id(coreview_scene) == "notification") it.rectangle(0, 0, width - 1, height - 1, color); + it.print(width / 2, 2, id(coreview_font_small), color, TextAlign::TOP_CENTER, id(coreview_title).c_str()); + it.print(width / 2, height / 2 + 4, id(coreview_font_small), color, TextAlign::CENTER, id(coreview_detail).c_str()); + } + if (!id(coreview_ticker).empty()) { + const int span = (int) id(coreview_ticker).size() * 6 + width; + const int x = width - ((now_ms / 65U) % span); + it.print(x, height - 9, id(coreview_font_small), color, id(coreview_ticker).c_str()); + } diff --git a/server/server.js b/server/server.js index 44fa62f..5c8ec80 100644 --- a/server/server.js +++ b/server/server.js @@ -20,6 +20,9 @@ const MQTT_CLIENT_ID_ENV = process.env.MQTT_CLIENT_ID || ""; const MQTT_TOPIC_ROOT = process.env.MQTT_TOPIC_ROOT || "home/signage"; const MQTT_SUBSCRIBE_PATTERN = process.env.MQTT_SUBSCRIBE_PATTERN || `${MQTT_TOPIC_ROOT}/+/cmd/+`; +const MATRIX_STATUS_SUBSCRIBE_PATTERN = `${MQTT_TOPIC_ROOT}/matrix/+/status`; +const MATRIX_ACK_SUBSCRIBE_PATTERN = `${MQTT_TOPIC_ROOT}/matrix/+/ack`; +const MATRIX_STATE_SCHEMA = "coreview.matrix.state.v1"; const HA_URL_ENV = (process.env.HA_URL || "").replace(/\/+$/, ""); const HA_TOKEN_ENV = process.env.HA_TOKEN || ""; const IMMICH_URL_ENV = (process.env.IMMICH_URL || "").replace(/\/+$/, ""); @@ -101,6 +104,7 @@ app.use(express.static("public", { const clients = new Map(); const onlineByTarget = new Map(); +const matrixTargetStatus = new Map(); const haCache = { lastSyncAt: null, lastError: null, @@ -422,6 +426,12 @@ function ensureDatabase() { created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS matrix_targets ( + screen_id TEXT PRIMARY KEY, + config_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + last_published_at TEXT + ); CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT, @@ -872,7 +882,67 @@ function getScreenRecord(screenId) { WHERE screen_id = ${sqlQuote(screenId)} LIMIT 1;` ); - return rows[0] || null; + return hydrateScreenTransport(rows[0] || null); +} + +function normalizeMatrixConfig(config = {}) { + const width = Math.max(8, Math.min(512, Math.round(Number(config.width || 64)))); + const height = Math.max(8, Math.min(256, Math.round(Number(config.height || 32)))); + const colorDepth = Math.max(1, Math.min(24, Math.round(Number(config.colorDepth || 4)))); + const rotation = [0, 90, 180, 270].includes(Number(config.rotation)) ? Number(config.rotation) : 0; + const supportedFeatures = new Set(["clock", "status", "notification", "ticker", "icons"]); + const features = Array.from(new Set((Array.isArray(config.features) ? config.features : []) + .map((item) => String(item || "").trim().toLowerCase()) + .filter((item) => supportedFeatures.has(item)))); + return { width, height, colorDepth, rotation, features }; +} + +function getMatrixTarget(screenId) { + const rows = sqliteQuery( + `SELECT screen_id AS screenId, config_json AS configJson, revision, last_published_at AS lastPublishedAt + FROM matrix_targets WHERE screen_id = ${sqlQuote(screenId)} LIMIT 1;` + ); + if (!rows[0]) { + return null; + } + return { + screenId: rows[0].screenId, + config: normalizeMatrixConfig(parseJsonOrDefault(rows[0].configJson, {})), + revision: Number(rows[0].revision || 0), + lastPublishedAt: rows[0].lastPublishedAt || null + }; +} + +function hydrateScreenTransport(screen) { + if (!screen) { + return null; + } + const matrix = getMatrixTarget(screen.screenId); + return matrix + ? { ...screen, transport: "matrix", matrix } + : { ...screen, transport: "browser" }; +} + +function saveMatrixTarget(screenId, friendlyName, viewId, config = {}) { + const now = new Date().toISOString(); + const current = getScreenRecord(screenId); + if (current && current.transport !== "matrix") { + throw new Error("screen ID is already used by a browser target"); + } + sqliteExec( + `INSERT INTO screens (screen_id, device_id, friendly_name, view_id, created_at, updated_at) + VALUES (${sqlQuote(screenId)}, NULL, ${sqlQuote(friendlyName)}, ${sqlQuote(viewId)}, ${sqlQuote(now)}, ${sqlQuote(now)}) + ON CONFLICT(screen_id) DO UPDATE SET + friendly_name = excluded.friendly_name, + view_id = excluded.view_id, + updated_at = excluded.updated_at;` + ); + sqliteExec( + `INSERT INTO matrix_targets (screen_id, config_json, revision, last_published_at) + VALUES (${sqlQuote(screenId)}, ${sqlQuote(JSON.stringify(normalizeMatrixConfig(config)))}, 0, NULL) + ON CONFLICT(screen_id) DO UPDATE SET config_json = excluded.config_json;` + ); + return getScreenRecord(screenId); } function listScreens() { @@ -885,7 +955,7 @@ function listScreens() { updated_at AS updatedAt FROM screens ORDER BY screen_id ASC;` - ); + ).map(hydrateScreenTransport); } function listScreensRaw() { @@ -904,7 +974,8 @@ function listScreensRaw() { friendlyName: row.friendlyName || "", viewId: row.viewId || null, createdAt: row.createdAt || null, - updatedAt: row.updatedAt || null + updatedAt: row.updatedAt || null, + ...(getMatrixTarget(row.screenId) ? { transport: "matrix", matrix: getMatrixTarget(row.screenId) } : { transport: "browser" }) })); } @@ -1737,6 +1808,7 @@ function unregisterScreen(screenId) { ); } sqliteExec(`DELETE FROM group_members WHERE screen_id = ${sqlQuote(screenId)};`); + sqliteExec(`DELETE FROM matrix_targets WHERE screen_id = ${sqlQuote(screenId)};`); sqliteExec(`DELETE FROM screens WHERE screen_id = ${sqlQuote(screenId)};`); const existingTimer = transientRestoreTimers.get(screenId); if (existingTimer) { @@ -1746,6 +1818,7 @@ function unregisterScreen(screenId) { activeRuntimeOverrides.delete(screenId); lastAppliedRuleByTarget.delete(screenId); onlineByTarget.delete(screenId); + matrixTargetStatus.delete(screenId); return { ...screen, pairingCode: nextPairingCode @@ -2953,6 +3026,71 @@ function getRuntimeStateForView(viewId) { }; } +function matrixTopic(screenId, channel) { + return `${MQTT_TOPIC_ROOT}/matrix/${screenId}/${channel}`; +} + +function compileMatrixScene(runtime) { + const layout = runtime?.layout || {}; + const ticker = runtime?.tickerState?.message ? { message: runtime.tickerState.message } : null; + if (runtime?.bannerState?.text) { + return { + kind: "notification", + severity: runtime.bannerState.variant || "info", + title: runtime.bannerState.text, + detail: runtime.bannerState.subtext || "", + ticker + }; + } + if (layout.template === "clock") { + return { kind: "clock", title: layout.title || runtime?.view?.name || "CoreView", ticker }; + } + if (layout.template === "custom") { + const fields = (Array.isArray(layout.widgets) ? layout.widgets : []).slice(0, 4).map((widget) => ({ + label: String(widget?.label || widget?.entityId || "Status").slice(0, 48), + value: String(widget?.text || widget?.value || layout.entityStates?.[widget?.entityId]?.state || "").slice(0, 96) + })); + return { kind: "status", title: runtime?.view?.name || "CoreView", fields, ticker }; + } + return { + kind: "unsupported", + title: runtime?.view?.name || "CoreView", + detail: `View template '${layout.template || "unknown"}' is not supported by this matrix target`, + ticker + }; +} + +function publishMatrixRuntime(screenId, runtime) { + const target = getMatrixTarget(screenId); + if (!target || !mqttClient || !mqttState.connected) { + return false; + } + const revision = target.revision + 1; + const payload = { + schema: MATRIX_STATE_SCHEMA, + target: screenId, + revision, + issuedAt: new Date().toISOString(), + display: target.config, + scene: compileMatrixScene(runtime) + }; + sqliteExec( + `UPDATE matrix_targets SET revision = ${revision} + WHERE screen_id = ${sqlQuote(screenId)};` + ); + mqttClient.publish(matrixTopic(screenId, "state"), JSON.stringify(payload), { qos: 1, retain: true }, (err) => { + if (err) { + console.error(`Matrix runtime publish failed for ${screenId}:`, err.message); + return; + } + sqliteExec( + `UPDATE matrix_targets SET last_published_at = ${sqlQuote(payload.issuedAt)} + WHERE screen_id = ${sqlQuote(screenId)};` + ); + }); + return true; +} + function publishCommandToResolvedTargets(targets, command, payload, options = {}) { const qos = options.qos === undefined ? 1 : Number(options.qos); const retain = Boolean(options.retain); @@ -3891,6 +4029,10 @@ async function immichFetch(pathname) { function broadcast(message) { const outbound = message && typeof message === "object" ? { ...message } : message; + if (outbound?.type === "screen_runtime" && outbound.target && getMatrixTarget(outbound.target)) { + publishMatrixRuntime(outbound.target, outbound.payload); + return; + } if (outbound && outbound.type === "screen_runtime" && outbound.target && !outbound.mediaToken) { outbound.mediaToken = getOrCreateMediaToken(outbound.target); } @@ -3989,6 +4131,11 @@ function setHaEntityState(entity) { payload: normalized }); refreshTemplatedRuntimeForEntity(normalized.entityId); + for (const screen of listScreens()) { + if (screen.transport === "matrix") { + broadcastRuntimeToScreen(screen.screenId); + } + } } function removeHaEntityState(entityId) { @@ -4003,6 +4150,11 @@ function removeHaEntityState(entityId) { payload: { entityId } }); refreshTemplatedRuntimeForEntity(entityId); + for (const screen of listScreens()) { + if (screen.transport === "matrix") { + broadcastRuntimeToScreen(screen.screenId); + } + } } function scheduleHaReconnect() { @@ -4224,10 +4376,14 @@ async function refreshFrigateStatus() { } function screenStateFor(target, info = {}) { - const lastSeen = Number(info.lastSeen || 0); + const matrixStatus = matrixTargetStatus.get(target); + const effectiveInfo = getMatrixTarget(target) && 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; let status = "offline"; - if (info.connected) { + if (effectiveInfo.connected) { status = ageMs !== null && ageMs <= HEARTBEAT_STALE_MS ? "online" : "stale"; } const screen = getScreenRecord(target); @@ -4245,14 +4401,14 @@ function screenStateFor(target, info = {}) { const lastRule = lastAppliedRuleByTarget.get(target) || null; return { target, - clientId: info.clientId || null, - connected: Boolean(info.connected), + clientId: effectiveInfo.clientId || null, + connected: Boolean(effectiveInfo.connected), lastSeen: lastSeen > 0 ? new Date(lastSeen).toISOString() : null, ageMs, status, - userAgent: info.userAgent || null, - connectedAt: info.connectedAt ? new Date(info.connectedAt).toISOString() : null, - disconnectedAt: info.disconnectedAt ? new Date(info.disconnectedAt).toISOString() : null, + userAgent: effectiveInfo.userAgent || null, + connectedAt: effectiveInfo.connectedAt ? new Date(effectiveInfo.connectedAt).toISOString() : null, + disconnectedAt: effectiveInfo.disconnectedAt ? new Date(effectiveInfo.disconnectedAt).toISOString() : null, currentViewId, currentProfileId: effectiveProfileId, currentThemeId, @@ -4379,6 +4535,7 @@ function importConfigSnapshot(snapshot) { }).filter(Boolean); sqliteExec(` + DELETE FROM matrix_targets; DELETE FROM group_members; DELETE FROM groups; DELETE FROM rules; @@ -4500,6 +4657,15 @@ function importConfigSnapshot(snapshot) { ${sqlQuote(row.updatedAt || now)} );` ); + if (row.transport === "matrix" && row.matrix?.config) { + sqliteExec( + `INSERT INTO matrix_targets (screen_id, config_json, revision, last_published_at) + VALUES (${sqlQuote(String(row.screenId).trim().toLowerCase())}, + ${sqlQuote(JSON.stringify(normalizeMatrixConfig(row.matrix.config)))}, + ${Math.max(0, Number(row.matrix.revision || 0))}, + ${sqlQuote(row.matrix.lastPublishedAt || null)});` + ); + } } for (const row of Array.isArray(snapshot.groups) ? snapshot.groups : []) { @@ -4659,7 +4825,7 @@ function attachMqttClientHandlers(client) { client.on("connect", () => { mqttState.connected = true; mqttState.lastError = null; - const topics = [MQTT_SUBSCRIBE_PATTERN]; + const topics = [MQTT_SUBSCRIBE_PATTERN, MATRIX_STATUS_SUBSCRIBE_PATTERN, MATRIX_ACK_SUBSCRIBE_PATTERN]; const frigateTopic = getFrigateConfig().mqttTopic; if (frigateTopic) { topics.push(frigateTopic); @@ -4698,6 +4864,27 @@ function attachMqttClientHandlers(client) { const parsed = safeJson(payload); const frigateTopic = getFrigateConfig().mqttTopic; + const matrixPrefix = `${MQTT_TOPIC_ROOT}/matrix/`; + if (topic.startsWith(matrixPrefix) && topic.endsWith("/status")) { + const target = String(topic.slice(matrixPrefix.length, -"/status".length) || "").trim().toLowerCase(); + if (getMatrixTarget(target)) { + matrixTargetStatus.set(target, { + online: parsed?.online !== false, + lastSeen: Date.now(), + payload: parsed && typeof parsed === "object" ? parsed : {} + }); + } + return; + } + if (topic.startsWith(matrixPrefix) && topic.endsWith("/ack")) { + 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 : {} }); + } + return; + } + if (frigateTopic && topic === frigateTopic) { const eventPayload = normalizeFrigateMqttEvent(parsed); if (!eventPayload) { @@ -6030,6 +6217,39 @@ app.get("/api/frigate/cameras/:camera/snapshot", requireMediaAuth, async (req, r ); }); +app.post("/api/matrix-targets", requireAdmin, (req, res) => { + const screenId = String(req.body?.screenId || "").trim().toLowerCase(); + const friendlyName = String(req.body?.friendlyName || screenId).trim(); + const viewId = String(req.body?.viewId || "").trim().toLowerCase(); + const config = req.body?.config && typeof req.body.config === "object" ? req.body.config : {}; + if (!screenId || !/^[a-z0-9_-]+$/.test(screenId)) { + return res.status(400).json({ error: "screenId must use lowercase letters, numbers, dashes, or underscores" }); + } + if (!viewId || !getView(viewId)) { + return res.status(404).json({ error: "view not found" }); + } + try { + const screen = saveMatrixTarget(screenId, friendlyName, viewId, config); + const runtime = getRuntimeStateForScreen(screenId); + broadcast({ type: "screen_runtime", target: screenId, command: "screen_runtime", payload: runtime }); + return res.status(201).json({ saved: true, screen, runtime, stateTopic: matrixTopic(screenId, "state") }); + } catch (err) { + return res.status(409).json({ error: err.message || "failed to save matrix target" }); + } +}); + +app.post("/api/matrix-targets/:screenId/publish", requireAdmin, (req, res) => { + const screenId = String(req.params.screenId || "").trim().toLowerCase(); + if (!getMatrixTarget(screenId)) { + return res.status(404).json({ error: "matrix target not found" }); + } + const published = publishMatrixRuntime(screenId, getRuntimeStateForScreen(screenId)); + if (!published) { + return res.status(503).json({ error: "MQTT broker is not connected" }); + } + return res.json({ published: true, screenId, stateTopic: matrixTopic(screenId, "state") }); +}); + app.post("/api/screens/register", requireAdmin, (req, res) => { const pairingCode = String(req.body?.pairingCode || "").trim().toUpperCase(); const screenId = String(req.body?.screenId || "").trim().toLowerCase(); From 9adb9a681f53dc5c86718d287902db41f75ce7b8 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:35:56 -0700 Subject: [PATCH 02/25] fix(storage): tolerate transient SQLite locks --- server/server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/server.js b/server/server.js index 5c8ec80..0831c85 100644 --- a/server/server.js +++ b/server/server.js @@ -389,11 +389,11 @@ function sqlQuote(value) { } function sqliteExec(sql) { - return execFileSync("sqlite3", [DB_PATH, sql], { encoding: "utf8" }); + return execFileSync("sqlite3", ["-cmd", ".timeout 5000", DB_PATH, sql], { encoding: "utf8" }); } function sqliteQuery(sql) { - const output = execFileSync("sqlite3", ["-json", DB_PATH, sql], { encoding: "utf8" }); + const output = execFileSync("sqlite3", ["-json", "-cmd", ".timeout 5000", DB_PATH, sql], { encoding: "utf8" }); const trimmed = output.trim(); if (!trimmed) { return []; From 9bff16c1c9befb34e7742b91d4dae1246379d7e6 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:57:18 -0700 Subject: [PATCH 03/25] feat(matrix): add target management UI and compatibility checks --- server/public/app.html | 162 ++++++++++++++++++++++++++++++++++++++++- server/server.js | 53 ++++++++++++++ 2 files changed, 211 insertions(+), 4 deletions(-) diff --git a/server/public/app.html b/server/public/app.html index 2d9effd..62aab2d 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -1807,6 +1807,48 @@

Screens

+
+

Matrix Target

+

Matrix targets render CoreView's compact MQTT scenes, not HTML. Only clock and supported custom status Views can be assigned.

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Manage Registered Screen

@@ -2359,6 +2401,28 @@

How This Connects

return formatScreenDisplayLabel(screen.screenId, screen.friendlyName); } + function matrixViewCompatibility(view) { + const profile = state.profiles.find((item) => item.profileId === view?.profileId); + if (!profile) { + return { compatible: false, reason: "no profile" }; + } + if (profile.template === "clock") { + return { compatible: true, reason: "" }; + } + if (profile.template !== "custom") { + return { compatible: false, reason: `${profile.template} requires HTML` }; + } + const allowed = new Set(["text", "entity", "weather", "status"]); + const unsupported = (Array.isArray(profile.config?.widgetIds) ? profile.config.widgetIds : []) + .map((widgetId) => state.widgets.find((widget) => widget.widgetId === widgetId)) + .filter(Boolean) + .map((widget) => String(widget.config?.kind || "text").toLowerCase()) + .filter((kind) => !allowed.has(kind)); + return unsupported.length + ? { compatible: false, reason: `unsupported: ${Array.from(new Set(unsupported)).join(", ")}` } + : { compatible: true, reason: "" }; + } + function targetDisplayLabel(target) { const normalized = String(target || "").trim().toLowerCase(); if (!normalized) { @@ -3246,6 +3310,16 @@

How This Connects

return $("targetInput").value.trim() || "all"; } + function currentTargetIncludesMatrix() { + const target = getCurrentTarget().toLowerCase(); + if (target === "all") return state.screens.some((screen) => screen.transport === "matrix"); + if (target.startsWith("group:")) { + const group = state.groups.find((item) => item.groupId === target.slice(6)); + return Boolean(group?.members?.some((screenId) => getScreenById(screenId)?.transport === "matrix")); + } + return getScreenById(target)?.transport === "matrix"; + } + function setResult(message, isError = false) { const el = $("resultText"); el.textContent = message || ""; @@ -3458,6 +3532,8 @@

How This Connects

$("manageScreenDeviceInput").value = ""; $("manageScreenFriendlyNameInput").value = ""; $("manageScreenRebindPairingInput").value = ""; + $("manageScreenRebindPairingInput").disabled = false; + $("rebindScreenBtn").disabled = false; $("manageScreenViewInput").value = ""; state.selectedScreenId = null; syncScreenEditorState(); @@ -4825,7 +4901,9 @@

How This Connects

? state.widgets.map((item) => ({ id: item.widgetId, label: widgetDisplayLabel(item) })) : type === "rule" ? state.rules.map((item) => ({ id: item.ruleId, label: ruleDisplayLabel(item) })) - : state.profiles.map((item) => ({ id: item.profileId, label: profileDisplayLabel(item) })); + : state.profiles + .filter((item) => !currentTargetIncludesMatrix() || matrixViewCompatibility(state.views.find((view) => view.profileId === item.profileId)).compatible) + .map((item) => ({ id: item.profileId, label: profileDisplayLabel(item) })); if (entries.length === 0) { const span = document.createElement("span"); span.className = "chip"; @@ -4854,6 +4932,12 @@

How This Connects

} function syncOverrideForm() { + const matrixTarget = currentTargetIncludesMatrix(); + $("overrideTypeInput").querySelector('option[value="camera"]').disabled = matrixTarget; + $("overrideTypeInput").querySelector('option[value="theme"]').disabled = matrixTarget; + if (matrixTarget && ["camera", "theme"].includes($("overrideTypeInput").value)) { + $("overrideTypeInput").value = "banner"; + } const type = $("overrideTypeInput").value; const isCamera = type === "camera"; const isRule = type === "rule"; @@ -5132,6 +5216,32 @@

How This Connects

} } + function renderMatrixViewPicker() { + const box = $("matrixViewChips"); + if (!box) return; + const current = $("matrixViewIdInput").value.trim().toLowerCase(); + box.innerHTML = ""; + const compatible = state.views.filter((view) => matrixViewCompatibility(view).compatible); + if (compatible.length === 0) { + const span = document.createElement("span"); + span.className = "chip"; + span.textContent = "No Matrix-compatible Views"; + box.appendChild(span); + return; + } + for (const view of compatible) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `chip${current === view.viewId ? " active" : ""}`; + chip.textContent = `${viewDisplayLabel(view)} (Matrix compatible)`; + chip.addEventListener("click", () => { + $("matrixViewIdInput").value = view.viewId; + renderMatrixViewPicker(); + }); + box.appendChild(chip); + } + } + function renderScreenManager() { const screenBox = $("registeredScreenChips"); const viewBox = $("manageScreenViewChips"); @@ -5147,7 +5257,10 @@

How This Connects

span.textContent = "No saved views"; viewBox.appendChild(span); } else { + const selectedScreen = getScreenById(state.selectedScreenId); + const matrixScreen = selectedScreen?.transport === "matrix"; for (const view of state.views) { + if (matrixScreen && !matrixViewCompatibility(view).compatible) continue; const chip = document.createElement("button"); chip.type = "button"; chip.className = `chip${$("manageScreenViewInput").value.trim().toLowerCase() === view.viewId ? " active" : ""}`; @@ -5172,7 +5285,7 @@

How This Connects

const chip = document.createElement("button"); chip.type = "button"; chip.className = `chip${state.selectedScreenId === screen.screenId ? " active" : ""}`; - chip.textContent = screenDisplayLabel(screen); + chip.textContent = `${screenDisplayLabel(screen)}${screen.transport === "matrix" ? " · Matrix" : " · HTML"}`; chip.addEventListener("click", () => { loadScreenIntoForm(screen); }); @@ -5595,13 +5708,17 @@

How This Connects

const card = document.createElement("article"); card.className = "screen-card"; const targetLabel = targetDisplayLabel(item.target); + const targetScreen = getScreenById(item.target); + const rendererLine = targetScreen?.transport === "matrix" + ? "
Renderer: Matrix / MQTT" + : "
Renderer: HTML / WebSocket"; card.innerHTML = `
${escapeHtml(targetLabel || item.target || "n/a")} ${item.status}
- Last seen: ${formatAge(item.ageMs)}${viewLine}${bannerLine}${tickerLine}${widgetOverrideLine}${groupLine}${lastRuleLine} + Last seen: ${formatAge(item.ageMs)}${rendererLine}${viewLine}${bannerLine}${tickerLine}${widgetOverrideLine}${groupLine}${lastRuleLine}
`; root.appendChild(card); @@ -5675,10 +5792,13 @@

How This Connects

} state.selectedScreenId = screen.screenId || null; $("manageScreenIdInput").value = screen.screenId || ""; - $("manageScreenDeviceInput").value = screen.deviceId || ""; + const matrix = screen.transport === "matrix"; + $("manageScreenDeviceInput").value = matrix ? "Matrix / MQTT" : (screen.deviceId || ""); $("manageScreenFriendlyNameInput").value = screen.friendlyName || ""; $("manageScreenRebindPairingInput").value = ""; $("manageScreenViewInput").value = screen.viewId || ""; + $("manageScreenRebindPairingInput").disabled = matrix; + $("rebindScreenBtn").disabled = matrix; syncScreenEditorState(); } @@ -5838,6 +5958,7 @@

How This Connects

renderTickerPresets(); renderRules(); renderRegisterViewPicker(); + renderMatrixViewPicker(); renderPendingDevices(); renderScreens(); renderBanners(); @@ -5893,6 +6014,29 @@

How This Connects

setResult(`Registered ${screenId} to pairing code ${pairingCode}`); } + async function saveMatrixTarget() { + const screenId = $("matrixTargetIdInput").value.trim().toLowerCase(); + const viewId = $("matrixViewIdInput").value.trim().toLowerCase(); + if (!screenId || !viewId) throw new Error("Matrix target ID and a compatible View are required"); + const data = await apiJson("/api/matrix-targets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + screenId, + friendlyName: $("matrixFriendlyNameInput").value.trim() || screenId, + viewId, + config: { + width: Number($("matrixWidthInput").value), + height: Number($("matrixHeightInput").value), + colorDepth: Number($("matrixColorDepthInput").value), + rotation: Number($("matrixRotationInput").value), + features: ["clock", "status", "notification", "ticker", "icons"] + } + }) + }); + setResult(`Saved Matrix target ${data.screen?.screenId || screenId}`); + } + async function saveScreen() { const screenId = $("manageScreenIdInput").value.trim().toLowerCase(); if (!screenId) { @@ -6481,6 +6625,11 @@

How This Connects

await loadState(); })); + $("saveMatrixTargetBtn").addEventListener("click", () => runAction(async () => { + await saveMatrixTarget(); + await loadState(); + })); + $("saveScreenBtn").addEventListener("click", () => runAction(async () => { await saveScreen(); await loadState(); @@ -6598,6 +6747,7 @@

How This Connects

$("targetInput").addEventListener("input", () => { setCurrentTarget($("targetInput").value.trim()); + syncOverrideForm(); }); $("manageScreenFriendlyNameInput").addEventListener("input", () => { @@ -6612,6 +6762,10 @@

How This Connects

renderRegisterViewPicker(); }); + $("matrixViewIdInput").addEventListener("input", () => { + renderMatrixViewPicker(); + }); + $("groupIdInput").addEventListener("input", () => { syncGroupEditorState(); }); diff --git a/server/server.js b/server/server.js index 0831c85..de49497 100644 --- a/server/server.js +++ b/server/server.js @@ -924,6 +924,10 @@ function hydrateScreenTransport(screen) { } function saveMatrixTarget(screenId, friendlyName, viewId, config = {}) { + const compatibility = getMatrixViewCompatibility(viewId); + if (!compatibility.compatible) { + throw new Error(`view is not compatible with Matrix targets: ${compatibility.reasons.join("; ")}`); + } const now = new Date().toISOString(); const current = getScreenRecord(screenId); if (current && current.transport !== "matrix") { @@ -945,6 +949,39 @@ function saveMatrixTarget(screenId, friendlyName, viewId, config = {}) { return getScreenRecord(screenId); } +function getMatrixViewCompatibility(viewId) { + const view = getView(viewId); + if (!view) { + return { compatible: false, reasons: ["view not found"] }; + } + const profile = view.profileId ? getProfile(view.profileId) : null; + if (!profile) { + return { compatible: false, reasons: ["view has no profile"] }; + } + return getMatrixProfileCompatibility(profile); +} + +function getMatrixProfileCompatibility(profile) { + if (!profile) { + return { compatible: false, reasons: ["profile not found"] }; + } + if (profile.template === "clock") { + return { compatible: true, reasons: [] }; + } + if (profile.template !== "custom") { + return { compatible: false, reasons: [`${profile.template} profiles require a browser renderer`] }; + } + const supportedWidgetKinds = new Set(["text", "entity", "weather", "status"]); + const unsupported = (Array.isArray(profile.config?.widgetIds) ? profile.config.widgetIds : []) + .map((widgetId) => getWidget(widgetId)) + .filter(Boolean) + .map((widget) => String(widget.config?.kind || "text").toLowerCase()) + .filter((kind) => !supportedWidgetKinds.has(kind)); + return unsupported.length > 0 + ? { compatible: false, reasons: [`unsupported widget types: ${Array.from(new Set(unsupported)).join(", ")}`] } + : { compatible: true, reasons: [] }; +} + function listScreens() { return sqliteQuery( `SELECT screen_id AS screenId, @@ -6333,6 +6370,12 @@ app.post("/api/screens/:screenId", requireAdmin, (req, res) => { if (!getView(viewId)) { return res.status(404).json({ error: "view not found" }); } + if (getMatrixTarget(screenId)) { + const compatibility = getMatrixViewCompatibility(viewId); + if (!compatibility.compatible) { + return res.status(400).json({ error: `view is not compatible with this Matrix target: ${compatibility.reasons.join("; ")}` }); + } + } const updated = updateScreenRecord(screenId, { friendlyName, viewId @@ -6574,6 +6617,10 @@ app.post("/api/overrides", requireAdmin, (req, res) => { if (targets.length === 0) { return res.status(404).json({ error: "target does not resolve to any registered screens" }); } + const matrixTargets = targets.filter((screenId) => Boolean(getMatrixTarget(screenId))); + if (matrixTargets.length > 0 && ["camera", "theme"].includes(type)) { + return res.status(400).json({ error: `${type} overrides are not supported by Matrix targets: ${matrixTargets.join(", ")}` }); + } if (type === "camera") { if (!camera && !snapshot) { @@ -6630,6 +6677,12 @@ app.post("/api/overrides", requireAdmin, (req, res) => { if (!resolvedResource) { return res.status(404).json({ error: "profile not found" }); } + if (matrixTargets.length > 0) { + const compatibility = getMatrixProfileCompatibility(resolvedResource); + if (!compatibility.compatible) { + return res.status(400).json({ error: `profile override is not compatible with Matrix targets: ${compatibility.reasons.join("; ")}` }); + } + } override.profile = resolvedResource; override.layout = buildLayoutPayloadFromProfile(resolvedResource); } else if (type === "banner") { From 5405a5e6946acbc3a76a525c6b0c26b847c7ef38 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:03:58 -0700 Subject: [PATCH 04/25] feat(matrix): improve status scenes and overflow rendering --- docs/matrix-mqtt-protocol.md | 5 +++ esphome/coreview-matrix-base.yaml | 3 +- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 19 +++++++++-- server/public/app.html | 6 +++- server/server.js | 33 +++++++++++++++++-- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 622156a..a4d5f1a 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -66,3 +66,8 @@ also declare color depth, rotation, and supported features. The reference firmware may render a richer local idle animation, but CoreView scenes must use only this protocol's portable primitives: text, short status fields, ticker, severity, and the standard icon names. + +The reference 32-pixel-tall renderer currently presents one custom status +widget at a time. It centers text that fits and marquees text that does not; +profiles with multiple custom widgets are intentionally treated as browser-only +until Matrix paging is introduced. diff --git a/esphome/coreview-matrix-base.yaml b/esphome/coreview-matrix-base.yaml index c4500af..ce5ec62 100644 --- a/esphome/coreview-matrix-base.yaml +++ b/esphome/coreview-matrix-base.yaml @@ -101,9 +101,8 @@ mqtt: JsonArrayConst fields = scene["fields"].as(); if (id(coreview_detail).empty() && !fields.isNull() && fields.size() > 0) { JsonObjectConst first = fields[0].as(); - const char *label = first["label"] | ""; const char *value = first["value"] | ""; - id(coreview_detail) = std::string(label) + " " + value; + id(coreview_detail) = value; } JsonObjectConst ticker = scene["ticker"].as(); id(coreview_ticker) = ticker.isNull() ? "" : (ticker["message"] | ""); diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index cf2e224..ccdcc28 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -54,20 +54,33 @@ display: const Color color = id(coreview_severity) == "critical" ? Color(255, 32, 0) : id(coreview_severity) == "warning" ? Color(255, 160, 0) : id(coreview_severity) == "success" ? Color(0, 255, 120) : Color(0, 200, 255); + auto print_line = [&](const std::string &text, int y, const Color &line_color) { + int x1, y1, text_width, text_height; + it.get_text_bounds(0, 0, text.c_str(), id(coreview_font_small), TextAlign::TOP_LEFT, &x1, &y1, &text_width, &text_height); + if (text_width <= width - 4) { + it.print(width / 2, y, id(coreview_font_small), line_color, TextAlign::TOP_CENTER, text.c_str()); + return; + } + const int span = text_width + width; + const int x = width - ((now_ms / 55U) % span); + it.start_clipping(0, y, width, text_height + 2); + it.print(x, y, id(coreview_font_small), line_color, text.c_str()); + it.end_clipping(); + }; if (id(coreview_scene) == "clock") { auto now = id(coreview_time).now(); if (now.is_valid()) { std::string stamp = now.strftime("%I:%M %p"); if (!stamp.empty() && stamp[0] == '0') stamp.erase(0, 1); it.print(width / 2, 1, id(coreview_font_large), color, TextAlign::TOP_CENTER, stamp.c_str()); - it.print(width / 2, height - 10, id(coreview_font_small), Color(0, 120, 255), TextAlign::TOP_CENTER, id(coreview_title).c_str()); + print_line(id(coreview_title), height - 10, Color(0, 120, 255)); } else { it.print(width / 2, height / 2, id(coreview_font_small), color, TextAlign::CENTER, "WAITING FOR TIME"); } } else { if (id(coreview_scene) == "notification") it.rectangle(0, 0, width - 1, height - 1, color); - it.print(width / 2, 2, id(coreview_font_small), color, TextAlign::TOP_CENTER, id(coreview_title).c_str()); - it.print(width / 2, height / 2 + 4, id(coreview_font_small), color, TextAlign::CENTER, id(coreview_detail).c_str()); + print_line(id(coreview_title), 2, color); + print_line(id(coreview_detail), height - 10, color); } if (!id(coreview_ticker).empty()) { const int span = (int) id(coreview_ticker).size() * 6 + width; diff --git a/server/public/app.html b/server/public/app.html index 62aab2d..a32efd1 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2412,8 +2412,12 @@

How This Connects

if (profile.template !== "custom") { return { compatible: false, reason: `${profile.template} requires HTML` }; } + const widgetIds = Array.isArray(profile.config?.widgetIds) ? profile.config.widgetIds : []; + if (widgetIds.length > 1) { + return { compatible: false, reason: "Matrix supports one status widget" }; + } const allowed = new Set(["text", "entity", "weather", "status"]); - const unsupported = (Array.isArray(profile.config?.widgetIds) ? profile.config.widgetIds : []) + const unsupported = widgetIds .map((widgetId) => state.widgets.find((widget) => widget.widgetId === widgetId)) .filter(Boolean) .map((widget) => String(widget.config?.kind || "text").toLowerCase()) diff --git a/server/server.js b/server/server.js index de49497..3fc1fe0 100644 --- a/server/server.js +++ b/server/server.js @@ -971,8 +971,12 @@ function getMatrixProfileCompatibility(profile) { if (profile.template !== "custom") { return { compatible: false, reasons: [`${profile.template} profiles require a browser renderer`] }; } + const widgetIds = Array.isArray(profile.config?.widgetIds) ? profile.config.widgetIds : []; + if (widgetIds.length > 1) { + return { compatible: false, reasons: ["Matrix status scenes currently support one widget"] }; + } const supportedWidgetKinds = new Set(["text", "entity", "weather", "status"]); - const unsupported = (Array.isArray(profile.config?.widgetIds) ? profile.config.widgetIds : []) + const unsupported = widgetIds .map((widgetId) => getWidget(widgetId)) .filter(Boolean) .map((widget) => String(widget.config?.kind || "text").toLowerCase()) @@ -3067,6 +3071,29 @@ function matrixTopic(screenId, channel) { return `${MQTT_TOPIC_ROOT}/matrix/${screenId}/${channel}`; } +function matrixWidgetValue(widget = {}, entity = null) { + if (widget.kind === "text") { + return String(widget.text || ""); + } + if (!entity) { + return "Unavailable"; + } + const attributes = entity.attributes && typeof entity.attributes === "object" ? entity.attributes : {}; + const valueSource = widget.kind === "weather" ? "attribute" : String(widget.valueSource || "state").toLowerCase(); + const key = widget.kind === "weather" ? String(widget.weatherField || "temperature") : String(widget.attributeKey || ""); + const value = valueSource === "attribute" && key ? attributes[key] : entity.state; + if (value === null || value === undefined || value === "") { + return "Unavailable"; + } + let unit = ""; + if (widget.showUnit !== false) { + unit = widget.kind === "weather" && key === "temperature" + ? String(attributes.temperature_unit || entity.unit || "") + : String(entity.unit || ""); + } + return `${String(value)}${unit ? ` ${unit}` : ""}`.trim(); +} + function compileMatrixScene(runtime) { const layout = runtime?.layout || {}; const ticker = runtime?.tickerState?.message ? { message: runtime.tickerState.message } : null; @@ -3085,9 +3112,9 @@ function compileMatrixScene(runtime) { if (layout.template === "custom") { const fields = (Array.isArray(layout.widgets) ? layout.widgets : []).slice(0, 4).map((widget) => ({ label: String(widget?.label || widget?.entityId || "Status").slice(0, 48), - value: String(widget?.text || widget?.value || layout.entityStates?.[widget?.entityId]?.state || "").slice(0, 96) + value: matrixWidgetValue(widget, layout.entityStates?.[widget?.entityId]).slice(0, 96) })); - return { kind: "status", title: runtime?.view?.name || "CoreView", fields, ticker }; + return { kind: "status", title: fields[0]?.label || runtime?.view?.name || "CoreView", fields, ticker }; } return { kind: "unsupported", From 60c8f1a509f4619434576f20a53ade4903534758 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:24:24 -0700 Subject: [PATCH 05/25] feat(matrix): add local effect scenes --- docs/matrix-mqtt-protocol.md | 6 +++- esphome/coreview-matrix-base.yaml | 20 +++++++++++ esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 32 ++++++++++++++++++ server/public/app.html | 33 +++++++++++++++++-- server/server.js | 27 +++++++++++++-- 5 files changed, 113 insertions(+), 5 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index a4d5f1a..90890c9 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -41,10 +41,14 @@ without waiting for another CoreView change. } ``` -`scene.kind` is one of `clock`, `status`, `notification`, or `unsupported`. +`scene.kind` is one of `clock`, `status`, `notification`, `effect`, or `unsupported`. `unsupported` is an explicit, renderable fallback for browser-only View types (for example maps and photo slideshows); it is never a silent failure. +Matrix-only custom widgets may emit an `effect` scene with an effect name, +palette, speed, intensity, and optional text. Effects are generated locally by +the target; CoreView sends parameters, never a pixel stream. + Transient events use `coreview.matrix.event.v1`, carry a UUID `eventId`, a `kind` of `notification` or `clear`, an optional RFC 3339 `expiresAt`, and a severity (`info`, `success`, `warning`, `critical`). Events do not replace the diff --git a/esphome/coreview-matrix-base.yaml b/esphome/coreview-matrix-base.yaml index ce5ec62..a797e1b 100644 --- a/esphome/coreview-matrix-base.yaml +++ b/esphome/coreview-matrix-base.yaml @@ -59,6 +59,22 @@ globals: type: uint32_t restore_value: no initial_value: '0' + - id: coreview_effect + type: std::string + restore_value: no + initial_value: '"scanner"' + - id: coreview_palette + type: std::string + restore_value: no + initial_value: '"neon"' + - id: coreview_effect_speed + type: int + restore_value: no + initial_value: '35' + - id: coreview_effect_intensity + type: int + restore_value: no + initial_value: '60' mqtt: broker: !secret mqtt_broker @@ -98,6 +114,10 @@ mqtt: id(coreview_title) = scene["title"] | "CoreView"; id(coreview_detail) = scene["detail"] | ""; id(coreview_severity) = scene["severity"] | "info"; + id(coreview_effect) = scene["effect"] | "scanner"; + id(coreview_palette) = scene["palette"] | "neon"; + id(coreview_effect_speed) = scene["speed"] | 35; + id(coreview_effect_intensity) = scene["intensity"] | 60; JsonArrayConst fields = scene["fields"].as(); if (id(coreview_detail).empty() && !fields.isNull() && fields.size() > 0) { JsonObjectConst first = fields[0].as(); diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index ccdcc28..62dc5ee 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -67,6 +67,38 @@ display: it.print(x, y, id(coreview_font_small), line_color, text.c_str()); it.end_clipping(); }; + if (id(coreview_scene) == "effect") { + const int speed = std::max(1, std::min(100, id(coreview_effect_speed))); + const uint8_t level = (uint8_t) (std::max(1, std::min(100, id(coreview_effect_intensity)) * 255 / 100)); + const uint32_t frame = now_ms / (uint32_t) (111 - speed); + auto hue = [&](uint8_t h) -> Color { + if (id(coreview_palette) == "ocean") return Color(0, h / 2, level); + if (id(coreview_palette) == "sunset") return Color(level, h / 3, 0); + if (id(coreview_palette) == "forest") return Color(h / 3, level, h / 5); + return Color((uint8_t) (h ^ level), (uint8_t) (level - h / 2), h); + }; + if (id(coreview_effect) == "aurora") { + for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) { + const int band = 15 + (int) (7.0f * sinf((x + frame) * 0.11f)); + const int d = abs(y - band); + if (d < 6) it.draw_pixel_at(x, y, hue((uint8_t) (90 + x + frame)) * (uint8_t) ((6 - d) * 38)); + } + } else if (id(coreview_effect) == "rainbow_waves" || id(coreview_effect) == "color_vortex") { + for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) it.draw_pixel_at(x, y, hue((uint8_t) (x * 3 + y * 7 + frame * 2))); + } else if (id(coreview_effect) == "digital_rain") { + for (int x = 0; x < width; x += 2) { const int head = (frame + x * 3) % (height + 12) - 6; for (int t = 0; t < 9; t++) if (head - t >= 0 && head - t < height) it.draw_pixel_at(x, head - t, Color(0, (uint8_t) (level - t * 20), 0)); } + } else if (id(coreview_effect) == "fire") { + for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) { const uint8_t heat = (uint8_t) std::max(0, (height - y) * 12 + (int) ((x * 17 + frame * 13) & 63) - 70); it.draw_pixel_at(x, y, Color(heat, (uint8_t) (heat / 3), 0)); } + } else if (id(coreview_effect) == "twinkle" || id(coreview_effect) == "confetti") { + for (int i = 0; i < width * height / 10; i++) { const uint32_t hash = (uint32_t) i * 2654435761UL + frame * 2246822519UL; const int x = hash % width; const int y = (hash >> 8) % height; it.draw_pixel_at(x, y, hue((uint8_t) (hash >> 16))); } + } else { + const int scanner = frame % (2 * width - 2); + const int x = scanner < width ? scanner : (2 * width - 2 - scanner); + it.line(x, 1, x, height - 2, hue((uint8_t) frame)); + print_line(id(coreview_title).empty() ? "COREVIEW" : id(coreview_title), height / 2 - 4, hue((uint8_t) frame)); + } + return; + } if (id(coreview_scene) == "clock") { auto now = id(coreview_time).now(); if (now.is_valid()) { diff --git a/server/public/app.html b/server/public/app.html index a32efd1..c881e29 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2416,7 +2416,7 @@

How This Connects

if (widgetIds.length > 1) { return { compatible: false, reason: "Matrix supports one status widget" }; } - const allowed = new Set(["text", "entity", "weather", "status"]); + const allowed = new Set(["text", "entity", "weather", "status", "matrix_effect"]); const unsupported = widgetIds .map((widgetId) => state.widgets.find((widget) => widget.widgetId === widgetId)) .filter(Boolean) @@ -3809,7 +3809,7 @@

How This Connects

function normalizeProfileWidget(widget = {}) { const kind = String(widget.kind || "text").trim().toLowerCase(); - const normalizedKind = ["clock", "entity", "text", "weather", "status", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; + const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; const verticalAlign = String(widget.verticalAlign || "top").trim().toLowerCase(); const base = { kind: normalizedKind, @@ -3871,6 +3871,18 @@

How This Connects

text: String(widget.text || widget.value || "").trim() }; } + if (normalizedKind === "matrix_effect") { + const effects = ["scanner", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; + const palettes = ["neon", "ocean", "sunset", "forest", "party"]; + return { + ...base, + effect: effects.includes(String(widget.effect || "scanner").toLowerCase()) ? String(widget.effect || "scanner").toLowerCase() : "scanner", + palette: palettes.includes(String(widget.palette || "neon").toLowerCase()) ? String(widget.palette || "neon").toLowerCase() : "neon", + speed: Math.max(1, Math.min(100, Math.round(Number(widget.speed || 35)))), + intensity: Math.max(1, Math.min(100, Math.round(Number(widget.intensity || 60)))), + text: String(widget.text || "").trim() + }; + } if (normalizedKind === "photo_slideshow") { return { ...base, @@ -3953,6 +3965,9 @@

How This Connects

verticalAlign: overrides.verticalAlign || "top" }); } + if (nextKind === "matrix_effect") { + return normalizeProfileWidget({ kind: "matrix_effect", label: overrides.label || "Matrix Effect", effect: "aurora", palette: "neon", speed: 35, intensity: 60, text: overrides.text || "" }); + } if (nextKind === "photo_slideshow") { return normalizeProfileWidget({ kind: "photo_slideshow", @@ -4142,6 +4157,12 @@

How This Connects

`; + } else if (widget.kind === "matrix_effect") { + extraField = ` +
+
+
+
`; } root.innerHTML = ` @@ -4153,6 +4174,7 @@

How This Connects

+ @@ -4204,6 +4226,13 @@

How This Connects

}); renderSingleWidgetEditor(rootId, stateKey); }); + if (widget.kind === "matrix_effect") { + root.querySelector(".widget-effect").value = widget.effect; + root.querySelector(".widget-palette").value = widget.palette; + root.querySelectorAll(".widget-effect, .widget-palette, .widget-effect-speed, .widget-effect-intensity").forEach((input) => input.addEventListener("input", () => { + state[stateKey] = normalizeProfileWidget({ ...state[stateKey], effect: root.querySelector(".widget-effect").value, palette: root.querySelector(".widget-palette").value, speed: root.querySelector(".widget-effect-speed").value, intensity: root.querySelector(".widget-effect-intensity").value }); + })); + } root.querySelector(".widget-label").addEventListener("input", (event) => { state[stateKey].label = event.target.value.trim(); diff --git a/server/server.js b/server/server.js index 3fc1fe0..764c458 100644 --- a/server/server.js +++ b/server/server.js @@ -975,7 +975,7 @@ function getMatrixProfileCompatibility(profile) { if (widgetIds.length > 1) { return { compatible: false, reasons: ["Matrix status scenes currently support one widget"] }; } - const supportedWidgetKinds = new Set(["text", "entity", "weather", "status"]); + const supportedWidgetKinds = new Set(["text", "entity", "weather", "status", "matrix_effect"]); const unsupported = widgetIds .map((widgetId) => getWidget(widgetId)) .filter(Boolean) @@ -2513,7 +2513,7 @@ function getProfile(profileId) { function normalizeCustomProfileWidget(widget = {}) { const kind = String(widget.kind || "text").trim().toLowerCase(); - const normalizedKind = ["clock", "entity", "text", "weather", "status", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; + const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; const verticalAlign = String(widget.verticalAlign || "top").trim().toLowerCase(); const displayFormat = String(widget.displayFormat || "auto").trim().toLowerCase(); const decimals = Math.max(0, Math.min(3, Number.isFinite(Number(widget.decimals)) ? Number(widget.decimals) : 1)); @@ -2567,6 +2567,18 @@ function normalizeCustomProfileWidget(widget = {}) { text: String(widget.text || widget.value || "").trim() }; } + if (normalizedKind === "matrix_effect") { + const effects = ["scanner", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; + const palettes = ["neon", "ocean", "sunset", "forest", "party"]; + return { + ...base, + effect: effects.includes(String(widget.effect || "scanner").toLowerCase()) ? String(widget.effect || "scanner").toLowerCase() : "scanner", + palette: palettes.includes(String(widget.palette || "neon").toLowerCase()) ? String(widget.palette || "neon").toLowerCase() : "neon", + speed: Math.max(1, Math.min(100, Math.round(Number(widget.speed || 35)))), + intensity: Math.max(1, Math.min(100, Math.round(Number(widget.intensity || 60)))), + text: String(widget.text || "").trim() + }; + } if (normalizedKind === "photo_slideshow") { return { ...base, @@ -3110,6 +3122,17 @@ function compileMatrixScene(runtime) { return { kind: "clock", title: layout.title || runtime?.view?.name || "CoreView", ticker }; } if (layout.template === "custom") { + const effectWidget = (Array.isArray(layout.widgets) ? layout.widgets : []).find((widget) => widget?.kind === "matrix_effect"); + if (effectWidget) { + return { + kind: "effect", + effect: effectWidget.effect || "scanner", + palette: effectWidget.palette || "neon", + speed: Number(effectWidget.speed || 35), + intensity: Number(effectWidget.intensity || 60), + text: effectWidget.text || "" + }; + } const fields = (Array.isArray(layout.widgets) ? layout.widgets : []).slice(0, 4).map((widget) => ({ label: String(widget?.label || widget?.entityId || "Status").slice(0, 48), value: matrixWidgetValue(widget, layout.entityStates?.[widget?.entityId]).slice(0, 96) From 82228ecb8f8cfd09c5505ca25bf50ed24abe2b01 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:57:24 -0700 Subject: [PATCH 06/25] feat(matrix): apply themes to targets --- docs/matrix-mqtt-protocol.md | 15 +++++ esphome/README.md | 4 +- esphome/coreview-matrix-base.yaml | 34 ++++++++++ esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 63 ++++++++++++++++--- server/public/app.html | 46 ++++++++++++-- server/server.js | 50 +++++++++++++-- 6 files changed, 190 insertions(+), 22 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 90890c9..ff329fd 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -33,6 +33,15 @@ without waiting for another CoreView change. "revision": 42, "issuedAt": "2026-07-25T19:30:00.000Z", "display": { "width": 96, "height": 32, "colorDepth": 4, "rotation": 0 }, + "theme": { + "background": "#000000", + "primary": "#e6e8ed", + "secondary": "#0080ff", + "accent": "#ffb000", + "critical": "#ff2000", + "effectPalette": "neon", + "brightness": 64 + }, "scene": { "kind": "clock", "title": "Kitchen", @@ -49,6 +58,12 @@ Matrix-only custom widgets may emit an `effect` scene with an effect name, palette, speed, intensity, and optional text. Effects are generated locally by the target; CoreView sends parameters, never a pixel stream. +`theme` is the effective CoreView Theme after normal assignment, schedules, and +manual overrides are resolved. All colors are six-digit CSS hex values and +`brightness` is an integer from 1 through 255. Clients that do not implement +theme support may ignore this object. Effects use `theme.effectPalette`, so a +theme change restyles both normal scenes and local animations consistently. + Transient events use `coreview.matrix.event.v1`, carry a UUID `eventId`, a `kind` of `notification` or `clear`, an optional RFC 3339 `expiresAt`, and a severity (`info`, `success`, `warning`, `critical`). Events do not replace the diff --git a/esphome/README.md b/esphome/README.md index ce9f059..5e1fc01 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -13,7 +13,9 @@ specific panel dimension. The base package reads the retained state topic documented in [`docs/matrix-mqtt-protocol.md`](../docs/matrix-mqtt-protocol.md), publishes availability/status, and acknowledges applied revisions. It deliberately does -not require Home Assistant's native API. +not require Home Assistant's native API. It also applies the active CoreView +Theme's Matrix colors, effect palette, and brightness locally; older CoreView +servers that omit `theme` continue to render with the reference defaults. ## Quick start diff --git a/esphome/coreview-matrix-base.yaml b/esphome/coreview-matrix-base.yaml index a797e1b..105071b 100644 --- a/esphome/coreview-matrix-base.yaml +++ b/esphome/coreview-matrix-base.yaml @@ -75,6 +75,30 @@ globals: type: int restore_value: no initial_value: '60' + - id: coreview_matrix_background + type: std::string + restore_value: no + initial_value: '"#000000"' + - id: coreview_matrix_primary + type: std::string + restore_value: no + initial_value: '"#e6e8ed"' + - id: coreview_matrix_secondary + type: std::string + restore_value: no + initial_value: '"#0080ff"' + - id: coreview_matrix_accent + type: std::string + restore_value: no + initial_value: '"#ffb000"' + - id: coreview_matrix_critical + type: std::string + restore_value: no + initial_value: '"#ff2000"' + - id: coreview_brightness + type: int + restore_value: no + initial_value: '64' mqtt: broker: !secret mqtt_broker @@ -109,7 +133,14 @@ mqtt: if (std::string(schema) != "coreview.matrix.state.v1" || revision <= id(coreview_revision)) return; JsonObjectConst scene = x["scene"].as(); if (scene.isNull()) return; + JsonObjectConst theme = x["theme"].as(); id(coreview_revision) = revision; + id(coreview_matrix_background) = theme["background"] | "#000000"; + id(coreview_matrix_primary) = theme["primary"] | "#e6e8ed"; + id(coreview_matrix_secondary) = theme["secondary"] | "#0080ff"; + id(coreview_matrix_accent) = theme["accent"] | "#ffb000"; + id(coreview_matrix_critical) = theme["critical"] | "#ff2000"; + id(coreview_brightness) = std::max(1, std::min(255, theme["brightness"] | 64)); id(coreview_scene) = scene["kind"] | "unsupported"; id(coreview_title) = scene["title"] | "CoreView"; id(coreview_detail) = scene["detail"] | ""; @@ -126,6 +157,9 @@ mqtt: } JsonObjectConst ticker = scene["ticker"].as(); id(coreview_ticker) = ticker.isNull() ? "" : (ticker["message"] | ""); + - hub75.set_brightness: + id: coreview_matrix + brightness: !lambda 'return (uint8_t) id(coreview_brightness);' - mqtt.publish_json: topic: ${coreview_ack_topic} payload: |- diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 62dc5ee..7fefa89 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -46,14 +46,26 @@ display: const int width = it.get_width(); const int height = it.get_height(); const uint32_t now_ms = millis(); - it.fill(Color::BLACK); + auto color_from_hex = [](const std::string &hex, const Color &fallback) -> Color { + unsigned int rgb = 0; + if (hex.size() == 7 && hex[0] == '#' && sscanf(hex.c_str() + 1, "%06x", &rgb) == 1) { + return Color((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); + } + return fallback; + }; + const Color background = color_from_hex(id(coreview_matrix_background), Color::BLACK); + const Color primary = color_from_hex(id(coreview_matrix_primary), Color(230, 232, 237)); + const Color secondary = color_from_hex(id(coreview_matrix_secondary), Color(0, 128, 255)); + const Color accent = color_from_hex(id(coreview_matrix_accent), Color(255, 176, 0)); + const Color critical = color_from_hex(id(coreview_matrix_critical), Color(255, 32, 0)); + it.fill(background); if ((int32_t) (now_ms - id(coreview_boot_until)) < 0) { - it.print(width / 2, height / 2, id(coreview_font_small), Color(0, 255, 180), TextAlign::CENTER, "COREVIEW"); + it.print(width / 2, height / 2, id(coreview_font_small), primary, TextAlign::CENTER, "COREVIEW"); return; } - const Color color = id(coreview_severity) == "critical" ? Color(255, 32, 0) - : id(coreview_severity) == "warning" ? Color(255, 160, 0) - : id(coreview_severity) == "success" ? Color(0, 255, 120) : Color(0, 200, 255); + const Color color = id(coreview_severity) == "critical" ? critical + : id(coreview_severity) == "warning" ? accent + : id(coreview_severity) == "success" ? primary : secondary; auto print_line = [&](const std::string &text, int y, const Color &line_color) { int x1, y1, text_width, text_height; it.get_text_bounds(0, 0, text.c_str(), id(coreview_font_small), TextAlign::TOP_LEFT, &x1, &y1, &text_width, &text_height); @@ -86,11 +98,42 @@ display: } else if (id(coreview_effect) == "rainbow_waves" || id(coreview_effect) == "color_vortex") { for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) it.draw_pixel_at(x, y, hue((uint8_t) (x * 3 + y * 7 + frame * 2))); } else if (id(coreview_effect) == "digital_rain") { - for (int x = 0; x < width; x += 2) { const int head = (frame + x * 3) % (height + 12) - 6; for (int t = 0; t < 9; t++) if (head - t >= 0 && head - t < height) it.draw_pixel_at(x, head - t, Color(0, (uint8_t) (level - t * 20), 0)); } + // Ported from the original Beacon renderer: seeded columns retain + // their own cadence and long, fading trails instead of moving in lockstep. + for (int x = 0; x < width; x += 2) { + uint32_t hash = (uint32_t) x * 2654435761UL + 0x9E3779B9UL; + hash ^= hash >> 15; + const int rate = 1 + (hash % 3U); + const int head = (int) ((frame * rate + (hash >> 8)) % (uint32_t) (height + 24)) - 12; + const uint8_t hue_shift = id(coreview_palette) == "forest" ? 30 : (id(coreview_palette) == "neon" ? 90 : 0); + for (int trail = 0; trail < 13; trail++) { + const int y = head - trail; + if (y >= 0 && y < height) { + const uint8_t value = trail == 0 ? level : (uint16_t) level * (13 - trail) / 18; + const uint8_t green = (uint16_t) value * (180 + ((hash >> 16) & 0x3F)) / 255; + it.draw_pixel_at(x, y, Color((uint8_t) (hue_shift / 4), green, (uint8_t) (hue_shift / 2))); + } + } + } } else if (id(coreview_effect) == "fire") { - for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) { const uint8_t heat = (uint8_t) std::max(0, (height - y) * 12 + (int) ((x * 17 + frame * 13) & 63) - 70); it.draw_pixel_at(x, y, Color(heat, (uint8_t) (heat / 3), 0)); } - } else if (id(coreview_effect) == "twinkle" || id(coreview_effect) == "confetti") { - for (int i = 0; i < width * height / 10; i++) { const uint32_t hash = (uint32_t) i * 2654435761UL + frame * 2246822519UL; const int x = hash % width; const int y = (hash >> 8) % height; it.draw_pixel_at(x, y, hue((uint8_t) (hash >> 16))); } + 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; + heat = std::max(0, std::min(255, heat)); + it.draw_pixel_at(x, y, Color((uint8_t) heat, (uint8_t) (heat / 3), 0)); + } + } else if (id(coreview_effect) == "twinkle") { + for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { + uint32_t hash = (uint32_t) x * 73856093UL ^ (uint32_t) y * 19349663UL; hash ^= hash >> 13; + const uint8_t phase = (uint8_t) ((frame + (hash & 0x3FU)) & 0x3FU); + if (phase < 9U) { const uint8_t envelope = phase <= 4U ? phase * 55U : (9U - phase) * 55U; it.draw_pixel_at(x, y, hue((uint8_t) (hash >> 16)) * envelope); } + } + } else if (id(coreview_effect) == "confetti") { + for (int trail = 3; trail >= 0; trail--) for (uint32_t p = 0; p < (uint32_t) (width * height / 44); p++) { + uint32_t hash = p * 2654435761UL + (frame - trail) * 2246822519UL; hash ^= hash >> 15; + it.draw_pixel_at(hash % width, (hash >> 8) % height, hue((uint8_t) (hash >> 16)) * (uint8_t) ((4 - trail) * 51)); + } } else { const int scanner = frame % (2 * width - 2); const int x = scanner < width ? scanner : (2 * width - 2 - scanner); @@ -105,7 +148,7 @@ display: std::string stamp = now.strftime("%I:%M %p"); if (!stamp.empty() && stamp[0] == '0') stamp.erase(0, 1); it.print(width / 2, 1, id(coreview_font_large), color, TextAlign::TOP_CENTER, stamp.c_str()); - print_line(id(coreview_title), height - 10, Color(0, 120, 255)); + print_line(id(coreview_title), height - 10, secondary); } else { it.print(width / 2, height / 2, id(coreview_font_small), color, TextAlign::CENTER, "WAITING FOR TIME"); } diff --git a/server/public/app.html b/server/public/app.html index c881e29..922cd78 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -1216,7 +1216,7 @@

Themes

-

Themes define presentation only: light or dark mode, font family, and text color. Keep content in profiles and use themes to control visual style across one or many screens.

+

Themes define presentation for browser screens and Matrix targets. Keep content in profiles and use themes to control visual style across one or many screens.

@@ -1246,6 +1246,18 @@

Themes

+
+ +
+
+
+
+
+
+
+
+
+
@@ -1255,7 +1267,7 @@

Themes

-
Themes define presentation only. Assign themes in Views; screens inherit through their assigned view.
+
Matrix settings are sent over MQTT whenever the assigned View, theme schedule, or manual theme override changes.
@@ -3581,6 +3593,13 @@

How This Connects

$("themeModeInput").value = "dark"; $("themeFontFamilyInput").value = "sans"; $("themeFontColorInput").value = "#e6e8ed"; + $("themeMatrixBackgroundInput").value = "#000000"; + $("themeMatrixPrimaryInput").value = "#e6e8ed"; + $("themeMatrixSecondaryInput").value = "#0080ff"; + $("themeMatrixAccentInput").value = "#ffb000"; + $("themeMatrixCriticalInput").value = "#ff2000"; + $("themeMatrixPaletteInput").value = "neon"; + $("themeMatrixBrightnessInput").value = "64"; state.selectedThemeId = null; syncThemeEditorState(); } @@ -4967,8 +4986,8 @@

How This Connects

function syncOverrideForm() { const matrixTarget = currentTargetIncludesMatrix(); $("overrideTypeInput").querySelector('option[value="camera"]').disabled = matrixTarget; - $("overrideTypeInput").querySelector('option[value="theme"]').disabled = matrixTarget; - if (matrixTarget && ["camera", "theme"].includes($("overrideTypeInput").value)) { + $("overrideTypeInput").querySelector('option[value="theme"]').disabled = false; + if (matrixTarget && $("overrideTypeInput").value === "camera") { $("overrideTypeInput").value = "banner"; } const type = $("overrideTypeInput").value; @@ -5876,6 +5895,14 @@

How This Connects

$("themeModeInput").value = theme.config?.mode || "dark"; $("themeFontFamilyInput").value = theme.config?.fontFamily || "sans"; $("themeFontColorInput").value = theme.config?.fontColor || (theme.config?.mode === "light" ? "#171a21" : "#e6e8ed"); + const matrix = theme.config?.matrix || {}; + $("themeMatrixBackgroundInput").value = matrix.background || "#000000"; + $("themeMatrixPrimaryInput").value = matrix.primary || "#e6e8ed"; + $("themeMatrixSecondaryInput").value = matrix.secondary || "#0080ff"; + $("themeMatrixAccentInput").value = matrix.accent || "#ffb000"; + $("themeMatrixCriticalInput").value = matrix.critical || "#ff2000"; + $("themeMatrixPaletteInput").value = matrix.effectPalette || "neon"; + $("themeMatrixBrightnessInput").value = String(matrix.brightness || 64); syncThemeEditorState(); } @@ -6378,7 +6405,16 @@

How This Connects

config: { mode: $("themeModeInput").value, fontFamily: $("themeFontFamilyInput").value, - fontColor: $("themeFontColorInput").value + fontColor: $("themeFontColorInput").value, + matrix: { + background: $("themeMatrixBackgroundInput").value, + primary: $("themeMatrixPrimaryInput").value, + secondary: $("themeMatrixSecondaryInput").value, + accent: $("themeMatrixAccentInput").value, + critical: $("themeMatrixCriticalInput").value, + effectPalette: $("themeMatrixPaletteInput").value, + brightness: Number($("themeMatrixBrightnessInput").value) + } } }) }); diff --git a/server/server.js b/server/server.js index 764c458..ce683da 100644 --- a/server/server.js +++ b/server/server.js @@ -1894,14 +1894,48 @@ function clearProfileAssignments(profileId) { ); } +function normalizeMatrixThemeConfig(config = {}, mode = "dark", fontColor = "") { + const matrix = config && typeof config === "object" && !Array.isArray(config) ? config : {}; + const normalizeColor = (value, fallback) => { + const color = String(value || "").trim(); + return /^#[0-9a-fA-F]{6}$/.test(color) ? color.toLowerCase() : fallback; + }; + const browserColor = normalizeColor(fontColor, ""); + const browserLuminance = browserColor + ? (Number.parseInt(browserColor.slice(1, 3), 16) * 0.2126) + + (Number.parseInt(browserColor.slice(3, 5), 16) * 0.7152) + + (Number.parseInt(browserColor.slice(5, 7), 16) * 0.0722) + : 0; + // Browser light themes commonly use near-black text; do not inherit that + // onto a black LED background where it would be invisible. + const defaultPrimary = browserLuminance < 48 + ? "#00d9ff" + : normalizeColor(browserColor, mode === "light" ? "#00d9ff" : "#e6e8ed"); + const brightness = Number(matrix.brightness); + return { + background: normalizeColor(matrix.background, "#000000"), + primary: normalizeColor(matrix.primary, defaultPrimary), + secondary: normalizeColor(matrix.secondary, "#0080ff"), + accent: normalizeColor(matrix.accent, "#ffb000"), + critical: normalizeColor(matrix.critical, "#ff2000"), + effectPalette: ["neon", "ocean", "sunset", "forest", "party"].includes(String(matrix.effectPalette || "").trim().toLowerCase()) + ? String(matrix.effectPalette).trim().toLowerCase() + : "neon", + brightness: Number.isFinite(brightness) ? Math.max(1, Math.min(255, Math.round(brightness))) : 64 + }; +} + function normalizeThemeConfig(config = {}) { const mode = String(config.mode || "dark").trim().toLowerCase(); const fontFamily = String(config.fontFamily || "sans").trim().toLowerCase(); const fontColor = String(config.fontColor || "").trim(); + const normalizedMode = ["light", "dark"].includes(mode) ? mode : "dark"; + const normalizedFontColor = /^#[0-9a-fA-F]{6}$/.test(fontColor) ? fontColor.toLowerCase() : ""; return { - mode: ["light", "dark"].includes(mode) ? mode : "dark", + mode: normalizedMode, fontFamily: ["sans", "serif", "mono", "humanist"].includes(fontFamily) ? fontFamily : "sans", - fontColor: /^#[0-9a-fA-F]{6}$/.test(fontColor) ? fontColor.toLowerCase() : "" + fontColor: normalizedFontColor, + matrix: normalizeMatrixThemeConfig(config.matrix, normalizedMode, normalizedFontColor) }; } @@ -2950,7 +2984,8 @@ function buildThemePayloadFromTheme(theme) { return { mode: config.mode || "dark", fontFamily: config.fontFamily || "sans", - fontColor: config.fontColor || "" + fontColor: config.fontColor || "", + matrix: normalizeMatrixThemeConfig(config.matrix, config.mode, config.fontColor) }; } @@ -3127,7 +3162,9 @@ function compileMatrixScene(runtime) { return { kind: "effect", effect: effectWidget.effect || "scanner", - palette: effectWidget.palette || "neon", + // Effects inherit their palette from the active Matrix theme so a + // scheduled/manual theme change restyles the whole physical display. + palette: runtime?.themeState?.matrix?.effectPalette || effectWidget.palette || "neon", speed: Number(effectWidget.speed || 35), intensity: Number(effectWidget.intensity || 60), text: effectWidget.text || "" @@ -3159,6 +3196,7 @@ function publishMatrixRuntime(screenId, runtime) { revision, issuedAt: new Date().toISOString(), display: target.config, + theme: runtime?.themeState?.matrix || normalizeMatrixThemeConfig(), scene: compileMatrixScene(runtime) }; sqliteExec( @@ -6668,8 +6706,8 @@ app.post("/api/overrides", requireAdmin, (req, res) => { return res.status(404).json({ error: "target does not resolve to any registered screens" }); } const matrixTargets = targets.filter((screenId) => Boolean(getMatrixTarget(screenId))); - if (matrixTargets.length > 0 && ["camera", "theme"].includes(type)) { - return res.status(400).json({ error: `${type} overrides are not supported by Matrix targets: ${matrixTargets.join(", ")}` }); + if (matrixTargets.length > 0 && type === "camera") { + return res.status(400).json({ error: `camera overrides are not supported by Matrix targets: ${matrixTargets.join(", ")}` }); } if (type === "camera") { From 8c441836c67fb5de96df0145578491b5887cbe32 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:15:40 -0700 Subject: [PATCH 07/25] fix(matrix): preserve effective theme colors --- server/server.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/server.js b/server/server.js index ce683da..2c32f13 100644 --- a/server/server.js +++ b/server/server.js @@ -3069,7 +3069,9 @@ function getRuntimeStateForScreen(screenId) { const assignedProfile = assignedView?.profileId ? getProfile(assignedView.profileId) : null; const assignedTheme = assignedView?.themeId ? getTheme(assignedView.themeId) : null; const profile = override.profile || (override.profileId ? getProfile(override.profileId) : assignedProfile); - const theme = override.theme || (override.themeId ? getTheme(override.themeId) : assignedTheme); + // Theme rules and manual theme overrides retain an ID, not a frozen visual + // snapshot. This keeps an active override in sync when its Theme is edited. + const theme = override.themeId ? getTheme(override.themeId) : (override.theme || assignedTheme); const banner = profile?.config?.bannerId ? getBanner(profile.config.bannerId) : null; const ticker = profile?.config?.tickerId ? getTicker(profile.config.tickerId) : null; const baseLayout = Object.prototype.hasOwnProperty.call(override, "layout") @@ -3082,9 +3084,11 @@ function getRuntimeStateForScreen(screenId) { profile, theme, layout: layoutWithWidgetOverrides, - themeState: Object.prototype.hasOwnProperty.call(override, "themeState") - ? override.themeState - : (theme ? buildThemePayloadFromTheme(theme) : null), + themeState: override.themeId + ? (theme ? buildThemePayloadFromTheme(theme) : null) + : (Object.prototype.hasOwnProperty.call(override, "themeState") + ? override.themeState + : (theme ? buildThemePayloadFromTheme(theme) : null)), bannerState: Object.prototype.hasOwnProperty.call(override, "bannerState") ? override.bannerState : (banner ? buildBannerPayloadFromBanner(banner) : null), From e2a30bb3b90788fce56a5e268ffe795e5517fc4f Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:23:02 -0700 Subject: [PATCH 08/25] feat(matrix): add effect preview controls --- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 2 +- server/public/app.html | 141 +++++++++++++++++- 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 7fefa89..221947e 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -65,7 +65,7 @@ display: } const Color color = id(coreview_severity) == "critical" ? critical : id(coreview_severity) == "warning" ? accent - : id(coreview_severity) == "success" ? primary : secondary; + : primary; auto print_line = [&](const std::string &text, int y, const Color &line_color) { int x1, y1, text_width, text_height; it.get_text_bounds(0, 0, text.c_str(), id(coreview_font_small), TextAlign::TOP_LEFT, &x1, &y1, &text_width, &text_height); diff --git a/server/public/app.html b/server/public/app.html index 922cd78..8f34985 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -349,6 +349,74 @@ padding: 0.72rem 0.82rem; font: inherit; } + .color-control { + display: flex; + align-items: center; + gap: 0.55rem; + } + .color-control input[type="color"] { + width: 3.3rem; + min-width: 3.3rem; + height: 2.55rem; + padding: 0.2rem; + cursor: pointer; + } + .color-swatch { + width: 1.2rem; + height: 1.2rem; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 999px; + flex: 0 0 auto; + } + .color-value { + color: var(--fg); + font: 0.78rem ui-monospace, SFMono-Regular, Consolas, monospace; + text-transform: uppercase; + } + .theme-preview-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.8rem; + margin-top: 0.15rem; + } + .theme-preview { + min-height: 5.25rem; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0.8rem; + display: grid; + align-content: center; + gap: 0.25rem; + overflow: hidden; + } + .theme-preview-label { + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + opacity: 0.72; + text-transform: uppercase; + } + .theme-preview-title { font-size: 1rem; font-weight: 700; } + .matrix-theme-preview { + grid-template-columns: 1fr auto; + align-items: center; + } + .matrix-theme-preview .matrix-pixels { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.04em; + } + .matrix-theme-preview .matrix-colors { + display: flex; + gap: 0.25rem; + } + .matrix-theme-preview .matrix-colors span { + display: block; + width: 0.72rem; + height: 2.8rem; + border-radius: 2px; + } option { background: var(--bg-elevated); color: var(--fg); @@ -1244,20 +1312,27 @@

Themes

- +
#E6E8ED
-
-
-
-
-
+
#000000
+
#E6E8ED
+
#0080FF
+
#FFB000
+
#FF2000
+
+ +
+
Browser screenCoreView presentation
+
Matrix target12:34 PM
+
+
@@ -3587,6 +3662,44 @@

How This Connects

syncProfileEditorState(); } + function syncThemeColorPreviews() { + const colorControls = [ + ["themeFontColorInput", "themeFontColorSwatch", "themeFontColorValue"], + ["themeMatrixBackgroundInput", "themeMatrixBackgroundSwatch", "themeMatrixBackgroundValue"], + ["themeMatrixPrimaryInput", "themeMatrixPrimarySwatch", "themeMatrixPrimaryValue"], + ["themeMatrixSecondaryInput", "themeMatrixSecondarySwatch", "themeMatrixSecondaryValue"], + ["themeMatrixAccentInput", "themeMatrixAccentSwatch", "themeMatrixAccentValue"], + ["themeMatrixCriticalInput", "themeMatrixCriticalSwatch", "themeMatrixCriticalValue"] + ]; + for (const [inputId, swatchId, valueId] of colorControls) { + const color = $(inputId).value || "#000000"; + $(swatchId).style.background = color; + $(valueId).textContent = color.toUpperCase(); + } + const browserMode = $("themeModeInput").value; + const browserPreview = $("themeBrowserPreview"); + browserPreview.style.background = browserMode === "light" ? "#f5f6f8" : "#111318"; + browserPreview.style.color = $("themeFontColorInput").value; + const fontFamily = $("themeFontFamilyInput").value; + browserPreview.style.fontFamily = fontFamily === "mono" + ? "ui-monospace, SFMono-Regular, Consolas, monospace" + : fontFamily === "serif" + ? "Georgia, serif" + : fontFamily === "humanist" + ? "Segoe UI, Arial, sans-serif" + : "Arial, sans-serif"; + const matrixPreview = $("themeMatrixPreview"); + const background = $("themeMatrixBackgroundInput").value; + const primary = $("themeMatrixPrimaryInput").value; + matrixPreview.style.background = background; + matrixPreview.style.color = primary; + $("themeMatrixPreviewPrimary").style.background = primary; + $("themeMatrixPreviewSecondary").style.background = $("themeMatrixSecondaryInput").value; + $("themeMatrixPreviewAccent").style.background = $("themeMatrixAccentInput").value; + $("themeMatrixPreviewCritical").style.background = $("themeMatrixCriticalInput").value; + $("themeMatrixPreviewBrightness").textContent = `Brightness ${$("themeMatrixBrightnessInput").value || 64} / 255`; + } + function resetThemeForm() { $("themeIdInput").value = ""; $("themeNameInput").value = ""; @@ -3600,6 +3713,7 @@

How This Connects

$("themeMatrixCriticalInput").value = "#ff2000"; $("themeMatrixPaletteInput").value = "neon"; $("themeMatrixBrightnessInput").value = "64"; + syncThemeColorPreviews(); state.selectedThemeId = null; syncThemeEditorState(); } @@ -5903,6 +6017,7 @@

How This Connects

$("themeMatrixCriticalInput").value = matrix.critical || "#ff2000"; $("themeMatrixPaletteInput").value = matrix.effectPalette || "neon"; $("themeMatrixBrightnessInput").value = String(matrix.brightness || 64); + syncThemeColorPreviews(); syncThemeEditorState(); } @@ -6872,6 +6987,20 @@

How This Connects

$("themeIdInput").addEventListener("input", () => { syncThemeEditorState(); }); + [ + "themeModeInput", + "themeFontFamilyInput", + "themeFontColorInput", + "themeMatrixBackgroundInput", + "themeMatrixPrimaryInput", + "themeMatrixSecondaryInput", + "themeMatrixAccentInput", + "themeMatrixCriticalInput", + "themeMatrixBrightnessInput" + ].forEach((inputId) => { + $(inputId).addEventListener("input", syncThemeColorPreviews); + $(inputId).addEventListener("change", syncThemeColorPreviews); + }); $("newThemeBtn").addEventListener("click", () => { resetThemeForm(); }); From 37242495e6455771adf1ddb673ce07fcdc660d7d Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:51:10 -0700 Subject: [PATCH 09/25] feat(matrix): add weather effect scenes --- docs/matrix-mqtt-protocol.md | 5 ++ esphome/coreview-matrix-base.yaml | 10 +++ esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 63 ++++++++++++++++++- server/public/app.html | 23 ++++++- server/server.js | 16 ++++- 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index ff329fd..3208f76 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -58,6 +58,11 @@ Matrix-only custom widgets may emit an `effect` scene with an effect name, palette, speed, intensity, and optional text. Effects are generated locally by the target; CoreView sends parameters, never a pixel stream. +Notification scenes may also include an optional `icon` (`info`, `warning`, +`success`, `door`, `lock`, `motion`, `water`, or `fire`) and `flashBorder`. +These are presentation hints: clients that do not support them can render the +same title, detail, and severity without loss of meaning. + `theme` is the effective CoreView Theme after normal assignment, schedules, and manual overrides are resolved. All colors are six-digit CSS hex values and `brightness` is an integer from 1 through 255. Clients that do not implement diff --git a/esphome/coreview-matrix-base.yaml b/esphome/coreview-matrix-base.yaml index 105071b..816ee59 100644 --- a/esphome/coreview-matrix-base.yaml +++ b/esphome/coreview-matrix-base.yaml @@ -55,6 +55,14 @@ globals: type: std::string restore_value: no initial_value: '"info"' + - id: coreview_icon + type: std::string + restore_value: no + initial_value: '""' + - id: coreview_flash_border + type: bool + restore_value: no + initial_value: 'false' - id: coreview_boot_until type: uint32_t restore_value: no @@ -145,6 +153,8 @@ mqtt: id(coreview_title) = scene["title"] | "CoreView"; id(coreview_detail) = scene["detail"] | ""; id(coreview_severity) = scene["severity"] | "info"; + id(coreview_icon) = scene["icon"] | ""; + id(coreview_flash_border) = scene["flashBorder"] | false; id(coreview_effect) = scene["effect"] | "scanner"; id(coreview_palette) = scene["palette"] | "neon"; id(coreview_effect_speed) = scene["speed"] | 35; diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 221947e..0bf369b 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -79,6 +79,51 @@ display: it.print(x, y, id(coreview_font_small), line_color, text.c_str()); it.end_clipping(); }; + auto print_notification_line = [&](const std::string &text, int y, const Color &line_color, bool with_icon) { + const int left = with_icon ? 25 : 0; + const int available = width - left - 2; + int x1, y1, text_width, text_height; + it.get_text_bounds(0, 0, text.c_str(), id(coreview_font_small), TextAlign::TOP_LEFT, &x1, &y1, &text_width, &text_height); + if (text_width <= available) { + it.print(left + available / 2, y, id(coreview_font_small), line_color, TextAlign::TOP_CENTER, text.c_str()); + return; + } + const int span = text_width + available; + const int x = left + available - ((now_ms / 55U) % span); + it.start_clipping(left, y, available, text_height + 2); + it.print(x, y, id(coreview_font_small), line_color, text.c_str()); + it.end_clipping(); + }; + auto draw_notification_icon = [&](const std::string &icon, const Color &icon_color) { + if (icon == "warning") { + it.filled_triangle(12, 3, 2, 26, 22, 26, icon_color); + it.line(12, 10, 12, 18, background); + it.filled_rectangle(11, 21, 3, 3, background); + } else if (icon == "door") { + it.rectangle(4, 4, 16, 24, icon_color); + it.line(17, 5, 17, 27, icon_color); + it.filled_rectangle(14, 16, 2, 2, icon_color); + } else if (icon == "lock") { + it.rectangle(4, 13, 17, 14, icon_color); + it.line(7, 13, 7, 9, icon_color); it.line(7, 9, 9, 6, icon_color); + it.line(9, 6, 16, 6, icon_color); it.line(16, 6, 19, 9, icon_color); it.line(19, 9, 19, 13, icon_color); + it.filled_rectangle(11, 18, 3, 5, icon_color); + } else if (icon == "motion") { + it.circle(12, 6, 3, icon_color); it.line(12, 9, 12, 18, icon_color); + it.line(12, 12, 5, 16, icon_color); it.line(12, 12, 20, 10, icon_color); + it.line(12, 18, 6, 26, icon_color); it.line(12, 18, 20, 25, icon_color); + } else if (icon == "water") { + it.filled_triangle(12, 3, 4, 19, 20, 19, icon_color); it.filled_circle(12, 19, 8, icon_color); + it.line(8, 20, 10, 23, background); + } else if (icon == "fire") { + it.filled_triangle(12, 2, 4, 23, 21, 23, icon_color); it.filled_circle(12, 21, 8, icon_color); + it.filled_triangle(12, 12, 9, 25, 16, 25, background); + } else if (icon == "success") { + it.circle(12, 16, 10, icon_color); it.line(6, 16, 10, 21, background); it.line(10, 21, 19, 10, background); + } else { + it.circle(12, 16, 10, icon_color); it.filled_rectangle(11, 13, 3, 10, icon_color); it.filled_rectangle(11, 8, 3, 3, icon_color); + } + }; if (id(coreview_scene) == "effect") { const int speed = std::max(1, std::min(100, id(coreview_effect_speed))); const uint8_t level = (uint8_t) (std::max(1, std::min(100, id(coreview_effect_intensity)) * 255 / 100)); @@ -153,9 +198,21 @@ display: it.print(width / 2, height / 2, id(coreview_font_small), color, TextAlign::CENTER, "WAITING FOR TIME"); } } else { - if (id(coreview_scene) == "notification") it.rectangle(0, 0, width - 1, height - 1, color); - print_line(id(coreview_title), 2, color); - print_line(id(coreview_detail), height - 10, color); + if (id(coreview_scene) == "notification") { + const bool with_icon = !id(coreview_icon).empty(); + if (id(coreview_flash_border) && ((now_ms / 250U) % 2U) == 0U) { + it.rectangle(0, 0, width - 1, height - 1, color); + it.rectangle(1, 1, width - 3, height - 3, color); + } else { + it.rectangle(0, 0, width - 1, height - 1, color); + } + if (with_icon) draw_notification_icon(id(coreview_icon), color); + print_notification_line(id(coreview_title), 2, color, with_icon); + print_notification_line(id(coreview_detail), height - 10, color, with_icon); + } else { + print_line(id(coreview_title), 2, color); + print_line(id(coreview_detail), height - 10, color); + } } if (!id(coreview_ticker).empty()) { const int span = (int) id(coreview_ticker).size() * 6 + width; diff --git a/server/public/app.html b/server/public/app.html index 8f34985..ecb6fbc 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -1631,6 +1631,21 @@

Banners

Neutral: Default styling
+
+ + + +
@@ -3735,6 +3750,8 @@

How This Connects

loadBannerFieldIntoForm("text", { type: "text", text: "" }); loadBannerFieldIntoForm("subtext", { type: "text", text: "" }); $("bannerVariantInput").value = "neutral"; + $("bannerMatrixIconInput").value = ""; + $("bannerMatrixFlashBorderInput").checked = false; state.selectedBannerId = null; syncBannerEditorState(); updateBannerPreview(); @@ -5978,6 +5995,8 @@

How This Connects

loadBannerFieldIntoForm("text", banner.config?.text || { type: "text", text: "" }); loadBannerFieldIntoForm("subtext", banner.config?.subtext || { type: "text", text: "" }); $("bannerVariantInput").value = banner.config?.variant || "neutral"; + $("bannerMatrixIconInput").value = banner.config?.matrixIcon || ""; + $("bannerMatrixFlashBorderInput").checked = Boolean(banner.config?.matrixFlashBorder); updateBannerPreview(); syncBannerEditorState(); } @@ -6439,7 +6458,9 @@

How This Connects

config: { text: normalizeBannerFieldValue("text"), subtext: normalizeBannerFieldValue("subtext"), - variant: $("bannerVariantInput").value + variant: $("bannerVariantInput").value, + matrixIcon: $("bannerMatrixIconInput").value, + matrixFlashBorder: $("bannerMatrixFlashBorderInput").checked } }) }); diff --git a/server/server.js b/server/server.js index 2c32f13..a193bc0 100644 --- a/server/server.js +++ b/server/server.js @@ -1951,6 +1951,7 @@ function normalizeThemeRecord(row = {}) { function normalizeBannerConfig(config = {}) { const variant = String(config.variant || "neutral").trim().toLowerCase(); + const icon = String(config.matrixIcon || "").trim().toLowerCase(); const normalizeBannerValue = (value) => { if (value && typeof value === "object" && !Array.isArray(value)) { if (String(value.type || "").trim().toLowerCase() === "entity") { @@ -1977,7 +1978,11 @@ function normalizeBannerConfig(config = {}) { return { text: normalizeBannerValue(config.text), subtext: normalizeBannerValue(config.subtext), - variant: ["neutral", "info", "success", "warn", "critical"].includes(variant) ? variant : "neutral" + variant: ["neutral", "info", "success", "warn", "critical"].includes(variant) ? variant : "neutral", + // Matrix-only presentation hints. Browser banner rendering intentionally + // ignores these, preserving its existing layout. + matrixIcon: ["", "info", "warning", "success", "door", "lock", "motion", "water", "fire"].includes(icon) ? icon : "", + matrixFlashBorder: Boolean(config.matrixFlashBorder) }; } @@ -2997,7 +3002,9 @@ function buildBannerPayloadFromBanner(banner) { bannerId: banner.bannerId, text: renderTickerItemText(banner.config?.text || ""), subtext: renderTickerItemText(banner.config?.subtext || ""), - variant: banner.config?.variant || "neutral" + variant: banner.config?.variant || "neutral", + matrixIcon: banner.config?.matrixIcon || "", + matrixFlashBorder: Boolean(banner.config?.matrixFlashBorder) }; } @@ -3149,11 +3156,14 @@ function compileMatrixScene(runtime) { const layout = runtime?.layout || {}; const ticker = runtime?.tickerState?.message ? { message: runtime.tickerState.message } : null; if (runtime?.bannerState?.text) { + const bannerVariant = runtime.bannerState.variant || "info"; return { kind: "notification", - severity: runtime.bannerState.variant || "info", + severity: bannerVariant === "warn" ? "warning" : (bannerVariant === "neutral" ? "info" : bannerVariant), title: runtime.bannerState.text, detail: runtime.bannerState.subtext || "", + icon: runtime.bannerState.matrixIcon || "", + flashBorder: Boolean(runtime.bannerState.matrixFlashBorder), ticker }; } From ddd5fce2572f8b050d2d9437c159bc4cf84888ca Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:46:02 -0700 Subject: [PATCH 10/25] feat(matrix): improve effect controls --- server/public/app.html | 57 ++++++++++++++++++++++++++++++++++++------ server/server.js | 10 +++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/server/public/app.html b/server/public/app.html index ecb6fbc..608dbc2 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -417,6 +417,24 @@ height: 2.8rem; border-radius: 2px; } + .matrix-banner-preview { + min-height: 5.25rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.7rem; + padding: 0.65rem 0.8rem; + border: 1px solid var(--matrix-banner-color, #0080ff); + border-radius: 4px; + background: #000000; + color: var(--matrix-banner-color, #0080ff); + } + .matrix-banner-preview.alert { + box-shadow: 0 0 0 2px var(--matrix-banner-color, #ff2000), 0 0 16px color-mix(in srgb, var(--matrix-banner-color, #ff2000) 60%, transparent); + } + .matrix-banner-icon { font-size: 1.45rem; line-height: 1; } + .matrix-banner-copy { min-width: 0; display: grid; gap: 0.2rem; } + .matrix-banner-copy strong, .matrix-banner-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } option { background: var(--bg-elevated); color: var(--fg); @@ -1408,7 +1426,7 @@

Rules

- + @@ -1481,9 +1499,10 @@

Rules

- + + +
Notifications show an optional icon. Their color comes from the active Matrix Theme and selected Level.
+
+
+ -
@@ -1658,6 +1684,7 @@

Banners

Banner preview text
+
ⓘ
Banner preview text
@@ -2845,6 +2872,17 @@

How This Connects

chip.style.borderColor = levelMeta.border; chip.style.color = levelMeta.fg; } + const presentation = $("bannerMatrixPresentationInput").value === "alert" ? "alert" : "notification"; + const iconGlyphs = { info: "ⓘ", warning: "⚠", success: "✓", door: "▯", lock: "▣", motion: "✦", water: "●", fire: "♨" }; + const preview = $("bannerMatrixPreview"); + preview.classList.toggle("alert", presentation === "alert"); + preview.style.setProperty("--matrix-banner-color", levelMeta.fg); + $("bannerMatrixPreviewIcon").textContent = iconGlyphs[$("bannerMatrixIconInput").value] || (presentation === "alert" ? "⚠" : "ⓘ"); + $("bannerMatrixPreviewTitle").textContent = text || "Banner preview text"; + $("bannerMatrixPreviewDetail").textContent = subtext || (presentation === "alert" ? "Flashing Matrix alert border" : "Matrix notification"); + $("bannerMatrixPresentationHelp").textContent = presentation === "alert" + ? "Alerts flash a double border on Matrix targets. Use them with a timed Rule or Manual override so the active View restores afterward." + : "Notifications show an optional icon. Their color comes from the active Matrix Theme and selected Level."; } function renderBannerLevelLegend() { @@ -3750,8 +3788,8 @@

How This Connects

loadBannerFieldIntoForm("text", { type: "text", text: "" }); loadBannerFieldIntoForm("subtext", { type: "text", text: "" }); $("bannerVariantInput").value = "neutral"; + $("bannerMatrixPresentationInput").value = "notification"; $("bannerMatrixIconInput").value = ""; - $("bannerMatrixFlashBorderInput").checked = false; state.selectedBannerId = null; syncBannerEditorState(); updateBannerPreview(); @@ -5995,8 +6033,8 @@

How This Connects

loadBannerFieldIntoForm("text", banner.config?.text || { type: "text", text: "" }); loadBannerFieldIntoForm("subtext", banner.config?.subtext || { type: "text", text: "" }); $("bannerVariantInput").value = banner.config?.variant || "neutral"; + $("bannerMatrixPresentationInput").value = banner.config?.matrixPresentation || (banner.config?.matrixFlashBorder ? "alert" : "notification"); $("bannerMatrixIconInput").value = banner.config?.matrixIcon || ""; - $("bannerMatrixFlashBorderInput").checked = Boolean(banner.config?.matrixFlashBorder); updateBannerPreview(); syncBannerEditorState(); } @@ -6459,8 +6497,9 @@

How This Connects

text: normalizeBannerFieldValue("text"), subtext: normalizeBannerFieldValue("subtext"), variant: $("bannerVariantInput").value, + matrixPresentation: $("bannerMatrixPresentationInput").value, matrixIcon: $("bannerMatrixIconInput").value, - matrixFlashBorder: $("bannerMatrixFlashBorderInput").checked + matrixFlashBorder: $("bannerMatrixPresentationInput").value === "alert" } }) }); @@ -7131,7 +7170,9 @@

How This Connects

"bannerSubtextValueSourceInput", "bannerSubtextAttributeInput", "bannerSubtextAppendUnitInput", - "bannerVariantInput" + "bannerVariantInput", + "bannerMatrixPresentationInput", + "bannerMatrixIconInput" ].forEach((id) => { $(id).addEventListener("input", () => { if (id.startsWith("profileUseDefaultAlbum") || id.startsWith("profileHud")) { diff --git a/server/server.js b/server/server.js index a193bc0..8d88dbe 100644 --- a/server/server.js +++ b/server/server.js @@ -1952,6 +1952,12 @@ function normalizeThemeRecord(row = {}) { function normalizeBannerConfig(config = {}) { const variant = String(config.variant || "neutral").trim().toLowerCase(); const icon = String(config.matrixIcon || "").trim().toLowerCase(); + const requestedPresentation = String(config.matrixPresentation || "").trim().toLowerCase(); + // Existing Banner records used matrixFlashBorder directly. Preserve those + // records as alerts while exposing an explicit presentation choice in UI. + const matrixPresentation = requestedPresentation === "alert" || Boolean(config.matrixFlashBorder) + ? "alert" + : "notification"; const normalizeBannerValue = (value) => { if (value && typeof value === "object" && !Array.isArray(value)) { if (String(value.type || "").trim().toLowerCase() === "entity") { @@ -1982,7 +1988,8 @@ function normalizeBannerConfig(config = {}) { // Matrix-only presentation hints. Browser banner rendering intentionally // ignores these, preserving its existing layout. matrixIcon: ["", "info", "warning", "success", "door", "lock", "motion", "water", "fire"].includes(icon) ? icon : "", - matrixFlashBorder: Boolean(config.matrixFlashBorder) + matrixPresentation, + matrixFlashBorder: matrixPresentation === "alert" }; } @@ -3004,6 +3011,7 @@ function buildBannerPayloadFromBanner(banner) { subtext: renderTickerItemText(banner.config?.subtext || ""), variant: banner.config?.variant || "neutral", matrixIcon: banner.config?.matrixIcon || "", + matrixPresentation: banner.config?.matrixPresentation || "notification", matrixFlashBorder: Boolean(banner.config?.matrixFlashBorder) }; } From 1ed6d0b2b821f43c1b7dda0a17b70936f64807ae Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:40:50 -0700 Subject: [PATCH 11/25] feat(matrix): add target self-registration --- docs/matrix-mqtt-protocol.md | 4 ++ esphome/README.md | 5 ++- esphome/coreview-matrix-base.yaml | 18 +++++++- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 6 +++ server/public/app.html | 41 +++++++++++++++++++ server/server.js | 36 +++++++++++++++- 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 3208f76..b3bd725 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -83,6 +83,10 @@ 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. +An unclaimed reference Beacon also includes a six-digit `claimCode` in its +status payload. CoreView uses this only to confirm physical possession while an +administrator claims the pending target; it is not a replacement for MQTT ACLs. + ## Capability policy Matrix dimensions are per target, never hard-coded in CoreView. Target records diff --git a/esphome/README.md b/esphome/README.md index 5e1fc01..945ee60 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -23,7 +23,10 @@ Use the example in place, or copy the complete `esphome/` directory into your ESPHome configuration directory so its relative package paths remain valid. Set `wifi_ssid`, `wifi_password`, `mqtt_broker`, `mqtt_username`, and `mqtt_password` in ESPHome secrets, then change the target ID and hardware -profile as appropriate. Create a Matrix target in CoreView with the same ID. +profile as appropriate. On first MQTT connection, the Matrix displays a +six-digit claim code and reports it with its capabilities. In CoreView, select +that pending Beacon, choose a compatible View, and save the Matrix target using +the displayed code—no second flash is required. The included hardware profile matches the draft Beacon: an ESP32-S3 and six 32×16 HUB75 panels arranged 3×2 (96×32). It is an example, not a requirement. diff --git a/esphome/coreview-matrix-base.yaml b/esphome/coreview-matrix-base.yaml index 816ee59..e905102 100644 --- a/esphome/coreview-matrix-base.yaml +++ b/esphome/coreview-matrix-base.yaml @@ -12,6 +12,10 @@ esphome: then: - lambda: |- id(coreview_boot_until) = millis() + 2500U; + id(coreview_claimed) = false; + if (id(coreview_claim_code) == "000000") { + id(coreview_claim_code) = std::to_string(1000000U + (esp_random() % 900000U)).substr(1); + } logger: @@ -35,6 +39,14 @@ globals: type: uint32_t restore_value: yes initial_value: '0' + - id: coreview_claim_code + type: std::string + restore_value: yes + initial_value: '"000000"' + - id: coreview_claimed + type: bool + restore_value: no + initial_value: 'false' - id: coreview_scene type: std::string restore_value: no @@ -132,13 +144,17 @@ mqtt: root["colorDepth"] = ${matrix_color_depth}; root["rotation"] = ${matrix_rotation}; root["firmwareVersion"] = "esphome"; + root["claimCode"] = id(coreview_claim_code); + root["claimed"] = id(coreview_claimed); on_json_message: topic: ${coreview_state_topic} then: - lambda: |- const char *schema = x["schema"] | ""; const uint32_t revision = x["revision"] | 0; - if (std::string(schema) != "coreview.matrix.state.v1" || revision <= id(coreview_revision)) return; + if (std::string(schema) != "coreview.matrix.state.v1") return; + id(coreview_claimed) = true; + if (revision <= id(coreview_revision)) return; JsonObjectConst scene = x["scene"].as(); if (scene.isNull()) return; JsonObjectConst theme = x["theme"].as(); diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 0bf369b..83e1e4b 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -63,6 +63,12 @@ display: it.print(width / 2, height / 2, id(coreview_font_small), primary, TextAlign::CENTER, "COREVIEW"); return; } + if (!id(coreview_claimed)) { + it.print(width / 2, 4, id(coreview_font_small), primary, TextAlign::TOP_CENTER, "COREVIEW BEACON"); + std::string claim = "CLAIM " + id(coreview_claim_code); + it.print(width / 2, height - 13, id(coreview_font_large), secondary, TextAlign::TOP_CENTER, claim.c_str()); + return; + } const Color color = id(coreview_severity) == "critical" ? critical : id(coreview_severity) == "warning" ? accent : primary; diff --git a/server/public/app.html b/server/public/app.html index 608dbc2..5f66658 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -1948,6 +1948,10 @@

Matrix Target

+
+ + +
@@ -1956,6 +1960,11 @@

Matrix Target

+
+ +
+
Flash the standard package with Wi-Fi and MQTT settings. Select the code shown on its Matrix here, choose a compatible View, then save to claim it.
+
@@ -2466,6 +2475,7 @@

How This Connects

rules: [], screenStates: [], pendingDevices: [], + pendingMatrixTargets: [], haEntities: [], tickerGroups: [], resultTimer: null, @@ -5876,6 +5886,34 @@

How This Connects

} } + function renderPendingMatrixTargets() { + const box = $("pendingMatrixChips"); + box.innerHTML = ""; + if (state.pendingMatrixTargets.length === 0) { + const span = document.createElement("span"); + span.className = "chip"; + span.textContent = "No pending Matrix devices"; + box.appendChild(span); + return; + } + for (const item of state.pendingMatrixTargets) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "chip"; + chip.textContent = `${item.target} · claim ${item.claimCode} · ${item.width}×${item.height}`; + chip.addEventListener("click", () => { + $("matrixTargetIdInput").value = item.target; + $("matrixFriendlyNameInput").value = item.target; + $("matrixClaimCodeInput").value = item.claimCode; + $("matrixWidthInput").value = String(item.width || 64); + $("matrixHeightInput").value = String(item.height || 32); + $("matrixColorDepthInput").value = String(item.colorDepth || 4); + $("matrixRotationInput").value = String(item.rotation || 0); + }); + box.appendChild(chip); + } + } + function renderHaEntities() { renderProfileEntityOptions(); } @@ -6167,6 +6205,7 @@

How This Connects

state.rules = Array.isArray(data.rules) ? data.rules : []; state.screenStates = Array.isArray(data.screenStates) ? data.screenStates : []; state.pendingDevices = Array.isArray(data.pendingDevices) ? data.pendingDevices : []; + state.pendingMatrixTargets = Array.isArray(data.pendingMatrixTargets) ? data.pendingMatrixTargets : []; const onlineCount = state.screenStates.filter((item) => item.status === "online").length; $("screenCount").textContent = String(state.screens.length); $("onlineCount").textContent = String(onlineCount); @@ -6192,6 +6231,7 @@

How This Connects

renderRegisterViewPicker(); renderMatrixViewPicker(); renderPendingDevices(); + renderPendingMatrixTargets(); renderScreens(); renderBanners(); renderScreenManager(); @@ -6257,6 +6297,7 @@

How This Connects

screenId, friendlyName: $("matrixFriendlyNameInput").value.trim() || screenId, viewId, + claimCode: $("matrixClaimCodeInput").value.trim(), config: { width: Number($("matrixWidthInput").value), height: Number($("matrixHeightInput").value), diff --git a/server/server.js b/server/server.js index 8d88dbe..13c37ad 100644 --- a/server/server.js +++ b/server/server.js @@ -105,6 +105,7 @@ app.use(express.static("public", { const clients = new Map(); const onlineByTarget = new Map(); const matrixTargetStatus = new Map(); +const pendingMatrixTargets = new Map(); const haCache = { lastSyncAt: null, lastError: null, @@ -913,6 +914,16 @@ function getMatrixTarget(screenId) { }; } +function listPendingMatrixTargets() { + const cutoff = Date.now() - PENDING_DEVICE_TTL_MS; + for (const [target, pending] of pendingMatrixTargets.entries()) { + if (Number(pending.lastSeen || 0) < cutoff) pendingMatrixTargets.delete(target); + } + return Array.from(pendingMatrixTargets.values()) + .sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0)) + .map((item) => ({ ...item, lastSeen: new Date(item.lastSeen).toISOString() })); +} + function hydrateScreenTransport(screen) { if (!screen) { return null; @@ -5015,11 +5026,27 @@ function attachMqttClientHandlers(client) { if (topic.startsWith(matrixPrefix) && topic.endsWith("/status")) { const target = String(topic.slice(matrixPrefix.length, -"/status".length) || "").trim().toLowerCase(); if (getMatrixTarget(target)) { + pendingMatrixTargets.delete(target); matrixTargetStatus.set(target, { online: parsed?.online !== false, lastSeen: Date.now(), payload: parsed && typeof parsed === "object" ? parsed : {} }); + } else if (/^[a-z0-9_-]+$/.test(target) && parsed?.schema === "coreview.matrix.status.v1") { + const claimCode = String(parsed.claimCode || "").trim(); + if (/^[0-9]{6}$/.test(claimCode)) { + pendingMatrixTargets.set(target, { + target, + claimCode, + width: Number(parsed.width || 64), + height: Number(parsed.height || 32), + colorDepth: Number(parsed.colorDepth || 4), + rotation: Number(parsed.rotation || 0), + firmwareVersion: String(parsed.firmwareVersion || "esphome"), + features: Array.isArray(parsed.features) ? parsed.features : [], + lastSeen: Date.now() + }); + } } return; } @@ -5351,7 +5378,8 @@ app.get("/api/state", requireAdmin, (req, res) => { rules: listRules(), screens: listScreens(), screenStates, - pendingDevices + pendingDevices, + pendingMatrixTargets: listPendingMatrixTargets() }); }); @@ -6369,14 +6397,20 @@ app.post("/api/matrix-targets", requireAdmin, (req, res) => { const friendlyName = String(req.body?.friendlyName || screenId).trim(); const viewId = String(req.body?.viewId || "").trim().toLowerCase(); const config = req.body?.config && typeof req.body.config === "object" ? req.body.config : {}; + const claimCode = String(req.body?.claimCode || "").trim(); if (!screenId || !/^[a-z0-9_-]+$/.test(screenId)) { return res.status(400).json({ error: "screenId must use lowercase letters, numbers, dashes, or underscores" }); } if (!viewId || !getView(viewId)) { return res.status(404).json({ error: "view not found" }); } + const pending = pendingMatrixTargets.get(screenId); + if (pending && claimCode !== pending.claimCode) { + return res.status(403).json({ error: "enter the six-digit claim code shown on this Matrix" }); + } try { const screen = saveMatrixTarget(screenId, friendlyName, viewId, config); + pendingMatrixTargets.delete(screenId); const runtime = getRuntimeStateForScreen(screenId); broadcast({ type: "screen_runtime", target: screenId, command: "screen_runtime", payload: runtime }); return res.status(201).json({ saved: true, screen, runtime, stateTopic: matrixTopic(screenId, "state") }); From 8d554dea10004a731ef55b436d7c8ad4a03fbacc Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:47:08 -0700 Subject: [PATCH 12/25] feat(sendspin): add music visualizer support --- server/public/app.html | 36 ++++++- server/server.js | 238 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 3 deletions(-) diff --git a/server/public/app.html b/server/public/app.html index 5f66658..5d31d5b 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2223,6 +2223,21 @@

Frigate

+
+

Music Visualizer (SendSpin)

+

CoreView connects once as a metadata and visualizer client, then adapts that music state for browser displays and Matrix Beacons.

+
+
+ + +
+
+
+ +
+
+
+

Photo Slideshow

The photo slideshow uses Immich as its media source. The default album can still be overridden per profile.

@@ -4781,6 +4796,9 @@

How This Connects

} else if (!integrations.mqtt?.connected) { entries.push("MQTT is configured but disconnected."); } + if (integrations.sendspin?.configured && !integrations.sendspin?.connected) { + entries.push("Music Visualizer is configured but disconnected."); + } if (!integrations.eventWebhook?.configured) { entries.push("Event webhook token is not configured."); } @@ -4818,6 +4836,7 @@

How This Connects

$("settingsMqttUrl").value = integrations.mqtt?.url || ""; $("settingsMqttUsername").value = integrations.mqtt?.username || ""; $("settingsMqttPassword").value = ""; + $("settingsSendspinUrl").value = integrations.sendspin?.url || ""; $("setupHaUrl").value = integrations.ha?.url || ""; $("setupImmichUrl").value = integrations.immich?.url || ""; $("setupImmichAlbum").value = integrations.immich?.albumId || ""; @@ -4842,8 +4861,13 @@

How This Connects

? "Connected" : `${mqttTransientReconnect ? "Reconnecting" : "Disconnected"}${integrations.mqtt?.lastError ? ` (${integrations.mqtt.lastError})` : ""}`); const eventWebhookStatus = integrations.eventWebhook?.configured ? "Ready" : "Not configured"; + const sendspinStatus = !integrations.sendspin?.configured + ? "Not configured" + : (integrations.sendspin?.connected + ? `Connected${integrations.sendspin?.serverName ? ` to ${integrations.sendspin.serverName}` : ""}` + : `Unreachable${integrations.sendspin?.lastError ? ` (${integrations.sendspin.lastError})` : ""}`); $("settingsStatusText").textContent = - `Home Assistant: ${haStatus} | Immich: ${immichStatus} | Frigate: ${frigateStatus} | MQTT: ${mqttStatus} | Events: ${eventWebhookStatus}`; + `Home Assistant: ${haStatus} | Immich: ${immichStatus} | Frigate: ${frigateStatus} | MQTT: ${mqttStatus} | Music: ${sendspinStatus} | Events: ${eventWebhookStatus}`; $("haCardStatusText").textContent = !integrations.ha?.configured ? "Status: not configured. Add the base URL and a long-lived token to enable entity discovery and live state updates." : (integrations.ha?.connected @@ -4864,6 +4888,11 @@

How This Connects

: (integrations.immich?.connected ? `Status: connected${integrations.immich?.albumId ? `, default album ${integrations.immich.albumId}` : ""}.` : `Status: configured but unreachable${integrations.immich?.lastError ? ` (${integrations.immich.lastError})` : ""}.`); + $("sendspinCardStatusText").textContent = !integrations.sendspin?.configured + ? "Status: not configured. Add your Music Assistant SendSpin WebSocket endpoint to begin receiving metadata and visualizer frames." + : (integrations.sendspin?.connected + ? `Status: connected${integrations.sendspin?.serverName ? ` to ${integrations.sendspin.serverName}` : ""}. Roles: ${(integrations.sendspin?.activeRoles || []).join(", ") || "negotiating"}. ${Number(integrations.sendspin?.visualizer?.framesReceived || 0)} visualizer frames received.` + : `Status: configured but unreachable${integrations.sendspin?.lastError ? ` (${integrations.sendspin.lastError})` : ""}.`); const setHeroStatus = (id, message, stateClass) => { const el = $(id); if (!el) { @@ -6860,7 +6889,8 @@

How This Connects

frigateMqttTopic: $("settingsFrigateMqttTopic").value, mqttUrl: $("settingsMqttUrl").value, mqttUsername: $("settingsMqttUsername").value, - mqttPassword: $("settingsMqttPassword").value + mqttPassword: $("settingsMqttPassword").value, + sendspinUrl: $("settingsSendspinUrl").value }) }); state.integrations = data.integrations || state.integrations; @@ -7355,7 +7385,7 @@

How This Connects

await login(); })); - ["saveHaSettingsBtn", "saveMqttSettingsBtn", "saveFrigateSettingsBtn", "saveImmichSettingsBtn"].forEach((id) => { + ["saveHaSettingsBtn", "saveMqttSettingsBtn", "saveFrigateSettingsBtn", "saveImmichSettingsBtn", "saveSendspinSettingsBtn"].forEach((id) => { $(id).addEventListener("click", () => runAction(async () => { await saveIntegrationSettings(); await refreshSetupStatus(); diff --git a/server/server.js b/server/server.js index 13c37ad..4ecc61c 100644 --- a/server/server.js +++ b/server/server.js @@ -29,6 +29,7 @@ const IMMICH_URL_ENV = (process.env.IMMICH_URL || "").replace(/\/+$/, ""); const IMMICH_API_KEY_ENV = process.env.IMMICH_API_KEY || ""; const IMMICH_ALBUM_ID_ENV = process.env.IMMICH_ALBUM_ID || ""; const FRIGATE_URL_ENV = (process.env.FRIGATE_URL || "").replace(/\/+$/, ""); +const SENDSPIN_URL_ENV = String(process.env.SENDSPIN_URL || "").trim(); const BOOTSTRAP_TOKEN_ENV = String(process.env.COREVIEW_BOOTSTRAP_TOKEN || process.env.BOOTSTRAP_TOKEN || "").trim(); const TRUST_PROXY = ["1", "true", "yes", "on"].includes(String(process.env.TRUST_PROXY || "").trim().toLowerCase()); const FORCE_SECURE_COOKIES = ["1", "true", "yes", "on"].includes(String(process.env.FORCE_SECURE_COOKIES || "").trim().toLowerCase()); @@ -117,6 +118,28 @@ const haStream = { connected: false, nextMessageId: 1 }; +const sendspinStream = { + ws: null, + reconnectTimer: null, + timeTimer: null, + connected: false, + activeRoles: [] +}; +const sendspinCache = { + lastConnectedAt: null, + lastMessageAt: null, + lastError: null, + serverName: null, + serverId: null, + playbackState: "stopped", + metadata: {}, + visualizer: { + active: false, + lastFrameAt: null, + lastFrameBytes: 0, + framesReceived: 0 + } +}; const immichCache = { reachable: false, lastCheckedAt: null, @@ -664,6 +687,28 @@ function getFrigateConfig() { }; } +function getSendspinConfig() { + return { + url: String(getSetting("sendspin_url", SENDSPIN_URL_ENV) || "").trim(), + clientId: getOrCreateSendspinClientId() + }; +} + +function getOrCreateSendspinClientId() { + const stored = String(getSetting("sendspin_client_id") || "").trim(); + if (stored) { + return stored; + } + const next = `coreview-${crypto.randomUUID()}`; + setSetting("sendspin_client_id", next); + return next; +} + +function sendspinConfigured() { + const config = getSendspinConfig(); + return /^wss?:\/\//i.test(config.url); +} + function getMqttConfig() { return { url: getSetting("mqtt_url", MQTT_URL_ENV) || "", @@ -4443,6 +4488,178 @@ function connectHaStream(force = false) { }); } +function sendSendspinMessage(message) { + if (!sendspinStream.ws || sendspinStream.ws.readyState !== WebSocket.OPEN) { + return false; + } + sendspinStream.ws.send(JSON.stringify(message)); + return true; +} + +function sendspinTimestampUs() { + return Number(process.hrtime.bigint() / 1000n); +} + +function scheduleSendspinReconnect() { + if (sendspinStream.reconnectTimer) { + clearTimeout(sendspinStream.reconnectTimer); + } + if (!sendspinConfigured()) { + return; + } + sendspinStream.reconnectTimer = setTimeout(() => connectSendspin(true), 3000); +} + +function publishSendspinState() { + broadcast({ + type: "sendspin_state", + command: "sendspin_state", + payload: { + playbackState: sendspinCache.playbackState, + metadata: sendspinCache.metadata, + visualizer: { ...sendspinCache.visualizer } + } + }); +} + +function normalizeSendspinMetadata(metadata = {}) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return {}; + } + const allowed = ["title", "artist", "album", "album_artist", "duration", "position", "artwork_url"]; + return Object.fromEntries( + allowed + .filter((key) => metadata[key] !== undefined && metadata[key] !== null) + .map((key) => [key, typeof metadata[key] === "string" ? metadata[key].slice(0, 512) : metadata[key]]) + ); +} + +function handleSendspinTextMessage(raw) { + let message; + try { + message = JSON.parse(String(raw)); + } catch { + return; + } + if (!message || typeof message !== "object") { + return; + } + sendspinCache.lastMessageAt = new Date().toISOString(); + const payload = message.payload && typeof message.payload === "object" ? message.payload : {}; + if (message.type === "server/hello") { + sendspinStream.connected = true; + sendspinStream.activeRoles = Array.isArray(payload.active_roles) ? payload.active_roles : []; + sendspinCache.lastConnectedAt = new Date().toISOString(); + sendspinCache.lastError = null; + sendspinCache.serverName = String(payload.name || "SendSpin server"); + sendspinCache.serverId = String(payload.server_id || ""); + sendSendspinMessage({ type: "client/state", payload: { state: "synchronized" } }); + if (sendspinStream.timeTimer) clearInterval(sendspinStream.timeTimer); + sendspinStream.timeTimer = setInterval(() => { + sendSendspinMessage({ type: "client/time", payload: { client_transmitted: sendspinTimestampUs() } }); + }, 10000); + publishSendspinState(); + return; + } + if (message.type === "server/state" && payload.metadata) { + sendspinCache.metadata = { ...sendspinCache.metadata, ...normalizeSendspinMetadata(payload.metadata) }; + publishSendspinState(); + return; + } + if (message.type === "group/update") { + if (["playing", "stopped"].includes(payload.playback_state)) { + sendspinCache.playbackState = payload.playback_state; + publishSendspinState(); + } + return; + } + if (message.type === "stream/start" && payload.visualizer) { + sendspinCache.visualizer.active = true; + publishSendspinState(); + return; + } + if (message.type === "stream/end" || (message.type === "stream/clear" && (!payload.roles || payload.roles.includes("visualizer")))) { + if (message.type === "stream/end" && payload.roles && !payload.roles.includes("visualizer")) return; + sendspinCache.visualizer.active = false; + publishSendspinState(); + } +} + +function handleSendspinBinaryMessage(raw) { + const frame = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + if (frame.length < 9 || frame[0] !== 16) { + return; + } + sendspinCache.lastMessageAt = new Date().toISOString(); + sendspinCache.visualizer.active = true; + sendspinCache.visualizer.lastFrameAt = sendspinCache.lastMessageAt; + sendspinCache.visualizer.lastFrameBytes = frame.length - 9; + sendspinCache.visualizer.framesReceived += 1; + // SendSpin deliberately leaves the v1 FFT payload open-ended. Keep the raw + // protocol at this boundary until Music Assistant's concrete frame format is + // observed, then normalize it once for every renderer. + publishSendspinState(); +} + +function connectSendspin(force = false) { + if (!sendspinConfigured()) { + sendspinStream.connected = false; + sendspinStream.activeRoles = []; + if (sendspinStream.reconnectTimer) clearTimeout(sendspinStream.reconnectTimer); + if (sendspinStream.timeTimer) clearInterval(sendspinStream.timeTimer); + sendspinStream.reconnectTimer = null; + sendspinStream.timeTimer = null; + if (sendspinStream.ws) sendspinStream.ws.close(); + sendspinStream.ws = null; + return; + } + if (!force && sendspinStream.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(sendspinStream.ws.readyState)) { + return; + } + if (sendspinStream.reconnectTimer) clearTimeout(sendspinStream.reconnectTimer); + if (sendspinStream.timeTimer) clearInterval(sendspinStream.timeTimer); + sendspinStream.reconnectTimer = null; + sendspinStream.timeTimer = null; + if (sendspinStream.ws) { + try { sendspinStream.ws.close(); } catch { /* ignore shutdown errors */ } + } + const config = getSendspinConfig(); + const ws = new WebSocket(config.url); + sendspinStream.ws = ws; + sendspinStream.connected = false; + ws.on("open", () => { + sendSendspinMessage({ + type: "client/hello", + payload: { + client_id: config.clientId, + name: "CoreView", + device_info: { product_name: "CoreView", software_version: APP_VERSION }, + version: 1, + supported_roles: ["metadata@v1", "visualizer@v1"], + "visualizer@v1_support": { buffer_capacity: 65536 } + } + }); + }); + ws.on("message", (raw, isBinary) => { + if (isBinary) handleSendspinBinaryMessage(raw); + else handleSendspinTextMessage(raw); + }); + ws.on("close", () => { + sendspinStream.connected = false; + sendspinStream.ws = null; + if (sendspinStream.timeTimer) clearInterval(sendspinStream.timeTimer); + sendspinStream.timeTimer = null; + if (sendspinConfigured()) { + sendspinCache.lastError = "SendSpin stream disconnected"; + scheduleSendspinReconnect(); + } + }); + ws.on("error", (err) => { + sendspinStream.connected = false; + sendspinCache.lastError = err.message || "SendSpin stream error"; + }); +} + async function refreshImmichStatus() { if (!immichConfigured()) { immichCache.reachable = false; @@ -5478,6 +5695,7 @@ function integrationStatusPayload() { const frigate = getFrigateConfig(); const mqttConfig = getMqttConfig(); const eventWebhook = getEventWebhookConfig(); + const sendspin = getSendspinConfig(); return { ha: { configured: Boolean(ha.url && ha.token), @@ -5517,6 +5735,19 @@ function integrationStatusPayload() { connected: mqttState.connected, lastError: mqttState.lastError }, + sendspin: { + configured: Boolean(sendspin.url), + url: sendspin.url || "", + connected: sendspinStream.connected, + activeRoles: sendspinStream.activeRoles, + serverName: sendspinCache.serverName, + playbackState: sendspinCache.playbackState, + metadata: sendspinCache.metadata, + visualizer: { ...sendspinCache.visualizer }, + lastConnectedAt: sendspinCache.lastConnectedAt, + lastMessageAt: sendspinCache.lastMessageAt, + lastError: sendspinCache.lastError + }, eventWebhook: { configured: Boolean(eventWebhook.token), tokenStatus: maskSecret(eventWebhook.token), @@ -6572,6 +6803,10 @@ app.post("/api/settings/integrations", requireAdmin, async (req, res) => { const mqttUrl = String(req.body?.mqttUrl || "").trim(); const mqttUsername = String(req.body?.mqttUsername || "").trim(); const mqttPassword = String(req.body?.mqttPassword || "").trim(); + const sendspinUrl = String(req.body?.sendspinUrl || "").trim(); + if (sendspinUrl && !/^wss?:\/\//i.test(sendspinUrl)) { + return res.status(400).json({ error: "SendSpin URL must start with ws:// or wss://" }); + } setSetting("ha_url", haUrl); if (haToken) { @@ -6589,12 +6824,14 @@ app.post("/api/settings/integrations", requireAdmin, async (req, res) => { if (mqttPassword) { setSetting("mqtt_password", mqttPassword, true); } + setSetting("sendspin_url", sendspinUrl); await refreshHaEntities(); connectHaStream(true); await refreshImmichStatus(); await refreshFrigateStatus(); connectMqttClient(true); + connectSendspin(true); return res.json({ saved: true, integrations: integrationStatusPayload() @@ -6910,6 +7147,7 @@ server.listen(PORT, () => { console.log(`Signage server listening on port ${PORT}`); connectMqttClient(true); connectHaStream(true); + connectSendspin(true); refreshHaEntities().catch((err) => { console.error("Initial Home Assistant refresh failed:", err.message); }); From a965e48a2bfa87ea5e902f4652c0280fc0de25e7 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:35:32 -0700 Subject: [PATCH 13/25] fix(sendspin): add pairing-capable adapter --- docker-compose.yml | 26 ++++++ sendspin-adapter/Dockerfile | 15 ++++ sendspin-adapter/adapter.py | 162 ++++++++++++++++++++++++++++++++++++ server/public/app.html | 16 ++-- server/server.js | 81 +++++++++++++----- 5 files changed, 270 insertions(+), 30 deletions(-) create mode 100644 sendspin-adapter/Dockerfile create mode 100644 sendspin-adapter/adapter.py diff --git a/docker-compose.yml b/docker-compose.yml index 571e9c3..9521cd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - .env environment: TZ: ${TZ:-UTC} + SENDSPIN_ADAPTER_URL: http://sendspin-adapter:8929 ports: - "${HOST_PORT:-3210}:3210" read_only: true @@ -26,3 +27,28 @@ services: timeout: 5s retries: 5 start_period: 20s + + sendspin-adapter: + container_name: coreview-sendspin-adapter + init: true + build: + context: ./sendspin-adapter + environment: + SENDSPIN_CLIENT_PORT: 8928 + SENDSPIN_STATUS_PORT: 8929 + ports: + - "${SENDSPIN_CLIENT_PORT:-8928}:8928" + read_only: true + tmpfs: + - /tmp + volumes: + - ./data/sendspin:/app/data + security_opt: + - no-new-privileges:true + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8929/health')"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s diff --git a/sendspin-adapter/Dockerfile b/sendspin-adapter/Dockerfile new file mode 100644 index 0000000..6720299 --- /dev/null +++ b/sendspin-adapter/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir "git+https://github.com/Sendspin/aiosendspin.git@755f34b804ad776eb702c31caf5d3527db0c182e" + +COPY adapter.py ./ + +RUN useradd --system --uid 10001 adapter && mkdir -p /app/data && chown -R adapter:adapter /app + +USER adapter + +EXPOSE 8928 8929 + +CMD ["python", "adapter.py"] diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py new file mode 100644 index 0000000..d5ebe36 --- /dev/null +++ b/sendspin-adapter/adapter.py @@ -0,0 +1,162 @@ +"""CoreView's pairing-capable SendSpin display/visualizer client.""" + +import asyncio +import os +from datetime import UTC, datetime +from pathlib import Path + +from aiohttp import web +from aiosendspin.client import ClientListener, SendspinClient +from aiosendspin.models.core import DeviceInfo +from aiosendspin.models.types import Roles, UndefinedField +from aiosendspin.models.visualizer import ( + ClientHelloVisualizerSpectrum, + ClientHelloVisualizerSupport, +) +from aiosendspin.noise.keys import Identity, b64url_decode +from aiosendspin.noise.trust_store import FileClientPairingStore + +DATA_DIR = Path(os.getenv("SENDSPIN_DATA_DIR", "/app/data")) +IDENTITY_PATH = DATA_DIR / "identity.key" +PAIRING_STORE_PATH = DATA_DIR / "pairing-store.json" +CLIENT_PORT = int(os.getenv("SENDSPIN_CLIENT_PORT", "8928")) +STATUS_PORT = int(os.getenv("SENDSPIN_STATUS_PORT", "8929")) + + +def now() -> str: + return datetime.now(UTC).isoformat() + + +def identity_from_disk() -> Identity: + DATA_DIR.mkdir(parents=True, exist_ok=True) + if IDENTITY_PATH.exists(): + return Identity.from_private_bytes(b64url_decode(IDENTITY_PATH.read_text().strip())) + identity = Identity.generate() + IDENTITY_PATH.write_text(identity.private_b64u + "\n") + IDENTITY_PATH.chmod(0o600) + return identity + + +class Adapter: + def __init__(self) -> None: + self.status = { + "configured": True, + "connected": False, + "clientPort": CLIENT_PORT, + "clientPath": "/sendspin", + "pairingCode": None, + "lastConnectedAt": None, + "lastMessageAt": None, + "lastError": None, + "serverName": None, + "activeRoles": [], + "playbackState": "stopped", + "metadata": {}, + "visualizer": {"active": False, "framesReceived": 0, "lastFrameAt": None, "bands": [], "level": 0, "peak": 0}, + } + self.client: SendspinClient | None = None + self.listener: ClientListener | None = None + + async def set_pairing_code(self, code: str | None) -> None: + self.status["pairingCode"] = code + + def on_metadata(self, payload) -> None: + metadata = getattr(payload, "metadata", None) + if not metadata or isinstance(metadata, UndefinedField): + return + fields = ("title", "artist", "album", "album_artist", "artwork_url") + self.status["metadata"].update({key: value for key in fields if isinstance((value := getattr(metadata, key, None)), str)}) + progress = getattr(metadata, "progress", None) + if progress and not isinstance(progress, UndefinedField): + self.status["metadata"].update({"position": progress.track_progress, "duration": progress.track_duration}) + self.status["lastMessageAt"] = now() + + def on_group(self, payload) -> None: + if getattr(payload, "playback_state", None): + self.status["playbackState"] = payload.playback_state + self.status["lastMessageAt"] = now() + + def on_visualizer(self, frames) -> None: + if not frames: + return + latest = frames[-1] + visualizer = self.status["visualizer"] + spectrum = getattr(latest, "spectrum", None) + if spectrum is not None: + visualizer["bands"] = [max(0, min(255, int(value))) for value in spectrum] + visualizer["level"] = max(0, min(255, int(getattr(latest, "loudness", 0) or 0))) + visualizer["peak"] = max(0, min(255, int(getattr(latest, "peak_strength", 0) or 0))) + visualizer["active"] = True + visualizer["framesReceived"] += len(frames) + visualizer["lastFrameAt"] = now() + self.status["lastMessageAt"] = visualizer["lastFrameAt"] + + def on_disconnect(self) -> None: + self.status["connected"] = False + self.status["visualizer"]["active"] = False + + async def handle_connection(self, ws) -> None: + assert self.client is not None + await self.client.attach_websocket(ws) + + async def start(self) -> None: + identity = identity_from_disk() + pairing_store = await FileClientPairingStore.open(PAIRING_STORE_PATH) + visualizer_support = ClientHelloVisualizerSupport( + buffer_capacity=65536, + rate_max=20, + types=["loudness", "peak", "spectrum", "beat"], + spectrum=ClientHelloVisualizerSpectrum(n_disp_bins=16, scale="mel", f_min=40, f_max=16000), + ) + self.client = SendspinClient( + identity, + "CoreView Music Visualizer", + [Roles.METADATA, Roles.VISUALIZER], + pairing_store=pairing_store, + device_info=DeviceInfo(product_name="CoreView", manufacturer="CoreView", software_version="1"), + visualizer_support=visualizer_support, + pin_display=self.set_pairing_code, + ) + self.client.add_metadata_listener(self.on_metadata) + self.client.add_group_update_listener(self.on_group) + self.client.add_visualizer_listener(self.on_visualizer) + self.client.add_disconnect_listener(self.on_disconnect) + self.listener = ClientListener(identity.peer_id, self.handle_connection, port=CLIENT_PORT, advertise_mdns=False, client_name="CoreView Music Visualizer") + await self.listener.start() + + async def stop(self) -> None: + if self.listener: + await self.listener.stop() + if self.client: + await self.client.disconnect() + + async def status_handler(self, _request: web.Request) -> web.Response: + if self.client and self.client.connected: + info = self.client.server_info + self.status["connected"] = True + self.status["lastConnectedAt"] = self.status["lastConnectedAt"] or now() + self.status["serverName"] = getattr(info, "name", None) + self.status["activeRoles"] = [role.value if hasattr(role, "value") else str(role) for role in self.client.roles] + self.status["lastError"] = None + return web.json_response(self.status) + + +async def main() -> None: + adapter = Adapter() + await adapter.start() + app = web.Application() + app.router.add_get("/health", lambda _request: web.Response(text="ok")) + app.router.add_get("/status", adapter.status_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", STATUS_PORT) + await site.start() + try: + await asyncio.Event().wait() + finally: + await runner.cleanup() + await adapter.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/server/public/app.html b/server/public/app.html index 5d31d5b..ecabb17 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2225,15 +2225,15 @@

Frigate

Music Visualizer (SendSpin)

-

CoreView connects once as a metadata and visualizer client, then adapts that music state for browser displays and Matrix Beacons.

+

CoreView exposes a pairing-capable SendSpin display client. Add the endpoint below to Music Assistant's SendSpin manual discovery list, then pair it in Music Assistant when prompted.

- - + +
- +
@@ -4836,7 +4836,7 @@

How This Connects

$("settingsMqttUrl").value = integrations.mqtt?.url || ""; $("settingsMqttUsername").value = integrations.mqtt?.username || ""; $("settingsMqttPassword").value = ""; - $("settingsSendspinUrl").value = integrations.sendspin?.url || ""; + $("settingsSendspinUrl").value = `ws://${window.location.hostname}:${integrations.sendspin?.clientPort || 8928}${integrations.sendspin?.clientPath || "/sendspin"}`; $("setupHaUrl").value = integrations.ha?.url || ""; $("setupImmichUrl").value = integrations.immich?.url || ""; $("setupImmichAlbum").value = integrations.immich?.albumId || ""; @@ -4865,7 +4865,7 @@

How This Connects

? "Not configured" : (integrations.sendspin?.connected ? `Connected${integrations.sendspin?.serverName ? ` to ${integrations.sendspin.serverName}` : ""}` - : `Unreachable${integrations.sendspin?.lastError ? ` (${integrations.sendspin.lastError})` : ""}`); + : `Disconnected${integrations.sendspin?.lastError ? ` (${integrations.sendspin.lastError})` : ""}`); $("settingsStatusText").textContent = `Home Assistant: ${haStatus} | Immich: ${immichStatus} | Frigate: ${frigateStatus} | MQTT: ${mqttStatus} | Music: ${sendspinStatus} | Events: ${eventWebhookStatus}`; $("haCardStatusText").textContent = !integrations.ha?.configured @@ -4889,10 +4889,10 @@

How This Connects

? `Status: connected${integrations.immich?.albumId ? `, default album ${integrations.immich.albumId}` : ""}.` : `Status: configured but unreachable${integrations.immich?.lastError ? ` (${integrations.immich.lastError})` : ""}.`); $("sendspinCardStatusText").textContent = !integrations.sendspin?.configured - ? "Status: not configured. Add your Music Assistant SendSpin WebSocket endpoint to begin receiving metadata and visualizer frames." + ? "Status: SendSpin adapter is not available yet." : (integrations.sendspin?.connected ? `Status: connected${integrations.sendspin?.serverName ? ` to ${integrations.sendspin.serverName}` : ""}. Roles: ${(integrations.sendspin?.activeRoles || []).join(", ") || "negotiating"}. ${Number(integrations.sendspin?.visualizer?.framesReceived || 0)} visualizer frames received.` - : `Status: configured but unreachable${integrations.sendspin?.lastError ? ` (${integrations.sendspin.lastError})` : ""}.`); + : `Status: waiting for Music Assistant to connect. Add the endpoint above as a manual SendSpin client, then pair it${integrations.sendspin?.pairingCode ? ` with code ${integrations.sendspin.pairingCode}` : ""}.${integrations.sendspin?.lastError ? ` Adapter: ${integrations.sendspin.lastError}` : ""}`); const setHeroStatus = (id, message, stateClass) => { const el = $(id); if (!el) { diff --git a/server/server.js b/server/server.js index 4ecc61c..a967501 100644 --- a/server/server.js +++ b/server/server.js @@ -30,6 +30,7 @@ const IMMICH_API_KEY_ENV = process.env.IMMICH_API_KEY || ""; const IMMICH_ALBUM_ID_ENV = process.env.IMMICH_ALBUM_ID || ""; const FRIGATE_URL_ENV = (process.env.FRIGATE_URL || "").replace(/\/+$/, ""); const SENDSPIN_URL_ENV = String(process.env.SENDSPIN_URL || "").trim(); +const SENDSPIN_ADAPTER_URL = String(process.env.SENDSPIN_ADAPTER_URL || "").replace(/\/+$/, ""); const BOOTSTRAP_TOKEN_ENV = String(process.env.COREVIEW_BOOTSTRAP_TOKEN || process.env.BOOTSTRAP_TOKEN || "").trim(); const TRUST_PROXY = ["1", "true", "yes", "on"].includes(String(process.env.TRUST_PROXY || "").trim().toLowerCase()); const FORCE_SECURE_COOKIES = ["1", "true", "yes", "on"].includes(String(process.env.FORCE_SECURE_COOKIES || "").trim().toLowerCase()); @@ -129,6 +130,8 @@ const sendspinCache = { lastConnectedAt: null, lastMessageAt: null, lastError: null, + lastCloseCode: null, + lastCloseReason: null, serverName: null, serverId: null, playbackState: "stopped", @@ -140,6 +143,12 @@ const sendspinCache = { framesReceived: 0 } }; +const sendspinAdapterCache = { + reachable: false, + lastCheckedAt: null, + lastError: null, + status: null +}; const immichCache = { reachable: false, lastCheckedAt: null, @@ -709,6 +718,27 @@ function sendspinConfigured() { return /^wss?:\/\//i.test(config.url); } +async function refreshSendspinAdapterStatus() { + if (!SENDSPIN_ADAPTER_URL) { + sendspinAdapterCache.reachable = false; + sendspinAdapterCache.lastError = "SendSpin adapter is not configured"; + return; + } + try { + const response = await fetch(`${SENDSPIN_ADAPTER_URL}/status`, { signal: AbortSignal.timeout(3000) }); + if (!response.ok) throw new Error(`SendSpin adapter HTTP ${response.status}`); + const status = await response.json(); + sendspinAdapterCache.reachable = true; + sendspinAdapterCache.lastCheckedAt = new Date().toISOString(); + sendspinAdapterCache.lastError = null; + sendspinAdapterCache.status = status && typeof status === "object" ? status : null; + } catch (err) { + sendspinAdapterCache.reachable = false; + sendspinAdapterCache.lastCheckedAt = new Date().toISOString(); + sendspinAdapterCache.lastError = err.message || "SendSpin adapter is unavailable"; + } +} + function getMqttConfig() { return { url: getSetting("mqtt_url", MQTT_URL_ENV) || "", @@ -4644,14 +4674,20 @@ function connectSendspin(force = false) { if (isBinary) handleSendspinBinaryMessage(raw); else handleSendspinTextMessage(raw); }); - ws.on("close", () => { + ws.on("close", (code, reason) => { + const wasConnected = sendspinStream.connected; sendspinStream.connected = false; sendspinStream.ws = null; if (sendspinStream.timeTimer) clearInterval(sendspinStream.timeTimer); sendspinStream.timeTimer = null; + sendspinCache.lastCloseCode = Number(code || 0) || null; + sendspinCache.lastCloseReason = Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || ""); if (sendspinConfigured()) { - sendspinCache.lastError = "SendSpin stream disconnected"; - scheduleSendspinReconnect(); + sendspinCache.lastError = `SendSpin server closed the connection${sendspinCache.lastCloseCode ? ` (${sendspinCache.lastCloseCode})` : ""}`; + // A close before server/hello is a protocol/admission rejection, not a + // transient network drop. Do not hammer Music Assistant while waiting + // for the supported SendSpin client implementation. + if (wasConnected) scheduleSendspinReconnect(); } }); ws.on("error", (err) => { @@ -5695,7 +5731,7 @@ function integrationStatusPayload() { const frigate = getFrigateConfig(); const mqttConfig = getMqttConfig(); const eventWebhook = getEventWebhookConfig(); - const sendspin = getSendspinConfig(); + const sendspin = sendspinAdapterCache.status || {}; return { ha: { configured: Boolean(ha.url && ha.token), @@ -5736,17 +5772,20 @@ function integrationStatusPayload() { lastError: mqttState.lastError }, sendspin: { - configured: Boolean(sendspin.url), - url: sendspin.url || "", - connected: sendspinStream.connected, - activeRoles: sendspinStream.activeRoles, - serverName: sendspinCache.serverName, - playbackState: sendspinCache.playbackState, - metadata: sendspinCache.metadata, - visualizer: { ...sendspinCache.visualizer }, - lastConnectedAt: sendspinCache.lastConnectedAt, - lastMessageAt: sendspinCache.lastMessageAt, - lastError: sendspinCache.lastError + configured: Boolean(SENDSPIN_ADAPTER_URL), + adapterReachable: sendspinAdapterCache.reachable, + clientPort: sendspin.clientPort || 8928, + clientPath: sendspin.clientPath || "/sendspin", + pairingCode: sendspin.pairingCode || null, + connected: Boolean(sendspin.connected), + activeRoles: Array.isArray(sendspin.activeRoles) ? sendspin.activeRoles : [], + serverName: sendspin.serverName || null, + playbackState: sendspin.playbackState || "stopped", + metadata: sendspin.metadata && typeof sendspin.metadata === "object" ? sendspin.metadata : {}, + visualizer: sendspin.visualizer && typeof sendspin.visualizer === "object" ? sendspin.visualizer : {}, + lastConnectedAt: sendspin.lastConnectedAt || null, + lastMessageAt: sendspin.lastMessageAt || null, + lastError: sendspinAdapterCache.lastError || sendspin.lastError || null }, eventWebhook: { configured: Boolean(eventWebhook.token), @@ -6803,10 +6842,6 @@ app.post("/api/settings/integrations", requireAdmin, async (req, res) => { const mqttUrl = String(req.body?.mqttUrl || "").trim(); const mqttUsername = String(req.body?.mqttUsername || "").trim(); const mqttPassword = String(req.body?.mqttPassword || "").trim(); - const sendspinUrl = String(req.body?.sendspinUrl || "").trim(); - if (sendspinUrl && !/^wss?:\/\//i.test(sendspinUrl)) { - return res.status(400).json({ error: "SendSpin URL must start with ws:// or wss://" }); - } setSetting("ha_url", haUrl); if (haToken) { @@ -6824,14 +6859,13 @@ app.post("/api/settings/integrations", requireAdmin, async (req, res) => { if (mqttPassword) { setSetting("mqtt_password", mqttPassword, true); } - setSetting("sendspin_url", sendspinUrl); await refreshHaEntities(); connectHaStream(true); await refreshImmichStatus(); await refreshFrigateStatus(); connectMqttClient(true); - connectSendspin(true); + await refreshSendspinAdapterStatus(); return res.json({ saved: true, integrations: integrationStatusPayload() @@ -7147,7 +7181,10 @@ server.listen(PORT, () => { console.log(`Signage server listening on port ${PORT}`); connectMqttClient(true); connectHaStream(true); - connectSendspin(true); + refreshSendspinAdapterStatus().catch((err) => console.error("SendSpin adapter status refresh failed:", err.message)); + setInterval(() => { + refreshSendspinAdapterStatus().catch((err) => console.error("SendSpin adapter status refresh failed:", err.message)); + }, 2000); refreshHaEntities().catch((err) => { console.error("Initial Home Assistant refresh failed:", err.message); }); From 0d7f4b52ad7beec9446330a6d3bee8543f49b79b Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:44:41 -0700 Subject: [PATCH 14/25] fix(sendspin): install git in adapter image --- sendspin-adapter/Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sendspin-adapter/Dockerfile b/sendspin-adapter/Dockerfile index 6720299..84f6b63 100644 --- a/sendspin-adapter/Dockerfile +++ b/sendspin-adapter/Dockerfile @@ -2,6 +2,10 @@ FROM python:3.12-slim WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + RUN pip install --no-cache-dir "git+https://github.com/Sendspin/aiosendspin.git@755f34b804ad776eb702c31caf5d3527db0c182e" COPY adapter.py ./ From 6597d06476586af226c30ab2a8f336cb01b5fbfa Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:48:42 -0700 Subject: [PATCH 15/25] fix(sendspin): run adapter with data owner --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 9521cd6..265bbf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,10 @@ services: sendspin-adapter: container_name: coreview-sendspin-adapter init: true + # The persistent CoreView data directory is owned by UID/GID 100:101 on + # the Docker host. Run the unprivileged adapter with that identity so it + # can persist its SendSpin pairing key without broadening permissions. + user: "100:101" build: context: ./sendspin-adapter environment: From 310c562de91611c430bdd948e4a8ac0f8a827a54 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:00:35 -0700 Subject: [PATCH 16/25] fix(sendspin): disable legacy direct client --- sendspin-adapter/adapter.py | 3 +++ server/server.js | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py index d5ebe36..6bfbc93 100644 --- a/sendspin-adapter/adapter.py +++ b/sendspin-adapter/adapter.py @@ -1,6 +1,7 @@ """CoreView's pairing-capable SendSpin display/visualizer client.""" import asyncio +import logging import os from datetime import UTC, datetime from pathlib import Path @@ -22,6 +23,8 @@ CLIENT_PORT = int(os.getenv("SENDSPIN_CLIENT_PORT", "8928")) STATUS_PORT = int(os.getenv("SENDSPIN_STATUS_PORT", "8929")) +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + def now() -> str: return datetime.now(UTC).isoformat() diff --git a/server/server.js b/server/server.js index a967501..2effbc0 100644 --- a/server/server.js +++ b/server/server.js @@ -4632,6 +4632,21 @@ function handleSendspinBinaryMessage(raw) { } function connectSendspin(force = false) { + // SendSpin now runs through the pairing-capable adapter service. Keep this + // inert teardown shim during the migration so an old call path can never + // resume the legacy direct WebSocket hello (which MA correctly rejects). + if (sendspinStream.reconnectTimer) clearTimeout(sendspinStream.reconnectTimer); + if (sendspinStream.timeTimer) clearInterval(sendspinStream.timeTimer); + sendspinStream.reconnectTimer = null; + sendspinStream.timeTimer = null; + sendspinStream.connected = false; + sendspinStream.activeRoles = []; + if (sendspinStream.ws) { + try { sendspinStream.ws.close(); } catch { /* ignore shutdown errors */ } + } + sendspinStream.ws = null; + return; + if (!sendspinConfigured()) { sendspinStream.connected = false; sendspinStream.activeRoles = []; From 1d4690f9a843f1361bf1d5334f026e78e5e6be58 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:08:53 -0700 Subject: [PATCH 17/25] chore(sendspin): log visualizer handshake support --- sendspin-adapter/adapter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py index 6bfbc93..413cb58 100644 --- a/sendspin-adapter/adapter.py +++ b/sendspin-adapter/adapter.py @@ -24,6 +24,7 @@ STATUS_PORT = int(os.getenv("SENDSPIN_STATUS_PORT", "8929")) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logging.getLogger("aiosendspin").setLevel(logging.DEBUG) def now() -> str: @@ -111,6 +112,7 @@ async def start(self) -> None: types=["loudness", "peak", "spectrum", "beat"], spectrum=ClientHelloVisualizerSpectrum(n_disp_bins=16, scale="mel", f_min=40, f_max=16000), ) + logging.getLogger(__name__).info("Visualizer client support: %s", visualizer_support.to_dict()) self.client = SendspinClient( identity, "CoreView Music Visualizer", From 7952bb72f7fc06bfb952f4306b6b61f36e20e725 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:33:33 -0700 Subject: [PATCH 18/25] fix(sendspin): match Music Assistant 2.9 SDK --- sendspin-adapter/Dockerfile | 4 +++- sendspin-adapter/adapter.py | 32 ++++++++++++-------------------- server/public/app.html | 4 ++-- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/sendspin-adapter/Dockerfile b/sendspin-adapter/Dockerfile index 84f6b63..f0f0f86 100644 --- a/sendspin-adapter/Dockerfile +++ b/sendspin-adapter/Dockerfile @@ -6,7 +6,9 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends git \ && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir "git+https://github.com/Sendspin/aiosendspin.git@755f34b804ad776eb702c31caf5d3527db0c182e" +# Match Music Assistant's installed SDK version. SendSpin is a technical +# preview, so its protocol surface must be pinned to the server we target. +RUN pip install --no-cache-dir "git+https://github.com/Sendspin/aiosendspin.git@d488d0b8b1457b1780658b37a35dcfc3102741fc" COPY adapter.py ./ diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py index 413cb58..98a4dd3 100644 --- a/sendspin-adapter/adapter.py +++ b/sendspin-adapter/adapter.py @@ -3,6 +3,7 @@ import asyncio import logging import os +import uuid from datetime import UTC, datetime from pathlib import Path @@ -14,12 +15,9 @@ ClientHelloVisualizerSpectrum, ClientHelloVisualizerSupport, ) -from aiosendspin.noise.keys import Identity, b64url_decode -from aiosendspin.noise.trust_store import FileClientPairingStore DATA_DIR = Path(os.getenv("SENDSPIN_DATA_DIR", "/app/data")) -IDENTITY_PATH = DATA_DIR / "identity.key" -PAIRING_STORE_PATH = DATA_DIR / "pairing-store.json" +CLIENT_ID_PATH = DATA_DIR / "client-id.txt" CLIENT_PORT = int(os.getenv("SENDSPIN_CLIENT_PORT", "8928")) STATUS_PORT = int(os.getenv("SENDSPIN_STATUS_PORT", "8929")) @@ -31,14 +29,14 @@ def now() -> str: return datetime.now(UTC).isoformat() -def identity_from_disk() -> Identity: +def client_id_from_disk() -> str: DATA_DIR.mkdir(parents=True, exist_ok=True) - if IDENTITY_PATH.exists(): - return Identity.from_private_bytes(b64url_decode(IDENTITY_PATH.read_text().strip())) - identity = Identity.generate() - IDENTITY_PATH.write_text(identity.private_b64u + "\n") - IDENTITY_PATH.chmod(0o600) - return identity + if CLIENT_ID_PATH.exists(): + return CLIENT_ID_PATH.read_text().strip() + client_id = f"coreview-{uuid.uuid4()}" + CLIENT_ID_PATH.write_text(client_id + "\n") + CLIENT_ID_PATH.chmod(0o600) + return client_id class Adapter: @@ -61,9 +59,6 @@ def __init__(self) -> None: self.client: SendspinClient | None = None self.listener: ClientListener | None = None - async def set_pairing_code(self, code: str | None) -> None: - self.status["pairingCode"] = code - def on_metadata(self, payload) -> None: metadata = getattr(payload, "metadata", None) if not metadata or isinstance(metadata, UndefinedField): @@ -104,8 +99,7 @@ async def handle_connection(self, ws) -> None: await self.client.attach_websocket(ws) async def start(self) -> None: - identity = identity_from_disk() - pairing_store = await FileClientPairingStore.open(PAIRING_STORE_PATH) + client_id = client_id_from_disk() visualizer_support = ClientHelloVisualizerSupport( buffer_capacity=65536, rate_max=20, @@ -114,19 +108,17 @@ async def start(self) -> None: ) logging.getLogger(__name__).info("Visualizer client support: %s", visualizer_support.to_dict()) self.client = SendspinClient( - identity, + client_id, "CoreView Music Visualizer", [Roles.METADATA, Roles.VISUALIZER], - pairing_store=pairing_store, device_info=DeviceInfo(product_name="CoreView", manufacturer="CoreView", software_version="1"), visualizer_support=visualizer_support, - pin_display=self.set_pairing_code, ) self.client.add_metadata_listener(self.on_metadata) self.client.add_group_update_listener(self.on_group) self.client.add_visualizer_listener(self.on_visualizer) self.client.add_disconnect_listener(self.on_disconnect) - self.listener = ClientListener(identity.peer_id, self.handle_connection, port=CLIENT_PORT, advertise_mdns=False, client_name="CoreView Music Visualizer") + self.listener = ClientListener(client_id, self.handle_connection, port=CLIENT_PORT, advertise_mdns=False, client_name="CoreView Music Visualizer") await self.listener.start() async def stop(self) -> None: diff --git a/server/public/app.html b/server/public/app.html index ecabb17..7efce80 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2225,7 +2225,7 @@

Frigate

Music Visualizer (SendSpin)

-

CoreView exposes a pairing-capable SendSpin display client. Add the endpoint below to Music Assistant's SendSpin manual discovery list, then pair it in Music Assistant when prompted.

+

CoreView exposes a SendSpin display client. Add the endpoint below to Music Assistant's SendSpin manual discovery list; Music Assistant will register the display when it connects.

@@ -4892,7 +4892,7 @@

How This Connects

? "Status: SendSpin adapter is not available yet." : (integrations.sendspin?.connected ? `Status: connected${integrations.sendspin?.serverName ? ` to ${integrations.sendspin.serverName}` : ""}. Roles: ${(integrations.sendspin?.activeRoles || []).join(", ") || "negotiating"}. ${Number(integrations.sendspin?.visualizer?.framesReceived || 0)} visualizer frames received.` - : `Status: waiting for Music Assistant to connect. Add the endpoint above as a manual SendSpin client, then pair it${integrations.sendspin?.pairingCode ? ` with code ${integrations.sendspin.pairingCode}` : ""}.${integrations.sendspin?.lastError ? ` Adapter: ${integrations.sendspin.lastError}` : ""}`); + : `Status: waiting for Music Assistant to connect. Add the endpoint above as a manual SendSpin client, then reload the SendSpin provider.${integrations.sendspin?.lastError ? ` Adapter: ${integrations.sendspin.lastError}` : ""}`); const setHeroStatus = (id, message, stateClass) => { const el = $(id); if (!el) { From 5ccefbe61331c403b2a71a20ac30920579a2cffd Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:39:12 -0700 Subject: [PATCH 19/25] fix(sendspin): serialize playback status --- sendspin-adapter/adapter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py index 98a4dd3..b661561 100644 --- a/sendspin-adapter/adapter.py +++ b/sendspin-adapter/adapter.py @@ -71,8 +71,9 @@ def on_metadata(self, payload) -> None: self.status["lastMessageAt"] = now() def on_group(self, payload) -> None: - if getattr(payload, "playback_state", None): - self.status["playbackState"] = payload.playback_state + state = getattr(payload, "playback_state", None) + if state: + self.status["playbackState"] = state.value if hasattr(state, "value") else str(state) self.status["lastMessageAt"] = now() def on_visualizer(self, frames) -> None: From 39baf577f905997e7d26ef6783d432b9f3fd5482 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:44:42 -0700 Subject: [PATCH 20/25] fix(sendspin): retain server websocket session --- sendspin-adapter/adapter.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py index b661561..6d58bd8 100644 --- a/sendspin-adapter/adapter.py +++ b/sendspin-adapter/adapter.py @@ -97,7 +97,13 @@ def on_disconnect(self) -> None: async def handle_connection(self, ws) -> None: assert self.client is not None - await self.client.attach_websocket(ws) + disconnected = asyncio.Event() + remove_listener = self.client.add_disconnect_listener(disconnected.set) + try: + await self.client.attach_websocket(ws) + await disconnected.wait() + finally: + remove_listener() async def start(self) -> None: client_id = client_id_from_disk() From 1b8754b8264b84c08d2a950239deca60b0ed3d4a Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:50:42 -0700 Subject: [PATCH 21/25] fix(sendspin): preserve configured adapter roles --- sendspin-adapter/adapter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sendspin-adapter/adapter.py b/sendspin-adapter/adapter.py index 6d58bd8..cc3d909 100644 --- a/sendspin-adapter/adapter.py +++ b/sendspin-adapter/adapter.py @@ -56,6 +56,7 @@ def __init__(self) -> None: "metadata": {}, "visualizer": {"active": False, "framesReceived": 0, "lastFrameAt": None, "bands": [], "level": 0, "peak": 0}, } + self.roles = [Roles.METADATA, Roles.VISUALIZER] self.client: SendspinClient | None = None self.listener: ClientListener | None = None @@ -117,7 +118,7 @@ async def start(self) -> None: self.client = SendspinClient( client_id, "CoreView Music Visualizer", - [Roles.METADATA, Roles.VISUALIZER], + self.roles, device_info=DeviceInfo(product_name="CoreView", manufacturer="CoreView", software_version="1"), visualizer_support=visualizer_support, ) @@ -140,7 +141,7 @@ async def status_handler(self, _request: web.Request) -> web.Response: self.status["connected"] = True self.status["lastConnectedAt"] = self.status["lastConnectedAt"] or now() self.status["serverName"] = getattr(info, "name", None) - self.status["activeRoles"] = [role.value if hasattr(role, "value") else str(role) for role in self.client.roles] + self.status["activeRoles"] = [role.value if hasattr(role, "value") else str(role) for role in self.roles] self.status["lastError"] = None return web.json_response(self.status) From 84880bbe913850364d1e529042b4deb81fdcf6bd Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:07:43 -0700 Subject: [PATCH 22/25] docs: document Matrix and SendSpin integrations --- .env.example | 5 ++ README.md | 42 +++++++++++++++- docs/integrations/sendspin.md | 94 +++++++++++++++++++++++++++++++++++ docs/matrix-mqtt-protocol.md | 14 ++++-- esphome/README.md | 85 +++++++++++++++++++++++++++---- 5 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 docs/integrations/sendspin.md diff --git a/.env.example b/.env.example index a55d084..d12ea17 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ IMMICH_URL= IMMICH_API_KEY= IMMICH_ALBUM_ID= +# Optional Music Assistant / SendSpin display-client host port. This maps the +# adapter's internal listener (8928) to a port on the CoreView host; it is not +# a Music Assistant URL and CoreView does not need Music Assistant credentials. +SENDSPIN_CLIENT_PORT=8928 + # First-run hardening # Set for first bootstrap claim, then clear if you no longer want env-managed bootstrap. COREVIEW_BOOTSTRAP_TOKEN= diff --git a/README.md b/README.md index 5400e44..50174c5 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,9 @@ System flow: Operator -> Admin UI -> CoreView Control Plane - -> WebSocket Runtime Channel - -> Screen Clients + -> WebSocket Runtime Channel -> Browser screens + -> MQTT desired-state channel -> Matrix Beacons + <- SendSpin display connection <- Music Assistant (optional) ``` Screen clients can run on any device capable of opening a modern web browser. @@ -83,6 +84,10 @@ Examples include: The client only needs a browser and a network connection to the CoreView server. +Matrix Beacons are a second, purpose-built target type. They receive a compact +MQTT scene description and render it locally, so they do not need a browser, +the Home Assistant native API, or a direct connection to CoreView. + --- ## Core Data Model @@ -154,6 +159,14 @@ Pixel matrices Capability-aware MQTT targets for constrained displays such as ESPHome/HUB75. Matrix targets use a retained desired-state protocol instead of the browser WebSocket runtime; see [the Matrix MQTT protocol](docs/matrix-mqtt-protocol.md). +The reference ESPHome firmware and setup guide live in +[`esphome/`](esphome/README.md). + +Music Assistant / SendSpin +An optional local SendSpin display client for now-playing metadata and audio +visualizer data. The adapter does not require a Music Assistant URL. Instead, +it exposes an endpoint on the CoreView host which Music Assistant may discover +or add manually. See [SendSpin integration](docs/integrations/sendspin.md). Frigate Object detection events and camera triggers. @@ -233,6 +246,31 @@ After starting the server: Once a screen connects it will begin rendering its assigned View. +### Optional Matrix Beacon + +Matrix Beacons require a reachable MQTT broker in addition to CoreView. Their +firmware reports the panel geometry and a physical claim code over MQTT; the +operator claims the pending Beacon in CoreView, selects a Matrix-compatible +View, and assigns it. Matrix dimensions are configured per Beacon—CoreView +does not assume a particular panel size. + +See the [ESPHome Beacon guide](esphome/README.md) for firmware configuration +and the [MQTT protocol reference](docs/matrix-mqtt-protocol.md) for network and +broker ACL guidance. + +### Optional Music Assistant visualizer + +The Compose stack includes a small SendSpin adapter which exposes host port +`8928` by default. It is passive until a SendSpin server, such as Music +Assistant, connects. From CoreView's **System** page, copy the generated +endpoint and add it to Music Assistant's SendSpin manual discovery list. The +adapter does not require a Music Assistant address, credential, or library +data. + +This integration only supplies a normalized stream of playback metadata and +visualizer frames to CoreView. It does not make CoreView an audio player and +does not proxy audio. + --- ## Screen Registration diff --git a/docs/integrations/sendspin.md b/docs/integrations/sendspin.md new file mode 100644 index 0000000..1efe14c --- /dev/null +++ b/docs/integrations/sendspin.md @@ -0,0 +1,94 @@ +# Music Assistant and SendSpin + +CoreView can act as a SendSpin **display client**. This optional integration +lets Music Assistant supply playback state, now-playing metadata, and +visualizer frames for SendSpin-aware CoreView widgets and renderers. + +It is intentionally not an audio output or a Music Assistant API integration: + +- CoreView does not play, relay, or store audio. +- CoreView does not require a Music Assistant URL, token, username, or + password. +- CoreView does not use a configured Music Assistant address for the adapter; + it waits for Music Assistant to connect inward. +- Music Assistant connects to the SendSpin endpoint exposed by CoreView. + +This direction keeps CoreView portable: an installation without Music +Assistant can leave the integration unused, while an installation with Music +Assistant chooses whether and where to register the CoreView client. + +## Architecture + +```text +Music Assistant (SendSpin server) + -> WebSocket client connection + -> CoreView SendSpin adapter (:8928/sendspin by default) + -> local adapter status API + -> CoreView runtime + -> browser and Matrix-capable visualizer widgets +``` + +The adapter is a separate container so the Python SendSpin SDK is isolated from +the CoreView Node.js application. It persists only a generated client identity +under `data/sendspin/`, allowing Music Assistant to recognize the same +CoreView display client after a container recreation. + +## Setup + +1. Start CoreView normally with Docker Compose. The adapter is included in the + standard stack and exposes TCP port `8928` by default. +2. Open CoreView **System** settings and copy the displayed CoreView SendSpin + Client Endpoint. It has the form: + + ```text + ws://:8928/sendspin + ``` + +3. In Music Assistant, add that host/port as a SendSpin manual discovery + address, then reload its SendSpin provider if needed. The CoreView adapter + deliberately does not advertise mDNS from its Docker network, so manual + registration is the reliable setup path. +4. Confirm CoreView reports **Connected** and lists the `metadata@v1` and + `visualizer@v1` roles. + +The endpoint uses the browser's current CoreView hostname. When accessing +CoreView through a reverse proxy or a different hostname, make sure that name +and port resolve from the Music Assistant host. The endpoint must be reachable +from Music Assistant; the adapter status API is internal to the Compose +network and is not published. + +## Configuration and security + +`SENDSPIN_CLIENT_PORT` controls the external host port and defaults to `8928`. +Inside the Compose network the adapter always listens on `8928`; this setting +only changes the host-to-container port mapping. Set it in `.env` before +starting Compose if the host port is occupied, then use that same port in the +manual Music Assistant endpoint. + +```dotenv +SENDSPIN_CLIENT_PORT=8928 +``` + +The adapter listens only for a SendSpin server connection. Treat it like any +other trusted LAN integration: do not expose the port publicly, and restrict +network access to the Music Assistant host or trusted network where practical. + +CoreView writes only its own stable SendSpin client identity under +`data/sendspin/`; it does not persist Music Assistant credentials or a target +Music Assistant address for this adapter flow. + +## Troubleshooting + +- **Waiting for Music Assistant to connect:** Verify the endpoint is added in + Music Assistant, port `8928` is reachable from its host/container, then + reload the SendSpin provider. +- **Adapter unavailable:** Check `coreview-sendspin-adapter` is running and + healthy with `docker compose ps` and inspect its logs with + `docker compose logs sendspin-adapter`. +- **Connected but no frames:** Start playback in Music Assistant and verify + its audio-analysis/visualizer capability is enabled. CoreView can remain + connected while playback is stopped. + +The integration status is diagnostic only at this stage; it does not alter +existing CoreView screens or Matrix Beacons unless a SendSpin-aware widget is +added to a View. diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index b3bd725..6b2471b 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -56,7 +56,9 @@ without waiting for another CoreView change. Matrix-only custom widgets may emit an `effect` scene with an effect name, palette, speed, intensity, and optional text. Effects are generated locally by -the target; CoreView sends parameters, never a pixel stream. +the target; CoreView sends parameters, never a pixel stream. The reference +firmware supports `scanner`, `rainbow_waves`, `aurora`, `digital_rain`, `fire`, +`twinkle`, `color_vortex`, and `confetti`. Notification scenes may also include an optional `icon` (`info`, `warning`, `success`, `door`, `lock`, `motion`, `water`, or `fire`) and `flashBorder`. @@ -95,7 +97,9 @@ firmware may render a richer local idle animation, but CoreView scenes must use only this protocol's portable primitives: text, short status fields, ticker, severity, and the standard icon names. -The reference 32-pixel-tall renderer currently presents one custom status -widget at a time. It centers text that fits and marquees text that does not; -profiles with multiple custom widgets are intentionally treated as browser-only -until Matrix paging is introduced. +The reference renderer currently presents one custom status widget at a time. +It centers text that fits and marquees text that does not; profiles with +multiple custom widgets are intentionally treated as browser-only until Matrix +paging is introduced. The reference 32-pixel-tall hardware profile reserves a +middle ticker lane when possible, temporarily hiding the status detail so the +ticker remains legible. diff --git a/esphome/README.md b/esphome/README.md index 945ee60..4c028fd 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -13,21 +13,88 @@ specific panel dimension. The base package reads the retained state topic documented in [`docs/matrix-mqtt-protocol.md`](../docs/matrix-mqtt-protocol.md), publishes availability/status, and acknowledges applied revisions. It deliberately does -not require Home Assistant's native API. It also applies the active CoreView -Theme's Matrix colors, effect palette, and brightness locally; older CoreView -servers that omit `theme` continue to render with the reference defaults. +not require Home Assistant's native API or direct network access to CoreView. +It also applies the active CoreView Theme's Matrix colors, effect palette, and +brightness locally; older CoreView servers that omit `theme` continue to render +with the reference defaults. ## Quick start -Use the example in place, or copy the complete `esphome/` directory into your -ESPHome configuration directory so its relative package paths remain valid. +Copy the complete contents of this `esphome/` directory into your ESPHome +configuration directory before compiling. The complete configuration in +`examples/coreview-beacon.yaml` is designed to become +`/coreview-beacon.yaml`, alongside +`coreview-matrix-base.yaml` and the `hardware/` directory. Preserve that +layout: its package references are relative and will fail if only the example +file is copied. + Set `wifi_ssid`, `wifi_password`, `mqtt_broker`, `mqtt_username`, and `mqtt_password` in ESPHome secrets, then change the target ID and hardware -profile as appropriate. On first MQTT connection, the Matrix displays a -six-digit claim code and reports it with its capabilities. In CoreView, select -that pending Beacon, choose a compatible View, and save the Matrix target using -the displayed code—no second flash is required. +profile as appropriate. Validate from the ESPHome environment before an OTA or +serial flash: + +```bash +esphome config /config/esphome/coreview-beacon.yaml +``` + +On first MQTT connection, the Matrix displays a six-digit claim code and +reports it with its capabilities. In CoreView, select that pending Beacon, +choose a compatible View, and save the Matrix target using the displayed +code—no second flash is required. The included hardware profile matches the draft Beacon: an ESP32-S3 and six 32×16 HUB75 panels arranged 3×2 (96×32). It is an example, not a requirement. Create a profile for other boards, pinouts, panel chains, or geometries. + +## What the reference firmware provides + +- A portable retained-state MQTT client with revision acknowledgements. +- First-connection claim-code display and capability reporting. +- Theme-aware foreground, background, accent, critical color, palette, and + brightness handling. +- Clock and one-field status scenes, including text fitting/marquee behavior. +- Matrix-only local effects: `scanner`, `rainbow_waves`, `aurora`, + `digital_rain`, `fire`, `twinkle`, `color_vortex`, and `confetti`. +- Notification/alert presentation with optional standard icons and flashing + border, plus transient ticker support. + +CoreView sends scenes and effect parameters, not a pixel stream. That keeps +the MQTT traffic small and lets the firmware remain responsive on different +panel geometries. Browser-only content such as camera streams, maps, and photo +slideshows is deliberately rejected or rendered as an explicit unsupported +scene instead of silently failing. + +## ESPHome configuration + +The example expects these secrets: + +```yaml +wifi_ssid: "your-wifi" +wifi_password: "your-password" +mqtt_broker: "192.168.1.10" +mqtt_username: "coreview-beacon" +mqtt_password: "use-a-unique-password" +``` + +`mqtt_broker` is a host name or IP address only—do not include `http://` or a +port such as `:1883`. ESPHome uses MQTT port 1883 by default; configure a +different port in the MQTT block if your broker requires it. + +Keep each Beacon restricted to its own MQTT topic prefix with broker ACLs. +The protocol document lists the exact topics and directions. + +## Add a new hardware profile + +1. Copy a profile from `hardware/` and give it a descriptive name. +2. Set the panel chain, geometry, color depth, rotation, and GPIO wiring for + the board. +3. Reference it from a complete configuration under `examples/` (or from your + own ESPHome configuration). +4. Run an ESPHome configuration validation, then flash the device (serial or + OTA) and watch the logs until it joins Wi-Fi and MQTT. +5. In CoreView, claim the pending Beacon, confirm the reported capabilities, + and assign a compatible View. + +Changing hardware geometry belongs in the hardware profile, not in +`coreview-matrix-base.yaml`. This keeps the CoreView protocol and reusable +renderer independent of a specific panel size. From c940a045fcc91b8d5c9e176375024b5cd04c6e04 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:13:58 -0700 Subject: [PATCH 23/25] feat: configure SendSpin and package ESPHome base --- docs/integrations/sendspin.md | 11 +++++---- esphome/README.md | 7 +++--- esphome/examples/coreview-beacon.yaml | 2 +- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 24 +++++++++++++++---- .../{ => packages}/coreview-matrix-base.yaml | 0 server/public/app.html | 15 ++++++++---- server/server.js | 21 +++++++++++++++- 7 files changed, 61 insertions(+), 19 deletions(-) rename esphome/{ => packages}/coreview-matrix-base.yaml (100%) diff --git a/docs/integrations/sendspin.md b/docs/integrations/sendspin.md index 1efe14c..89b5bef 100644 --- a/docs/integrations/sendspin.md +++ b/docs/integrations/sendspin.md @@ -37,18 +37,21 @@ CoreView display client after a container recreation. 1. Start CoreView normally with Docker Compose. The adapter is included in the standard stack and exposes TCP port `8928` by default. -2. Open CoreView **System** settings and copy the displayed CoreView SendSpin - Client Endpoint. It has the form: +2. In CoreView **System** settings, enter the Music Assistant SendSpin server + URL (for example, `ws://music-assistant:8927/sendspin`). This is an optional + saved integration setting: it identifies the intended server in CoreView but + does not reverse the protocol's connection direction. +3. Copy the displayed CoreView SendSpin Client Endpoint. It has the form: ```text ws://:8928/sendspin ``` -3. In Music Assistant, add that host/port as a SendSpin manual discovery +4. In Music Assistant, add that host/port as a SendSpin manual discovery address, then reload its SendSpin provider if needed. The CoreView adapter deliberately does not advertise mDNS from its Docker network, so manual registration is the reliable setup path. -4. Confirm CoreView reports **Connected** and lists the `metadata@v1` and +5. Confirm CoreView reports **Connected** and lists the `metadata@v1` and `visualizer@v1` roles. The endpoint uses the browser's current CoreView hostname. When accessing diff --git a/esphome/README.md b/esphome/README.md index 4c028fd..bcda140 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -24,9 +24,10 @@ Copy the complete contents of this `esphome/` directory into your ESPHome configuration directory before compiling. The complete configuration in `examples/coreview-beacon.yaml` is designed to become `/coreview-beacon.yaml`, alongside -`coreview-matrix-base.yaml` and the `hardware/` directory. Preserve that -layout: its package references are relative and will fail if only the example -file is copied. +the `packages/` and `hardware/` directories. Preserve that layout: its package +references are relative and will fail if only the example file is copied. The +shared base package intentionally lives under `packages/` so ESPHome Dashboard +does not present it as a flashable device. Set `wifi_ssid`, `wifi_password`, `mqtt_broker`, `mqtt_username`, and `mqtt_password` in ESPHome secrets, then change the target ID and hardware diff --git a/esphome/examples/coreview-beacon.yaml b/esphome/examples/coreview-beacon.yaml index 2dd03f3..7086d51 100644 --- a/esphome/examples/coreview-beacon.yaml +++ b/esphome/examples/coreview-beacon.yaml @@ -7,5 +7,5 @@ esphome: friendly_name: CoreView Beacon packages: - coreview_base: !include coreview-matrix-base.yaml + coreview_base: !include packages/coreview-matrix-base.yaml beacon_hardware: !include hardware/esp32s3-hub75-3x2-32x16.yaml diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 83e1e4b..7f3ac01 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -72,6 +72,10 @@ display: const Color color = id(coreview_severity) == "critical" ? critical : id(coreview_severity) == "warning" ? accent : primary; + const bool middle_ticker_lane = !id(coreview_ticker).empty() + && id(coreview_scene) != "notification" && height >= 30; + const bool ticker_replaces_bottom_line = !id(coreview_ticker).empty() + && id(coreview_scene) != "notification" && height < 30; auto print_line = [&](const std::string &text, int y, const Color &line_color) { int x1, y1, text_width, text_height; it.get_text_bounds(0, 0, text.c_str(), id(coreview_font_small), TextAlign::TOP_LEFT, &x1, &y1, &text_width, &text_height); @@ -198,8 +202,10 @@ display: if (now.is_valid()) { std::string stamp = now.strftime("%I:%M %p"); if (!stamp.empty() && stamp[0] == '0') stamp.erase(0, 1); - it.print(width / 2, 1, id(coreview_font_large), color, TextAlign::TOP_CENTER, stamp.c_str()); - print_line(id(coreview_title), height - 10, secondary); + // Text glyphs have an internal top bearing, so a small negative + // origin uses the otherwise-empty top margin on HUB75 panels. + it.print(width / 2, -2, id(coreview_font_large), color, TextAlign::TOP_CENTER, stamp.c_str()); + if (!ticker_replaces_bottom_line) print_line(id(coreview_title), height - 12, secondary); } else { it.print(width / 2, height / 2, id(coreview_font_small), color, TextAlign::CENTER, "WAITING FOR TIME"); } @@ -214,14 +220,22 @@ display: } if (with_icon) draw_notification_icon(id(coreview_icon), color); print_notification_line(id(coreview_title), 2, color, with_icon); - print_notification_line(id(coreview_detail), height - 10, color, with_icon); + // Leave breathing room above the notification border. When a + // profile ticker is active, reserve the bottom row for it as well. + print_notification_line(id(coreview_detail), id(coreview_ticker).empty() ? height - 13 : height - 18, color, with_icon); } else { print_line(id(coreview_title), 2, color); - print_line(id(coreview_detail), height - 10, color); + if (!ticker_replaces_bottom_line) print_line(id(coreview_detail), height - 12, color); } } if (!id(coreview_ticker).empty()) { const int span = (int) id(coreview_ticker).size() * 6 + width; const int x = width - ((now_ms / 65U) % span); - it.print(x, height - 9, id(coreview_font_small), color, id(coreview_ticker).c_str()); + // Taller Matrix layouts can reserve a middle ticker lane between the + // primary/top content and persistent bottom detail. Small layouts and + // notifications keep the bottom lane for readability and borders. + const int ticker_y = middle_ticker_lane + ? height - 21 + : height - 9; + it.print(x, ticker_y, id(coreview_font_small), color, id(coreview_ticker).c_str()); } diff --git a/esphome/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml similarity index 100% rename from esphome/coreview-matrix-base.yaml rename to esphome/packages/coreview-matrix-base.yaml diff --git a/server/public/app.html b/server/public/app.html index 7efce80..6a14e96 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2225,15 +2225,19 @@

Frigate

Music Visualizer (SendSpin)

-

CoreView exposes a SendSpin display client. Add the endpoint below to Music Assistant's SendSpin manual discovery list; Music Assistant will register the display when it connects.

+

Enter the optional Music Assistant SendSpin server URL to enable this integration in CoreView. CoreView then exposes its own display-client endpoint below; add that generated endpoint to Music Assistant's SendSpin manual discovery list.

- + + +
+
+
- +
@@ -4836,6 +4840,7 @@

How This Connects

$("settingsMqttUrl").value = integrations.mqtt?.url || ""; $("settingsMqttUsername").value = integrations.mqtt?.username || ""; $("settingsMqttPassword").value = ""; + $("settingsSendspinServerUrl").value = integrations.sendspin?.serverUrl || ""; $("settingsSendspinUrl").value = `ws://${window.location.hostname}:${integrations.sendspin?.clientPort || 8928}${integrations.sendspin?.clientPath || "/sendspin"}`; $("setupHaUrl").value = integrations.ha?.url || ""; $("setupImmichUrl").value = integrations.immich?.url || ""; @@ -4889,7 +4894,7 @@

How This Connects

? `Status: connected${integrations.immich?.albumId ? `, default album ${integrations.immich.albumId}` : ""}.` : `Status: configured but unreachable${integrations.immich?.lastError ? ` (${integrations.immich.lastError})` : ""}.`); $("sendspinCardStatusText").textContent = !integrations.sendspin?.configured - ? "Status: SendSpin adapter is not available yet." + ? "Status: not configured. Add the Music Assistant SendSpin server URL, then add the generated CoreView endpoint to Music Assistant's manual discovery list." : (integrations.sendspin?.connected ? `Status: connected${integrations.sendspin?.serverName ? ` to ${integrations.sendspin.serverName}` : ""}. Roles: ${(integrations.sendspin?.activeRoles || []).join(", ") || "negotiating"}. ${Number(integrations.sendspin?.visualizer?.framesReceived || 0)} visualizer frames received.` : `Status: waiting for Music Assistant to connect. Add the endpoint above as a manual SendSpin client, then reload the SendSpin provider.${integrations.sendspin?.lastError ? ` Adapter: ${integrations.sendspin.lastError}` : ""}`); @@ -6890,7 +6895,7 @@

How This Connects

mqttUrl: $("settingsMqttUrl").value, mqttUsername: $("settingsMqttUsername").value, mqttPassword: $("settingsMqttPassword").value, - sendspinUrl: $("settingsSendspinUrl").value + sendspinUrl: $("settingsSendspinServerUrl").value }) }); state.integrations = data.integrations || state.integrations; diff --git a/server/server.js b/server/server.js index 2effbc0..45c6f05 100644 --- a/server/server.js +++ b/server/server.js @@ -5747,6 +5747,7 @@ function integrationStatusPayload() { const mqttConfig = getMqttConfig(); const eventWebhook = getEventWebhookConfig(); const sendspin = sendspinAdapterCache.status || {}; + const sendspinConfig = getSendspinConfig(); return { ha: { configured: Boolean(ha.url && ha.token), @@ -5787,7 +5788,11 @@ function integrationStatusPayload() { lastError: mqttState.lastError }, sendspin: { - configured: Boolean(SENDSPIN_ADAPTER_URL), + // The configured server URL records the operator's intended SendSpin + // server. The protocol connection itself is server -> CoreView adapter. + configured: Boolean(sendspinConfig.url), + serverUrl: sendspinConfig.url, + adapterAvailable: Boolean(SENDSPIN_ADAPTER_URL), adapterReachable: sendspinAdapterCache.reachable, clientPort: sendspin.clientPort || 8928, clientPath: sendspin.clientPath || "/sendspin", @@ -6857,6 +6862,19 @@ app.post("/api/settings/integrations", requireAdmin, async (req, res) => { const mqttUrl = String(req.body?.mqttUrl || "").trim(); const mqttUsername = String(req.body?.mqttUsername || "").trim(); const mqttPassword = String(req.body?.mqttPassword || "").trim(); + const sendspinUrl = String(req.body?.sendspinUrl || "").trim(); + + if (sendspinUrl) { + let parsedSendspinUrl; + try { + parsedSendspinUrl = new URL(sendspinUrl); + } catch { + return res.status(400).json({ error: "Music Assistant SendSpin server URL must be a valid ws:// or wss:// URL" }); + } + if (!["ws:", "wss:"].includes(parsedSendspinUrl.protocol)) { + return res.status(400).json({ error: "Music Assistant SendSpin server URL must use ws:// or wss://" }); + } + } setSetting("ha_url", haUrl); if (haToken) { @@ -6874,6 +6892,7 @@ app.post("/api/settings/integrations", requireAdmin, async (req, res) => { if (mqttPassword) { setSetting("mqtt_password", mqttPassword, true); } + setSetting("sendspin_url", sendspinUrl.replace(/\/+$/, "")); await refreshHaEntities(); connectHaStream(true); From c478a26a8042b0dd80815e350c1ddf8d92c5c626 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:03:38 -0700 Subject: [PATCH 24/25] feat: add SendSpin music visualizer widgets --- docs/matrix-mqtt-protocol.md | 7 +- esphome/README.md | 2 + esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 48 +++++++++- esphome/packages/coreview-matrix-base.yaml | 30 ++++++ server/public/app.html | 31 +++++- server/public/index.html | 94 ++++++++++++++++++- server/server.js | 85 ++++++++++++++++- 7 files changed, 287 insertions(+), 10 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 6b2471b..3764e86 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -50,7 +50,7 @@ without waiting for another CoreView change. } ``` -`scene.kind` is one of `clock`, `status`, `notification`, `effect`, or `unsupported`. +`scene.kind` is one of `clock`, `status`, `notification`, `effect`, `music`, or `unsupported`. `unsupported` is an explicit, renderable fallback for browser-only View types (for example maps and photo slideshows); it is never a silent failure. @@ -65,6 +65,11 @@ Notification scenes may also include an optional `icon` (`info`, `warning`, These are presentation hints: clients that do not support them can render the same title, detail, and severity without loss of meaning. +Music scenes carry a `mode` (`spectrum`, `meter`, or `pulse`) and a +`visualizer` object containing normalized 0–255 `bands`, `level`, `peak`, and +an `active` flag. They may include now-playing `title` and `detail`. This is +live display data only; CoreView never sends or relays audio. + `theme` is the effective CoreView Theme after normal assignment, schedules, and manual overrides are resolved. All colors are six-digit CSS hex values and `brightness` is an integer from 1 through 255. Clients that do not implement diff --git a/esphome/README.md b/esphome/README.md index bcda140..bc99add 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -58,6 +58,8 @@ Create a profile for other boards, pinouts, panel chains, or geometries. `digital_rain`, `fire`, `twinkle`, `color_vortex`, and `confetti`. - Notification/alert presentation with optional standard icons and flashing border, plus transient ticker support. +- SendSpin Music Visualizer scenes: local spectrum bars, level meter, and + pulse modes driven by normalized live data from CoreView (never audio). CoreView sends scenes and effect parameters, not a pixel stream. That keeps the MQTT traffic small and lets the firmware remain responsive on different diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 7f3ac01..7d64d8a 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -73,7 +73,7 @@ display: : id(coreview_severity) == "warning" ? accent : primary; const bool middle_ticker_lane = !id(coreview_ticker).empty() - && id(coreview_scene) != "notification" && height >= 30; + && id(coreview_scene) != "notification" && id(coreview_scene) != "music" && height >= 30; const bool ticker_replaces_bottom_line = !id(coreview_ticker).empty() && id(coreview_scene) != "notification" && height < 30; auto print_line = [&](const std::string &text, int y, const Color &line_color) { @@ -134,6 +134,22 @@ display: it.circle(12, 16, 10, icon_color); it.filled_rectangle(11, 13, 3, 10, icon_color); it.filled_rectangle(11, 8, 3, 3, icon_color); } }; + auto music_band = [&](int wanted) -> int { + const std::string &packed = id(coreview_music_bands); + size_t start = 0; + int index = 0; + while (start <= packed.size()) { + const size_t end = packed.find(',', start); + if (index == wanted) { + const std::string value = packed.substr(start, end == std::string::npos ? std::string::npos : end - start); + return std::max(0, std::min(255, atoi(value.c_str()))); + } + if (end == std::string::npos) break; + start = end + 1; + index++; + } + return 0; + }; if (id(coreview_scene) == "effect") { const int speed = std::max(1, std::min(100, id(coreview_effect_speed))); const uint8_t level = (uint8_t) (std::max(1, std::min(100, id(coreview_effect_intensity)) * 255 / 100)); @@ -209,6 +225,36 @@ display: } else { it.print(width / 2, height / 2, id(coreview_font_small), color, TextAlign::CENTER, "WAITING FOR TIME"); } + } else if (id(coreview_scene) == "music") { + // Music needs its chart area uninterrupted. If a ticker is active, + // reserve the bottom lane for it and move the metadata line above. + const int text_y = id(coreview_ticker).empty() ? height - 11 : height - 20; + const int chart_height = std::max(5, text_y - 2); + if (!id(coreview_music_active)) { + print_line("WAITING FOR AUDIO", chart_height / 2 - 4, secondary); + } else if (id(coreview_music_mode) == "pulse") { + const int radius = std::max(2, (std::min(width, chart_height) * (70 + id(coreview_music_level) * 30 / 255)) / 200); + it.filled_circle(width / 2, chart_height / 2, radius, accent); + it.circle(width / 2, chart_height / 2, radius + 1, primary); + } else if (id(coreview_music_mode) == "meter") { + const int meter_width = std::max(4, width / 7); + const int meter_height = std::max(1, chart_height * id(coreview_music_level) / 255); + const int meter_x = (width - meter_width) / 2; + it.rectangle(meter_x, 0, meter_width, chart_height, secondary); + it.filled_rectangle(meter_x + 1, chart_height - meter_height, meter_width - 2, meter_height, accent); + } else { + const int bar_count = std::max(1, std::min(16, width / 4)); + const int gap = 1; + const int bar_width = std::max(1, (width - (bar_count - 1) * gap) / bar_count); + const int chart_width = bar_count * bar_width + (bar_count - 1) * gap; + const int offset_x = (width - chart_width) / 2; + for (int bar = 0; bar < bar_count; bar++) { + const int bar_height = std::max(1, chart_height * music_band(bar) / 255); + const int x = offset_x + bar * (bar_width + gap); + it.filled_rectangle(x, chart_height - bar_height, bar_width, bar_height, accent); + } + } + print_line(id(coreview_detail).empty() ? id(coreview_title) : id(coreview_detail), text_y, primary); } else { if (id(coreview_scene) == "notification") { const bool with_icon = !id(coreview_icon).empty(); diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index e905102..b223d0f 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -95,6 +95,22 @@ globals: type: int restore_value: no initial_value: '60' + - id: coreview_music_mode + type: std::string + restore_value: no + initial_value: '"spectrum"' + - id: coreview_music_bands + type: std::string + restore_value: no + initial_value: '""' + - id: coreview_music_level + type: int + restore_value: no + initial_value: '0' + - id: coreview_music_active + type: bool + restore_value: no + initial_value: 'false' - id: coreview_matrix_background type: std::string restore_value: no @@ -144,6 +160,9 @@ mqtt: 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); on_json_message: @@ -175,6 +194,17 @@ mqtt: id(coreview_palette) = scene["palette"] | "neon"; id(coreview_effect_speed) = scene["speed"] | 35; id(coreview_effect_intensity) = scene["intensity"] | 60; + id(coreview_music_mode) = scene["mode"] | "spectrum"; + JsonObjectConst visualizer = scene["visualizer"].as(); + id(coreview_music_level) = std::max(0, std::min(255, visualizer["level"] | 0)); + id(coreview_music_active) = visualizer["active"] | false; + std::string packed_bands; + JsonArrayConst bands = visualizer["bands"].as(); + for (JsonVariantConst band : bands) { + if (!packed_bands.empty()) packed_bands += ','; + packed_bands += std::to_string(std::max(0, std::min(255, band.as()))); + } + id(coreview_music_bands) = packed_bands; JsonArrayConst fields = scene["fields"].as(); if (id(coreview_detail).empty() && !fields.isNull() && fields.size() > 0) { JsonObjectConst first = fields[0].as(); diff --git a/server/public/app.html b/server/public/app.html index 6a14e96..296f5ee 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -2574,7 +2574,7 @@

How This Connects

if (widgetIds.length > 1) { return { compatible: false, reason: "Matrix supports one status widget" }; } - const allowed = new Set(["text", "entity", "weather", "status", "matrix_effect"]); + const allowed = new Set(["text", "entity", "weather", "status", "matrix_effect", "music_visualizer"]); const unsupported = widgetIds .map((widgetId) => state.widgets.find((widget) => widget.widgetId === widgetId)) .filter(Boolean) @@ -4026,7 +4026,7 @@

How This Connects

function normalizeProfileWidget(widget = {}) { const kind = String(widget.kind || "text").trim().toLowerCase(); - const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; + const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "music_visualizer", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; const verticalAlign = String(widget.verticalAlign || "top").trim().toLowerCase(); const base = { kind: normalizedKind, @@ -4100,6 +4100,14 @@

How This Connects

text: String(widget.text || "").trim() }; } + if (normalizedKind === "music_visualizer") { + const mode = String(widget.mode || "spectrum").trim().toLowerCase(); + return { + ...base, + mode: ["spectrum", "meter", "pulse"].includes(mode) ? mode : "spectrum", + showMetadata: widget.showMetadata === undefined ? true : widget.showMetadata !== false + }; + } if (normalizedKind === "photo_slideshow") { return { ...base, @@ -4185,6 +4193,9 @@

How This Connects

if (nextKind === "matrix_effect") { return normalizeProfileWidget({ kind: "matrix_effect", label: overrides.label || "Matrix Effect", effect: "aurora", palette: "neon", speed: 35, intensity: 60, text: overrides.text || "" }); } + if (nextKind === "music_visualizer") { + return normalizeProfileWidget({ kind: "music_visualizer", label: overrides.label || "Now Playing", mode: overrides.mode || "spectrum", showMetadata: overrides.showMetadata === undefined ? true : overrides.showMetadata !== false, verticalAlign: overrides.verticalAlign || "top" }); + } if (nextKind === "photo_slideshow") { return normalizeProfileWidget({ kind: "photo_slideshow", @@ -4380,6 +4391,10 @@

How This Connects

`; + } else if (widget.kind === "music_visualizer") { + extraField = ` +
+
`; } root.innerHTML = ` @@ -4392,6 +4407,7 @@

How This Connects

+ @@ -4439,6 +4455,8 @@

How This Connects

healthyValues: currentWidget.healthyValues || currentWidget.goodStates || [], degradedValues: currentWidget.degradedValues || [], errorValues: currentWidget.errorValues || [], + mode: currentWidget.mode || "spectrum", + showMetadata: currentWidget.showMetadata, verticalAlign: currentWidget.verticalAlign || "top" }); renderSingleWidgetEditor(rootId, stateKey); @@ -4450,6 +4468,15 @@

How This Connects

state[stateKey] = normalizeProfileWidget({ ...state[stateKey], effect: root.querySelector(".widget-effect").value, palette: root.querySelector(".widget-palette").value, speed: root.querySelector(".widget-effect-speed").value, intensity: root.querySelector(".widget-effect-intensity").value }); })); } + if (widget.kind === "music_visualizer") { + root.querySelector(".widget-music-mode").value = widget.mode; + root.querySelector(".widget-music-mode").addEventListener("change", (event) => { + state[stateKey].mode = event.target.value; + }); + root.querySelector(".widget-music-metadata").addEventListener("change", (event) => { + state[stateKey].showMetadata = event.target.checked; + }); + } root.querySelector(".widget-label").addEventListener("input", (event) => { state[stateKey].label = event.target.value.trim(); diff --git a/server/public/index.html b/server/public/index.html index cfa26d0..58ef14c 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -218,6 +218,51 @@ background: #0a0e12; overflow: hidden; } + .music-visualizer { + width: 100%; + min-height: 0; + flex: 1 1 auto; + display: flex; + align-items: end; + justify-content: center; + gap: clamp(0.2rem, 0.55vw, 0.6rem); + padding: 0.7rem 0.35rem; + } + .music-bar { + flex: 1 1 0; + min-width: 0.25rem; + max-width: 2.4rem; + height: max(4%, calc(var(--music-level) * 100%)); + border-radius: 0.5rem 0.5rem 0.16rem 0.16rem; + background: linear-gradient(to top, var(--accent), color-mix(in srgb, var(--accent) 35%, white)); + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 50%, transparent); + transition: height 120ms ease-out; + } + .music-meter-track { + width: min(100%, 5rem); + height: 100%; + min-height: 4rem; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--border) 75%, transparent); + display: flex; + align-items: end; + } + .music-meter-fill { + width: 100%; + height: max(4%, calc(var(--music-level) * 100%)); + background: linear-gradient(to top, var(--accent), #facc15, #f87171); + transition: height 120ms ease-out; + } + .music-pulse { + width: min(64%, 12rem); + aspect-ratio: 1; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--accent) 78%, white), var(--accent) 60%, transparent 70%); + transform: scale(calc(0.55 + var(--music-level) * 0.45)); + transition: transform 120ms ease-out; + box-shadow: 0 0 52px color-mix(in srgb, var(--accent) 64%, transparent); + } .map-layout { display: none; width: min(1760px, 97vw); @@ -749,6 +794,7 @@

CoreView

customWidgets: [], customSlides: {}, customMaps: {}, + music: { connected: false, playbackState: "stopped", metadata: {}, visualizer: {} }, haEntityState: {}, frameTimer: null, frameAssets: [], @@ -1689,6 +1735,48 @@

CoreView

? "is-bad" : "is-warn" ); + } else if (kind === "music_visualizer") { + const music = state.music && typeof state.music === "object" ? state.music : {}; + const visualizer = music.visualizer && typeof music.visualizer === "object" ? music.visualizer : {}; + const level = Math.max(0, Math.min(1, Number(visualizer.level || 0) / 255)); + const bands = Array.isArray(visualizer.bands) && visualizer.bands.length > 0 + ? visualizer.bands.slice(0, 24) + : Array.from({ length: 12 }, () => 0); + const active = Boolean(music.connected && visualizer.active); + value.classList.add("is-media"); + const visual = document.createElement("div"); + visual.className = "music-visualizer"; + if (!active) { + value.classList.remove("is-media"); + value.textContent = music.connected ? "Waiting for audio" : "Music Assistant unavailable"; + } else if (widget.mode === "meter") { + const track = document.createElement("div"); + track.className = "music-meter-track"; + const fill = document.createElement("div"); + fill.className = "music-meter-fill"; + fill.style.setProperty("--music-level", String(level)); + track.appendChild(fill); + visual.appendChild(track); + value.appendChild(visual); + } else if (widget.mode === "pulse") { + const pulse = document.createElement("div"); + pulse.className = "music-pulse"; + pulse.style.setProperty("--music-level", String(level)); + visual.appendChild(pulse); + value.appendChild(visual); + } else { + for (const band of bands) { + const bar = document.createElement("div"); + bar.className = "music-bar"; + bar.style.setProperty("--music-level", String(Math.max(0, Math.min(1, Number(band || 0) / 255)))); + visual.appendChild(bar); + } + value.appendChild(visual); + } + const metadata = music.metadata && typeof music.metadata === "object" ? music.metadata : {}; + meta.textContent = widget.showMetadata === false + ? "" + : [metadata.title, metadata.artist].filter(Boolean).join(" — ") || (music.playbackState === "playing" ? "Playing" : "Ready"); } else if (kind === "photo_slideshow") { const slide = ensureCustomSlide(widgetIndex, widget); value.classList.add("is-media"); @@ -1935,6 +2023,9 @@

CoreView

...next.entityStates }; } + if (next.music && typeof next.music === "object") { + state.music = next.music; + } if (next.template) { setTemplate(String(next.template)); if (String(next.template) === "photo_slideshow") { @@ -1981,8 +2072,9 @@

CoreView

if (state.currentTemplate === "custom" && !next.template) { if (next.widgets) { renderCustomLayout(); - } else if (next.entityStates) { + } else if (next.entityStates || next.music) { updateCustomDataCards(); + if (next.music) renderCustomLayout(); } } else if (state.currentTemplate === "map" && !next.template && next.entityStates) { state.mapRuntime = renderMapRuntime(mapCanvasEl, state.mapRuntime, state.mapConfig, 24) || state.mapRuntime; diff --git a/server/server.js b/server/server.js index 45c6f05..8a7dab5 100644 --- a/server/server.js +++ b/server/server.js @@ -728,10 +728,14 @@ async function refreshSendspinAdapterStatus() { const response = await fetch(`${SENDSPIN_ADAPTER_URL}/status`, { signal: AbortSignal.timeout(3000) }); if (!response.ok) throw new Error(`SendSpin adapter HTTP ${response.status}`); const status = await response.json(); + const previousSignature = JSON.stringify(sendspinAdapterCache.status || null); sendspinAdapterCache.reachable = true; sendspinAdapterCache.lastCheckedAt = new Date().toISOString(); sendspinAdapterCache.lastError = null; sendspinAdapterCache.status = status && typeof status === "object" ? status : null; + if (previousSignature !== JSON.stringify(sendspinAdapterCache.status || null)) { + refreshMusicVisualizerRuntime(); + } } catch (err) { sendspinAdapterCache.reachable = false; sendspinAdapterCache.lastCheckedAt = new Date().toISOString(); @@ -966,7 +970,7 @@ function normalizeMatrixConfig(config = {}) { const height = Math.max(8, Math.min(256, Math.round(Number(config.height || 32)))); const colorDepth = Math.max(1, Math.min(24, Math.round(Number(config.colorDepth || 4)))); const rotation = [0, 90, 180, 270].includes(Number(config.rotation)) ? Number(config.rotation) : 0; - const supportedFeatures = new Set(["clock", "status", "notification", "ticker", "icons"]); + const supportedFeatures = new Set(["clock", "status", "notification", "ticker", "icons", "music"]); const features = Array.from(new Set((Array.isArray(config.features) ? config.features : []) .map((item) => String(item || "").trim().toLowerCase()) .filter((item) => supportedFeatures.has(item)))); @@ -1061,7 +1065,7 @@ function getMatrixProfileCompatibility(profile) { if (widgetIds.length > 1) { return { compatible: false, reasons: ["Matrix status scenes currently support one widget"] }; } - const supportedWidgetKinds = new Set(["text", "entity", "weather", "status", "matrix_effect"]); + const supportedWidgetKinds = new Set(["text", "entity", "weather", "status", "matrix_effect", "music_visualizer"]); const unsupported = widgetIds .map((widgetId) => getWidget(widgetId)) .filter(Boolean) @@ -2645,7 +2649,7 @@ function getProfile(profileId) { function normalizeCustomProfileWidget(widget = {}) { const kind = String(widget.kind || "text").trim().toLowerCase(); - const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; + const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "music_visualizer", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; const verticalAlign = String(widget.verticalAlign || "top").trim().toLowerCase(); const displayFormat = String(widget.displayFormat || "auto").trim().toLowerCase(); const decimals = Math.max(0, Math.min(3, Number.isFinite(Number(widget.decimals)) ? Number(widget.decimals) : 1)); @@ -2711,6 +2715,14 @@ function normalizeCustomProfileWidget(widget = {}) { text: String(widget.text || "").trim() }; } + if (normalizedKind === "music_visualizer") { + const mode = String(widget.mode || "spectrum").trim().toLowerCase(); + return { + ...base, + mode: ["spectrum", "meter", "pulse"].includes(mode) ? mode : "spectrum", + showMetadata: widget.showMetadata === undefined ? true : Boolean(widget.showMetadata) + }; + } if (normalizedKind === "photo_slideshow") { return { ...base, @@ -2982,6 +2994,9 @@ function buildLayoutPayloadFromProfile(profile) { .map((widget) => widget.config); payload.widgets = widgets; payload.widgetIds = widgetIds; + if (widgets.some((widget) => widget?.kind === "music_visualizer")) { + payload.music = buildSendspinRuntimePayload(); + } const entityIds = collectEntityIdsFromCustomWidgets(widgets); if (entityIds.length > 0) { payload.entityStates = buildEntityStateMap(entityIds); @@ -3007,6 +3022,32 @@ function buildLayoutPayloadFromProfile(profile) { return payload; } +function buildSendspinRuntimePayload() { + const status = sendspinAdapterCache.status && typeof sendspinAdapterCache.status === "object" + ? sendspinAdapterCache.status + : {}; + const visualizer = status.visualizer && typeof status.visualizer === "object" ? status.visualizer : {}; + const metadata = status.metadata && typeof status.metadata === "object" ? status.metadata : {}; + return { + connected: Boolean(status.connected), + playbackState: String(status.playbackState || "stopped"), + metadata: { + title: String(metadata.title || ""), + artist: String(metadata.artist || ""), + album: String(metadata.album || "") + }, + visualizer: { + active: Boolean(visualizer.active), + bands: (Array.isArray(visualizer.bands) ? visualizer.bands : []) + .slice(0, 32) + .map((value) => Math.max(0, Math.min(255, Math.round(Number(value) || 0)))), + level: Math.max(0, Math.min(255, Math.round(Number(visualizer.level) || 0))), + peak: Math.max(0, Math.min(255, Math.round(Number(visualizer.peak) || 0))), + lastFrameAt: visualizer.lastFrameAt || null + } + }; +} + function collectEntityIdsFromCustomWidgets(widgets = []) { return Array.from( new Set( @@ -3070,7 +3111,8 @@ function applyWidgetOverridesToLayout(layout, widgetOverrides) { return { ...layout, widgets, - entityStates: nextEntityStates + entityStates: nextEntityStates, + ...(widgets.some((widget) => widget?.kind === "music_visualizer") ? { music: buildSendspinRuntimePayload() } : {}) }; } @@ -3134,6 +3176,25 @@ function broadcastRuntimeToScreen(screenId) { return true; } +function refreshMusicVisualizerRuntime() { + for (const screen of listScreens()) { + const runtime = getRuntimeStateForScreen(screen.screenId); + if (runtime?.layout?.template !== "custom" || !runtime.layout.widgets?.some((widget) => widget?.kind === "music_visualizer")) { + continue; + } + if (screen.transport === "matrix") { + publishMatrixRuntime(screen.screenId, runtime); + continue; + } + broadcast({ + type: "screen_runtime", + target: screen.screenId, + command: "screen_runtime", + payload: runtime + }); + } +} + function refreshTemplatedRuntimeForEntity(entityId) { const targetEntityId = String(entityId || "").trim().toLowerCase(); if (!targetEntityId) { @@ -3278,6 +3339,20 @@ function compileMatrixScene(runtime) { text: effectWidget.text || "" }; } + const musicWidget = (Array.isArray(layout.widgets) ? layout.widgets : []).find((widget) => widget?.kind === "music_visualizer"); + if (musicWidget) { + const music = layout.music && typeof layout.music === "object" ? layout.music : buildSendspinRuntimePayload(); + return { + kind: "music", + title: String(musicWidget.label || "Now Playing").slice(0, 48), + detail: musicWidget.showMetadata === false + ? "" + : [music.metadata?.title, music.metadata?.artist].filter(Boolean).join(" — ").slice(0, 96), + mode: musicWidget.mode || "spectrum", + visualizer: music.visualizer || {}, + ticker + }; + } const fields = (Array.isArray(layout.widgets) ? layout.widgets : []).slice(0, 4).map((widget) => ({ label: String(widget?.label || widget?.entityId || "Status").slice(0, 48), value: matrixWidgetValue(widget, layout.entityStates?.[widget?.entityId]).slice(0, 96) @@ -7218,7 +7293,7 @@ server.listen(PORT, () => { refreshSendspinAdapterStatus().catch((err) => console.error("SendSpin adapter status refresh failed:", err.message)); setInterval(() => { refreshSendspinAdapterStatus().catch((err) => console.error("SendSpin adapter status refresh failed:", err.message)); - }, 2000); + }, 500); refreshHaEntities().catch((err) => { console.error("Initial Home Assistant refresh failed:", err.message); }); From b342a7c05f8998cc25f4951be67eb4e59664b055 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:13:19 -0700 Subject: [PATCH 25/25] docs: use public-safe ESPHome examples --- esphome/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/README.md b/esphome/README.md index bc99add..cdfa6d3 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -74,9 +74,9 @@ The example expects these secrets: ```yaml wifi_ssid: "your-wifi" wifi_password: "your-password" -mqtt_broker: "192.168.1.10" +mqtt_broker: "mqtt.example.net" mqtt_username: "coreview-beacon" -mqtt_password: "use-a-unique-password" +mqtt_password: "change-me" ``` `mqtt_broker` is a host name or IP address only—do not include `http://` or a