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 8c12f46..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 @@ -150,6 +155,19 @@ 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). +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. @@ -228,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/docker-compose.yml b/docker-compose.yml index 571e9c3..265bbf7 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,32 @@ services: timeout: 5s retries: 5 start_period: 20s + + 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: + 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/docs/integrations/sendspin.md b/docs/integrations/sendspin.md new file mode 100644 index 0000000..89b5bef --- /dev/null +++ b/docs/integrations/sendspin.md @@ -0,0 +1,97 @@ +# 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. 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 + ``` + +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. +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 +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 new file mode 100644 index 0000000..3764e86 --- /dev/null +++ b/docs/matrix-mqtt-protocol.md @@ -0,0 +1,110 @@ +# 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 }, + "theme": { + "background": "#000000", + "primary": "#e6e8ed", + "secondary": "#0080ff", + "accent": "#ffb000", + "critical": "#ff2000", + "effectPalette": "neon", + "brightness": 64 + }, + "scene": { + "kind": "clock", + "title": "Kitchen", + "ticker": { "message": "Trash night" } + } +} +``` + +`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. + +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`. + +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. + +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 +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 +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. + +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 +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 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 new file mode 100644 index 0000000..cdfa6d3 --- /dev/null +++ b/esphome/README.md @@ -0,0 +1,103 @@ +# 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 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 + +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 +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 +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. +- 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 +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: "mqtt.example.net" +mqtt_username: "coreview-beacon" +mqtt_password: "change-me" +``` + +`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. diff --git a/esphome/examples/coreview-beacon.yaml b/esphome/examples/coreview-beacon.yaml new file mode 100644 index 0000000..7086d51 --- /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 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 new file mode 100644 index 0000000..7d64d8a --- /dev/null +++ b/esphome/hardware/esp32s3-hub75-3x2-32x16.yaml @@ -0,0 +1,287 @@ +# 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(); + 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), 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; + const bool middle_ticker_lane = !id(coreview_ticker).empty() + && 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) { + 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(); + }; + 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); + } + }; + 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)); + 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") { + // 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 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); + 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()) { + std::string stamp = now.strftime("%I:%M %p"); + if (!stamp.empty() && stamp[0] == '0') stamp.erase(0, 1); + // 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"); + } + } 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(); + 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); + // 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); + 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); + // 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/packages/coreview-matrix-base.yaml b/esphome/packages/coreview-matrix-base.yaml new file mode 100644 index 0000000..b223d0f --- /dev/null +++ b/esphome/packages/coreview-matrix-base.yaml @@ -0,0 +1,232 @@ +# 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; + id(coreview_claimed) = false; + if (id(coreview_claim_code) == "000000") { + id(coreview_claim_code) = std::to_string(1000000U + (esp_random() % 900000U)).substr(1); + } + +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_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 + 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_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 + 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' + - 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 + 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 + 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"; + 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: + 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") 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(); + 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"] | ""; + 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; + 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(); + const char *value = first["value"] | ""; + id(coreview_detail) = value; + } + 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: |- + 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/sendspin-adapter/Dockerfile b/sendspin-adapter/Dockerfile new file mode 100644 index 0000000..f0f0f86 --- /dev/null +++ b/sendspin-adapter/Dockerfile @@ -0,0 +1,21 @@ +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/* + +# 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 ./ + +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..cc3d909 --- /dev/null +++ b/sendspin-adapter/adapter.py @@ -0,0 +1,167 @@ +"""CoreView's pairing-capable SendSpin display/visualizer client.""" + +import asyncio +import logging +import os +import uuid +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, +) + +DATA_DIR = Path(os.getenv("SENDSPIN_DATA_DIR", "/app/data")) +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")) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logging.getLogger("aiosendspin").setLevel(logging.DEBUG) + + +def now() -> str: + return datetime.now(UTC).isoformat() + + +def client_id_from_disk() -> str: + DATA_DIR.mkdir(parents=True, exist_ok=True) + 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: + 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.roles = [Roles.METADATA, Roles.VISUALIZER] + self.client: SendspinClient | None = None + self.listener: ClientListener | None = None + + 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: + 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: + 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 + 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() + 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), + ) + logging.getLogger(__name__).info("Visualizer client support: %s", visualizer_support.to_dict()) + self.client = SendspinClient( + client_id, + "CoreView Music Visualizer", + self.roles, + device_info=DeviceInfo(product_name="CoreView", manufacturer="CoreView", software_version="1"), + visualizer_support=visualizer_support, + ) + 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(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: + 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.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 2d9effd..296f5ee 100644 --- a/server/public/app.html +++ b/server/public/app.html @@ -349,6 +349,92 @@ 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; + } + .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); @@ -1216,7 +1302,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.

