1a6308aba7
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>
165 lines
9.1 KiB
Python
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()
|