Show live values on the mockup, blink a screen to identify it, try an alert, copy and share layouts (0.2.74)

The tiles draw what Home Assistant reports (a new /api/states answers for the tiles on the page); the ···
menu gets Identify (the screen's own show_alert with flash), Copy layout from another screen, Export and
Import as JSON; the Alerts page opens with Try it (/api/alerts/test, one screen or all); the library filters
by room and hides what is placed, with lit icons for what is on; ⌘K searches screens, entities and actions;
the Update badge lists what's new from the changelog (copied into the image) and a running update shows a
progress bar with the ESPHome stage and the last log line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 08:21:37 +02:00
parent ef8e94f6d1
commit 6148dfd211
27 changed files with 818 additions and 79 deletions
+41
View File
@@ -0,0 +1,41 @@
"""What's new for the Update badge (app 0.2.74): the CHANGELOG sections since a screen's firmware.
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
Each `## <app> (firmware <fw>)` section gives its bullet lines as plain text, newest first.
"""
import re
from pathlib import Path
HEADER = re.compile(r'^## (\d+\.\d+\.\d+) \(firmware (\d+\.\d+\.\d+)\)')
BOLD = re.compile(r'\*\*(.+?)\*\*')
CODE = re.compile(r'`([^`]*)`')
LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)')
def load(path=None, limit=20):
"""[{app, firmware, lines}] for the latest sections; [] without a changelog."""
candidates = [Path(path)] if path else [Path(__file__).with_name('CHANGELOG.md'), Path(__file__).parent.parent / 'CHANGELOG.md']
for candidate in candidates:
if candidate.exists():
return parse(candidate.read_text(), limit)
return []
def parse(text, limit=20):
sections = []
for line in text.splitlines():
match = HEADER.match(line)
if match:
if len(sections) >= limit:
break
sections.append({'app': match.group(1), 'firmware': match.group(2), 'lines': []})
elif sections and line.startswith('- '):
sections[-1]['lines'].append(plain(line[2:]))
return sections
def plain(markdown):
text = BOLD.sub(r'\1', markdown)
text = CODE.sub(r'\1', text)
text = LINK.sub(r'\1', text)
return text.strip()
+45
View File
@@ -1623,6 +1623,48 @@ def create_app(manager, development=False):
manager.registry_index().get(t['entity']),getattr(manager.ha,'state_words',None)),
'attributes':state_message(i,t,manager.ha.states)['a'],
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def states(request):
"""Live values for the editor's mockup (app 0.2.74): the state, Home Assistant's word and the attributes a
card shows, for the tiles on the page, saved or not. At most sixty entities per request."""
index = manager.registry_index()
result = {}
for eid in request.query.getall('entity', [])[:60]:
if not isinstance(eid, str) or eid not in manager.ha.states:
continue
state = manager.ha.states.get(eid, {})
entry = index.get(eid)
message = state_message(0, {'entity': eid, 'name': ''}, manager.ha.states,
precision=header_bar.precision_of(entry) if eid.startswith('sensor.') else None)
result[eid] = {'state': message['state'], 'a': message['a'],
'word': state_word(eid, state.get('state'), state.get('attributes'), entry, getattr(manager.ha, 'state_words', None))}
return web.json_response({'states': result})
def one_alert_target(inbox):
screen = manager.screen(inbox)
if screen is None:
raise ValueError('Unknown screen.')
ready, skipped = alert_targets([screen])
if not ready:
raise ValueError(f"{screen['name']} can't show an alert: {skipped[0][1] if skipped else 'not ready'}.")
return ready[0]
async def identify(request):
"""Identify (app 0.2.74): the screen shows a short card and blinks its backlight, so you know which one it is."""
screen = one_alert_target(request.match_info['inbox'])
data, _ = alert_data({'title': f"This is {screen['name']}", 'subtitle': 'Identify, from ESP Screens', 'icon': 'bell-ring',
'color': 'blue', 'button_text': 'OK', 'timeout': 8, 'flash': True})
await manager.ha.call(alert_service(screen['node']), data)
return web.json_response({'ok': True})
async def test_alert(request):
"""Alerts → Try it (app 0.2.74): one alert to one screen or to every screen, with the fields an automation sends."""
body = await request.json()
if not isinstance(body, dict):
raise ValueError('Invalid alert.')
data = body.get('data') if isinstance(body.get('data'), dict) else {}
if body.get('screen') == 'all':
return web.json_response(await manager.broadcast(BROADCAST_SHOW, data))
screen = one_alert_target(str(body.get('screen', '')))
service, unusable = alert_data(data)
await manager.ha.call(alert_service(screen['node']), service)
return web.json_response({'sent': 1, 'failed': 0, 'skipped': 0, 'unusable': unusable})
async def install_claude_skill(request):
"""Settings → Claude → Install: writes the skill into Home Assistant's configuration folder, only on request."""
return web.json_response(claude_skill.install(manager.skill_dir))
@@ -1667,6 +1709,9 @@ def create_app(manager, development=False):
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
app.router.add_get('/api/capabilities', capabilities)
app.router.add_get('/api/states', states)
app.router.add_post('/api/screens/{inbox}/identify', identify)
app.router.add_post('/api/alerts/test', test_alert)
app.router.add_get('/api/entity-actions', entity_actions)
app.router.add_post('/api/header-preview', header_preview)
app.router.add_post('/api/claude-skill', install_claude_skill)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-DOjlT4hj.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D7h6hKdu.css">
<script type="module" crossorigin src="./assets/index-BuQ5a76G.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-PqOTdpJB.css">
</head>
<body>
<div id="app"></div>
+4 -1
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path
import re
import time
import changelog
from core import FIRMWARE_VERSION
LOG = logging.getLogger('screen_manager')
@@ -31,6 +32,8 @@ class Updater:
self.manager, self.path = manager, Path(path)
self.auto, self.hosts, self.results, self.last_round = False, {}, {}, None
self.task, self.current, self.queue, self.phase = None, None, [], None
# What's new since a screen's firmware, for the Update badge (app 0.2.74).
self.changelog = changelog.load()
if self.path.exists():
raw = json.loads(self.path.read_text())
if raw.get('version') != 1:
@@ -103,7 +106,7 @@ class Updater:
def summary(self, screens=None, profiles=None):
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round}
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round, 'changelog': self.changelog}
def pending(self, screens=None, profiles=None):
# Callers that already hold the inventory and profile list pass them in; the inventory