From bfb958e80fb6cd4a8fc246460de54448f04cc873 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:00:28 -0700 Subject: [PATCH] feat(matrix): add splash and text effects --- docs/matrix-mqtt-protocol.md | 6 +++-- esphome/README.md | 6 +++-- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 26 ++++++++++++++++++- esphome/packages/coreview-matrix-base.yaml | 6 ++++- server/public/app.html | 9 ++++--- server/server.js | 7 ++--- 6 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 03bfd16..10e4ea3 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -57,8 +57,10 @@ 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 reference -firmware supports `scanner`, `rainbow_waves`, `aurora`, `digital_rain`, `fire`, -`twinkle`, `color_vortex`, and `confetti`. +firmware supports `scanner`, `bouncing_text`, `rainbow_waves`, `aurora`, +`digital_rain`, `fire`, `twinkle`, `color_vortex`, and `confetti`. Scanner and +Bouncing Text use the optional `text` field as their display word; the +reference UI limits it to one word of up to 16 characters. Notification scenes may also include an optional `icon` (`info`, `warning`, `success`, `door`, `lock`, `motion`, `water`, or `fire`) and `flashBorder`. diff --git a/esphome/README.md b/esphome/README.md index cdfa6d3..783f414 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -54,8 +54,10 @@ Create a profile for other boards, pinouts, panel chains, or geometries. - 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`. +- Matrix-only local effects: `scanner`, `bouncing_text`, `rainbow_waves`, + `aurora`, `digital_rain`, `fire`, `twinkle`, `color_vortex`, and `confetti`. + Scanner and Bouncing Text use the one-word display text configured on their + Matrix Effect widget. - Notification/alert presentation with optional standard icons and flashing border, plus transient ticker support. - SendSpin Music Visualizer scenes: local spectrum bars, level meter, and diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 06f5067..597b24d 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -60,7 +60,18 @@ display: 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), primary, TextAlign::CENTER, "COREVIEW"); + // A short local splash confirms that the panel and renderer are alive + // before Wi-Fi/MQTT have finished connecting. + const uint32_t splash_ms = 2500U; + const uint32_t remaining = id(coreview_boot_until) > now_ms ? id(coreview_boot_until) - now_ms : 0U; + const uint32_t elapsed = remaining < splash_ms ? splash_ms - remaining : 0U; + const int bar_width = std::max(20, std::min(width - 16, 56)); + const int bar_x = (width - bar_width) / 2; + const int bar_y = std::min(height - 7, height / 2 + 5); + const int fill_width = std::max(1, (int) ((uint32_t) (bar_width - 2) * elapsed / splash_ms)); + it.print(width / 2, bar_y - 12, id(coreview_font_small), primary, TextAlign::TOP_CENTER, "COREVIEW"); + it.rectangle(bar_x, bar_y, bar_width, 5, secondary); + it.filled_rectangle(bar_x + 1, bar_y + 1, fill_width, 3, accent); return; } if (!id(coreview_claimed)) { @@ -166,6 +177,19 @@ display: 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) == "bouncing_text") { + const std::string word = id(coreview_title).empty() ? "COREVIEW" : id(coreview_title); + int x1, y1, text_width, text_height; + it.get_text_bounds(0, 0, word.c_str(), id(coreview_font_small), TextAlign::TOP_LEFT, &x1, &y1, &text_width, &text_height); + const int max_x = std::max(0, width - text_width); + const int max_y = std::max(0, height - text_height); + const int x_period = std::max(1, max_x * 2); + const int y_period = std::max(1, max_y * 2); + const int x_phase = (int) (frame % (uint32_t) x_period); + const int y_phase = (int) ((frame * 2U) % (uint32_t) y_period); + const int x = x_phase <= max_x ? x_phase : x_period - x_phase; + const int y = y_phase <= max_y ? y_phase : y_period - y_phase; + it.print(x, y, id(coreview_font_small), hue((uint8_t) (frame * 3U)), TextAlign::TOP_LEFT, word.c_str()); } 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") { diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index 19d359b..4f8a2ea 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -199,7 +199,11 @@ mqtt: 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"; + const char *scene_title = scene["title"] | "CoreView"; + const char *effect_text = scene["text"] | ""; + id(coreview_title) = id(coreview_scene) == "effect" && effect_text[0] + ? effect_text + : scene_title; id(coreview_detail) = scene["detail"] | ""; id(coreview_severity) = scene["severity"] | "info"; id(coreview_icon) = scene["icon"] | ""; diff --git a/server/public/app.html b/server/public/app.html index 296f5ee..80e2d88 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -4089,7 +4089,7 @@

How This Connects

}; } if (normalizedKind === "matrix_effect") { - const effects = ["scanner", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; + const effects = ["scanner", "bouncing_text", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; const palettes = ["neon", "ocean", "sunset", "forest", "party"]; return { ...base, @@ -4097,7 +4097,7 @@

How This Connects

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() + text: String(widget.text || "").trim().split(/\s+/)[0].slice(0, 16) }; } if (normalizedKind === "music_visualizer") { @@ -4387,10 +4387,11 @@

How This Connects

`; } else if (widget.kind === "matrix_effect") { extraField = ` -
+
-
`; +
+
`; } else if (widget.kind === "music_visualizer") { extraField = `
diff --git a/server/server.js b/server/server.js index b3477aa..523762d 100644 --- a/server/server.js +++ b/server/server.js @@ -2708,7 +2708,7 @@ function normalizeCustomProfileWidget(widget = {}) { }; } if (normalizedKind === "matrix_effect") { - const effects = ["scanner", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; + const effects = ["scanner", "bouncing_text", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; const palettes = ["neon", "ocean", "sunset", "forest", "party"]; return { ...base, @@ -2716,7 +2716,7 @@ function normalizeCustomProfileWidget(widget = {}) { 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() + text: String(widget.text || "").trim().split(/\s+/)[0].slice(0, 16) }; } if (normalizedKind === "music_visualizer") { @@ -3340,7 +3340,8 @@ function compileMatrixScene(runtime) { palette: runtime?.themeState?.matrix?.effectPalette || effectWidget.palette || "neon", speed: Number(effectWidget.speed || 35), intensity: Number(effectWidget.intensity || 60), - text: effectWidget.text || "" + text: effectWidget.text || "", + title: effectWidget.text || "COREVIEW" }; } const musicWidget = (Array.isArray(layout.widgets) ? layout.widgets : []).find((widget) => widget?.kind === "music_visualizer");