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: The configured swipe order is:
1. Fleet — one row per printer: status, job name, progress, finish time and 1. Fleet — one steam gauge per printer, matching the progress arcs on the
time remaining. The header counts printers that need attention, are printing, printer pages. The needle is print progress, and the arc color follows
paused, idle, offline or still syncing. Tap a row to open that printer. 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. 2. PrintsCharming — P1S, Office, both four-slot AMS units.
3. Polly-Pocket — A1 mini, Office, external spool. 3. Polly-Pocket — A1 mini, Office, external spool.
4. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS. 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 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 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 same navigation. Swipes propagate through the fleet gauges and filament
slots. A swipe consumes the rest of that touch so dragging across a row or a slots. A swipe consumes the rest of that touch so dragging across a gauge or a
navigation button does not also click it. navigation button does not also click it.
FDM pages show print status, job name, progress, nozzle and bed current/target 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). 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; 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 limit depends on the board's available RAM and flash, and adding printers requires
regenerating and installing the firmware. Seven printers are configured; generation 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. 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; lv_obj_t *elapsed = nullptr;
}; };
struct OverviewWidgets { 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) { 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) { inline void render_overview(const Snapshot &data, const OverviewWidgets &widgets) {
const bool live = data.live(); const bool live = data.live();
const uint32_t color = data.status_color(); const uint32_t color = data.status_color();
set_text(widgets.status, data.status_text()); const float shown = live && std::isfinite(data.progress)
lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0); ? std::clamp(data.progress, 0.0f, 100.0f) : NAN;
if (widgets.task) set_text(widgets.task, data.overview_task()); lv_arc_set_value(widgets.arc, live ? gauge_value(data.progress, 100) : 0);
set_text(widgets.meta, data.overview_meta()); lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(color), LV_PART_INDICATOR);
lv_bar_set_value(widgets.progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF); set_text(widgets.value, number(shown));
lv_obj_set_style_bg_color(widgets.progress, lv_color_hex(color), LV_PART_INDICATOR); lv_obj_set_style_text_color(widgets.value, lv_color_hex(live ? color : MUTED), 0);
lv_obj_set_style_bg_color(widgets.accent, lv_color_hex(color), 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) { 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 return lines
def overview_layout(count): def overview_slots(count):
row_h = (436 - 46) // count """Place one steam gauge per printer. Full-size arcs match the printer pages."""
return row_h, row_h >= 52 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'] pid = printer['id']
name_w, status_x, status_w = (430, 448, 312) if two_line else (240, 258, 150) scale = arc / 154
meta_x, meta_w, meta_y = (448, 312, 28) if two_line else (414, 346, 8) value_font = 32 if arc >= 140 else 18
children = [ detail_font = 14 if arc >= 140 else 12
{'obj': {'id': f'{pid}_overview_accent', 'x': 0, 'y': 8, 'width': 4, name_font = 14 if arc >= 140 else 12
'height': max(row_h - 16, 8), 'bg_color': MUTED, 'bg_opa': 'COVER', open_page = [{'lvgl.page.show': f'{pid}_page'}]
'border_width': 0, 'radius': 2, 'pad_all': 0, 'scrollable': False, inner = max(8, int(round(13 * scale)))
'gesture_bubble': True}}, return [
label(printer['name'], 12, 2 if two_line else 6, name_w, 18 if two_line else 16), {'arc': {'id': f'{pid}_overview_arc', 'x': x + int(round(8 * scale)), 'y': y,
label('SYNCING', status_x, 6 if two_line else 8, status_w, 16 if two_line else 14, 'width': arc, 'height': arc, 'min_value': 0, 'max_value': 100, 'value': 0,
id=f'{pid}_overview_status', text_align='RIGHT', text_color=MUTED), '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): def overview_page(printers):
row_h, two_line = overview_layout(len(printers)) widgets = []
widgets = [ for printer, (x, y, arc) in zip(printers, overview_slots(len(printers))):
label('FLEET', 16, 8, 180, 26, text_color=GREEN), widgets += overview_gauge(printer, x, y, arc)
label('SYNCING', 210, 16, 574, 14, id='fleet_summary', text_align='RIGHT', text_color=MUTED), widgets.append(label('SYNCING', 90, 404, 620, 14, id='fleet_summary',
] text_align='CENTER', text_color=MUTED))
widgets += [overview_row(printer, 46 + index * row_h, row_h, two_line)
for index, printer in enumerate(printers)]
for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')): for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')):
widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30, widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30,
'bg_color': TRACK, 'radius': 8, 'border_width': 0, 'bg_color': TRACK, 'radius': 8, 'border_width': 0,
@@ -308,16 +316,14 @@ def overview_page(printers):
def fleet_refresh_lines(snapshots): def fleet_refresh_lines(snapshots):
_, two_line = overview_layout(len(snapshots))
lines = ['bambu_display::FleetCounts counts;'] lines = ['bambu_display::FleetCounts counts;']
for pid, snapshot in snapshots: for pid, snapshot in snapshots:
task = f'id({pid}_overview_task)' if two_line else 'nullptr'
lines.append('{') lines.append('{')
lines.extend(snapshot) lines.extend(snapshot)
lines.append('bambu_display::tally(counts, data);') lines.append('bambu_display::tally(counts, data);')
lines.append('bambu_display::render_overview(data, {' + ', '.join([ lines.append('bambu_display::render_overview(data, {' + ', '.join([
f'id({pid}_overview_status)', task, f'id({pid}_overview_meta)', f'id({pid}_overview_arc)', f'id({pid}_overview_value)',
f'id({pid}_overview_progress)', f'id({pid}_overview_accent)']) + '});') f'id({pid}_overview_detail)']) + '});')
lines.append('}') lines.append('}')
lines.append('bambu_display::render_fleet_summary(id(fleet_summary), counts);') lines.append('bambu_display::render_fleet_summary(id(fleet_summary), counts);')
return lines 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: for printer in printers:
pid = printer['id'] pid = printer['id']
steps.append({'lambda': Lambda( 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); }}')}) f'{{ ESP_LOGE("preview", "Fleet row render failed: {pid}"); exit(7); }}')})
for printer in printers: for printer in printers:
steps += [{'lvgl.page.show': printer['id'] + '_page'}, steps += [{'lvgl.page.show': printer['id'] + '_page'},
+10 -10
View File
@@ -152,19 +152,19 @@ class FleetTests(unittest.TestCase):
page = package['lvgl']['pages'][0] page = package['lvgl']['pages'][0]
self.assertEqual(page['id'], 'fleet_page') self.assertEqual(page['id'], 'fleet_page')
self.assertFalse(page.get('skip')) self.assertFalse(page.get('skip'))
rows = [widget['obj'] for widget in page['widgets'] arcs = [widget['arc'] for widget in page['widgets'] if 'arc' in widget]
if 'obj' in widget and str(widget['obj'].get('id', '')).endswith('_overview_row')] self.assertEqual([arc['id'][:-len('_overview_arc')] for arc in arcs],
self.assertEqual([row['id'][:-len('_overview_row')] for row in rows],
[printer['id'] for printer in self.printers]) [printer['id'] for printer in self.printers])
previous = 46 for arc, printer in zip(arcs, self.printers):
for row, printer in zip(rows, self.printers): self.assertEqual(arc['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}])
self.assertEqual(row['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}]) self.assertEqual(arc['width'], 154)
self.assertGreaterEqual(row['y'], previous) self.assertEqual(arc['start_angle'], 135)
self.assertLessEqual(row['y'] + row['height'], 440) self.assertLessEqual(arc['x'] + arc['width'], 800)
previous = row['y'] + row['height'] self.assertLessEqual(arc['y'] + arc['height'], 480)
code = next(script['then'][0]['lambda'] for script in package['script'] if script['id'] == 'fleet_refresh') code = next(script['then'][0]['lambda'] for script in package['script'] if script['id'] == 'fleet_refresh')
for printer in self.printers: 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']] shown = [step['if']['condition']['lvgl.page.is_showing'] for step in package['interval'][0]['then']]
self.assertIn('fleet_page', shown) self.assertIn('fleet_page', shown)
charming = next(item for item in package['lvgl']['pages'] if item['id'] == 'prints_charming_page') charming = next(item for item in package['lvgl']['pages'] if item['id'] == 'prints_charming_page')