Skip to content

A better Vestaboard - Mushroom Panel #16

Description

@craig-waldman

I have a contribution to make.
If you use mushroom in HomeAssistant. Here is a way to keep a visual of what is currently on the Vestaboard.

Then you can create a card with the following YAML:

type: custom:vestaboard-mirror-card
title: Vestaboard
scale: 0.9
entities:
  - sensor.vestaboard_line_0
  - sensor.vestaboard_line_1
  - sensor.vestaboard_line_2
  - sensor.vestaboard_line_3
  - sensor.vestaboard_line_4
  - sensor.vestaboard_line_5

You need this file: "/homeassistant/www/vestaboard-mirror-card.js"
With the following contents:

/* vestaboard-mirror-card.js
 * Vestaboard-like 6x22 split-flap grid from line sensors, with:
 *  - global scaling (config: scale)
 *  - legacy color marker glyphs: ÇÁÂÃÄÅÆÈ
 *  - optional private-use codes: \uE000-\uE0FF
 *
 * IMPORTANT BEHAVIOR (matches what you’re seeing in HA):
 * - Legacy markers (ÇÁÂÃÄÅÆÈ) are treated as COLORED BLANK TILES
 *   that consume a character cell (not rendered as letters).
 * - They also set the “current color” for subsequent visible characters.
 * - È means reset/NULL/black (default tile): it consumes a cell as a blank default tile
 *   and clears the current color.
 */

const DEFAULTS = {
  title: "Vestaboard",
  columns: 22,
  rows: 6,

  // tile sizing (px)
  cellW: 26,
  cellH: 34,
  gap: 6,

  // frame styling
  bezelRadius: 18,

  // global scale for entire grid
  scale: 0.92,

  // rendering options
  uppercase: true,
  show_empty_cells: true,

  // color parsing
  enableColors: true,
};

class VestaboardMirrorCard extends HTMLElement {
  set hass(hass) {
    this._hass = hass;
    if (!this._config) return;
    this._render();
  }

  setConfig(config) {
    if (!config || !Array.isArray(config.entities)) {
      throw new Error(
        "vestaboard-mirror-card: 'entities' must be an array of entity_ids (typically 6 line sensors)."
      );
    }
    this._config = { ...DEFAULTS, ...config };
    this._render();
  }

  getCardSize() {
    return 6;
  }

  // -----------------------------
  // Color token parsing
  // -----------------------------

  _isLegacyColorChar(ch) {
    return "ÇÁÂÃÄÅÆÈ".includes(ch);
  }

  // Returns a CSS color string, or null to RESET to default (black / NULL)
  _colorFromLegacyChar(ch) {
    switch (ch) {
      case "Ç": return "#f5f5f5"; // white
      case "Á": return "#e14b4b"; // red
      case "Â": return "#ff9f43"; // orange
      case "Ã": return "#ffd166"; // yellow
      case "Ä": return "#7bd389"; // green
      case "Å": return "#5dade2"; // blue
      case "Æ": return "#b784f7"; // purple
      case "È": return null;      // reset / NULL / black
      default:  return null;
    }
  }

  // Optional private-use mapping (best-effort)
  _colorFromCode(code) {
    switch (code) {
      case 0xE000: return "#f5f5f5"; // white
      case 0xE001: return "#e14b4b"; // red
      case 0xE002: return "#ff9f43"; // orange
      case 0xE003: return "#ffd166"; // yellow
      case 0xE004: return "#7bd389"; // green
      case 0xE005: return "#5dade2"; // blue
      case 0xE006: return "#b784f7"; // purple
      default:     return null;
    }
  }

  /**
   * Parse a raw line into an array of cells:
   * Returns: Array<{char: string, tile: string|null, glyph: string|null}>
   *
   * - tile: background tint color (null = default/black)
   * - char: visible character (space allowed)
   *
   * Legacy color markers and PUA codes are treated as *blank tiles* that consume a cell.
   */
  _parseLineToCells(lineRaw) {
    const line = String(lineRaw ?? "");
    const out = [];
    let currentColor = null;

    for (const ch of line) {
      if (this._config.enableColors) {
        // Legacy markers: consume a cell as a colored blank tile
        if (this._isLegacyColorChar(ch)) {
          const col = this._colorFromLegacyChar(ch);
          currentColor = col; // sets current color for subsequent chars
          // IMPORTANT: consume a cell
          out.push({ char: " ", tile: col }); // col null => default/black
          continue;
        }

        // PUA codes: also consume a cell as a blank tile (best-effort)
        const code = ch.charCodeAt(0);
        if (code >= 0xE000 && code <= 0xE0FF) {
          const col = this._colorFromCode(code);
          currentColor = col;
          out.push({ char: " ", tile: col });
          continue;
        }
      }

      // Normal visible char: inherits currentColor as tile tint (optional)
      out.push({ char: ch, tile: currentColor });
    }

    return out;
  }

  _padOrTrimCells(cells) {
    const cols = this._config.columns;

    if (cells.length >= cols) return cells.slice(0, cols);

    const padded = cells.slice();
    while (padded.length < cols) padded.push({ char: " ", tile: null });
    return padded;
  }

  _getCellRows() {
    const rows = this._config.rows;
    const ents = this._config.entities || [];
    const cellRows = [];

    for (let i = 0; i < rows; i++) {
      const entId = ents[i];
      const stateObj = entId ? this._hass?.states?.[entId] : null;
      const raw = stateObj ? stateObj.state : "";
      const cells = this._parseLineToCells(raw);
      cellRows.push(this._padOrTrimCells(cells));
    }

    return cellRows;
  }

