Files
Home-Assistant/esphome/bambu-display/preview.py
T

198 lines
12 KiB
Python

#!/usr/bin/env python3
"""Render the real LVGL pages with demo telemetry using ESPHome's headless SDL host."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
from PIL import Image
import yaml
from generate import Dumper, HERE, Lambda, generate
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=HERE / 'previews')
args = parser.parse_args()
printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
package = generate(printers)
demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60,
'bed_target': 60, 'time_left': 1.75, 'layer': 134, 'total_layers': 200,
'fan': 80, 'aux_fan': 45, 'exhaust_fan': 30, 'left_nozzle': 219, 'left_target': 220,
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30,
'total_time': 200}
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C',
'uv_unit': '°C', 'elapsed_unit': 'min', 'total_time_unit': 'min',
'print_error_reason': 'ok'}
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
for printer in printers:
prefix = printer['id'] + '_'
for section in ('sensor', 'text_sensor'):
for entry in package[section]:
if not entry['id'].startswith(prefix):
continue
key = entry['id'][len(prefix):]
if section == 'sensor':
value = str(demo_values[key])
if key == 'time_left' and printer.get('profile', '').startswith('elegoo_'):
value = '105'
else:
if key.startswith('tray_'):
_, index, attribute = key.split('_')
text = {'type': 'PLA', 'color': colors[int(index) % len(colors)],
'active': 'on' if index == '0' else 'off'}[attribute]
elif key.startswith(('hms_1_', 'hms_2_', 'hms_3_')):
text = ''
else:
text = demo_text[key]
if printer.get('profile', '').startswith('elegoo_'):
text = {'status': 'printing', 'print_error': 'none',
'remaining_unit': 'min'}.get(key, text)
value = 'std::string(' + json.dumps(text, ensure_ascii=False) + ')'
entry.clear()
entry.update({'platform': 'template', 'id': prefix + key, 'internal': True,
'lambda': Lambda('return ' + value + ';'), 'update_interval': '60s'})
for script in package['script']:
code = script['then'][0]['lambda']
script['then'][0]['lambda'] = Lambda(code.replace(
'id(ha_api).is_connected_with_state_subscription()', 'true'))
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='bambu-lvgl-preview-') as work:
root = Path(work)
# Inject real LVGL pointer samples; test bubbling through cards/buttons
# without a GUI window or a physical touchscreen.
touch_header = root / 'preview_touch.h'
touch_header.write_text('''#pragma once
#include <lvgl.h>
namespace bambu_preview {
inline int x = 0, y = 0;
inline bool pressed = false;
inline void setup() {
auto *input = lv_indev_create();
lv_indev_set_type(input, LV_INDEV_TYPE_POINTER);
lv_indev_set_display(input, lv_display_get_default());
lv_indev_set_read_cb(input, [](lv_indev_t *, lv_indev_data_t *data) {
data->point.x = x;
data->point.y = y;
data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
});
}
}
''')
steps = [{'component.update': entry['id']}
for section in ('sensor', 'text_sensor') for entry in package[section]]
steps += [{'delay': '1s'},
{'lvgl.page.show': 'fleet_page'},
{'script.execute': 'fleet_refresh'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'fleet.bmp'}},
{'lambda': Lambda(
'if (std::string(lv_label_get_text(id(fleet_clock))) == "--:--") '
'{ ESP_LOGE("preview", "Fleet clock render failed"); exit(7); }')}]
for printer in printers:
pid = printer['id']
steps.append({'lambda': Lambda(
f'if (std::string(lv_label_get_text(id({pid}_overview_value))) != "67" || '
f'lv_arc_get_value(id({pid}_overview_arc)) != 67 || '
f'std::string(lv_label_get_text(id({pid}_overview_percent))) != "%" || '
f'std::string(lv_label_get_text(id({pid}_overview_finish))) == "--") '
f'{{ ESP_LOGE("preview", "Fleet gauge render failed: {pid}"); exit(7); }}')})
for printer in printers:
steps += [{'lvgl.page.show': printer['id'] + '_page'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': printer['id'] + '.bmp'}}]
if printer.get('profile', '').startswith('elegoo_'):
pid = printer['id']
checks = {f'{pid}_status_label': 'PRINTING', f'{pid}_progress_detail': '1h 45m'}
if printer['profile'] == 'elegoo_resin':
checks.update({f'{pid}_uv_label': '42', f'{pid}_layer_label': '134',
f'{pid}_elapsed_label': 'Elapsed: 0h 30m'})
for widget, expected in checks.items():
steps.append({'lambda': Lambda(f'if (std::string(lv_label_get_text(id({widget}))) != {json.dumps(expected)}) {{ ESP_LOGE("preview", "Elegoo telemetry render failed: {widget}"); exit(4); }}')})
steps += [
{'text_sensor.template.publish': {'id': f'{pid}_print_error_reason', 'state': 'filament_runout'}},
{'script.execute': f'{pid}_refresh'},
{'lambda': Lambda(f'if (std::string(lv_label_get_text(id({pid}_status_label))) != "PRINT ERROR" || std::string(lv_label_get_text(id({pid}_health_details))).find("Filament runout") == std::string::npos) {{ ESP_LOGE("preview", "Elegoo error render failed"); exit(5); }}')},
{'text_sensor.template.publish': {'id': f'{pid}_online', 'state': 'off'}},
{'script.execute': f'{pid}_refresh'},
{'lambda': Lambda(f'if (std::string(lv_label_get_text(id({pid}_status_label))) != "PRINTER OFFLINE" || std::string(lv_label_get_text(id({pid}_progress_label))) != "--") {{ ESP_LOGE("preview", "Elegoo offline render failed"); exit(6); }}')},
{'text_sensor.template.publish': {'id': f'{pid}_print_error_reason', 'state': 'ok'}},
{'text_sensor.template.publish': {'id': f'{pid}_online', 'state': 'on'}},
{'script.execute': f'{pid}_refresh'},
]
steps += [{'lvgl.page.show': 'color_test_page'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'color_test.bmp'}}]
demo_printer = printers[0]['id']
for key, value in {'hms_error': 'on', 'hms_count': '1', 'hms_1_code': 'DEMO_CODE',
'hms_1_severity': 'common',
'hms_1_message': 'DEMO: A printer maintenance notification. This is synthetic preview data, not an actual fault diagnosis.'}.items():
steps.append({'text_sensor.template.publish': {'id': f'{demo_printer}_{key}', 'state': value}})
steps += [{'lvgl.page.show': demo_printer + '_page'},
{'script.execute': demo_printer + '_refresh'},
{'lvgl.widget.show': demo_printer + '_health_panel'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'health_notice.bmp'}},
{'lvgl.widget.hide': demo_printer + '_health_panel'},
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
{'script.execute': demo_printer + '_refresh'}]
steps += [{'lvgl.page.show': 'fleet_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}]
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
steps += [
{'lambda': Lambda(f'bambu_preview::x = {x}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
{'delay': '70ms'}, {'lambda': Lambda('bambu_preview::pressed = false;')}, {'delay': '150ms'},
{'lambda': Lambda(f'if (lv_obj_has_flag(id({demo_printer}_health_panel), LV_OBJ_FLAG_HIDDEN) != {str(expect_hidden).lower()}) {{ ESP_LOGE("preview", "Health panel tap failed"); exit(3); }}')},
]
if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV.
# Fleet -> first printer -> fleet -> last printer -> fleet, then a
# swipe starting on NEXT advances exactly once.
gestures = [(650, 180, 180, printers[0]['id']),
(180, 650, 180, 'fleet'),
(180, 650, 180, printers[-1]['id']),
(650, 180, 180, 'fleet'),
(730, 60, 460, printers[0]['id'])]
for start, end, y, expected in gestures:
steps += [
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
{'delay': '70ms'},
{'lambda': Lambda(f'bambu_preview::x = {end};')}, {'delay': '70ms'},
{'lambda': Lambda('bambu_preview::pressed = false;')}, {'delay': '350ms'},
{'lambda': Lambda(f'if (!id({expected}_page).is_showing()) {{ ESP_LOGE("preview", "Swipe navigation failed"); exit(2); }}')},
]
steps += [{'lambda': Lambda('exit(0);')}]
config = {'esphome': {'name': 'bambu-display-preview',
'includes': [str(HERE / 'dashboard.h'), str(touch_header)],
'on_boot': {'priority': -100, 'then': steps}},
'host': {}, 'logger': {'level': 'WARN'},
'time': [{'platform': 'host', 'id': 'ha_time', 'timezone': 'America/New_York'}],
'display': [{'platform': 'sdl', 'id': 'panel_display', 'dimensions': '800x480',
'headless': True, 'auto_clear_enabled': False, 'update_interval': 'never'}],
'lvgl': {'id': 'dashboard', 'displays': ['panel_display'], 'buffer_size': '100%',
'color_depth': 16, 'bg_color': 0x000000, 'text_color': 0xEEEEEE,
'default_font': 'montserrat_18', **package.pop('lvgl')}, **package}
filename = root / 'preview.yaml'
filename.write_text(yaml.dump(config, Dumper=Dumper, sort_keys=False, allow_unicode=True))
subprocess.run(['esphome', 'compile', str(filename)], check=True)
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
for name in ['fleet'] + [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
with Image.open(bmp) as image:
image.save(args.output / (name + '.png'))
print(f'Saved {len(printers)} demo screenshots to {args.output}')
print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.')
print('Passed status-tap health panel open and Close-button tests.')
print('Passed Elegoo telemetry, text-error and offline rendering checks.')
if __name__ == '__main__':
main()