Roger that. Here’s the full writeup! You should definitely watch your AI token usage if you take this path.
Kitchen News Board — A Splitflap + RSS Dashboard for Home Assistant
I wanted a kitchen dashboard that felt more like ambient information and less like staring at my phone. After some experimentation, I landed on a two-part layout: a retro split-flap board at the top showing AI-generated headlines, stock news, and weather — and a rotating 3-column news feed below showing real RSS headlines with photos. Tap any story and it opens in your browser. Swipe left to get back.
Here’s how it works.
The Layout
The dashboard uses custom:grid-layout (from the Layout Card HACS plugin) with two rows:
- Top row —
custom:splitflap-card, full width, auto height
- Bottom row — a custom
news-headlines-card web component, takes the remaining height
views:
- title: News Board
type: custom:grid-layout
layout:
grid-template-columns: 1fr
grid-template-rows: auto 1fr
margin: "0"
padding: "0"
cards:
- type: custom:splitflap-card
entity: input_text.splitflap_message
rows: 5
columns: 36
font_size: 48px
line_separator: "|"
word_wrap: false
scramble_duration: 500
stagger_delay: 15
sound: false
accent_color: "#e8572a"
view_layout:
grid-column: "1"
grid-row: "1"
- type: custom:news-headlines-card
entities:
- sensor.npr_news
- sensor.the_verge
- sensor.deadline
- sensor.the_hill
source_names:
- NPR News
- The Verge
- Deadline
- The Hill
rotate_seconds: 60
view_layout:
grid-column: "1"
grid-row: "2"
The dashboard has two additional views (swiped to with Swipe Navigation) for the kitchen media player and lyrics display, but those are separate from the news board itself.
The Splitflap Board
The splitflap card is Split-Flap Display Card by RazManSource, available in HACS. It watches an input_text helper and animates whenever the value changes.
The board rotates between 6 slots: weather, stocks, packages, entertainment, tech, and weather alerts. Each slot is a separate input_text helper. A rotate automation cycles through them every 20-30 seconds.
AI-Generated Content
Three of the slots are filled by Claude Haiku via the Anthropic REST API. Each automation runs on a staggered schedule to avoid hitting the 30,000 tokens/minute rate limit:
- Weather — every 3 hours at :00 (uses local sensor data, no web search)
- Entertainment — every 3 hours at :30 (web search)
- Tech — every 3 hours at :45 (web search)
The prompts are tuned to generate exactly 3 lines separated by |, with each line 30 characters or fewer. This took significant iteration — Haiku struggles to count characters reliably, so the prompts are explicit about it and include compliant examples.
Example entertainment automation action:
action: rest_command.claude_haiku_briefing
data:
prompt: >
Search the web for today's top entertainment or pop culture headline.
Write display text for a retro split-flap airport board.
CRITICAL RULES: Output ONLY the display text. Never explain, never hedge.
ALL CAPS ONLY. EXACTLY 3 lines separated by pipe |.
EACH LINE MUST BE 30 CHARACTERS OR FEWER — count every character
including spaces carefully before outputting.
Be punchy and opinionated — like a gossip columnist.
No punctuation except pipe.
Example: LOUIS CK RETURNS TO NETFLIX|HOLLYWOOD SAID NO NETFLIX YES|WILD TIMES IN STREAMING
response_variable: entertainment_response
The REST command points to the Anthropic API:
claude_haiku_briefing:
url: "https://api.anthropic.com/v1/messages"
method: POST
timeout: 30
headers:
Content-Type: application/json
x-api-key: !secret anthropic_api_key
anthropic-version: "2023-06-01"
payload: >
{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 256,
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
"messages": [
{
"role": "user",
"content": "{{ prompt | replace('\"', '\\\"') | replace('\n', '\\n') }}"
}
]
}
Stocks Slot
The stocks slot uses Sonnet (not Haiku) because it requires reasoning about financial context. It fires every hour at :15 during market hours (weekdays, 8:30am–3:05pm local time) and searches the web for the actual reason the S&P is moving that day.
At 3:30pm, a separate automation writes the final close number directly from a sensor — no API call needed:
action: input_text.set_value
target:
entity_id: input_text.splitflap_stocks
data:
value: >-
{% set change = states('sensor.sp500_change') | float %}
{% set direction = 'UP' if change >= 0 else 'DOWN' %}
|MARKET NEWS:|S&P {{ direction }} {{ change | abs | round(2) }}%|MARKET CLOSED|
Packages Slot
The packages slot uses a 17track sensor and doesn’t call Claude at all — it’s pure Jinja templating.
Weather Alert Slot
Uses ai_task.generate_data (Haiku via the HA Anthropic integration) to analyze NWS alert sensor data and format it for the display. Only fires when an active alert is detected.
The News Headlines Card
The bottom section is a custom web component registered as an inline Lovelace resource. No files to upload — the code is embedded directly in HA.
It reads from feedparser sensors (the Feedparser custom integration) and displays 3 headlines side by side with images. Every 60 seconds it rotates to the next news source. There’s also a manual next button in the bottom right corner.
RSS Sensors (configuration.yaml)
sensor:
- platform: feedparser
name: "NPR News"
feed_url: 'https://feeds.npr.org/1001/rss.xml'
date_format: '%a, %d %b %Y %H:%M:%S %z'
scan_interval:
minutes: 30
- platform: feedparser
name: "The Verge"
feed_url: 'https://www.theverge.com/rss/index.xml'
date_format: '%a, %d %b %Y %H:%M:%S %z'
scan_interval:
minutes: 30
- platform: feedparser
name: "Deadline"
feed_url: 'https://deadline.com/feed/'
date_format: '%a, %d %b %Y %H:%M:%S %z'
scan_interval:
minutes: 30
- platform: feedparser
name: "The Hill"
feed_url: 'https://thehill.com/feed/'
date_format: '%a, %d %b %Y %H:%M:%S %z'
scan_interval:
minutes: 30
The Custom Card (Lovelace Resource)
Register this as an inline module resource in HA. The card handles image extraction across multiple RSS feed formats — enclosure links (The Hill), media_thumbnail arrays (Deadline), content HTML (NPR), and direct image fields (The Verge).
class NewsHeadlinesCard extends HTMLElement {
setConfig(config) {
this._config = config;
this._lastState = null;
this._currentIndex = 0;
this._entities = config.entities || (config.entity ? [config.entity] : ['sensor.npr_news']);
this._sources = config.source_names || this._entities;
this._rotateInterval = (config.rotate_seconds || 60) * 1000;
}
connectedCallback() {
this.innerHTML = `
<style>
.nh-wrap { position:relative; height:100%; }
.nh-grid { display:grid; grid-template-columns:1fr 1fr 1fr; height:100%; background:#111; gap:0; }
.nh-story { display:flex; flex-direction:column; border-right:1px solid #222; overflow:hidden; cursor:pointer; }
.nh-story:last-child { border-right:none; }
.nh-img { width:100%; aspect-ratio:16/9; object-fit:cover; display:block; flex-shrink:0; }
.nh-body { padding:14px 16px; display:flex; flex-direction:column; gap:8px; }
.nh-src { font-size:11px; font-weight:700; letter-spacing:.1em; color:#e8572a; text-transform:uppercase; }
.nh-title { font-size:15px; font-weight:700; color:#fff; line-height:1.35; }
.nh-summary { font-size:12px; color:rgba(255,255,255,.5); line-height:1.4; }
.nh-next {
position:absolute; bottom:16px; right:16px;
width:44px; height:44px; border-radius:50%;
background:rgba(255,255,255,0.1);
border:1px solid rgba(255,255,255,0.2);
display:flex; align-items:center; justify-content:center;
cursor:pointer; z-index:10;
}
.nh-next svg { width:20px; height:20px; fill:rgba(255,255,255,0.8); }
</style>
<div class="nh-wrap">
<div class="nh-grid" id="nh-grid"></div>
<div class="nh-next" id="nh-next">
<svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
</div>
</div>
`;
this.querySelector('#nh-next').onclick = function() { this._advance(); }.bind(this);
if (this._hass) this._render(this._hass);
this._startRotation();
}
disconnectedCallback() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
}
_advance() {
this._currentIndex = (this._currentIndex + 1) % this._entities.length;
this._lastState = null;
if (this._hass) this._render(this._hass);
if (this._timer) clearInterval(this._timer);
this._startRotation();
}
_startRotation() {
if (this._entities.length <= 1) return;
if (this._timer) clearInterval(this._timer);
this._timer = setInterval(function() { this._advance(); }.bind(this), this._rotateInterval);
}
_extractImage(entry) {
// 1. Enclosure links (The Hill, many WordPress sites)
if (Array.isArray(entry.links)) {
for (var i = 0; i < entry.links.length; i++) {
var link = entry.links[i];
if (link.rel === 'enclosure' && link.href && (link.type || '').indexOf('image') === 0) {
return link.href;
}
}
}
// 2. media_thumbnail array (Deadline)
if (entry.media_thumbnail && entry.media_thumbnail.length > 0 && entry.media_thumbnail[0].url) {
return entry.media_thumbnail[0].url;
}
// 3. media_content array
if (entry.media_content && entry.media_content.length > 0 && entry.media_content[0].url) {
return entry.media_content[0].url;
}
// 4. image field (non-placeholder)
if (entry.image && !entry.image.includes('favicon-192x192-full.png')) {
return entry.image;
}
// 5. image embedded in content HTML (NPR)
var contentVal = '';
if (Array.isArray(entry.content) && entry.content.length > 0) {
contentVal = entry.content[0].value || '';
} else if (typeof entry.content === 'string') {
contentVal = entry.content;
}
if (contentVal) {
var m = contentVal.match(/<img[^>]+src=["']([^"']+)["']/i);
if (m && m[1] && !m[1].includes('favicon') && !m[1].includes('tracking') && !m[1].includes('pixel')) return m[1];
}
return null;
}
_render(hass) {
var grid = this.querySelector('#nh-grid');
if (!grid || !this._config) return;
var entityId = this._entities[this._currentIndex];
var state = hass.states[entityId];
if (!state) return;
// Only re-render when sensor state actually changes
var stateKey = this._currentIndex + ':' + state.state + state.last_updated;
if (stateKey === this._lastState) return;
this._lastState = stateKey;
var entries = (state.attributes.entries || []).slice(0, 3);
var sourceName = this._sources[this._currentIndex] || entityId;
var self = this;
grid.innerHTML = '';
entries.forEach(function(e) {
var story = document.createElement('div');
story.className = 'nh-story';
var img = self._extractImage(e);
if (img) {
var imgEl = document.createElement('img');
imgEl.className = 'nh-img';
imgEl.src = img;
imgEl.onerror = function() { imgEl.remove(); };
story.appendChild(imgEl);
}
var body = document.createElement('div');
body.className = 'nh-body';
body.innerHTML = '<div class="nh-src">' + sourceName + '</div>'
+ '<div class="nh-title">' + (e.title || '') + '</div>'
+ '<div class="nh-summary">' + (e.summary || '') + '</div>';
story.appendChild(body);
if (e.link) story.onclick = function() { window.open(e.link, '_blank'); };
grid.appendChild(story);
});
}
set hass(hass) {
this._hass = hass;
if (this.querySelector('#nh-grid')) this._render(hass);
}
getCardSize() { return 3; }
}
if (!customElements.get('news-headlines-card')) {
customElements.define('news-headlines-card', NewsHeadlinesCard);
}
window.customCards = window.customCards || [];
window.customCards.push({
type: 'news-headlines-card',
name: 'News Headlines Card',
description: '3-column rotating news headline display'
});
HACS Dependencies
Notes
On AI token usage: The Anthropic API has a 30,000 input tokens/minute rate limit. The staggered schedule (weather at :00, entertainment at :30, tech at :45) prevents the automations from colliding. Haiku is used for the splitflap slots (cheap, fast, good enough for short text). Sonnet is used only for the stocks analysis where reasoning quality matters.
On Haiku and character limits: Haiku is genuinely bad at counting characters. The prompts include explicit instructions and compliant examples, but occasional overflow still happens. word_wrap: false on the splitflap card helps — lines that overflow are clipped rather than wrapping onto extra rows, which at least keeps the layout predictable.
On the news card: The set hass setter fires constantly as HA updates state. The card uses a stateKey check (index + state + last_updated) to skip re-renders when nothing has changed — this was critical for preventing lag on older tablet hardware.
On tapping stories: When you tap a headline it opens in the browser via window.open(link, '_blank'). Combined with swipe navigation back to the dashboard, this creates a genuinely nice read-and-return flow on a wall-mounted tablet.