Files
Max Gramser 1a6308aba7 Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:25:23 +02:00

216 lines
12 KiB
Python

"""Download: ESP Screens builds a screen's firmware and the owner flashes the file from their own computer."""
import importlib.util
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from firmware import Firmware # noqa: E402
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
from test_screen_owned_settings import fake_ha
PROFILE = {'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen', 'wifi_ssid': 'ssid', 'wifi_password': 'password'}
# Stands in for the ESPHome CLI: it logs, and `compile` writes the factory image where ESPHome 2026.6 puts it
# (PlatformIO: <build>/<node>/.pioenvs/<node>/, native ESP-IDF: <build>/<node>/build/). FAKE_LAYOUT=none writes
# nothing, FAKE_EXIT makes it fail, FAKE_SLEEP keeps it busy.
FAKE_CLI = '''#!{python}
import os, sys, time
from pathlib import Path
stage, profile = [arg for arg in sys.argv[1:] if arg != '--quiet'][:2]
profile = Path(profile)
print('INFO Reading configuration ' + profile.name, flush=True)
time.sleep(float(os.environ.get('FAKE_SLEEP', '0')))
if os.environ.get('FAKE_EXIT'):
print('ERROR Compiling ' + profile.stem + ' failed', flush=True)
sys.exit(int(os.environ['FAKE_EXIT']))
if stage == 'compile':
build = Path(os.environ['ESPHOME_BUILD_PATH']) / profile.stem
folder = {{'pio': build / '.pioenvs' / profile.stem, 'idf': build / 'build'}}.get(os.environ.get('FAKE_LAYOUT', 'pio'))
if folder:
folder.mkdir(parents=True, exist_ok=True)
(folder / 'firmware.bin').write_bytes(b'app')
(folder / 'firmware.factory.bin').write_bytes(b'\\xe9factory-' + profile.stem.encode())
print('INFO Successfully compiled program.', flush=True)
'''
def fake_cli(folder):
"""The environment that finds the stand-in `esphome` before any real one."""
bin_dir = Path(folder) / 'bin'
bin_dir.mkdir(exist_ok=True)
cli = bin_dir / 'esphome'
cli.write_text(FAKE_CLI.format(python=sys.executable))
cli.chmod(0o755)
return {'PATH': f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"}
class DownloadChecks(unittest.TestCase):
def test_the_download_target_is_checked_before_anything_is_written(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
with patch('firmware.shutil.which', return_value=None):
with self.assertRaisesRegex(ValueError, 'ESPHome CLI is missing'):
f.install({**PROFILE, 'target': 'download'})
with patch('firmware.shutil.which', return_value='/usr/bin/esphome'):
f.task = Mock(done=lambda: False)
with self.assertRaisesRegex(ValueError, 'already running'):
f.install({**PROFILE, 'target': 'download'})
f.task = None
for target in ('downloads', ['download'], '/dev/ttyUSB0'):
with self.assertRaisesRegex(ValueError, 'USB port'):
f.install({**PROFILE, 'target': target})
self.assertFalse((Path(tmp) / 'esphome' / 'kitchen.yaml').exists())
self.assertFalse((Path(tmp) / 'esphome' / 'secrets.yaml').exists())
def test_only_a_profile_built_by_this_app_is_served(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.create(PROFILE)
for name in ('../secrets.yaml', 'secrets', 'kitchen.local.yaml', 'missing.yaml', None):
with self.assertRaises(ValueError):
f.image(name)
# A factory image left in the build folder, from before this app started, is not offered.
old = Path(tmp) / 'data' / 'build' / 'kitchen' / 'kitchen' / '.pioenvs' / 'kitchen' / 'firmware.factory.bin'
old.parent.mkdir(parents=True)
old.write_bytes(b'old')
with self.assertRaisesRegex(ValueError, 'Build the firmware first'):
f.image('kitchen.yaml')
self.assertEqual(f.downloaded, set())
self.assertEqual(f.status()['downloads'], [])
def test_the_newest_image_wins_when_the_node_was_renamed(self):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
profile = f.profile(f.create(PROFILE)['file'])
build = Path(tmp) / 'data' / 'build' / 'kitchen'
images = []
for node, when in (('old-name', 1_000_000), ('kitchen', 2_000_000)):
image = build / node / '.pioenvs' / node / 'firmware.factory.bin'
image.parent.mkdir(parents=True)
image.write_bytes(node.encode())
os.utime(image, (when, when))
images.append(image)
# A link is never served, even when it points at something newer.
outside = Path(tmp) / 'elsewhere.bin'
outside.write_bytes(b'not ours')
os.utime(outside, (3_000_000, 3_000_000))
link = build / 'linked' / 'build' / 'firmware.factory.bin'
link.parent.mkdir(parents=True)
link.symlink_to(outside)
self.assertEqual(f.factory_image(profile), images[1])
class DownloadJobs(unittest.IsolatedAsyncioTestCase):
async def test_the_download_target_builds_and_offers_the_factory_image(self):
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, fake_cli(tmp)):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
result = f.install({**PROFILE, 'target': 'download'})
self.assertEqual((result['file'], result['job']['action'], result['job']['state']), ('kitchen.yaml', 'download', 'running'))
self.assertEqual(len(result['api_key']), 44, 'the page still shows the key for pairing')
with self.assertRaisesRegex(ValueError, 'still being built'):
f.image('kitchen.yaml')
await f.task
self.assertEqual(f.job['state'], 'success', list(f.logs))
self.assertIn('ESPHome: compile', f.logs)
self.assertNotIn('ESPHome: upload', f.logs, 'nothing is flashed from Home Assistant')
self.assertEqual(f.status()['downloads'], ['kitchen.yaml'])
path, name = f.image('kitchen.yaml')
self.assertEqual((name, path.read_bytes()), ('kitchen.factory.bin', b'\xe9factory-kitchen'))
self.assertEqual((f.installed, f.downloaded), (set(), {'kitchen.yaml'}))
async def test_a_build_without_a_factory_image_fails_instead_of_offering_nothing(self):
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {**fake_cli(tmp), 'FAKE_LAYOUT': 'none'}):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.install({**PROFILE, 'target': 'download'})
await f.task
self.assertEqual(f.job['state'], 'failed')
self.assertIn('no factory image', f.logs[-1])
self.assertEqual(f.status()['downloads'], [])
with self.assertRaisesRegex(ValueError, 'Build the firmware first'):
f.image('kitchen.yaml')
async def test_the_native_esp_idf_layout_is_found_too(self):
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {**fake_cli(tmp), 'FAKE_LAYOUT': 'idf'}):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.install({**PROFILE, 'target': 'download'})
await f.task
path, _ = f.image('kitchen.yaml')
self.assertEqual(path.parent.name, 'build')
async def test_a_new_build_withdraws_the_old_file_until_it_succeeds(self):
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, fake_cli(tmp)):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.install({**PROFILE, 'target': 'download'})
await f.task
path, _ = f.image('kitchen.yaml')
with patch.dict(os.environ, {'FAKE_EXIT': '1'}):
f.start({'file': 'kitchen.yaml', 'action': 'build'})
with self.assertRaisesRegex(ValueError, 'still being built'):
f.image('kitchen.yaml')
await f.task
self.assertEqual(f.job['state'], 'failed')
self.assertTrue(path.exists(), 'the old file is still on disk')
with self.assertRaisesRegex(ValueError, 'Build the firmware first'):
f.image('kitchen.yaml')
async def test_build_only_offers_the_file_and_a_check_does_not(self):
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, fake_cli(tmp)):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.create(PROFILE)
f.start({'file': 'kitchen.yaml', 'action': 'validate'})
await f.task
self.assertEqual((f.job['state'], f.status()['downloads']), ('success', []))
f.start({'file': 'kitchen.yaml', 'action': 'build'})
await f.task
self.assertEqual((f.job['state'], f.status()['downloads']), ('success', ['kitchen.yaml']))
with self.assertRaisesRegex(ValueError, 'Unknown firmware action'):
f.start({'file': 'kitchen.yaml', 'action': 'flash'})
self.assertEqual(f.status()['downloads'], ['kitchen.yaml'], 'a refused job keeps the file')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class DownloadRoute(unittest.IsolatedAsyncioTestCase):
async def test_the_page_downloads_the_built_file_and_the_card_says_so(self):
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {**fake_cli(tmp), 'FAKE_SLEEP': '0.3',
'ESPHOME_CONFIG': str(Path(tmp) / 'esphome')}):
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(m, True))) as client:
inventory = await (await client.get('/api/inventory')).json()
headers = {'X-Screen-CSRF': inventory['csrf']}
refused = await client.post('/api/firmware/profiles', json={**PROFILE, 'target': 'download'})
self.assertEqual(refused.status, 403, 'CSRF like every other change')
response = await client.post('/api/firmware/profiles', headers=headers, json={**PROFILE, 'target': 'download'})
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())['job']['action'], 'download')
url = '/api/firmware/profiles/kitchen.yaml/download'
early = await client.get(url)
self.assertEqual(early.status, 400)
self.assertIn('still being built', (await early.json())['error'])
await m.firmware.task
self.assertEqual((await (await client.get('/api/firmware')).json())['downloads'], ['kitchen.yaml'])
pending = (await (await client.get('/api/inventory?light=1')).json())['pending']
self.assertEqual([(p['file'], p['installed'], p['downloaded']) for p in pending], [('kitchen.yaml', False, False)])
download = await client.get(url)
self.assertEqual(download.status, 200)
self.assertEqual(download.headers['Content-Type'], 'application/octet-stream')
self.assertEqual(download.headers['Content-Disposition'], 'attachment; filename="kitchen.factory.bin"')
self.assertEqual(download.headers['Cache-Control'], 'no-store', 'the file holds the Wi-Fi password')
self.assertEqual(await download.read(), b'\xe9factory-kitchen')
pending = (await (await client.get('/api/inventory?light=1')).json())['pending']
self.assertEqual([(p['file'], p['installed'], p['downloaded']) for p in pending], [('kitchen.yaml', False, True)])
self.assertEqual((await client.get('/api/firmware/profiles/other.yaml/download')).status, 400)
self.assertNotEqual((await client.get('/api/firmware/profiles/..%2Fsecrets.yaml/download')).status, 200)
if __name__ == '__main__':
unittest.main()