From ac98e385ee14e3cc1722a3951be23cb3a3b3a1e8 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:13:40 -0700 Subject: [PATCH 01/10] feat(matrix): add local microphone audio reactive scene --- README.md | 3 + docs/matrix-mqtt-protocol.md | 8 +- esphome/README.md | 9 ++- esphome/components/coreview_audio_reactive.h | 81 +++++++++++++++++++ esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 51 +++++++++++- esphome/packages/coreview-matrix-base.yaml | 8 +- server/public/app.html | 16 +++- server/server.js | 23 +++++- 8 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 esphome/components/coreview_audio_reactive.h diff --git a/README.md b/README.md index 50174c5..d112d0e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ 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. +The reference HUB75 S3 profile can also render a Matrix-only Audio Reactive +scene from its onboard microphones; this stays on the device and never carries +raw room audio through CoreView. --- diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 10e4ea3..163ec7e 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -67,11 +67,17 @@ 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 +Music scenes carry a `mode` (`spectrum`, `meter`, `pulse`, or `local`) 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. +`local` is a Matrix-only capability. A target that advertises `localAudio` +uses its onboard microphone/audio hardware to generate bands locally and +ignores the remote `visualizer` values. The reference HUB75 S3 profile retains +no samples and publishes no raw audio; it is intentionally a room-audio effect, +not a network audio source. + `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 783f414..7e6351d 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -60,8 +60,13 @@ Create a profile for other boards, pinouts, panel chains, or geometries. 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 - pulse modes driven by normalized live data from CoreView (never audio). +- SendSpin Music Visualizer scenes: spectrum bars, level meter, and pulse + modes driven by normalized live data from CoreView (never audio). +- The bundled RGB Matrix HUB75 S3 hardware profile also supports the board's + ES7210-connected microphones for the Matrix-only **Audio Reactive** widget. + It analyzes room sound locally into eight display bands and immediately + discards the samples—no microphone audio is published, stored, or sent to + CoreView. Use automation to select that profile when music is playing. 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/components/coreview_audio_reactive.h b/esphome/components/coreview_audio_reactive.h new file mode 100644 index 0000000..e74fc5e --- /dev/null +++ b/esphome/components/coreview_audio_reactive.h @@ -0,0 +1,81 @@ +#pragma once + +#include "esphome.h" + +#include +#include +#include + +// A deliberately small local audio analyzer for the Matrix Beacon. It takes +// 16-bit microphone samples, derives eight visual bands, and discards the +// samples immediately. This is not a recorder or an audio transport. +class CoreViewAudioReactive { + public: + explicit CoreViewAudioReactive(esphome::microphone::Microphone *microphone) : microphone_(microphone) {} + + void setup() { + this->microphone_->add_data_callback([this](const std::vector &data) { this->consume_(data); }); + this->microphone_->start(); + } + + bool is_active() const { return this->has_samples_; } + uint8_t level() const { return this->level_; } + uint8_t band(uint8_t index) const { return this->bands_[std::min(index, this->bands_.size() - 1)]; } + + protected: + static constexpr size_t WINDOW_SIZE = 256; + static constexpr float SAMPLE_RATE = 16000.0f; + static constexpr float PI = 3.14159265358979323846f; + static constexpr std::array CENTERS = {80.0f, 160.0f, 315.0f, 630.0f, 1250.0f, 2500.0f, 4000.0f, 6000.0f}; + + void consume_(const std::vector &data) { + for (size_t offset = 0; offset + 1 < data.size(); offset += 2) { + const int16_t sample = static_cast(static_cast(data[offset]) | + (static_cast(data[offset + 1]) << 8)); + this->samples_[this->sample_count_++] = sample; + if (this->sample_count_ == WINDOW_SIZE) { + this->analyze_(); + this->sample_count_ = 0; + } + } + } + + void analyze_() { + float rms_sum = 0.0f; + for (int16_t sample : this->samples_) { + const float normalized = static_cast(sample) / 32768.0f; + rms_sum += normalized * normalized; + } + const float rms = std::sqrt(rms_sum / WINDOW_SIZE); + this->peak_ = std::max(rms, this->peak_ * 0.985f); + const float divisor = std::max(0.015f, this->peak_); + const float normalized_level = std::min(1.0f, std::max(0.0f, (rms - 0.008f) / divisor)); + const uint8_t next_level = static_cast(normalized_level * 255.0f); + this->level_ = static_cast((this->level_ * 3U + next_level) / 4U); + + for (size_t band = 0; band < CENTERS.size(); band++) { + const float coefficient = 2.0f * std::cos(2.0f * PI * CENTERS[band] / SAMPLE_RATE); + float q0 = 0.0f; + float q1 = 0.0f; + float q2 = 0.0f; + for (int16_t sample : this->samples_) { + q0 = static_cast(sample) + coefficient * q1 - q2; + q2 = q1; + q1 = q0; + } + const float magnitude = std::sqrt(std::max(0.0f, q1 * q1 + q2 * q2 - coefficient * q1 * q2)) / WINDOW_SIZE; + const float band_value = std::min(1.0f, std::max(0.0f, (magnitude - 55.0f) / 1600.0f)); + const uint8_t next_band = static_cast(band_value * 255.0f); + this->bands_[band] = static_cast((this->bands_[band] * 2U + next_band) / 3U); + } + this->has_samples_ = true; + } + + esphome::microphone::Microphone *microphone_; + std::array samples_{}; + std::array bands_{}; + size_t sample_count_{0}; + float peak_{0.015f}; + uint8_t level_{0}; + bool has_samples_{false}; +}; diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index bca0f14..168d36c 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -13,6 +13,46 @@ psram: mode: octal speed: 80MHZ +# The RGB Matrix HUB75 S3 board provides dual microphones through an ES7210 +# audio ADC. This is intentionally local-only: the analyzer turns room sound +# into compact display bands and never publishes or records raw audio. +i2c: + sda: GPIO1 + scl: GPIO2 + scan: false + +audio_adc: + - platform: es7210 + id: coreview_es7210 + sample_rate: 16000 + bits_per_sample: 16bit + mic_gain: 24db + +i2s_audio: + - id: coreview_audio_bus + i2s_mclk_pin: GPIO38 + i2s_bclk_pin: GPIO48 + i2s_lrclk_pin: GPIO21 + +microphone: + - platform: i2s_audio + id: coreview_microphone + i2s_audio_id: coreview_audio_bus + adc_type: external + i2s_din_pin: GPIO14 + channel: left + sample_rate: 16000 + bits_per_sample: 16bit + correct_dc_offset: true + +esphome: + on_boot: + priority: -90 + then: + - lambda: |- + id(coreview_audio_reactive) = new CoreViewAudioReactive(id(coreview_microphone)); + id(coreview_audio_reactive)->setup(); + display: - platform: hub75 id: coreview_matrix @@ -288,7 +328,11 @@ display: // 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)) { + const bool local_audio = id(coreview_music_mode) == "local"; + const bool audio_active = local_audio && id(coreview_audio_reactive) != nullptr + ? id(coreview_audio_reactive)->is_active() + : id(coreview_music_active); + if (!audio_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); @@ -307,7 +351,10 @@ display: 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 band_level = local_audio && id(coreview_audio_reactive) != nullptr + ? id(coreview_audio_reactive)->band(bar * 8 / bar_count) + : music_band(bar); + const int bar_height = std::max(1, chart_height * band_level / 255); const int x = offset_x + bar * (bar_width + gap); it.filled_rectangle(x, chart_height - bar_height, bar_width, bar_height, accent); } diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index 0bcb036..6210703 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -7,6 +7,8 @@ substitutions: coreview_ack_topic: ${coreview_topic_root}/matrix/${coreview_target_id}/ack esphome: + includes: + - components/coreview_audio_reactive.h on_boot: priority: -100 then: @@ -111,6 +113,10 @@ globals: type: bool restore_value: no initial_value: 'false' + - id: coreview_audio_reactive + type: CoreViewAudioReactive * + restore_value: no + initial_value: 'nullptr' - id: coreview_matrix_background type: std::string restore_value: no @@ -152,7 +158,7 @@ script: 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"); + features.add("ticker"); features.add("icons"); features.add("music"); features.add("localAudio"); root["claimCode"] = id(coreview_claim_code); root["claimed"] = id(coreview_claimed); diff --git a/server/public/app.html b/server/public/app.html index b375355..8869d28 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", "music_visualizer"]); + const allowed = new Set(["text", "entity", "weather", "status", "matrix_effect", "music_visualizer", "matrix_audio_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", "music_visualizer", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; + const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "music_visualizer", "matrix_audio_visualizer", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; const verticalAlign = String(widget.verticalAlign || "top").trim().toLowerCase(); const base = { kind: normalizedKind, @@ -4108,6 +4108,12 @@

How This Connects

showMetadata: widget.showMetadata === undefined ? true : widget.showMetadata !== false }; } + if (normalizedKind === "matrix_audio_visualizer") { + return { + ...base, + showMetadata: false + }; + } if (normalizedKind === "photo_slideshow") { return { ...base, @@ -4196,6 +4202,9 @@

How This Connects

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 === "matrix_audio_visualizer") { + return normalizeProfileWidget({ kind: "matrix_audio_visualizer", label: overrides.label || "Audio Reactive", verticalAlign: overrides.verticalAlign || "top" }); + } if (nextKind === "photo_slideshow") { return normalizeProfileWidget({ kind: "photo_slideshow", @@ -4396,6 +4405,8 @@

How This Connects

extraField = `
`; + } else if (widget.kind === "matrix_audio_visualizer") { + extraField = `