@@ -1244,7 +1330,26 @@

Themes

- +
#E6E8ED
+
+
+ +
+
#000000
+
#E6E8ED
+
#0080FF
+
#FFB000
+
#FF2000
+
+
+
+
+
+ +
+
Browser screenCoreView presentation
+
Matrix target12:34 PM
+
@@ -1255,7 +1360,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.
@@ -1321,7 +1426,7 @@

Rules

- + @@ -1394,9 +1499,10 @@

Rules

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

Banners

Banner preview text
+
ⓘ
Banner preview text
@@ -1807,6 +1936,57 @@

Screens

+
+

Matrix Target

+

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

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
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.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Manage Registered Screen

@@ -2043,6 +2223,25 @@

Frigate

+
+

Music Visualizer (SendSpin)

+

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.

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

Photo Slideshow

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

@@ -2295,6 +2494,7 @@

How This Connects

rules: [], screenStates: [], pendingDevices: [], + pendingMatrixTargets: [], haEntities: [], tickerGroups: [], resultTimer: null, @@ -2359,6 +2559,32 @@

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 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", "matrix_effect", "music_visualizer"]); + const unsupported = 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) { @@ -2675,6 +2901,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() { @@ -3246,6 +3483,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 +3705,8 @@

How This Connects

$("manageScreenDeviceInput").value = ""; $("manageScreenFriendlyNameInput").value = ""; $("manageScreenRebindPairingInput").value = ""; + $("manageScreenRebindPairingInput").disabled = false; + $("rebindScreenBtn").disabled = false; $("manageScreenViewInput").value = ""; state.selectedScreenId = null; syncScreenEditorState(); @@ -3495,12 +3744,58 @@

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 = ""; $("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"; + syncThemeColorPreviews(); state.selectedThemeId = null; syncThemeEditorState(); } @@ -3522,6 +3817,8 @@

How This Connects

loadBannerFieldIntoForm("text", { type: "text", text: "" }); loadBannerFieldIntoForm("subtext", { type: "text", text: "" }); $("bannerVariantInput").value = "neutral"; + $("bannerMatrixPresentationInput").value = "notification"; + $("bannerMatrixIconInput").value = ""; state.selectedBannerId = null; syncBannerEditorState(); updateBannerPreview(); @@ -3729,7 +4026,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", "music_visualizer", "photo_slideshow", "camera_stream", "map"].includes(kind) ? kind : "text"; const verticalAlign = String(widget.verticalAlign || "top").trim().toLowerCase(); const base = { kind: normalizedKind, @@ -3791,6 +4088,26 @@

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 === "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, @@ -3873,6 +4190,12 @@

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 === "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", @@ -4062,6 +4385,16 @@

How This Connects

`; + } else if (widget.kind === "matrix_effect") { + extraField = ` +
+
+
+
`; + } else if (widget.kind === "music_visualizer") { + extraField = ` +
+
`; } root.innerHTML = ` @@ -4073,6 +4406,8 @@

How This Connects

