Free memory on the CYD and drop the old manual profile (0.2.50)
The CYD restarted under load: a slider fill rounded less than its track made LVGL draw the fill into a buffer of its own size on every redraw (15-20 KB), and when that allocation failed the refresh retried until the task watchdog fired. Track and fill now share one radius, and tests/test_layer_free.py keeps every layer-making style out of the profiles, the packages and the firmware headers. A tile keeps only the fields every card uses; climate modes, select options, weather, sun and timer times, the media title and the vacuum rows moved to an Extra block that exists only while a state needs one, and the state copy became a fingerprint. runtime_tiles::model went from 24,296 to 8,216 bytes. The manual profile that bound tiles to fixed entities in the YAML is gone: 331 TILE* substitutions, DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS, the 80 Home Assistant subscriptions, its tap handlers and scripts, its own vacuum card, its guides and helper scripts, and the five stale header copies in the repository root that two C++ tests were compiling instead of the components. esphome config of both Easy profiles is unchanged by that cleanup. tile_scroll and the wake overlay no longer light up on a tap, and the debug sensor min_free reports the lowest free heap since boot. On the bench CYD static RAM went from 32.0% to 22.8% and free heap from 64-77 KB to about 101 KB, with 78 KB as the lowest point over thirteen hours without a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
"""Export an allowlisted, neutral starter ZIP (never copies local secrets/logs)."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml',
|
||||
'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'light_controls.h', 'LICENSE',
|
||||
'guition_diagnostics.h', 'guition-4848s040.yaml', 'guition-device.example.yaml', 'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md', 'repository.yaml']
|
||||
FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts']
|
||||
SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}
|
||||
|
||||
|
||||
def neutral_base(root):
|
||||
defaults = '\n'.join((root / name).read_text() for name in ['device.example.yaml', 'calibration.example.yaml'])
|
||||
replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
|
||||
base = (root / 'home-like-2432s028.yaml').read_text()
|
||||
def replace(match):
|
||||
key = match[1]
|
||||
return f' {key}: {replacements.get(key, match[2])}'
|
||||
return re.sub(r'^ (\w+): ("[^"\n]*")', replace, base, flags=re.M)
|
||||
|
||||
|
||||
def export(root, output):
|
||||
root, output = Path(root).resolve(), Path(output)
|
||||
paths = [root / name for name in FILES]
|
||||
for folder in FOLDERS:
|
||||
for path in (root / folder).rglob('*'):
|
||||
if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES or path.name == 'LICENSE'):
|
||||
paths.append(path)
|
||||
# Dedicated distribution directories only; never recursively include owner YAML.
|
||||
for folder in ['screen_manager', 'packages', 'installers']:
|
||||
for path in (root / folder).rglob('*'):
|
||||
if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES | {'.yaml', '.html', '.js', '.css'} or path.name == 'Dockerfile'):
|
||||
paths.append(path)
|
||||
for path in paths:
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||
raise ValueError(f'No files outside the source folder or symlinks allowed: {path}')
|
||||
if not path.is_file():
|
||||
raise ValueError(f'Missing bundle file: {path}')
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Exclusive mode refuses to replace a previously delivered bundle.
|
||||
with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('cyd-display/home-like-2432s028.yaml', neutral_base(root))
|
||||
for path in sorted(set(paths)):
|
||||
archive.write(path, 'cyd-display/' + path.relative_to(root).as_posix())
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, default=ROOT / 'dist' / 'cyd-starter.zip')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(f'Share this ZIP: {export(ROOT, args.output)}')
|
||||
print('No local secrets, device.yaml, calibration measurements, logs or firmware backups included.')
|
||||
except (OSError, ValueError) as exc:
|
||||
parser.exit(1, f'Error: {exc}\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -7,21 +7,14 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
|
||||
def generate(board):
|
||||
def generate(board, text=None):
|
||||
source = ROOT / ('home-like-2432s028.yaml' if board == 'cyd' else 'guition-4848s040.yaml')
|
||||
s = source.read_text()
|
||||
defaults = (ROOT / ('device.example.yaml' if board == 'cyd' else 'guition-device.example.yaml')).read_text()
|
||||
defaults += '\n' + (ROOT / 'calibration.example.yaml').read_text()
|
||||
replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
|
||||
replacements.update(DYNAMIC_TILES='"true"', DASHBOARD_INBOX_INTERNAL='"false"', DIRECT_ACTIONS='"true"',
|
||||
ROOM_NAME='"Choose tiles in HA"', TILE_COUNT='"1"')
|
||||
s = re.sub(r'^ (\w+): ("[^"\n]*")', lambda m: f' {m[1]}: {replacements.get(m[1],m[2])}', s, flags=re.M)
|
||||
s = source.read_text() if text is None else text
|
||||
# The owner's wizard YAML supplies wifi/API/OTA credentials. No shared keys.
|
||||
s = re.sub(r'^ encryption:\n key: !secret api_encryption_key\n', '', s, flags=re.M)
|
||||
s = re.sub(r'^ota:\n.*?(?=^\w)', '', s, flags=re.M | re.S)
|
||||
s = re.sub(r'^wifi:\n.*?(?=^\w)', '', s, flags=re.M | re.S)
|
||||
# Referenced files are fetched as an external component or over HTTPS.
|
||||
s = re.sub(r'^ - (cyd_ui.h|light_controls.h|guition_diagnostics.h|backlight_fade.h|alert_overlay.h)$', r' - <esphome/components/smart_display/\1>', s, flags=re.M)
|
||||
# Fonts are fetched over HTTPS; the headers come with the external component.
|
||||
components = '[xpt2046, smart_display]' if board == 'cyd' else '[smart_display]'
|
||||
external = f'''external_components:
|
||||
- source:
|
||||
@@ -35,17 +28,12 @@ def generate(board):
|
||||
'''
|
||||
s = re.sub(r'^external_components:\n.*?(?=^\w)', lambda m: external, s, flags=re.M | re.S)
|
||||
s = re.sub(r'file: ["\']fonts/([^"\']+)["\']', lambda m: 'file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/' + m[1] + '"', s)
|
||||
# Keep the established card bindings, but remove all fixed HA subscriptions.
|
||||
# Runtime values arrive through ESP Screen Manager, including after changing entities.
|
||||
# A screen gets its values from ESP Screen Manager while it runs, entity changes included:
|
||||
# a fixed Home Assistant subscription in a board profile is a leftover.
|
||||
for section in ['sensor', 'text_sensor']:
|
||||
match = re.search(r'^' + section + r':\n.*?(?=^[a-zA-Z_]+:|\Z)', s, re.M | re.S)
|
||||
if not match: continue
|
||||
block = match[0]
|
||||
def sensor(m):
|
||||
body = re.sub(r'^ (entity_id|attribute):.*\n', '', m[1], flags=re.M)
|
||||
return ' - platform: template\n update_interval: never\n' + body
|
||||
block = re.sub(r'^ - platform: homeassistant\n(.*?)(?=^ - platform:|\Z)', sensor, block, flags=re.M | re.S)
|
||||
s = s[:match.start()] + block + s[match.end():]
|
||||
if match and re.search(r'^ - platform: homeassistant$', match[0], re.M):
|
||||
raise ValueError(f'{source.name}: a {section} subscribes to Home Assistant; runtime tiles bring their own values')
|
||||
if '!secret' in s: raise ValueError('Remote package still depends on local secrets')
|
||||
return '# GENERATED by tools/generate_packages.py; edit the source board profile.\n' + s
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Create local device, calibration and secrets files without overwriting any."""
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def create(directory, name, friendly_name, board="cyd"):
|
||||
if not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', name) or name.endswith('-'):
|
||||
raise ValueError('Use a unique name of 1-31 characters: lowercase letters, digits, and dashes.')
|
||||
if board not in ("cyd", "guition"):
|
||||
raise ValueError("Unknown board profile")
|
||||
import json
|
||||
device = (ROOT / ('guition-device.example.yaml' if board == 'guition' else 'device.example.yaml')).read_text().replace('"guition-new"' if board == 'guition' else '"cyd-new"', json.dumps(name), 1)
|
||||
device = device.replace('"My Guition"' if board == 'guition' else '"My CYD"', json.dumps(friendly_name, ensure_ascii=False), 1)
|
||||
secret_text = (ROOT / 'secrets.yaml.example').read_text()
|
||||
replacements = [base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(18)]
|
||||
for value in replacements:
|
||||
secret_text = secret_text.replace('WILL_BE_UNIQUELY_GENERATED', value, 1)
|
||||
files = {'guition-device.yaml' if board == 'guition' else 'device.yaml': device, 'secrets.yaml': secret_text}
|
||||
if board == 'cyd': files['calibration.yaml'] = (ROOT / 'calibration.example.yaml').read_text()
|
||||
directory = Path(directory)
|
||||
if not directory.is_dir():
|
||||
raise ValueError('The target folder must already exist and contain the unpacked code.')
|
||||
for name in files:
|
||||
if (directory / name).exists():
|
||||
raise ValueError(f'{directory / name} already exists; nothing overwritten. Use a fresh copy of the folder.')
|
||||
created = []
|
||||
try:
|
||||
for name, contents in files.items():
|
||||
path = directory / name
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
created.append(path)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||
handle.write(contents)
|
||||
except Exception:
|
||||
for path in created:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
return created
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--board', choices=['cyd', 'guition'], default='cyd')
|
||||
parser.add_argument('--name', required=True, help='Unique ESPHome name, for example display-kitchen')
|
||||
parser.add_argument('--friendly-name')
|
||||
parser.add_argument('--directory', type=Path, default=ROOT)
|
||||
args = parser.parse_args()
|
||||
args.friendly_name = args.friendly_name or ('My Guition' if args.board == 'guition' else 'My CYD')
|
||||
try:
|
||||
for path in create(args.directory, args.name, args.friendly_name, args.board):
|
||||
print(f'Created: {path}')
|
||||
print('Fill in Wi-Fi locally in secrets.yaml. Keys are uniquely generated and are not shown.')
|
||||
print('Follow docs/GUITION.md; flash guition-device.yaml.' if args.board == 'guition' else 'Follow README.md; flash device.yaml.')
|
||||
except (ValueError, OSError) as exc:
|
||||
parser.exit(1, f'Error: {exc}\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user