Send one alert to every screen, teach Claude about ESP Screens, and add a Settings page (0.2.45)
- The app listens for the Home Assistant events esp_screens_show_alert and esp_screens_dismiss_alert and calls each screen's show_alert/dismiss_alert action, screens added later included. Fields are typed like Home Assistant's ESPHome action schema; an unusable value is left empty instead of losing the alert. - Settings → Claude installs an ESP Screens skill for Claude Code in Home Assistant (/homeassistant/.claude/skills/esp-screens) or downloads it as a zip for claude.ai. It is generated from the alert reference, so fields, colors and icons match the firmware. - New screen, Firmware & USB, firmware updates, Alerts and Claude move to a Settings view; the header keeps one button. Unselected screens in My screens get a light grey card. App only; firmware stays 0.2.38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""The Claude Code skill behind Alerts → Install for Claude: complete, valid, and written only on request."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import claude_skill # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
from core import ALERT_EVENT, ALERT_FIELDS, ALERT_LIMITS, ALERT_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW, TILE_BACKGROUNDS # noqa: E402
|
||||
|
||||
HAS_YAML = importlib.util.find_spec('yaml') is not None
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
|
||||
class SkillText(unittest.TestCase):
|
||||
def test_frontmatter_names_the_skill_and_says_when_to_use_it(self):
|
||||
text = claude_skill.text()
|
||||
front = re.match(r'---\nname: (.+)\ndescription: (.+)\n---\n', text)
|
||||
self.assertIsNotNone(front)
|
||||
self.assertEqual(front[1], 'esp-screens')
|
||||
self.assertLessEqual(len(front[2]), 200, 'claude.ai refuses longer descriptions')
|
||||
for words in ('ESP Screens', 'alerts', 'all screens', 'automation', 'icons'):
|
||||
self.assertIn(words, front[2])
|
||||
self.assertEqual(text, claude_skill.text(), 'same text every time, so status() can compare')
|
||||
self.assertNotRegex(text, r'\{[A-Z_]+\}', 'no placeholder left over')
|
||||
self.assertLess(text.count('\n'), 500, 'Claude Code advises SKILL.md under 500 lines')
|
||||
|
||||
def test_it_carries_everything_the_cheatsheet_does(self):
|
||||
text = claude_skill.text()
|
||||
for needle in (BROADCAST_SHOW, BROADCAST_DISMISS, ALERT_EVENT, ALERT_MIN_FIRMWARE, '_show_alert', '_dismiss_alert'):
|
||||
self.assertIn(needle, text)
|
||||
for name, kind, *_ in ALERT_FIELDS:
|
||||
self.assertRegex(text, rf'\| `{name}` \| ', name)
|
||||
for field, limit in ALERT_LIMITS['guition'].items():
|
||||
self.assertIn(f"CYD {ALERT_LIMITS['cyd'][field]} · Guition {limit} bytes", text)
|
||||
for name, item in TILE_BACKGROUNDS.items():
|
||||
if item['color']:
|
||||
self.assertIn(f'`{name}`', text)
|
||||
icons = text.split('## Icons', 1)[1].split('## When an alert ends', 1)[0]
|
||||
self.assertEqual(set(re.findall(r'`([a-z0-9-]+)`', icons)) - {'icon'}, set(tile_icons.GLYPHS), 'exactly the glyphs the firmware carries')
|
||||
|
||||
@unittest.skipUnless(HAS_YAML, 'PyYAML needed')
|
||||
def test_every_yaml_example_parses(self):
|
||||
import yaml
|
||||
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
|
||||
self.assertEqual(len(blocks), 4)
|
||||
parsed = [yaml.safe_load(block) for block in blocks]
|
||||
self.assertEqual(parsed[0]['actions'][0]['event'], BROADCAST_SHOW)
|
||||
self.assertEqual(set(parsed[0]['actions'][0]['event_data']), {name for name, *_ in ALERT_FIELDS})
|
||||
self.assertEqual(set(parsed[1]['actions'][0]['data']), {name for name, *_ in ALERT_FIELDS}, 'the per-screen action needs all seven')
|
||||
self.assertEqual(parsed[2][0]['wait_for_trigger'][0]['event_type'], ALERT_EVENT)
|
||||
choose = parsed[3]['actions'][0]['choose']
|
||||
self.assertEqual([option['sequence'][0]['event'] for option in choose], [BROADCAST_SHOW, BROADCAST_DISMISS])
|
||||
|
||||
|
||||
class Archive(unittest.TestCase):
|
||||
def test_zip_holds_the_skill_folder_as_claude_ai_expects(self):
|
||||
import io
|
||||
import zipfile
|
||||
data = claude_skill.archive()
|
||||
self.assertEqual(data, claude_skill.archive(), 'same bytes every time')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as bundle:
|
||||
self.assertEqual(bundle.namelist(), ['esp-screens/SKILL.md'])
|
||||
self.assertEqual(bundle.read('esp-screens/SKILL.md').decode('utf8'), claude_skill.text())
|
||||
|
||||
|
||||
class Install(unittest.TestCase):
|
||||
def test_status_install_update(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = claude_skill.skill_dir(tmp)
|
||||
self.assertEqual(target, Path(tmp) / '.claude' / 'skills' / 'esp-screens')
|
||||
self.assertEqual(claude_skill.status(target), {'installed': False, 'current': False, 'path': str(target)})
|
||||
first = claude_skill.install(target)
|
||||
self.assertEqual(first, {'installed': True, 'current': True, 'path': str(target), 'restart': True})
|
||||
self.assertEqual((target / 'SKILL.md').read_text(encoding='utf8'), claude_skill.text())
|
||||
self.assertEqual(sorted(p.name for p in target.iterdir()), ['SKILL.md'], 'no temp file left behind')
|
||||
self.assertFalse(claude_skill.install(target)['restart'], 'the skills folder already existed')
|
||||
(target / 'SKILL.md').write_text('older text', encoding='utf8')
|
||||
self.assertEqual(claude_skill.status(target)['current'], False)
|
||||
self.assertTrue(claude_skill.install(target)['current'])
|
||||
|
||||
def test_a_new_skill_next_to_existing_skills_needs_no_restart(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.claude' / 'skills' / 'other').mkdir(parents=True)
|
||||
self.assertFalse(claude_skill.install(claude_skill.skill_dir(tmp))['restart'])
|
||||
self.assertTrue((Path(tmp) / '.claude' / 'skills' / 'other').is_dir(), 'other skills untouched')
|
||||
|
||||
def test_a_folder_that_cannot_be_written_gives_a_message(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.claude').write_text('a file, not a folder')
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
claude_skill.install(claude_skill.skill_dir(tmp))
|
||||
self.assertIn("Couldn't write the skill", str(caught.exception))
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Endpoint(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_inventory_reports_and_the_button_installs(self):
|
||||
class HA:
|
||||
online = True
|
||||
registry, devices, areas, states = [], [], [], {}
|
||||
changed = asyncio.Event()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(HA(), Path(tmp) / 'data' / 'screens.json')
|
||||
manager.skill_dir = claude_skill.skill_dir(Path(tmp) / 'config')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
full = await (await client.get('/api/inventory')).json()
|
||||
self.assertEqual(full['claude_skill'], {'installed': False, 'current': False, 'path': str(manager.skill_dir)})
|
||||
self.assertNotIn('claude_skill', await (await client.get('/api/inventory?light=1')).json())
|
||||
download = await client.get('/api/claude-skill.zip')
|
||||
self.assertEqual(download.status, 200)
|
||||
self.assertEqual(download.headers['Content-Type'], 'application/zip')
|
||||
self.assertEqual(download.headers['Content-Disposition'], 'attachment; filename="esp-screens.zip"')
|
||||
self.assertEqual(await download.read(), claude_skill.archive())
|
||||
self.assertFalse(manager.skill_dir.exists(), 'opening the page or downloading writes nothing')
|
||||
refused = await client.post('/api/claude-skill')
|
||||
self.assertEqual(refused.status, 403, 'CSRF like every other change')
|
||||
response = await client.post('/api/claude-skill', headers={'X-Screen-CSRF': full['csrf']})
|
||||
self.assertEqual(await response.json(), {'installed': True, 'current': True, 'path': str(manager.skill_dir), 'restart': True})
|
||||
self.assertTrue((manager.skill_dir / 'SKILL.md').is_file())
|
||||
(Path(tmp) / 'blocked').write_text('a file, not a folder')
|
||||
manager.skill_dir = claude_skill.skill_dir(Path(tmp) / 'blocked')
|
||||
failed = await client.post('/api/claude-skill', headers={'X-Screen-CSRF': full['csrf']})
|
||||
self.assertEqual(failed.status, 400)
|
||||
self.assertIn("Couldn't write the skill", (await failed.json())['error'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user