+ + @@ -4120,10 +4455,28 @@

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); }); + 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 }); + })); + } + 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(); @@ -4474,6 +4827,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."); } @@ -4511,6 +4867,8 @@

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 || ""; $("setupImmichAlbum").value = integrations.immich?.albumId || ""; @@ -4535,8 +4893,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}` : ""}` + : `Disconnected${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 @@ -4557,6 +4920,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 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}` : ""}`); const setHeroStatus = (id, message, stateClass) => { const el = $(id); if (!el) { @@ -4825,7 +5193,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 +5224,12 @@

How This Connects

} function syncOverrideForm() { + const matrixTarget = currentTargetIncludesMatrix(); + $("overrideTypeInput").querySelector('option[value="camera"]').disabled = matrixTarget; + $("overrideTypeInput").querySelector('option[value="theme"]').disabled = false; + if (matrixTarget && $("overrideTypeInput").value === "camera") { + $("overrideTypeInput").value = "banner"; + } const type = $("overrideTypeInput").value; const isCamera = type === "camera"; const isRule = type === "rule"; @@ -5132,6 +5508,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 +5549,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 +5577,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); }); @@ -5542,6 +5947,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(); } @@ -5595,13 +6028,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 +6112,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(); } @@ -5692,6 +6132,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 || ""; updateBannerPreview(); syncBannerEditorState(); } @@ -5723,6 +6165,15 @@

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); + syncThemeColorPreviews(); syncThemeEditorState(); } @@ -5815,6 +6266,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); @@ -5838,7 +6290,9 @@

How This Connects

renderTickerPresets(); renderRules(); renderRegisterViewPicker(); + renderMatrixViewPicker(); renderPendingDevices(); + renderPendingMatrixTargets(); renderScreens(); renderBanners(); renderScreenManager(); @@ -5893,6 +6347,30 @@

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, + claimCode: $("matrixClaimCodeInput").value.trim(), + 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) { @@ -6120,7 +6598,10 @@

How This Connects

config: { text: normalizeBannerFieldValue("text"), subtext: normalizeBannerFieldValue("subtext"), - variant: $("bannerVariantInput").value + variant: $("bannerVariantInput").value, + matrixPresentation: $("bannerMatrixPresentationInput").value, + matrixIcon: $("bannerMatrixIconInput").value, + matrixFlashBorder: $("bannerMatrixPresentationInput").value === "alert" } }) }); @@ -6201,7 +6682,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) + } } }) }); @@ -6431,7 +6921,8 @@

How This Connects

frigateMqttTopic: $("settingsFrigateMqttTopic").value, mqttUrl: $("settingsMqttUrl").value, mqttUsername: $("settingsMqttUsername").value, - mqttPassword: $("settingsMqttPassword").value + mqttPassword: $("settingsMqttPassword").value, + sendspinUrl: $("settingsSendspinServerUrl").value }) }); state.integrations = data.integrations || state.integrations; @@ -6481,6 +6972,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 +7094,7 @@

How This Connects

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

How This Connects

renderRegisterViewPicker(); }); + $("matrixViewIdInput").addEventListener("input", () => { + renderMatrixViewPicker(); + }); + $("groupIdInput").addEventListener("input", () => { syncGroupEditorState(); }); @@ -6649,6 +7150,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(); }); @@ -6758,7 +7273,9 @@

How This Connects

"bannerSubtextValueSourceInput", "bannerSubtextAttributeInput", "bannerSubtextAppendUnitInput", - "bannerVariantInput" + "bannerVariantInput", + "bannerMatrixPresentationInput", + "bannerMatrixIconInput" ].forEach((id) => { $(id).addEventListener("input", () => { if (id.startsWith("profileUseDefaultAlbum") || id.startsWith("profileHud")) { @@ -6900,7 +7417,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/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 44fa62f..8a7dab5 100644 --- a/server/server.js +++ b/server/server.js @@ -20,12 +20,17 @@ 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(/\/+$/, ""); 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()); @@ -101,6 +106,8 @@ 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, @@ -112,6 +119,36 @@ 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, + lastCloseCode: null, + lastCloseReason: null, + serverName: null, + serverId: null, + playbackState: "stopped", + metadata: {}, + visualizer: { + active: false, + lastFrameAt: null, + lastFrameBytes: 0, + framesReceived: 0 + } +}; +const sendspinAdapterCache = { + reachable: false, + lastCheckedAt: null, + lastError: null, + status: null +}; const immichCache = { reachable: false, lastCheckedAt: null, @@ -385,11 +422,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 []; @@ -422,6 +459,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, @@ -653,6 +696,53 @@ 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); +} + +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(); + 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(); + sendspinAdapterCache.lastError = err.message || "SendSpin adapter is unavailable"; + } +} + function getMqttConfig() { return { url: getSetting("mqtt_url", MQTT_URL_ENV) || "", @@ -872,7 +962,118 @@ 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", "music"]); + 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 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; + } + const matrix = getMatrixTarget(screen.screenId); + return matrix + ? { ...screen, transport: "matrix", matrix } + : { ...screen, transport: "browser" }; +} + +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") { + 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 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 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", "matrix_effect", "music_visualizer"]); + const unsupported = 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() { @@ -885,7 +1086,7 @@ function listScreens() { updated_at AS updatedAt FROM screens ORDER BY screen_id ASC;` - ); + ).map(hydrateScreenTransport); } function listScreensRaw() { @@ -904,7 +1105,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 +1939,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 +1949,7 @@ function unregisterScreen(screenId) { activeRuntimeOverrides.delete(screenId); lastAppliedRuleByTarget.delete(screenId); onlineByTarget.delete(screenId); + matrixTargetStatus.delete(screenId); return { ...screen, pairingCode: nextPairingCode @@ -1780,14 +1984,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) }; } @@ -1803,6 +2041,13 @@ 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") { @@ -1829,7 +2074,12 @@ 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 : "", + matrixPresentation, + matrixFlashBorder: matrixPresentation === "alert" }; } @@ -2399,7 +2649,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", "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)); @@ -2453,6 +2703,26 @@ 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 === "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, @@ -2724,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); @@ -2749,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( @@ -2812,7 +3111,8 @@ function applyWidgetOverridesToLayout(layout, widgetOverrides) { return { ...layout, widgets, - entityStates: nextEntityStates + entityStates: nextEntityStates, + ...(widgets.some((widget) => widget?.kind === "music_visualizer") ? { music: buildSendspinRuntimePayload() } : {}) }; } @@ -2824,7 +3124,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) }; } @@ -2836,7 +3137,10 @@ 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 || "", + matrixPresentation: banner.config?.matrixPresentation || "notification", + matrixFlashBorder: Boolean(banner.config?.matrixFlashBorder) }; } @@ -2872,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) { @@ -2908,7 +3231,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") @@ -2921,9 +3246,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), @@ -2953,6 +3280,125 @@ function getRuntimeStateForView(viewId) { }; } +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; + if (runtime?.bannerState?.text) { + const bannerVariant = runtime.bannerState.variant || "info"; + return { + kind: "notification", + 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 + }; + } + if (layout.template === "clock") { + 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", + // 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 || "" + }; + } + 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) + })); + return { kind: "status", title: fields[0]?.label || 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, + theme: runtime?.themeState?.matrix || normalizeMatrixThemeConfig(), + 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 +4337,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 +4439,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 +4458,11 @@ function removeHaEntityState(entityId) { payload: { entityId } }); refreshTemplatedRuntimeForEntity(entityId); + for (const screen of listScreens()) { + if (screen.transport === "matrix") { + broadcastRuntimeToScreen(screen.screenId); + } + } } function scheduleHaReconnect() { @@ -4133,6 +4593,199 @@ 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) { + // 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 = []; + 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", (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 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) => { + sendspinStream.connected = false; + sendspinCache.lastError = err.message || "SendSpin stream error"; + }); +} + async function refreshImmichStatus() { if (!immichConfigured()) { immichCache.reachable = false; @@ -4224,10 +4877,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 +4902,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 +5036,7 @@ function importConfigSnapshot(snapshot) { }).filter(Boolean); sqliteExec(` + DELETE FROM matrix_targets; DELETE FROM group_members; DELETE FROM groups; DELETE FROM rules; @@ -4500,6 +5158,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 +5326,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 +5365,43 @@ 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)) { + 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; + } + 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) { @@ -5017,7 +5721,8 @@ app.get("/api/state", requireAdmin, (req, res) => { rules: listRules(), screens: listScreens(), screenStates, - pendingDevices + pendingDevices, + pendingMatrixTargets: listPendingMatrixTargets() }); }); @@ -5116,6 +5821,8 @@ function integrationStatusPayload() { const frigate = getFrigateConfig(); const mqttConfig = getMqttConfig(); const eventWebhook = getEventWebhookConfig(); + const sendspin = sendspinAdapterCache.status || {}; + const sendspinConfig = getSendspinConfig(); return { ha: { configured: Boolean(ha.url && ha.token), @@ -5155,6 +5862,26 @@ function integrationStatusPayload() { connected: mqttState.connected, lastError: mqttState.lastError }, + sendspin: { + // 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", + 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), tokenStatus: maskSecret(eventWebhook.token), @@ -6030,6 +6757,45 @@ 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 : {}; + 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") }); + } 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(); @@ -6113,6 +6879,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 @@ -6165,6 +6937,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) { @@ -6182,12 +6967,14 @@ 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); await refreshImmichStatus(); await refreshFrigateStatus(); connectMqttClient(true); + await refreshSendspinAdapterStatus(); return res.json({ saved: true, integrations: integrationStatusPayload() @@ -6354,6 +7141,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 && type === "camera") { + return res.status(400).json({ error: `camera overrides are not supported by Matrix targets: ${matrixTargets.join(", ")}` }); + } if (type === "camera") { if (!camera && !snapshot) { @@ -6410,6 +7201,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") { @@ -6493,6 +7290,10 @@ server.listen(PORT, () => { console.log(`Signage server listening on port ${PORT}`); connectMqttClient(true); connectHaStream(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)); + }, 500); refreshHaEntities().catch((err) => { console.error("Initial Home Assistant refresh failed:", err.message); });