Give each screen its own YAML override that survives updates (0.2.61)

Override YAML on a screen edits <name>.local.yaml, a package loaded after
the shared board package. New profiles get an empty one; an existing
profile is attached on the first save without reformatting the file. The
editor validates YAML, refuses the managed sections and substitutions,
and Save & check runs ESPHome's full validation of the complete profile.

The example uses !extend: ESPHome appends package lists instead of
merging them by id, so a bare id added a second display without a
platform and failed the build. An override is 12 KB at most and a
too-large body answers as JSON instead of a bare 413. The sidecar is
written before the profile includes it, and a symlinked sidecar is
refused. Firmware stays 0.2.52.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 08:23:13 +02:00
parent 87b2082f81
commit 8a587303d7
12 changed files with 694 additions and 4 deletions
+160
View File
@@ -0,0 +1,160 @@
"""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'))
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'])
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 4000}, headers=headers)
self.assertEqual(response.status, 413)
self.assertIn('12 KB', (await response.json())['error'])
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()