Matrix only. The Beacon analyzes its own room microphones; raw audio never leaves the device.

`; } root.innerHTML = ` @@ -4409,6 +4420,7 @@

How This Connects

+ diff --git a/server/server.js b/server/server.js index 523762d..b601519 100644 --- a/server/server.js +++ b/server/server.js @@ -974,7 +974,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", "music"]); + const supportedFeatures = new Set(["clock", "status", "notification", "ticker", "icons", "music", "localaudio"]); const features = Array.from(new Set((Array.isArray(config.features) ? config.features : []) .map((item) => String(item || "").trim().toLowerCase()) .filter((item) => supportedFeatures.has(item)))); @@ -1069,7 +1069,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", "music_visualizer"]); + const supportedWidgetKinds = new Set(["text", "entity", "weather", "status", "matrix_effect", "music_visualizer", "matrix_audio_visualizer"]); const unsupported = widgetIds .map((widgetId) => getWidget(widgetId)) .filter(Boolean) @@ -2653,7 +2653,7 @@ function getProfile(profileId) { function normalizeCustomProfileWidget(widget = {}) { const kind = String(widget.kind || "text").trim().toLowerCase(); - const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "music_visualizer", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; + const normalizedKind = ["clock", "entity", "text", "weather", "status", "matrix_effect", "music_visualizer", "matrix_audio_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)); @@ -2727,6 +2727,12 @@ function normalizeCustomProfileWidget(widget = {}) { showMetadata: widget.showMetadata === undefined ? true : Boolean(widget.showMetadata) }; } + if (normalizedKind === "matrix_audio_visualizer") { + return { + ...base, + showMetadata: false + }; + } if (normalizedKind === "photo_slideshow") { return { ...base, @@ -3344,6 +3350,17 @@ function compileMatrixScene(runtime) { title: effectWidget.text || "COREVIEW" }; } + const localAudioWidget = (Array.isArray(layout.widgets) ? layout.widgets : []).find((widget) => widget?.kind === "matrix_audio_visualizer"); + if (localAudioWidget) { + return { + kind: "music", + title: String(localAudioWidget.label || "Audio Reactive").slice(0, 48), + detail: "", + mode: "local", + visualizer: {}, + ticker + }; + } 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(); From f5b9f6ebeae00501dfda6e40b0f0666445e36901 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:30:59 -0700 Subject: [PATCH 02/10] fix(esphome): resolve audio analyzer header ordering --- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 11 ++++++----- esphome/packages/coreview-matrix-base.yaml | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 168d36c..5db6ce8 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -51,7 +51,7 @@ esphome: then: - lambda: |- id(coreview_audio_reactive) = new CoreViewAudioReactive(id(coreview_microphone)); - id(coreview_audio_reactive)->setup(); + static_cast(id(coreview_audio_reactive))->setup(); display: - platform: hub75 @@ -329,8 +329,9 @@ display: const int text_y = id(coreview_ticker).empty() ? height - 11 : height - 20; const int chart_height = std::max(5, text_y - 2); const bool local_audio = id(coreview_music_mode) == "local"; - const bool audio_active = local_audio && id(coreview_audio_reactive) != nullptr - ? id(coreview_audio_reactive)->is_active() + const auto *audio_reactive = static_cast(id(coreview_audio_reactive)); + const bool audio_active = local_audio && audio_reactive != nullptr + ? audio_reactive->is_active() : id(coreview_music_active); if (!audio_active) { print_line("WAITING FOR AUDIO", chart_height / 2 - 4, secondary); @@ -351,8 +352,8 @@ display: 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 band_level = local_audio && id(coreview_audio_reactive) != nullptr - ? id(coreview_audio_reactive)->band(bar * 8 / bar_count) + const int band_level = local_audio && audio_reactive != nullptr + ? audio_reactive->band(bar * 8 / bar_count) : music_band(bar); const int bar_height = std::max(1, chart_height * band_level / 255); const int x = offset_x + bar * (bar_width + gap); diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index 6210703..73cdd69 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -114,7 +114,9 @@ globals: restore_value: no initial_value: 'false' - id: coreview_audio_reactive - type: CoreViewAudioReactive * + # ESPHome emits globals before its copied-header include block. Keep this + # opaque here, then cast it only in generated lambdas after that include. + type: void * restore_value: no initial_value: 'nullptr' - id: coreview_matrix_background From c73086758ed3318ae28339da4c8c7bec13e7a988 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:57:47 -0700 Subject: [PATCH 03/10] fix(esphome): use Beacon ES7210 data pin --- esphome/components/coreview_audio_reactive.h | 8 ++++++++ esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/esphome/components/coreview_audio_reactive.h b/esphome/components/coreview_audio_reactive.h index e74fc5e..693cfc6 100644 --- a/esphome/components/coreview_audio_reactive.h +++ b/esphome/components/coreview_audio_reactive.h @@ -69,6 +69,13 @@ class CoreViewAudioReactive { this->bands_[band] = static_cast((this->bands_[band] * 2U + next_band) / 3U); } this->has_samples_ = true; + const uint32_t now = millis(); + if (now - this->last_log_ms_ >= 1000U) { + this->last_log_ms_ = now; + ESP_LOGD("coreview.audio", "rms=%.4f level=%u bands=%u,%u,%u,%u,%u,%u,%u,%u", rms, this->level_, + this->bands_[0], this->bands_[1], this->bands_[2], this->bands_[3], this->bands_[4], + this->bands_[5], this->bands_[6], this->bands_[7]); + } } esphome::microphone::Microphone *microphone_; @@ -78,4 +85,5 @@ class CoreViewAudioReactive { float peak_{0.015f}; uint8_t level_{0}; bool has_samples_{false}; + uint32_t last_log_ms_{0}; }; diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 5db6ce8..5b8e025 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -26,7 +26,7 @@ audio_adc: id: coreview_es7210 sample_rate: 16000 bits_per_sample: 16bit - mic_gain: 24db + mic_gain: 30db i2s_audio: - id: coreview_audio_bus @@ -39,8 +39,10 @@ microphone: id: coreview_microphone i2s_audio_id: coreview_audio_bus adc_type: external - i2s_din_pin: GPIO14 - channel: left + # The ES7210's SDOUT is connected to GPIO47. GPIO14 is the reverse + # direction for the board's output codec. + i2s_din_pin: GPIO47 + channel: right sample_rate: 16000 bits_per_sample: 16bit correct_dc_offset: true From e53dd1c4c37c926cc9301da67935ccb230c7bbc3 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:06:29 -0700 Subject: [PATCH 04/10] tune(esphome): amplify local Matrix audio bands --- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 5b8e025..18f28ab 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -354,9 +354,15 @@ display: 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 band_level = local_audio && audio_reactive != nullptr + const int raw_band_level = local_audio && audio_reactive != nullptr ? audio_reactive->band(bar * 8 / bar_count) : music_band(bar); + // Room microphones have substantially less energy than a direct + // digital visualizer feed. Expand local bands for this 32-pixel + // Matrix without changing the SendSpin scale. + const int band_level = local_audio + ? std::min(255, raw_band_level * 5) + : raw_band_level; const int bar_height = std::max(1, chart_height * band_level / 255); const int x = offset_x + bar * (bar_width + gap); it.filled_rectangle(x, chart_height - bar_height, bar_width, bar_height, accent); From c352315df33451557032692176fe016a47d1a37e Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:16:18 -0700 Subject: [PATCH 05/10] feat(esphome): expand local audio spectrum bands --- esphome/components/coreview_audio_reactive.h | 14 ++++++++++---- esphome/hardware/esp32s3-hub75-3x2-32x16.yaml | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/esphome/components/coreview_audio_reactive.h b/esphome/components/coreview_audio_reactive.h index 693cfc6..7cc116c 100644 --- a/esphome/components/coreview_audio_reactive.h +++ b/esphome/components/coreview_audio_reactive.h @@ -7,7 +7,7 @@ #include // A deliberately small local audio analyzer for the Matrix Beacon. It takes -// 16-bit microphone samples, derives eight visual bands, and discards the +// 16-bit microphone samples, derives sixteen visual bands, and discards the // samples immediately. This is not a recorder or an audio transport. class CoreViewAudioReactive { public: @@ -26,7 +26,9 @@ class CoreViewAudioReactive { static constexpr size_t WINDOW_SIZE = 256; static constexpr float SAMPLE_RATE = 16000.0f; static constexpr float PI = 3.14159265358979323846f; - static constexpr std::array CENTERS = {80.0f, 160.0f, 315.0f, 630.0f, 1250.0f, 2500.0f, 4000.0f, 6000.0f}; + static constexpr std::array CENTERS = { + 70.0f, 100.0f, 150.0f, 220.0f, 320.0f, 450.0f, 640.0f, 900.0f, + 1250.0f, 1750.0f, 2400.0f, 3200.0f, 4200.0f, 5200.0f, 6200.0f, 7000.0f}; void consume_(const std::vector &data) { for (size_t offset = 0; offset + 1 < data.size(); offset += 2) { @@ -64,7 +66,11 @@ class CoreViewAudioReactive { q1 = q0; } const float magnitude = std::sqrt(std::max(0.0f, q1 * q1 + q2 * q2 - coefficient * q1 * q2)) / WINDOW_SIZE; - const float band_value = std::min(1.0f, std::max(0.0f, (magnitude - 55.0f) / 1600.0f)); + // Room microphones and the Matrix enclosure naturally roll off higher + // frequencies. Preserve the low-band scale while gently compensating + // upward so all sixteen physical bars can participate. + const float compensated = magnitude * (1.0f + static_cast(band) * 0.55f); + const float band_value = std::min(1.0f, std::max(0.0f, (compensated - 25.0f) / 1800.0f)); const uint8_t next_band = static_cast(band_value * 255.0f); this->bands_[band] = static_cast((this->bands_[band] * 2U + next_band) / 3U); } @@ -80,7 +86,7 @@ class CoreViewAudioReactive { esphome::microphone::Microphone *microphone_; std::array samples_{}; - std::array bands_{}; + std::array bands_{}; size_t sample_count_{0}; float peak_{0.015f}; uint8_t level_{0}; diff --git a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml index 18f28ab..e38add5 100644 --- a/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -355,7 +355,7 @@ display: const int offset_x = (width - chart_width) / 2; for (int bar = 0; bar < bar_count; bar++) { const int raw_band_level = local_audio && audio_reactive != nullptr - ? audio_reactive->band(bar * 8 / bar_count) + ? audio_reactive->band(bar * 16 / bar_count) : music_band(bar); // Room microphones have substantially less energy than a direct // digital visualizer feed. Expand local bands for this 32-pixel From 1aa2c2421c82830f82f92d3ab3b662f4ea625def Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:26:17 -0700 Subject: [PATCH 06/10] tune(esphome): retain quiet local treble bands --- esphome/components/coreview_audio_reactive.h | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/components/coreview_audio_reactive.h b/esphome/components/coreview_audio_reactive.h index 7cc116c..3733856 100644 --- a/esphome/components/coreview_audio_reactive.h +++ b/esphome/components/coreview_audio_reactive.h @@ -67,10 +67,10 @@ class CoreViewAudioReactive { } const float magnitude = std::sqrt(std::max(0.0f, q1 * q1 + q2 * q2 - coefficient * q1 * q2)) / WINDOW_SIZE; // Room microphones and the Matrix enclosure naturally roll off higher - // frequencies. Preserve the low-band scale while gently compensating - // upward so all sixteen physical bars can participate. - const float compensated = magnitude * (1.0f + static_cast(band) * 0.55f); - const float band_value = std::min(1.0f, std::max(0.0f, (compensated - 25.0f) / 1800.0f)); + // frequencies. Do not hard-gate individual bands: quiet treble should + // remain visible while music is playing, rather than vanishing outright. + const float compensated = magnitude * (1.0f + static_cast(band) * 0.85f); + const float band_value = std::min(1.0f, compensated / 1450.0f); const uint8_t next_band = static_cast(band_value * 255.0f); this->bands_[band] = static_cast((this->bands_[band] * 2U + next_band) / 3U); } @@ -78,9 +78,11 @@ class CoreViewAudioReactive { const uint32_t now = millis(); if (now - this->last_log_ms_ >= 1000U) { this->last_log_ms_ = now; - ESP_LOGD("coreview.audio", "rms=%.4f level=%u bands=%u,%u,%u,%u,%u,%u,%u,%u", rms, this->level_, + ESP_LOGD("coreview.audio", "rms=%.4f level=%u bands=%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u", rms, this->level_, this->bands_[0], this->bands_[1], this->bands_[2], this->bands_[3], this->bands_[4], - this->bands_[5], this->bands_[6], this->bands_[7]); + this->bands_[5], this->bands_[6], this->bands_[7], this->bands_[8], this->bands_[9], + this->bands_[10], this->bands_[11], this->bands_[12], this->bands_[13], this->bands_[14], + this->bands_[15]); } } From cc1927a0539709d2c1ec082845aaa81a9ca40c85 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:12:25 -0700 Subject: [PATCH 07/10] fix(matrix): publish rule-driven runtime changes --- server/server.js | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/server/server.js b/server/server.js index b601519..87ef457 100644 --- a/server/server.js +++ b/server/server.js @@ -1582,12 +1582,7 @@ function scheduleRuntimeRestore(targets, seconds, restoreOverrides = {}, chained if (!runtime) { return; } - broadcast({ - type: "screen_runtime", - target, - command: "screen_runtime", - payload: runtime - }); + broadcastRuntimeToScreen(target); }, durationMs); transientRestoreTimers.set(target, { timer, restoreOverride }); } @@ -1665,12 +1660,7 @@ function broadcastTransientRuntime(targets, override = {}, seconds) { if (!payload) { continue; } - broadcast({ - type: "screen_runtime", - target, - command: "screen_runtime", - payload - }); + broadcastRuntimeToScreen(target); } if (seconds) { scheduleRuntimeRestore(targets, seconds, restoreOverrides, chainedRestoreOverrides); @@ -3183,6 +3173,13 @@ function broadcastRuntimeToScreen(screenId) { command: "screen_runtime", payload: runtime }); + // Browser displays consume the WebSocket runtime payload. Matrix targets + // need the same runtime compiled and published as retained MQTT state. + // Keeping that behavior here makes manual, rule-driven, and restore paths + // consistent instead of leaving a physical Beacon on an old scene. + if (getMatrixTarget(screenId)) { + publishMatrixRuntime(screenId, runtime); + } return true; } @@ -6809,7 +6806,7 @@ app.post("/api/matrix-targets", requireAdmin, (req, res) => { 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 }); + broadcastRuntimeToScreen(screenId); 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" }); From 367b9e2e1528805c094ab76033b42e3d0469b912 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:09:55 -0700 Subject: [PATCH 08/10] fix(matrix): avoid redundant HA runtime publishes --- server/server.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/server/server.js b/server/server.js index 87ef457..01023bf 100644 --- a/server/server.js +++ b/server/server.js @@ -4458,11 +4458,6 @@ function setHaEntityState(entity) { payload: normalized }); refreshTemplatedRuntimeForEntity(normalized.entityId); - for (const screen of listScreens()) { - if (screen.transport === "matrix") { - broadcastRuntimeToScreen(screen.screenId); - } - } } function removeHaEntityState(entityId) { @@ -4477,11 +4472,6 @@ function removeHaEntityState(entityId) { payload: { entityId } }); refreshTemplatedRuntimeForEntity(entityId); - for (const screen of listScreens()) { - if (screen.transport === "matrix") { - broadcastRuntimeToScreen(screen.screenId); - } - } } function scheduleHaReconnect() { From a456fe610049ef442cc40c8d25404e9cb48ee188 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:37:10 -0700 Subject: [PATCH 09/10] feat(matrix): add CoreView-mediated ESPHome controls --- docs/matrix-mqtt-protocol.md | 27 +++++ esphome/README.md | 4 + esphome/packages/coreview-matrix-base.yaml | 128 +++++++++++++++++++++ server/server.js | 119 ++++++++++++++++++- 4 files changed, 274 insertions(+), 4 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 163ec7e..5dab8bd 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -14,6 +14,7 @@ With `MQTT_TOPIC_ROOT=home/signage` and a target ID of `kitchen-matrix`: | 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 | +| target → CoreView | `home/signage/matrix/kitchen-matrix/control` | 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` @@ -84,6 +85,32 @@ manual overrides are resolved. All colors are six-digit CSS hex values and theme support may ignore this object. Effects use `theme.effectPalette`, so a theme change restyles both normal scenes and local animations consistently. +## Home Assistant / ESPHome control surface + +The reference firmware exposes a small Home Assistant control card through +ESPHome: Matrix mode, effect, palette, brightness, speed, intensity, device +restart, and health sensors. Those entities do **not** mutate the renderer +directly. A user change publishes this non-retained request: + +```json +{ + "schema": "coreview.matrix.control.v1", + "control": "effect", + "value": "Digital Rain" +} +``` + +CoreView validates the request, persists the target's Matrix control state, +and republishes a new retained `state` revision. The target reflects that state +back into its ESPHome entities without emitting another request. This keeps +CoreView, Themes, alerts, and Home Assistant from competing for device state. + +Supported controls are `mode` (`Display`, `Lights`, `Music`), `effect`, +`palette`, `brightness`, `speed`, and `intensity`. `Lights` selects one of the +portable firmware effects; `Music` enables the Matrix's local microphone +visualizer. CoreView notifications continue to take priority over this manual +control surface. + 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 7e6351d..e7d0e72 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -62,6 +62,10 @@ Create a profile for other boards, pinouts, panel chains, or geometries. border, plus transient ticker support. - SendSpin Music Visualizer scenes: spectrum bars, level meter, and pulse modes driven by normalized live data from CoreView (never audio). +- A Home Assistant-native control card exposed by ESPHome: Matrix mode, + lighting effect, palette, brightness, speed, intensity, restart, Wi-Fi, + uptime, scene revision, and online status. Controls request changes from + CoreView over MQTT; they do not bypass Themes or retained state. - The bundled RGB Matrix HUB75 S3 hardware profile also supports the board's ES7210-connected microphones for the Matrix-only **Audio Reactive** widget. It analyzes room sound locally into eight display bands and immediately diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index 73cdd69..c56b76e 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -5,6 +5,7 @@ substitutions: 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 + coreview_control_topic: ${coreview_topic_root}/matrix/${coreview_target_id}/control esphome: includes: @@ -239,6 +240,27 @@ mqtt: } JsonObjectConst ticker = scene["ticker"].as(); id(coreview_ticker) = ticker.isNull() ? "" : (ticker["message"] | ""); + JsonObjectConst controls = x["controls"].as(); + if (!controls.isNull()) { + const std::string mode = controls["mode"] | "display"; + id(coreview_control_mode).publish_state(mode == "lights" ? "Lights" : (mode == "music" ? "Music" : "Display")); + const std::string effect = controls["effect"] | "scanner"; + std::string effect_label = "Scanner"; + if (effect == "bouncing_text") effect_label = "Bouncing Text"; + else if (effect == "rainbow_waves") effect_label = "Rainbow Waves"; + else if (effect == "digital_rain") effect_label = "Digital Rain"; + else if (effect == "color_vortex") effect_label = "Color Vortex"; + else if (effect == "fire") effect_label = "Fire"; + else if (effect == "twinkle") effect_label = "Twinkle"; + else if (effect == "aurora") effect_label = "Aurora"; + else if (effect == "confetti") effect_label = "Confetti"; + id(coreview_control_effect).publish_state(effect_label); + const std::string palette = controls["palette"] | "neon"; + id(coreview_control_palette).publish_state(palette == "ocean" ? "Ocean" : (palette == "sunset" ? "Sunset" : (palette == "forest" ? "Forest" : (palette == "party" ? "Party" : "Neon")))); + id(coreview_control_brightness).publish_state(std::max(1, std::min(100, controls["brightness"] | 25))); + id(coreview_control_speed).publish_state(std::max(1, std::min(100, controls["speed"] | 35))); + id(coreview_control_intensity).publish_state(std::max(1, std::min(100, controls["intensity"] | 60))); + } - hub75.set_brightness: id: coreview_matrix brightness: !lambda 'return (uint8_t) id(coreview_brightness);' @@ -249,6 +271,112 @@ mqtt: root["revision"] = id(coreview_revision); root["ok"] = true; +select: + - platform: template + id: coreview_control_mode + name: "CoreView Matrix Mode" + options: ["Display", "Lights", "Music"] + optimistic: false + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "mode"; + root["value"] = x; + - platform: template + id: coreview_control_effect + name: "CoreView Matrix Effect" + options: ["Scanner", "Bouncing Text", "Rainbow Waves", "Aurora", "Digital Rain", "Fire", "Twinkle", "Color Vortex", "Confetti"] + optimistic: false + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "effect"; + root["value"] = x; + - platform: template + id: coreview_control_palette + name: "CoreView Matrix Palette" + options: ["Neon", "Ocean", "Sunset", "Forest", "Party"] + optimistic: false + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "palette"; + root["value"] = x; + +number: + - platform: template + id: coreview_control_brightness + name: "CoreView Matrix Brightness" + unit_of_measurement: "%" + min_value: 1 + max_value: 100 + step: 1 + optimistic: false + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "brightness"; + root["value"] = x; + - platform: template + id: coreview_control_speed + name: "CoreView Matrix Effect Speed" + unit_of_measurement: "%" + min_value: 1 + max_value: 100 + step: 1 + optimistic: false + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "speed"; + root["value"] = x; + - platform: template + id: coreview_control_intensity + name: "CoreView Matrix Effect Intensity" + unit_of_measurement: "%" + min_value: 1 + max_value: 100 + step: 1 + optimistic: false + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "intensity"; + root["value"] = x; + +button: + - platform: restart + name: "CoreView Beacon Restart" + +sensor: + - platform: wifi_signal + name: "CoreView Beacon WiFi Signal" + update_interval: 60s + - platform: uptime + name: "CoreView Beacon Uptime" + type: seconds + - platform: template + name: "CoreView Matrix Scene Revision" + accuracy_decimals: 0 + update_interval: 30s + lambda: 'return id(coreview_revision);' + +binary_sensor: + - platform: status + name: "CoreView Beacon Online" + font: - file: "gfonts://Roboto Mono" id: coreview_font_small diff --git a/server/server.js b/server/server.js index 01023bf..3004746 100644 --- a/server/server.js +++ b/server/server.js @@ -22,6 +22,7 @@ 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_CONTROL_SUBSCRIBE_PATTERN = `${MQTT_TOPIC_ROOT}/matrix/+/control`; 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 || ""; @@ -969,6 +970,30 @@ function getScreenRecord(screenId) { return hydrateScreenTransport(rows[0] || null); } +const MATRIX_EFFECTS = ["scanner", "bouncing_text", "rainbow_waves", "aurora", "digital_rain", "fire", "twinkle", "color_vortex", "confetti"]; +const MATRIX_PALETTES = ["neon", "ocean", "sunset", "forest", "party"]; + +function normalizeMatrixControlState(value = {}) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const mode = String(value.mode || "").trim().toLowerCase(); + if (!['display', 'lights', 'music'].includes(mode)) return null; + const effect = String(value.effect || "scanner").trim().toLowerCase(); + const palette = String(value.palette || "").trim().toLowerCase(); + const percent = (raw, fallback = null) => { + if (raw === null || raw === undefined || raw === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) ? Math.max(1, Math.min(100, Math.round(parsed))) : fallback; + }; + return { + mode, + effect: MATRIX_EFFECTS.includes(effect) ? effect : "scanner", + palette: MATRIX_PALETTES.includes(palette) ? palette : null, + brightness: percent(value.brightness), + speed: percent(value.speed, 35), + intensity: percent(value.intensity, 60) + }; +} + 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)))); @@ -978,7 +1003,7 @@ function normalizeMatrixConfig(config = {}) { 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 }; + return { width, height, colorDepth, rotation, features, controls: normalizeMatrixControlState(config.controls) }; } function getMatrixTarget(screenId) { @@ -3233,6 +3258,7 @@ function getRuntimeStateForScreen(screenId) { if (!screen) { return null; } + const matrixControls = screen.transport === "matrix" ? screen.matrix?.config?.controls || null : null; const override = getActiveRuntimeOverride(screenId); const assignedView = screen.viewId ? getView(screen.viewId) : null; const assignedProfile = assignedView?.profileId ? getProfile(assignedView.profileId) : null; @@ -3263,7 +3289,8 @@ function getRuntimeStateForScreen(screenId) { : (banner ? buildBannerPayloadFromBanner(banner) : null), tickerState: Object.prototype.hasOwnProperty.call(override, "tickerState") ? override.tickerState - : (ticker ? buildTickerPayloadFromTicker(ticker) : null) + : (ticker ? buildTickerPayloadFromTicker(ticker) : null), + matrixControls }; } @@ -3329,6 +3356,33 @@ function compileMatrixScene(runtime) { ticker }; } + // Matrix controls are intentionally resolved here, after alert banners but + // before the assigned View. Home Assistant asks CoreView for a scene; it + // never writes renderer state directly, so the retained scene stays the + // one authoritative device contract. + const controls = runtime?.matrixControls; + if (controls?.mode === "lights") { + return { + kind: "effect", + effect: controls.effect || "scanner", + palette: controls.palette || runtime?.themeState?.matrix?.effectPalette || "neon", + speed: Number(controls.speed || 35), + intensity: Number(controls.intensity || 60), + text: "", + title: "COREVIEW", + ticker + }; + } + if (controls?.mode === "music") { + return { + kind: "music", + title: "VIBES", + detail: "", + mode: "local", + visualizer: {}, + ticker + }; + } if (layout.template === "clock") { return { kind: "clock", title: layout.title || runtime?.view?.name || "CoreView", ticker }; } @@ -3392,13 +3446,28 @@ function publishMatrixRuntime(screenId, runtime) { return false; } const revision = target.revision + 1; + const controls = normalizeMatrixControlState(runtime?.matrixControls || {}); + const theme = { + ...(runtime?.themeState?.matrix || normalizeMatrixThemeConfig()) + }; + if (controls?.brightness !== null && controls?.brightness !== undefined) { + theme.brightness = Math.round(controls.brightness * 255 / 100); + } const payload = { schema: MATRIX_STATE_SCHEMA, target: screenId, revision, issuedAt: new Date().toISOString(), display: target.config, - theme: runtime?.themeState?.matrix || normalizeMatrixThemeConfig(), + theme, + controls: controls || { + mode: "display", + effect: "scanner", + palette: theme.effectPalette || "neon", + brightness: Math.max(1, Math.min(100, Math.round(Number(theme.brightness || 64) * 100 / 255))), + speed: 35, + intensity: 60 + }, scene: compileMatrixScene(runtime) }; sqliteExec( @@ -3418,6 +3487,40 @@ function publishMatrixRuntime(screenId, runtime) { return true; } +function applyMatrixControl(screenId, control, value) { + const target = getMatrixTarget(screenId); + if (!target) return { ok: false, error: "unknown Matrix target" }; + const key = String(control || "").trim().toLowerCase(); + const existing = normalizeMatrixControlState(target.config.controls || {}) || { + mode: "display", effect: "scanner", palette: null, brightness: null, speed: 35, intensity: 60 + }; + const next = { ...existing }; + const text = String(value ?? "").trim().toLowerCase().replace(/\s+/g, "_"); + if (key === "mode") { + if (!['display', 'lights', 'music'].includes(text)) return { ok: false, error: "unsupported Matrix mode" }; + next.mode = text; + } else if (key === "effect") { + if (!MATRIX_EFFECTS.includes(text)) return { ok: false, error: "unsupported Matrix effect" }; + next.effect = text; + } else if (key === "palette") { + if (!MATRIX_PALETTES.includes(text)) return { ok: false, error: "unsupported Matrix palette" }; + next.palette = text; + } else if (['brightness', 'speed', 'intensity'].includes(key)) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return { ok: false, error: `Matrix ${key} must be numeric` }; + next[key] = Math.max(1, Math.min(100, Math.round(numeric))); + } else { + return { ok: false, error: "unsupported Matrix control" }; + } + const normalized = normalizeMatrixControlState(next); + sqliteExec( + `UPDATE matrix_targets SET config_json = ${sqlQuote(JSON.stringify({ ...target.config, controls: normalized }))} + WHERE screen_id = ${sqlQuote(screenId)};` + ); + broadcastRuntimeToScreen(screenId); + return { ok: true, controls: normalized }; +} + function publishCommandToResolvedTargets(targets, command, payload, options = {}) { const qos = options.qos === undefined ? 1 : Number(options.qos); const retain = Boolean(options.retain); @@ -5337,7 +5440,7 @@ function attachMqttClientHandlers(client) { client.on("connect", () => { mqttState.connected = true; mqttState.lastError = null; - const topics = [MQTT_SUBSCRIBE_PATTERN, MATRIX_STATUS_SUBSCRIBE_PATTERN, MATRIX_ACK_SUBSCRIBE_PATTERN]; + const topics = [MQTT_SUBSCRIBE_PATTERN, MATRIX_STATUS_SUBSCRIBE_PATTERN, MATRIX_ACK_SUBSCRIBE_PATTERN, MATRIX_CONTROL_SUBSCRIBE_PATTERN]; const frigateTopic = getFrigateConfig().mqttTopic; if (frigateTopic) { topics.push(frigateTopic); @@ -5420,6 +5523,14 @@ function attachMqttClientHandlers(client) { } return; } + if (topic.startsWith(matrixPrefix) && topic.endsWith("/control")) { + const target = String(topic.slice(matrixPrefix.length, -"/control".length) || "").trim().toLowerCase(); + if (getMatrixTarget(target) && parsed?.schema === "coreview.matrix.control.v1") { + const result = applyMatrixControl(target, parsed.control, parsed.value); + if (!result.ok) console.warn(`Rejected Matrix control for ${target}: ${result.error}`); + } + return; + } if (frigateTopic && topic === frigateTopic) { const eventPayload = normalizeFrigateMqttEvent(parsed); From b436c6c0d30a622f8a2135dcddc8e25c380fe593 Mon Sep 17 00:00:00 2001 From: 2shepard2 <107826835+2shepard2@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:08:30 -0700 Subject: [PATCH 10/10] feat(matrix): add ESPHome effect text controls --- docs/matrix-mqtt-protocol.md | 4 ++- esphome/README.md | 5 ++-- esphome/packages/coreview-matrix-base.yaml | 32 ++++++++++++++++++++++ server/server.js | 25 +++++++++++++---- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/matrix-mqtt-protocol.md b/docs/matrix-mqtt-protocol.md index 5dab8bd..64698c3 100644 --- a/docs/matrix-mqtt-protocol.md +++ b/docs/matrix-mqtt-protocol.md @@ -106,7 +106,9 @@ back into its ESPHome entities without emitting another request. This keeps CoreView, Themes, alerts, and Home Assistant from competing for device state. Supported controls are `mode` (`Display`, `Lights`, `Music`), `effect`, -`palette`, `brightness`, `speed`, and `intensity`. `Lights` selects one of the +`palette`, `brightness`, `speed`, `intensity`, `scanner_text`, and +`bouncing_text`. The two text values are single words limited to 16 characters +and are used only by their matching effect. `Lights` selects one of the portable firmware effects; `Music` enables the Matrix's local microphone visualizer. CoreView notifications continue to take priority over this manual control surface. diff --git a/esphome/README.md b/esphome/README.md index e7d0e72..ed1fd41 100644 --- a/esphome/README.md +++ b/esphome/README.md @@ -63,8 +63,9 @@ Create a profile for other boards, pinouts, panel chains, or geometries. - SendSpin Music Visualizer scenes: spectrum bars, level meter, and pulse modes driven by normalized live data from CoreView (never audio). - A Home Assistant-native control card exposed by ESPHome: Matrix mode, - lighting effect, palette, brightness, speed, intensity, restart, Wi-Fi, - uptime, scene revision, and online status. Controls request changes from + lighting effect, palette, brightness, speed, intensity, Scanner/Bouncing + Text words, restart, Wi-Fi, uptime, scene revision, and online status. + Controls request changes from CoreView over MQTT; they do not bypass Themes or retained state. - The bundled RGB Matrix HUB75 S3 hardware profile also supports the board's ES7210-connected microphones for the Matrix-only **Audio Reactive** widget. diff --git a/esphome/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml index c56b76e..a5bfa10 100644 --- a/esphome/packages/coreview-matrix-base.yaml +++ b/esphome/packages/coreview-matrix-base.yaml @@ -260,6 +260,8 @@ mqtt: id(coreview_control_brightness).publish_state(std::max(1, std::min(100, controls["brightness"] | 25))); id(coreview_control_speed).publish_state(std::max(1, std::min(100, controls["speed"] | 35))); id(coreview_control_intensity).publish_state(std::max(1, std::min(100, controls["intensity"] | 60))); + id(coreview_control_scanner_text).publish_state(controls["scannerText"] | ""); + id(coreview_control_bouncing_text).publish_state(controls["bouncingText"] | ""); } - hub75.set_brightness: id: coreview_matrix @@ -356,6 +358,36 @@ number: root["control"] = "intensity"; root["value"] = x; +text: + - platform: template + id: coreview_control_scanner_text + name: "CoreView Scanner Word" + mode: text + optimistic: false + min_length: 0 + max_length: 16 + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "scanner_text"; + root["value"] = x; + - platform: template + id: coreview_control_bouncing_text + name: "CoreView Bouncing Text Word" + mode: text + optimistic: false + min_length: 0 + max_length: 16 + set_action: + - mqtt.publish_json: + topic: ${coreview_control_topic} + payload: |- + root["schema"] = "coreview.matrix.control.v1"; + root["control"] = "bouncing_text"; + root["value"] = x; + button: - platform: restart name: "CoreView Beacon Restart" diff --git a/server/server.js b/server/server.js index 3004746..1a50f66 100644 --- a/server/server.js +++ b/server/server.js @@ -984,13 +984,19 @@ function normalizeMatrixControlState(value = {}) { const parsed = Number(raw); return Number.isFinite(parsed) ? Math.max(1, Math.min(100, Math.round(parsed))) : fallback; }; + const word = (raw) => String(raw || "") + .trim() + .split(/\s+/)[0] + .slice(0, 16); return { mode, effect: MATRIX_EFFECTS.includes(effect) ? effect : "scanner", palette: MATRIX_PALETTES.includes(palette) ? palette : null, brightness: percent(value.brightness), speed: percent(value.speed, 35), - intensity: percent(value.intensity, 60) + intensity: percent(value.intensity, 60), + scannerText: word(value.scannerText || value.scanner_text), + bouncingText: word(value.bouncingText || value.bouncing_text) }; } @@ -3362,14 +3368,17 @@ function compileMatrixScene(runtime) { // one authoritative device contract. const controls = runtime?.matrixControls; if (controls?.mode === "lights") { + const text = controls.effect === "scanner" + ? controls.scannerText + : (controls.effect === "bouncing_text" ? controls.bouncingText : ""); return { kind: "effect", effect: controls.effect || "scanner", palette: controls.palette || runtime?.themeState?.matrix?.effectPalette || "neon", speed: Number(controls.speed || 35), intensity: Number(controls.intensity || 60), - text: "", - title: "COREVIEW", + text, + title: text || "COREVIEW", ticker }; } @@ -3466,7 +3475,9 @@ function publishMatrixRuntime(screenId, runtime) { palette: theme.effectPalette || "neon", brightness: Math.max(1, Math.min(100, Math.round(Number(theme.brightness || 64) * 100 / 255))), speed: 35, - intensity: 60 + intensity: 60, + scannerText: "", + bouncingText: "" }, scene: compileMatrixScene(runtime) }; @@ -3492,7 +3503,8 @@ function applyMatrixControl(screenId, control, value) { if (!target) return { ok: false, error: "unknown Matrix target" }; const key = String(control || "").trim().toLowerCase(); const existing = normalizeMatrixControlState(target.config.controls || {}) || { - mode: "display", effect: "scanner", palette: null, brightness: null, speed: 35, intensity: 60 + mode: "display", effect: "scanner", palette: null, brightness: null, speed: 35, intensity: 60, + scannerText: "", bouncingText: "" }; const next = { ...existing }; const text = String(value ?? "").trim().toLowerCase().replace(/\s+/g, "_"); @@ -3505,6 +3517,9 @@ function applyMatrixControl(screenId, control, value) { } else if (key === "palette") { if (!MATRIX_PALETTES.includes(text)) return { ok: false, error: "unsupported Matrix palette" }; next.palette = text; + } else if (key === "scanner_text" || key === "bouncing_text") { + const word = String(value ?? "").trim().split(/\s+/)[0].slice(0, 16); + next[key === "scanner_text" ? "scannerText" : "bouncingText"] = word; } else if (['brightness', 'speed', 'intensity'].includes(key)) { const numeric = Number(value); if (!Number.isFinite(numeric)) return { ok: false, error: `Matrix ${key} must be numeric` };