Show each printer on the fleet page as a status gauge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joshua King
2026-10-20 15:13:24 -04:00
co-authored by Cursor
parent 4626019a3e
commit 360e86e46a
6 changed files with 537 additions and 698 deletions
+9 -6
View File
@@ -17,9 +17,12 @@ Home Assistant must be running and its printer integrations must have current pr
The configured swipe order is:
1. Fleet — one row per printer: status, job name, progress, finish time and
time remaining. The header counts printers that need attention, are printing,
paused, idle, offline or still syncing. Tap a row to open that printer.
1. Fleet — one steam gauge per printer, matching the progress arcs on the
printer pages. The needle is print progress, and the arc color follows
status: green while printing, amber while paused, red for an error. The
center is the percent complete and the caption is the status. A line under
the gauges counts printers that need attention, are printing, paused, idle,
offline or still syncing. Tap a gauge to open that printer.
2. PrintsCharming — P1S, Office, both four-slot AMS units.
3. Polly-Pocket — A1 mini, Office, external spool.
4. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
@@ -36,8 +39,8 @@ The configured swipe order is:
Swipe left for the next page or right for the previous page. Navigation wraps
from the last printer back to the fleet page. Previous/Next buttons provide the
same navigation. Swipes propagate through the fleet rows, arc gauges and filament
slots. A swipe consumes the rest of that touch so dragging across a row or a
same navigation. Swipes propagate through the fleet gauges and filament
slots. A swipe consumes the rest of that touch so dragging across a gauge or a
navigation button does not also click it.
FDM pages show print status, job name, progress, nozzle and bed current/target
@@ -146,7 +149,7 @@ python esphome/bambu-display/generate.py --check
The generator requires PyYAML (already included in ESPHome's Python environment).
Do not hand-edit `generated.yaml`. There is no two-printer limit in the generator;
adding a printer creates its own subscriptions, widgets, swipe page and fleet row. The practical
adding a printer creates its own subscriptions, widgets, swipe page and fleet gauge. The practical
limit depends on the board's available RAM and flash, and adding printers requires
regenerating and installing the firmware. Seven printers are configured; generation
is also tested with nine. Each Bambu printer's filament strip supports up to eight slots.
+9 -8
View File
@@ -290,7 +290,7 @@ struct Widgets {
lv_obj_t *elapsed = nullptr;
};
struct OverviewWidgets {
lv_obj_t *status, *task, *meta, *progress, *accent;
lv_obj_t *arc, *value, *detail;
};
inline void set_text(lv_obj_t *label, const std::string &text) {
@@ -362,13 +362,14 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
inline void render_overview(const Snapshot &data, const OverviewWidgets &widgets) {
const bool live = data.live();
const uint32_t color = data.status_color();
set_text(widgets.status, data.status_text());
lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0);
if (widgets.task) set_text(widgets.task, data.overview_task());
set_text(widgets.meta, data.overview_meta());
lv_bar_set_value(widgets.progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(widgets.progress, lv_color_hex(color), LV_PART_INDICATOR);
lv_obj_set_style_bg_color(widgets.accent, lv_color_hex(color), 0);
const float shown = live && std::isfinite(data.progress)
? std::clamp(data.progress, 0.0f, 100.0f) : NAN;
lv_arc_set_value(widgets.arc, live ? gauge_value(data.progress, 100) : 0);
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(color), LV_PART_INDICATOR);
set_text(widgets.value, number(shown));
lv_obj_set_style_text_color(widgets.value, lv_color_hex(live ? color : MUTED), 0);
set_text(widgets.detail, data.status_text());
lv_obj_set_style_text_color(widgets.detail, lv_color_hex(color), 0);
}
inline void render_fleet_summary(lv_obj_t *label, const FleetCounts &counts) {
+45 -39
View File
@@ -245,48 +245,56 @@ def snapshot_lines(printer, expressions, trays):
return lines
def overview_layout(count):
row_h = (436 - 46) // count
return row_h, row_h >= 52
def overview_slots(count):
"""Place one steam gauge per printer. Full-size arcs match the printer pages."""
columns = 4 if count > 4 else max(count, 1)
rows = (count + columns - 1) // columns
top, bottom = 8, 400
arc = min(154, (800 // columns) - 20, (bottom - top) // rows - 36)
col_w = 800 // columns
row_h = (bottom - top) // rows
slots = []
for index in range(count):
row, col = divmod(index, columns)
in_row = min(columns, count - row * columns)
x0 = (800 - in_row * col_w) // 2
gauge_w = arc + 16
slots.append((x0 + col * col_w + (col_w - gauge_w) // 2,
top + row * row_h, arc))
return slots
def overview_row(printer, y, row_h, two_line):
def overview_gauge(printer, x, y, arc):
pid = printer['id']
name_w, status_x, status_w = (430, 448, 312) if two_line else (240, 258, 150)
meta_x, meta_w, meta_y = (448, 312, 28) if two_line else (414, 346, 8)
children = [
{'obj': {'id': f'{pid}_overview_accent', 'x': 0, 'y': 8, 'width': 4,
'height': max(row_h - 16, 8), 'bg_color': MUTED, 'bg_opa': 'COVER',
'border_width': 0, 'radius': 2, 'pad_all': 0, 'scrollable': False,
'gesture_bubble': True}},
label(printer['name'], 12, 2 if two_line else 6, name_w, 18 if two_line else 16),
label('SYNCING', status_x, 6 if two_line else 8, status_w, 16 if two_line else 14,
id=f'{pid}_overview_status', text_align='RIGHT', text_color=MUTED),
scale = arc / 154
value_font = 32 if arc >= 140 else 18
detail_font = 14 if arc >= 140 else 12
name_font = 14 if arc >= 140 else 12
open_page = [{'lvgl.page.show': f'{pid}_page'}]
inner = max(8, int(round(13 * scale)))
return [
{'arc': {'id': f'{pid}_overview_arc', 'x': x + int(round(8 * scale)), 'y': y,
'width': arc, 'height': arc, 'min_value': 0, 'max_value': 100, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': inner, 'arc_color': TRACK,
'indicator': {'arc_color': GREEN, 'arc_width': inner}, 'adjustable': False,
'gesture_bubble': True, 'clickable': True, 'on_click': open_page}},
label('--', x + int(round(14 * scale)), y + int(round(47 * scale)), int(round(142 * scale)),
value_font, id=f'{pid}_overview_value', text_align='CENTER',
clickable=True, on_click=open_page),
label('SYNCING', x + int(round(14 * scale)), y + int(round(86 * scale)),
int(round(142 * scale)), detail_font, id=f'{pid}_overview_detail',
text_align='CENTER', text_color=MUTED, clickable=True, on_click=open_page),
label(printer['name'], x, y + int(round(137 * scale)), arc + 16, name_font,
text_align='CENTER', text_color=GREEN, clickable=True, on_click=open_page),
]
if two_line:
children.append(label('Waiting for Home Assistant', 12, 28, 430, 12,
id=f'{pid}_overview_task', text_color=MUTED))
children += [
label('--', meta_x, meta_y, meta_w, 12 if two_line else 14,
id=f'{pid}_overview_meta', text_align='RIGHT', text_color=MUTED),
{'bar': {'id': f'{pid}_overview_progress', 'x': 12, 'y': row_h - 7, 'width': 748, 'height': 4,
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
]
return {'obj': {'id': f'{pid}_overview_row', 'x': 8, 'y': y, 'width': 784, 'height': row_h,
'bg_color': BG, 'bg_opa': 'COVER', 'border_width': 0, 'pad_all': 0,
'radius': 0, 'scrollable': False, 'clickable': True, 'gesture_bubble': True,
'on_click': [{'lvgl.page.show': f'{pid}_page'}], 'widgets': children}}
def overview_page(printers):
row_h, two_line = overview_layout(len(printers))
widgets = [
label('FLEET', 16, 8, 180, 26, text_color=GREEN),
label('SYNCING', 210, 16, 574, 14, id='fleet_summary', text_align='RIGHT', text_color=MUTED),
]
widgets += [overview_row(printer, 46 + index * row_h, row_h, two_line)
for index, printer in enumerate(printers)]
widgets = []
for printer, (x, y, arc) in zip(printers, overview_slots(len(printers))):
widgets += overview_gauge(printer, x, y, arc)
widgets.append(label('SYNCING', 90, 404, 620, 14, id='fleet_summary',
text_align='CENTER', text_color=MUTED))
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,
@@ -308,16 +316,14 @@ def overview_page(printers):
def fleet_refresh_lines(snapshots):
_, two_line = overview_layout(len(snapshots))
lines = ['bambu_display::FleetCounts counts;']
for pid, snapshot in snapshots:
task = f'id({pid}_overview_task)' if two_line else 'nullptr'
lines.append('{')
lines.extend(snapshot)
lines.append('bambu_display::tally(counts, data);')
lines.append('bambu_display::render_overview(data, {' + ', '.join([
f'id({pid}_overview_status)', task, f'id({pid}_overview_meta)',
f'id({pid}_overview_progress)', f'id({pid}_overview_accent)']) + '});')
f'id({pid}_overview_arc)', f'id({pid}_overview_value)',
f'id({pid}_overview_detail)']) + '});')
lines.append('}')
lines.append('bambu_display::render_fleet_summary(id(fleet_summary), counts);')
return lines
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -98,7 +98,7 @@ inline void setup() {
for printer in printers:
pid = printer['id']
steps.append({'lambda': Lambda(
f'if (std::string(lv_label_get_text(id({pid}_overview_status))) != "PRINTING") '
f'if (std::string(lv_label_get_text(id({pid}_overview_detail))) != "PRINTING") '
f'{{ ESP_LOGE("preview", "Fleet row render failed: {pid}"); exit(7); }}')})
for printer in printers:
steps += [{'lvgl.page.show': printer['id'] + '_page'},
+10 -10
View File
@@ -152,19 +152,19 @@ class FleetTests(unittest.TestCase):
page = package['lvgl']['pages'][0]
self.assertEqual(page['id'], 'fleet_page')
self.assertFalse(page.get('skip'))
rows = [widget['obj'] for widget in page['widgets']
if 'obj' in widget and str(widget['obj'].get('id', '')).endswith('_overview_row')]
self.assertEqual([row['id'][:-len('_overview_row')] for row in rows],
arcs = [widget['arc'] for widget in page['widgets'] if 'arc' in widget]
self.assertEqual([arc['id'][:-len('_overview_arc')] for arc in arcs],
[printer['id'] for printer in self.printers])
previous = 46
for row, printer in zip(rows, self.printers):
self.assertEqual(row['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}])
self.assertGreaterEqual(row['y'], previous)
self.assertLessEqual(row['y'] + row['height'], 440)
previous = row['y'] + row['height']
for arc, printer in zip(arcs, self.printers):
self.assertEqual(arc['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}])
self.assertEqual(arc['width'], 154)
self.assertEqual(arc['start_angle'], 135)
self.assertLessEqual(arc['x'] + arc['width'], 800)
self.assertLessEqual(arc['y'] + arc['height'], 480)
code = next(script['then'][0]['lambda'] for script in package['script'] if script['id'] == 'fleet_refresh')
for printer in self.printers:
self.assertIn(f'id({printer["id"]}_overview_status)', code)
self.assertIn(f'id({printer["id"]}_overview_arc)', code)
self.assertIn(f'id({printer["id"]}_overview_detail)', code)
shown = [step['if']['condition']['lvgl.page.is_showing'] for step in package['interval'][0]['then']]
self.assertIn('fleet_page', shown)
charming = next(item for item in package['lvgl']['pages'] if item['id'] == 'prints_charming_page')