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 ? " " : 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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
}
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