Vestaboard Integration

Is there any interest in adding the auto alignment functionality to the integration itself? Not using their API and cloud, but doing it within the integration itself? This wouldn’t be a particularly complicated task, but I don’t actually know what programming language integrations use, as I’ve never written one. Thoughts?

1 Like

OK, So I made a little thing. If you use Mushroom, and Vestaboard.
Create vestaboard-mirror-card.js (you should see it at http://homeassistant.local:8123/local/vestaboard-mirror-card.js)

with the following code:

/* 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);

Then in a a card in a dashboard add 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