198 lines
12 KiB
Python
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()
|