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

165 lines
9.1 KiB
Python

"""Per-device YAML overrides stay local, validated, and out of the profile list."""
import importlib.util
import os
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'))
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
class OverrideTests(unittest.TestCase):
def make_firmware(self, tmp):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.create({'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen',
'wifi_ssid': 'ssid', 'wifi_password': 'password'})
return f
def test_new_profiles_get_a_hidden_empty_sidecar(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
profile = f.profile('kitchen.yaml').read_text()
self.assertIn('local_overrides: !include kitchen.local.yaml', profile)
self.assertEqual((f.root / 'kitchen.local.yaml').read_text(), '{}\n')
self.assertEqual(f.profiles(), [{'file': 'kitchen.yaml'}])
def test_override_is_attached_without_reformatting_and_survives_reload(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
original = f.profile('kitchen.yaml').read_text()
content = 'display:\n - id: my_display\n model: ST7789V\n'
saved = f.save_override('kitchen.yaml', content)
self.assertEqual(saved['content'], content)
self.assertTrue(saved['attached'])
self.assertEqual(f.profile('kitchen.yaml').read_text(), original)
fresh = Firmware(f.root, f.data)
self.assertEqual(fresh.override('kitchen.yaml')['content'], content)
self.assertEqual(fresh.profiles(), [{'file': 'kitchen.yaml'}])
def test_existing_profile_gets_sidecar_on_first_save(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'esphome'
root.mkdir()
profile = root / 'existing.yaml'
profile.write_text('esphome:\n name: existing\n\npackages:\n display: !include board.yaml\n\napi:\n')
f = Firmware(root, Path(tmp) / 'data')
f.save_override('existing.yaml', 'display:\n - id: my_display\n model: ST7789V\n')
text = profile.read_text()
self.assertIn(' local_overrides: !include existing.local.yaml\n', text)
self.assertIn('\napi:\n', text)
def test_managed_sections_and_bad_yaml_are_rejected_without_writing(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
path = f.root / 'kitchen.local.yaml'
before = path.read_text()
for content in ('api:\n encryption: {}\n', 'substitutions:\n DEVICE_NAME: bad\n', 'display: [\n'):
with self.assertRaises(ValueError):
f.save_override('kitchen.yaml', content)
self.assertEqual(path.read_text(), before)
def test_extend_of_a_package_component_is_accepted(self):
# ESPHome appends package lists, so the way to change the shared display is !extend; LenientLoader
# reads the tag as None and the file is stored as typed.
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
content = 'display:\n - id: !extend my_display\n model: ST7789V\n'
self.assertEqual(f.save_override('kitchen.yaml', content)['content'], content)
def test_empty_text_and_missing_newline_are_normalised(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
self.assertEqual(f.save_override('kitchen.yaml', ' \n')['content'], '{}\n')
self.assertEqual(f.save_override('kitchen.yaml', 'logger:\n level: DEBUG')['content'], 'logger:\n level: DEBUG\n')
def test_oversized_override_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
big = 'logger:\n' + ''.join(f' # padding line {i}\n' for i in range(700))
self.assertGreater(len(big), Firmware.OVERRIDE_LIMIT)
with self.assertRaisesRegex(ValueError, 'too large'):
f.save_override('kitchen.yaml', big)
def test_a_symlinked_sidecar_is_never_read_or_replaced(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
secret = Path(tmp) / 'secret.txt'
secret.write_text('wifi_password: hunter2\n')
link = f.root / 'kitchen.local.yaml'
link.unlink()
link.symlink_to(secret)
with self.assertRaises(ValueError):
f.override('kitchen.yaml')
with self.assertRaises(ValueError):
f.save_override('kitchen.yaml', 'logger:\n level: DEBUG\n')
self.assertEqual(secret.read_text(), 'wifi_password: hunter2\n')
self.assertTrue(link.is_symlink())
def test_include_is_appended_when_packages_is_the_last_section_without_newline(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'esphome'
root.mkdir()
profile = root / 'tail.yaml'
profile.write_text('esphome:\n name: tail\npackages:\n display: !include board.yaml')
f = Firmware(root, Path(tmp) / 'data')
f.save_override('tail.yaml', 'logger:\n level: DEBUG\n')
self.assertEqual(profile.read_text(),
'esphome:\n name: tail\npackages:\n display: !include board.yaml\n local_overrides: !include tail.local.yaml\n')
def test_a_foreign_local_overrides_include_leaves_the_profile_alone(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'esphome'
root.mkdir()
profile = root / 'other.yaml'
text = 'esphome:\n name: other\npackages:\n local_overrides: !include elsewhere.yaml\n'
profile.write_text(text)
f = Firmware(root, Path(tmp) / 'data')
with self.assertRaises(ValueError):
f.save_override('other.yaml', 'logger:\n level: DEBUG\n')
self.assertEqual(profile.read_text(), text)
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class OverrideRoutes(unittest.IsolatedAsyncioTestCase):
async def test_the_page_reads_saves_and_is_told_when_the_text_is_too_big(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {'ESPHOME_CONFIG': str(Path(tmp) / 'esphome')}):
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
m.firmware.create({'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen',
'wifi_ssid': 'ssid', 'wifi_password': 'password'})
async with TestClient(TestServer(create_app(m, True))) as client:
inventory = await (await client.get('/api/inventory')).json()
headers = {'X-Screen-CSRF': inventory['csrf']}
url = '/api/firmware/profiles/kitchen.yaml/override'
data = await (await client.get(url)).json()
self.assertEqual((data['override_file'], data['attached'], data['content']), ('kitchen.local.yaml', True, '{}\n'))
self.assertEqual((await client.put(url, json={'content': 'logger: {}\n'})).status, 403, 'CSRF')
response = await client.put(url, json={'content': 'display:\n - id: !extend my_display\n model: ST7789V\n'}, headers=headers)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
response = await client.put(url, json={'content': 'api:\n reboot_timeout: 0s\n'}, headers=headers)
self.assertEqual(response.status, 400)
self.assertIn('api', (await response.json())['error'])
# The override keeps its own 12 KB limit; the request limit is far above it (app 0.2.78).
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 4000}, headers=headers)
self.assertEqual(response.status, 400)
self.assertIn('12 KB', (await response.json())['error'])
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 40000}, headers=headers)
self.assertEqual((response.status, (await response.json())['error']), (413, 'That request is too large.'))
self.assertEqual((await (await client.get(url)).json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
self.assertEqual((await (await client.get('/api/firmware')).json())['profiles'], [{'file': 'kitchen.yaml'}])
if __name__ == '__main__':
unittest.main()