Files
homeassistant_espscreen/tests/test_extended.py
T
Max Gramser 6809d3b941 Take what a tile can do, its tap actions, words and icons from Home Assistant (0.2.67)
GitHub issue #7 asked for cover.toggle on tap. ESP Screens now asks Home
Assistant which actions each entity has instead of keeping lists: the editor
offers On / off (covers included), sliders, controls and displays only where
Home Assistant has the action, and refuses only new unsupported settings.
Perform action runs any action Home Assistant offers for the tile's entity,
under its own names and fields; the screen sends it with a call id and says
Refused when Home Assistant refuses. Sensors round by display precision, and
tiles, the top bar, timelines, vacuum chips and the inspector use Home
Assistant's words where they showed raw states. Tiles show Home Assistant's
icons, picked as its frontend does (the fonts grow to 309 glyphs).

Existing screens keep every setting and tap as before: stored layouts load
leniently, legacy taps route the same (1020 combinations), and messages are
byte-identical without Home Assistant's words and icons. Firmware 0.2.58.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:41:38 +02:00

113 lines
8.4 KiB
Python

import asyncio
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
from server import KEEPALIVE_SECONDS
from core import validate_layout, state_message, packets
from firmware import Firmware
from test_portal import ManagerTests
class ExtendedTests(unittest.TestCase):
def test_options_validation_and_new_domains(self):
for domain in ['sensor','number','input_number','select','weather','media_player']:
layout=validate_layout({'title':'Test','tiles':[{'entity':domain+'.x','options':{'display':'watch'}}]})
self.assertEqual(layout['tiles'][0]['options']['display'],'watch')
for options in [{'inline':'slider'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
# On / off: whether Home Assistant offers <domain>.toggle is checked when saving (app 0.2.67); a built-in card never.
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'screen.clock','options':{'tap':'toggle'}}]})
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
def test_attributes_and_history_fit_protocol(self):
tile={'entity':'sensor.a','name':'Temperature','options':{'history_hours':24,'display':'watch'}}
msg=state_message(0,tile,{'sensor.a':{'state':'21','attributes':{'unit_of_measurement':'°C','password':'hidden'}}})
msg['history']={'hours':24,'values':[None,21.5]*12}
self.assertNotIn('password',msg['a']);self.assertEqual(msg['o'],tile['options']);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
def test_firmware_profiles_no_overwrite_or_traversal(self):
with tempfile.TemporaryDirectory() as tmp:
f=Firmware(Path(tmp)/'config',Path(tmp)/'data');data={'board':'cyd','name':'test','friendly_name':'Test','wifi_ssid':'local-wifi','wifi_password':'private-wifi'}
result=f.create(data);before=f.profile('test.yaml').read_text();secrets=(f.root/'secrets.yaml').read_text()
with self.assertRaises(ValueError):f.create(data)
self.assertEqual(before,f.profile('test.yaml').read_text());self.assertEqual(secrets,(f.root/'secrets.yaml').read_text())
for value in ['../secrets.yaml','/etc/passwd','test.yaml;echo']:
with self.assertRaises(ValueError):f.profile(value)
(f.root/'linked.yaml').symlink_to(f.profile('test.yaml'))
with self.assertRaises(ValueError):f.profile('linked.yaml')
self.assertEqual(f.profiles(),[{'file':'test.yaml'}])
class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
async def test_cli_arguments_redaction_and_busy(self):
with tempfile.TemporaryDirectory() as tmp:
f=Firmware(tmp,tmp);f.create({'board':'guition','name':'test','friendly_name':'Test','wifi_ssid':'homewifi','wifi_password':'secretwifi'})
async def waiting(*args):await asyncio.sleep(20)
with patch('firmware.shutil.which',return_value='/bin/esphome'),patch.object(f,'run',side_effect=waiting):
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'install','target':'1.2.3.4;evil'})
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'install','target':'/dev/unknown'})
f.start({'file':'test.yaml','action':'validate'})
self.assertNotIn('secretwifi',f.redact('Using secretwifi on homewifi'))
with self.assertRaises(ValueError):f.start({'file':'test.yaml','action':'build'})
f.task.cancel()
try:await f.task
except asyncio.CancelledError:pass
async def test_old_browser_preserves_new_tile_options(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','name':'Lamp'}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
class SpecialTiles(unittest.TestCase):
def test_new_domains_displays_and_wide_option(self):
from core import extras, min_firmware
from zoneinfo import ZoneInfo
layout=validate_layout({'title':'T','tiles':[{'entity':'screen.clock','options':{'display':'analog','size':'wide'}},{'entity':'sun.sun'},{'entity':'timer.egg'},{'entity':'person.max'},
{'entity':'weather.home','options':{'display':'forecast'}},{'entity':'sensor.t','options':{'display':'graph','size':'wide'}}]})
self.assertEqual(layout['tiles'][4]['options'],{'display':'forecast','size':'wide'},'forecast forces a wide card')
self.assertEqual(validate_layout({'title':'T','tiles':[{'entity':'sun.sun','options':{'display':'sunpath'}}]})['tiles'][0]['options'],{'display':'sunpath','size':'wide'})
self.assertEqual(layout['tiles'][0]['options']['display'],'analog')
for tile in [{'entity':'screen.other'},{'entity':'light.a','options':{'size':'double'}},{'entity':'light.a','options':{'display':'graph'}},
{'entity':'sensor.a','options':{'display':'forecast'}},{'entity':'screen.clock','options':{'display':'watch'}}]:
with self.assertRaises(ValueError):validate_layout({'title':'T','tiles':[tile]})
self.assertEqual(min_firmware(layout),(0,2,14))
self.assertEqual(min_firmware({'tiles':[{'entity':'light.a'}]*11}),(0,2,7))
self.assertIsNone(min_firmware({'tiles':[{'entity':'light.a'}]}))
tz=ZoneInfo('Europe/Amsterdam')
states={'sun.sun':{'state':'above_horizon','attributes':{'next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12.000000+00:00'}},
'timer.egg':{'state':'active','attributes':{'finishes_at':'2026-09-13T12:00:00+00:00','duration':'0:05:00','remaining':'0:05:00'}},
'weather.home':{'state':'sunny','attributes':{'temperature':21.5,'temperature_unit':'°C'}}}
self.assertEqual(extras({'entity':'sun.sun'},states,None,tz),{'rise':'07:15','set':'19:50'})
self.assertEqual(extras({'entity':'timer.egg'},states,None,tz),{'end':1789300800,'dur':'0:05:00','rem':'0:05:00'})
forecast=[{'datetime':f'2026-09-{13+i}T10:00:00+00:00','condition':'partlycloudy','temperature':20+i,'templow':11.5} for i in range(7)]+['junk']
days=extras({'entity':'weather.home'},states,forecast,tz)['days']
self.assertEqual([d['d'] for d in days],['Su','Mo','Tu','We','Th'])
self.assertEqual(days[0],{'d':'Su','c':'partlycloudy','h':20,'l':11.5})
self.assertIsNone(extras({'entity':'light.a'},states,None,tz))
msg=state_message(0,{'entity':'screen.clock','name':'','options':{'display':'analog'}},{})
self.assertEqual((msg['state'],msg['name'],msg['o']),('ok','Clock',{'display':'analog'}))
msg=state_message(1,{'entity':'weather.home','name':''},states,extras({'entity':'weather.home'},states,forecast,tz))
self.assertEqual(len(msg['x']['days']),5);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
async def test_new_domains_need_firmware_and_send_extras(self):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Sun','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry.append({'entity_id':'sensor.screen_firmware','platform':'esphome','original_name':'Screen firmware'})
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.states['sensor.screen_firmware']={'state':'0.2.14'}
m.save('text.screen',layout)
await m.sync_one('text.screen',m.layouts['text.screen'])
sent=[message for _,message in m.ha.messages]
self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun'])
self.assertEqual(sent[0]['keepalive'],KEEPALIVE_SECONDS,'the screen sizes its feed watchdog from the declared cadence')
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Clock'))
self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC')
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear()
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(m.ha.messages,[]);self.assertIn('0.2.14',m.status['text.screen'])