  // -----------------------------
  // Rendering
  // -----------------------------

  _render() {
    if (!this._hass || !this._config) return;

    const {
      title,
      columns,
      rows,
      cellW,
      cellH,
      gap,
      bezelRadius,
      scale,
      hide_title,
      show_empty_cells,
      uppercase,
    } = this._config;

    const safeScale = typeof scale === "number" ? scale : DEFAULTS.scale;

    const cellRows = this._getCellRows();
    const cellsHtml = [];

    for (let r = 0; r < rows; r++) {
      const row =
        cellRows[r] || Array.from({ length: columns }, () => ({ char: " ", tile: null }));

      for (let c = 0; c < columns; c++) {
        const item = row[c] || { char: " ", tile: null };
        let ch = item.char ?? " ";

        if (uppercase) ch = String(ch).toUpperCase();

        const isEmpty = ch === " ";
        const safe = isEmpty ? "&nbsp;" : this._escapeHtml(ch);

        // Apply tile tint via CSS variable (shows even when glyph is blank)
        const tileStyle = item.tile ? `style="--vb-tile:${item.tile};"` : "";

        if (!show_empty_cells && isEmpty && !item.tile) {
          cellsHtml.push(`<div class="cell empty"></div>`);
        } else {
          cellsHtml.push(
            `<div class="cell${isEmpty ? " empty" : ""}" ${tileStyle}>
              <span class="glyph">${safe}</span>
            </div>`
          );
        }
      }
    }

    if (!this._root) this._root = this.attachShadow({ mode: "open" });

    this._root.innerHTML = `
      <ha-card>
        ${hide_title ? "" : `<div class="title">${this._escapeHtml(title)}</div>`}
        <div class="bezel">
          <div class="screen">
            <div class="grid">
              ${cellsHtml.join("")}
            </div>
          </div>
        </div>
      </ha-card>

      <style>
        :host { display: block; }

        ha-card {
          border-radius: ${bezelRadius}px;
          overflow: hidden;
          background: linear-gradient(180deg, #141414 0%, #0b0b0b 100%);
          border: 1px solid rgba(255,255,255,0.08);
          box-shadow: 0 10px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.06);
          padding: 14px 14px 16px;
        }

        .title {
          font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
          font-size: 16px;
          letter-spacing: 0.12em;
          text-transform: uppercase;
          color: rgba(255,255,255,0.88);
          margin: 2px 4px 12px;
        }

        .bezel {
          border-radius: ${bezelRadius - 6}px;
          padding: 12px;
          background: radial-gradient(120% 120% at 20% 0%, rgba(255,255,255,0.10), transparent 55%),
                      linear-gradient(180deg, rgba(255,255,255,0.06), rgba(0,0,0,0.25));
          border: 1px solid rgba(255,255,255,0.06);
        }

        .screen {
          border-radius: ${bezelRadius - 10}px;
          background: #070707;
          border: 1px solid rgba(255,255,255,0.08);
          box-shadow: inset 0 0 0 1px rgba(0,0,0,0.35);
          padding: 12px;
        }

        .grid {
          display: grid;
          grid-template-columns: repeat(${columns}, ${cellW}px);
          grid-template-rows: repeat(${rows}, ${cellH}px);
          gap: ${gap}px;
          justify-content: center;

          transform: scale(${safeScale});
          transform-origin: top center;
        }

        .cell {
          position: relative;
          border-radius: 6px;

          /* Default dark tile look */
          background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(0,0,0,0.35));
          border: 1px solid rgba(255,255,255,0.08);
          box-shadow:
            inset 0 1px 0 rgba(255,255,255,0.08),
            inset 0 -10px 14px rgba(0,0,0,0.45);

          display: flex;
          align-items: center;
          justify-content: center;
        }

        /* Tint the tile itself when --vb-tile is present */
        .cell[style*="--vb-tile"] {
          background:
            linear-gradient(180deg, rgba(255,255,255,0.18), rgba(0,0,0,0.35)),
            linear-gradient(180deg, var(--vb-tile), var(--vb-tile));
          border: 1px solid rgba(255,255,255,0.16);
          box-shadow:
            inset 0 1px 0 rgba(255,255,255,0.22),
            inset 0 -10px 14px rgba(0,0,0,0.35);
        }

        /* split-flap seam */
        .cell::after {
          content: "";
          position: absolute;
          left: 6%;
          right: 6%;
          top: 50%;
          height: 1px;
          background: rgba(255,255,255,0.10);
          transform: translateY(-0.5px);
          box-shadow: 0 1px 0 rgba(0,0,0,0.35);
          pointer-events: none;
        }

        .glyph {
          font-family: ui-monospace, "SF Mono", "Roboto Mono", Menlo, monospace;
          font-size: 18px;
          letter-spacing: 0.10em;
          color: rgba(255,255,255,0.92);
          transform: translateX(0.06em);
          user-select: none;
          -webkit-font-smoothing: antialiased;
          font-variant-ligatures: none;
        }

        /* Empty cells hide the glyph, but tile can still be tinted */
        .cell.empty .glyph {
          color: rgba(255,255,255,0.0);
        }

        @media (max-width: 520px) {
          .grid {
            transform: scale(${Math.min(safeScale, 0.86)});
            transform-origin: top center;
          }
        }
      </style>
    `;
  }

  _escapeHtml(s) {
    return String(s)
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;")
      .replaceAll('"', "&quot;")
      .replaceAll("'", "&#039;");
  }
}

customElements.define("vestaboard-mirror-card", VestaboardMirrorCard);`

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions