This commit is contained in:
Joshua King
2026-10-17 17:26:25 -04:00
parent 19adc71fef
commit f672de484e
14 changed files with 2946 additions and 1661 deletions
+98 -57
View File
@@ -20,10 +20,12 @@ DUAL = {
'left_nozzle': 'left_nozzle_temperature', 'left_target': 'left_nozzle_target_temperature',
'right_nozzle': 'right_nozzle_temperature', 'right_target': 'right_nozzle_target_temperature',
}
EXTRA_FANS = {'aux_fan': 'aux_fan_speed', 'exhaust_fan': 'chamber_fan_speed'}
TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'}
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'}
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
0x0D131B, 0x17222E, 0x263442, 0xEDF3F7, 0x91A3B5, 0x60DE9A)
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
class Lambda(str):
@@ -67,6 +69,8 @@ def validate_printers(printers):
raise ValueError(f'{pid}: the filament strip fits up to eight slots')
if 'dual_nozzle' in printer and not isinstance(printer['dual_nozzle'], bool):
raise ValueError(f'{pid}: dual_nozzle must be true or false')
if any(key not in EXTRA_FANS for key in printer.get('extra_fans', [])):
raise ValueError(f'{pid}: extra_fans supports aux_fan and exhaust_fan')
for value in printer.get('entities', {}).values():
if not isinstance(value, str) or not re.fullmatch(r'(sensor|binary_sensor)\.[a-z0-9_]+', value):
raise ValueError(f'{pid}: invalid entity override: {value}')
@@ -79,70 +83,76 @@ def label(text, x, y, width, font=18, **kwargs):
'gesture_bubble': True, **kwargs}}
def card(pid, name, title, x, y, width=238, height=104, font=26):
return {'obj': {'x': x, 'y': y, 'width': width, 'height': height,
'bg_color': CARD, 'radius': 16, 'border_width': 0,
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True,
'widgets': [
label(title, 16, 12, width - 32, 14, text_color=MUTED),
label('--', 16, 38, width - 32, font, id=f'{pid}_{name}_label', height=height - 42),
]}}
def gauge(pid, key, title, x, y, color, maximum):
return [
{'arc': {'id': f'{pid}_{key}_arc', 'x': x + 8, 'y': y, 'width': 154, 'height': 154,
'min_value': 0, 'max_value': maximum, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': 13,
'arc_color': TRACK, 'indicator': {'arc_color': color, 'arc_width': 13},
'adjustable': False, 'gesture_bubble': True}},
label('--', x + 14, y + 47, 142, 32, id=f'{pid}_{key}_label', text_align='CENTER'),
label('--', x + 14, y + 86, 142, 16, id=f'{pid}_{key}_detail', text_align='CENTER', text_color=MUTED),
label(title, x, y + 137, 170, 18, text_align='CENTER', text_color=color),
]
def page_for(printer, index, total):
pid = printer['id']
widgets = [
label(printer['name'], 24, 16, 545, 28),
label(printer['subtitle'], 24, 53, 545, 14, text_color=MUTED),
{'obj': {'x': 586, 'y': 20, 'width': 190, 'height': 42,
'bg_color': CARD, 'radius': 21, 'border_width': 0, 'pad_all': 0,
'scrollable': False, 'gesture_bubble': True,
'widgets': [label('SYNCING', 0, 11, 190, 16,
id=f'{pid}_status_label', text_align='CENTER', text_color=MUTED)]}},
label('Waiting for Home Assistant', 24, 88, 752, 20, id=f'{pid}_task_label'),
{'arc': {'id': f'{pid}_progress_arc', 'x': 28, 'y': 132, 'width': 224, 'height': 224,
'min_value': 0, 'max_value': 100, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': 14,
'arc_color': TRACK, 'indicator': {'arc_color': GREEN, 'arc_width': 14},
'adjustable': False, 'gesture_bubble': True}},
label('--', 42, 207, 196, 48, id=f'{pid}_progress_label', text_align='CENTER'),
label('PRINT PROGRESS', 42, 266, 196, 14, text_align='CENTER', text_color=MUTED),
card(pid, 'nozzle', 'NOZZLES L/R / TARGET' if printer.get('dual_nozzle')
else 'NOZZLE / TARGET', 282, 132, height=118,
font=18 if printer.get('dual_nozzle') else 26),
card(pid, 'bed', 'BED / TARGET', 538, 132, height=118),
card(pid, 'remaining', 'REMAINING', 282, 262, height=86),
card(pid, 'layers', 'LAYER', 538, 262, height=86),
label('Telemetry unavailable', 24, 358, 752, 16, id=f'{pid}_stage_label', text_color=MUTED),
label('FILAMENT / ACTIVE SLOT OUTLINED IN GREEN', 24, 383, 752, 12, text_color=MUTED),
{'bar': {'id': f'{pid}_top_progress', 'x': 0, 'y': 0, 'width': 800, 'height': 5,
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
label(printer['name'], 16, 17, 485, 26, text_color=GREEN),
label(f'{index + 1} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED),
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
'border_width': 0, 'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
]
widgets += gauge(pid, 'progress', 'Progress', 16, 62, GREEN, 100)
widgets += gauge(pid, 'nozzle', 'Nozzle L' if printer.get('dual_nozzle') else 'Nozzle',
202, 62, ORANGE, 300)
widgets += gauge(pid, 'bed', 'Bed', 388, 62, CYAN, 120)
widgets += gauge(pid, 'fan', 'Part', 16, 229, 0xEEEEEE, 100)
widgets += gauge(pid, 'aux', 'Nozzle R' if printer.get('dual_nozzle') else 'Aux',
202, 229, ORANGE if printer.get('dual_nozzle') else 0xEEEEEE,
300 if printer.get('dual_nozzle') else 100)
widgets += gauge(pid, 'exhaust', 'Exhaust', 388, 229, GREEN, 100)
widgets += [
label('--', 16, 401, 544, 26, id=f'{pid}_remaining_label', text_color=GREEN, text_align='CENTER'),
label('Telemetry unavailable', 16, 430, 544, 12, id=f'{pid}_stage_label', text_color=MUTED, text_align='CENTER'),
label('--', 594, 397, 190, 16, id=f'{pid}_layers_label', text_color=MUTED, text_align='CENTER'),
]
trays = trays_for(printer)
if trays:
width = (752 - 8 * (len(trays) - 1)) // len(trays)
for i, tray in enumerate(trays):
widgets.append({'obj': {
'id': f'{pid}_tray_{i}_card', 'x': 24 + (width + 8) * i, 'y': 401,
'width': width, 'height': 32, 'bg_color': CARD, 'radius': 6,
'border_width': 1, 'border_color': TRACK, 'pad_all': 0,
'scrollable': False, 'gesture_bubble': True,
'widgets': [
{'obj': {'id': f'{pid}_tray_{i}_swatch', 'x': 1, 'y': 1, 'width': width - 4,
'height': 5, 'bg_color': TRACK, 'radius': 2, 'border_width': 0,
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
label(f'{tray["label"]} --', 6, 11, width - 12, 12,
id=f'{pid}_tray_{i}_label'),
]}})
else:
widgets.append(label('No filament slots configured', 24, 403, 752, 14, text_color=MUTED))
for direction, x, text in (('previous', 24, '< PREV'), ('next', 688, 'NEXT >')):
widgets.append({'button': {'x': x, 'y': 444, 'width': 88, 'height': 30,
'bg_color': CARD, 'radius': 8, 'border_width': 0,
for i, tray in enumerate(trays):
bank = i // 4
slot = i % 4
if slot == 0:
title = 'AMS ' + tray['label'][:-1] if tray['label'][-1:].isdigit() else 'EXTERNAL SPOOL'
widgets.append(label(title, 594, 70 + bank * 145, 190, 20,
text_align='CENTER', text_color=MUTED))
widgets.append({'obj': {
'id': f'{pid}_tray_{i}_card', 'x': 594 + 48 * slot, 'y': 102 + bank * 145,
'width': 44, 'height': 82, 'bg_color': BG, 'radius': 5,
'border_width': 1, 'border_color': TRACK, 'pad_all': 0,
'scrollable': False, 'gesture_bubble': True,
'widgets': [
{'obj': {'id': f'{pid}_tray_{i}_swatch', 'x': 3, 'y': 3, 'width': 36,
'height': 40, 'bg_color': TRACK, 'radius': 4, 'border_width': 0,
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
label(f'{tray["label"]}\n--', 1, 46, 40, 12, height=34,
id=f'{pid}_tray_{i}_label', text_align='CENTER'),
]}})
if not trays:
widgets.append(label('No AMS', 594, 150, 190, 20, text_color=MUTED, text_align='CENTER'))
for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')):
widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30,
'bg_color': TRACK, 'radius': 8, 'border_width': 0,
'pad_all': 0, 'gesture_bubble': True,
'widgets': [{'label': {'text': text, 'align': 'CENTER',
'text_font': 'montserrat_12'}}],
'text_color': TEXT_COLOR, 'text_font': 'montserrat_18'}}],
'on_click': [{f'lvgl.page.{direction}': {}}]}})
widgets.append(label(f'{index + 1} / {total} SWIPE LEFT / RIGHT', 130, 451, 540, 14,
text_align='CENTER', text_color=MUTED))
widgets.append(label('Waiting for Home Assistant', 94, 449, 612, 14,
id=f'{pid}_task_label', text_align='CENTER'))
return {'id': f'{pid}_page', 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
'on_swipe_left': [
@@ -154,6 +164,25 @@ def page_for(printer, index, total):
'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets}
def color_test_page():
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
label('Each block must match its label. Swipe to return to the printers.',
16, 62, 768, 18)]
colors = [('RED', 0xFF0000), ('GREEN', 0x00FF00), ('BLUE', 0x0000FF),
('WHITE', 0xFFFFFF), ('CYAN', 0x00FFFF), ('MAGENTA', 0xFF00FF)]
for i, (name, color) in enumerate(colors):
widgets += [
{'obj': {'x': 16 + i * 129, 'y': 118, 'width': 122, 'height': 200,
'bg_color': color, 'bg_opa': 'COVER', 'border_width': 0,
'scrollable': False, 'gesture_bubble': True}},
label(name, 16 + i * 129, 332, 122, 18, text_align='CENTER'),
]
return {'id': 'color_test_page', 'skip': True, 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False,
'on_swipe_left': [{'lvgl.page.next': {}}],
'on_swipe_right': [{'lvgl.page.previous': {}}], 'widgets': widgets}
def generate(printers):
validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [],
@@ -163,6 +192,7 @@ def generate(printers):
fields = dict(NUMERIC)
if printer.get('dual_nozzle'):
fields.update(DUAL)
fields.update({key: EXTRA_FANS[key] for key in printer.get('extra_fans', [])})
expressions = {}
for key, suffix in fields.items():
sid = f'{pid}_{key}'
@@ -193,6 +223,12 @@ def generate(printers):
lines.append(f'data.{key} = {expr};')
if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
lines += [
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
'const auto clock = id(ha_time).now();',
'if (clock.is_valid() && seconds_left >= 0)',
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
]
tray_widgets = []
for i, tray in enumerate(trays):
lines.append('data.trays.push_back({' + ', '.join([
@@ -201,8 +237,9 @@ def generate(printers):
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}')
refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'progress_arc', 'progress_label', 'nozzle_label',
'bed_label', 'remaining_label', 'layers_label', 'stage_label')]
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label')]
for key in ('progress', 'nozzle', 'bed', 'fan', 'aux', 'exhaust'):
refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}')
refs.append('{' + ', '.join(tray_widgets) + '}')
lines.append('bambu_display::render(data, {' + ', '.join(refs) + '});')
package['script'].append({'id': f'{pid}_refresh', 'then': [{'lambda': Lambda('\n'.join(lines))}]})
@@ -210,6 +247,10 @@ def generate(printers):
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
'then': [{'script.execute': f'{pid}_refresh'}]}})
package['lvgl']['pages'].append(page_for(printer, index, len(printers)))
package['lvgl']['pages'].append(color_test_page())
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
'entity_category': 'diagnostic',
'on_press': [{'lvgl.page.show': 'color_test_page'}]}]
return package