Add a fleet overview page that shows every printer's status at once.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joshua King
2026-10-20 13:05:32 -04:00
co-authored by Cursor
parent 0ec94a909e
commit 43f416ab69
7 changed files with 1283 additions and 61 deletions
+16 -12
View File
@@ -17,24 +17,28 @@ Home Assistant must be running and its printer integrations must have current pr
The configured swipe order is: The configured swipe order is:
1. PrintsCharming — P1S, Office, both four-slot AMS units. 1. Fleet — one row per printer: status, job name, progress, finish time and
2. Polly-Pocket — A1 mini, Office, external spool. time remaining. The header counts printers that need attention, are printing,
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS. paused, idle, offline or still syncing. Tap a row to open that printer.
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS. 2. PrintsCharming — P1S, Office, both four-slot AMS units.
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units. 3. Polly-Pocket — A1 mini, Office, external spool.
4. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
5. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
6. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
AMS A uses `constipation_orion_ams_1`; AMS B retains its original AMS A uses `constipation_orion_ams_1`; AMS B retains its original
`h2c_31b8ap5a2200288_ams_2` entity prefix. `h2c_31b8ap5a2200288_ams_2` entity prefix.
6. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures 7. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
and model/auxiliary/enclosure fan speeds. and model/auxiliary/enclosure fan speeds.
7. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers, 8. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
elapsed time, remaining time and estimated finish. elapsed time, remaining time and estimated finish.
![Saturn resin page with synthetic telemetry](previews/saturn.png) ![Saturn resin page with synthetic telemetry](previews/saturn.png)
Swipe left for the next printer or right for the previous printer. Navigation wraps Swipe left for the next page or right for the previous page. Navigation wraps
from the last page to the first. Previous/Next buttons provide the same navigation. from the last printer back to the fleet page. Previous/Next buttons provide the
Swipes propagate through the arc gauges and filament slots. A swipe consumes the same navigation. Swipes propagate through the fleet rows, arc gauges and filament
rest of that touch so dragging across a navigation button does not also click it. slots. A swipe consumes the rest of that touch so dragging across a row or a
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
temperatures, remaining time, estimated finish time, current/total layers, print temperatures, remaining time, estimated finish time, current/total layers, print
@@ -142,7 +146,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 and swipe page. The practical adding a printer creates its own subscriptions, widgets, swipe page and fleet row. 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.
+71
View File
@@ -7,6 +7,7 @@
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
namespace bambu_display { namespace bambu_display {
@@ -212,6 +213,19 @@ struct Snapshot {
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c))); for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return text; return text;
} }
std::string overview_task() const {
if (!live()) return connected ? "Waiting for printer data" : "Waiting for Home Assistant";
return valid_text(task) ? task : "No active print";
}
std::string overview_meta() const {
if (!live()) return "--";
const std::string progress_text = std::isfinite(progress)
? number(std::clamp(progress, 0.0f, 100.0f)) + "%" : "--";
const std::string time_line = remaining(time_left, remaining_unit);
if (!finish_time.empty() && time_line != "--")
return progress_text + " " + finish_time + " " + time_line;
return progress_text + " " + time_line;
}
uint32_t status_color() const { uint32_t status_color() const {
if (!live()) return MUTED; if (!live()) return MUTED;
const auto state = lower(status); const auto state = lower(status);
@@ -223,6 +237,42 @@ struct Snapshot {
} }
}; };
struct FleetCounts {
int attention = 0, printing = 0, paused = 0, idle = 0, offline = 0, syncing = 0;
};
inline void tally(FleetCounts &counts, const Snapshot &data) {
if (!data.connected || lower(data.online) == "off" || lower(data.online) == "false") {
++counts.offline;
return;
}
if (!data.live()) {
++counts.syncing;
return;
}
if (data.has_print_error() || is_on(data.hms_error) || lower(data.status) == "failed") {
++counts.attention;
return;
}
const auto state = lower(data.status);
if (state == "running" || state == "printing") ++counts.printing;
else if (state == "pause" || state == "paused" || state == "pausing") ++counts.paused;
else ++counts.idle;
}
inline std::string fleet_summary(const FleetCounts &counts) {
const std::pair<int, const char *> items[] = {
{counts.attention, "attention"}, {counts.printing, "printing"}, {counts.paused, "paused"},
{counts.idle, "idle"}, {counts.offline, "offline"}, {counts.syncing, "syncing"}};
std::string text;
for (const auto &item : items) {
if (!item.first) continue;
if (!text.empty()) text += ", ";
text += std::to_string(item.first) + " " + item.second;
}
return text.empty() ? "Syncing" : text;
}
} // namespace bambu_display } // namespace bambu_display
#ifdef USE_LVGL #ifdef USE_LVGL
@@ -239,6 +289,9 @@ struct Widgets {
GaugeWidgets uv{}, layer{}; GaugeWidgets uv{}, layer{};
lv_obj_t *elapsed = nullptr; lv_obj_t *elapsed = nullptr;
}; };
struct OverviewWidgets {
lv_obj_t *status, *task, *meta, *progress, *accent;
};
inline void set_text(lv_obj_t *label, const std::string &text) { inline void set_text(lv_obj_t *label, const std::string &text) {
if (text != lv_label_get_text(label)) lv_label_set_text(label, text.c_str()); if (text != lv_label_get_text(label)) lv_label_set_text(label, text.c_str());
@@ -306,5 +359,23 @@ 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);
}
inline void render_fleet_summary(lv_obj_t *label, const FleetCounts &counts) {
set_text(label, fleet_summary(counts));
const uint32_t color = counts.attention ? RED : counts.printing ? GREEN : MUTED;
lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
}
} // namespace bambu_display } // namespace bambu_display
#endif #endif
+128 -33
View File
@@ -121,7 +121,7 @@ def fdm_gauges(printer):
return widgets return widgets
def page_for(printer, index, total): def page_for(printer, display_index, total):
pid = printer['id'] pid = printer['id']
resin = printer.get('profile') == 'elegoo_resin' resin = printer.get('profile') == 'elegoo_resin'
widgets = [ widgets = [
@@ -129,7 +129,7 @@ def page_for(printer, index, total):
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK, 'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}}, 'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
label(printer['name'], 16, 17, 485, 26, text_color=GREEN), 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(f'{display_index} / {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, label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED,
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]), clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK, {'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
@@ -213,6 +213,116 @@ def page_for(printer, index, total):
'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets} 'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets}
def snapshot_lines(printer, expressions, trays):
profile = printer.get('profile', 'bambu')
elegoo = profile.startswith('elegoo_')
resin = profile == 'elegoo_resin'
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};')
if elegoo:
lines.append('data.elegoo = true;')
else:
for number in range(1, 4):
lines.append('data.health_notices.push_back({' + ', '.join(
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
if resin:
lines.append('data.resin = true;')
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");',
]
for i, tray in enumerate(trays):
lines.append('data.trays.push_back({' + ', '.join([
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
return lines
def overview_layout(count):
row_h = (436 - 46) // count
return row_h, row_h >= 52
def overview_row(printer, y, row_h, two_line):
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),
]
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)]
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_color': TEXT_COLOR, 'text_font': 'montserrat_18'}}],
'on_click': [{f'lvgl.page.{direction}': {}}]}})
widgets.append(label(f'1 / {len(printers) + 1}', 94, 449, 612, 14,
text_align='CENTER', text_color=MUTED))
return {'id': 'fleet_page', 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
'on_swipe_left': [
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
{'lvgl.page.next': {'animation': 'MOVE_LEFT', 'time': '180ms'}}],
'on_swipe_right': [
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
{'lvgl.page.previous': {'animation': 'MOVE_RIGHT', 'time': '180ms'}}],
'on_load': [{'script.execute': 'fleet_refresh'}], 'widgets': widgets}
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)']) + '});')
lines.append('}')
lines.append('bambu_display::render_fleet_summary(id(fleet_summary), counts);')
return lines
def color_test_page(): def color_test_page():
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28), widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
label('Each block must match its label. Swipe to return to the printers.', label('Each block must match its label. Swipe to return to the printers.',
@@ -236,6 +346,8 @@ def generate(printers):
validate_printers(printers) validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [], package = {'sensor': [], 'text_sensor': [], 'script': [],
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}} 'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
snapshots = []
page_total = len(printers) + 1
for index, printer in enumerate(printers): for index, printer in enumerate(printers):
pid = printer['id'] pid = printer['id']
profile = printer.get('profile', 'bambu') profile = printer.get('profile', 'bambu')
@@ -287,34 +399,11 @@ def generate(printers):
subscription['attribute'] = attribute subscription['attribute'] = attribute
package['text_sensor'].append(subscription) package['text_sensor'].append(subscription)
expressions[key] = f'id({sid}).state' expressions[key] = f'id({sid}).state'
lines = ['bambu_display::Snapshot data;', lines = snapshot_lines(printer, expressions, trays)
'data.connected = id(ha_api).is_connected_with_state_subscription();'] snapshots.append((pid, list(lines)))
for key, expr in expressions.items(): tray_widgets = ['{' + ', '.join(f'id({pid}_tray_{i}_{part})'
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')): for part in ('card', 'swatch', 'label')) + '}'
lines.append(f'data.{key} = {expr};') for i in range(len(trays))]
if elegoo:
lines.append('data.elegoo = true;')
else:
for number in range(1, 4):
lines.append('data.health_notices.push_back({' + ', '.join(
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
if resin:
lines.append('data.resin = true;')
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([
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}')
refs = [f'id({pid}_{key})' for key in ( refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label', 'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
'health_details')] 'health_details')]
@@ -331,7 +420,13 @@ def generate(printers):
package['interval'][0]['then'].append({'if': { package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': f'{pid}_page'}, 'condition': {'lvgl.page.is_showing': f'{pid}_page'},
'then': [{'script.execute': f'{pid}_refresh'}]}}) 'then': [{'script.execute': f'{pid}_refresh'}]}})
package['lvgl']['pages'].append(page_for(printer, index, len(printers))) package['lvgl']['pages'].append(page_for(printer, index + 2, page_total))
package['script'].append({'id': 'fleet_refresh', 'then': [
{'lambda': Lambda('\n'.join(fleet_refresh_lines(snapshots)))}]})
package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': 'fleet_page'},
'then': [{'script.execute': 'fleet_refresh'}]}})
package['lvgl']['pages'].insert(0, overview_page(printers))
package['lvgl']['pages'].append(color_test_page()) package['lvgl']['pages'].append(color_test_page())
package['button'] = [{'platform': 'template', 'name': 'Display Color Test', package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
'entity_category': 'diagnostic', 'entity_category': 'diagnostic',
@@ -351,10 +446,10 @@ def main():
if args.check: if args.check:
if not destination.exists() or destination.read_text() != content: if not destination.exists() or destination.read_text() != content:
raise SystemExit('generated.yaml is stale; run generate.py') raise SystemExit('generated.yaml is stale; run generate.py')
print(f'Generated configuration is current ({len(printers)} printer pages).') print(f'Generated configuration is current ({len(printers)} printer pages plus fleet overview).')
else: else:
destination.write_text(content) destination.write_text(content)
print(f'Generated {destination.name} with {len(printers)} printer pages.') print(f'Generated {destination.name} with {len(printers)} printer pages plus fleet overview.')
if __name__ == '__main__': if __name__ == '__main__':
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -87,7 +87,19 @@ inline void setup() {
''') ''')
steps = [{'component.update': entry['id']} steps = [{'component.update': entry['id']}
for section in ('sensor', 'text_sensor') for entry in package[section]] for section in ('sensor', 'text_sensor') for entry in package[section]]
steps += [{'delay': '1s'}] 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_summary))) != '
+ json.dumps(f'{len(printers)} printing')
+ ') { ESP_LOGE("preview", "Fleet summary 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_status))) != "PRINTING") '
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'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'}, {'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
@@ -125,7 +137,7 @@ inline void setup() {
{'lvgl.widget.hide': demo_printer + '_health_panel'}, {'lvgl.widget.hide': demo_printer + '_health_panel'},
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}}, {'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
{'script.execute': demo_printer + '_refresh'}] {'script.execute': demo_printer + '_refresh'}]
steps += [{'lvgl.page.show': printers[0]['id'] + '_page'}, {'delay': '250ms'}, steps += [{'lvgl.page.show': 'fleet_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}] {'lambda': Lambda('bambu_preview::setup();')}]
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]: for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
steps += [ steps += [
@@ -136,11 +148,13 @@ inline void setup() {
if len(printers) > 1: if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting # First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV. # on NEXT must advance exactly once even when released over PREV.
gestures = [(650, 180, 180, printers[1]['id']), # Fleet -> first printer -> fleet -> last printer -> fleet, then a
(180, 650, 180, printers[0]['id']), # swipe starting on NEXT advances exactly once.
gestures = [(650, 180, 180, printers[0]['id']),
(180, 650, 180, 'fleet'),
(180, 650, 180, printers[-1]['id']), (180, 650, 180, printers[-1]['id']),
(650, 180, 180, printers[0]['id']), (650, 180, 180, 'fleet'),
(730, 60, 460, printers[1]['id'])] (730, 60, 460, printers[0]['id'])]
for start, end, y, expected in gestures: for start, end, y, expected in gestures:
steps += [ steps += [
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')}, {'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
@@ -165,7 +179,7 @@ inline void setup() {
subprocess.run(['esphome', 'compile', str(filename)], check=True) subprocess.run(['esphome', 'compile', str(filename)], check=True)
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program' binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
subprocess.run([str(binary)], check=True, timeout=30, cwd=root) subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
for name in [printer['id'] for printer in printers] + ['color_test', 'health_notice']: for name in ['fleet'] + [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp') bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
with Image.open(bmp) as image: with Image.open(bmp) as image:
image.save(args.output / (name + '.png')) image.save(args.output / (name + '.png'))
+37
View File
@@ -103,4 +103,41 @@ int main() {
assert(!data.live()); assert(!data.live());
assert(data.status_text() == "PRINTER OFFLINE"); assert(data.status_text() == "PRINTER OFFLINE");
assert(data.health_details().find("unavailable") != std::string::npos); assert(data.health_details().find("unavailable") != std::string::npos);
data.connected = true;
data.online = "on";
data.elegoo = false;
data.print_error = "off";
data.print_error_reason = "ok";
data.hms_error = "off";
data.status = "running";
data.task = "Workshop organizer";
data.progress = 67;
data.time_left = 1.75f;
data.remaining_unit = "h";
data.finish_time = "16:05";
assert(data.overview_task() == "Workshop organizer");
assert(data.overview_meta() == "67% 16:05 1h 45m");
data.task = "";
assert(data.overview_task() == "No active print");
data.connected = false;
assert(data.overview_task() == "Waiting for Home Assistant");
assert(data.overview_meta() == "--");
FleetCounts counts;
data.connected = true;
data.online = "on";
data.status = "printing";
tally(counts, data);
data.status = "pause";
tally(counts, data);
data.print_error = "on";
tally(counts, data);
data.print_error = "off";
data.online = "off";
tally(counts, data);
data.online = "unavailable";
data.connected = true;
tally(counts, data);
assert(fleet_summary(counts) == "1 attention, 1 printing, 1 paused, 1 offline, 1 syncing");
} }
+26 -2
View File
@@ -27,8 +27,8 @@ class FleetTests(unittest.TestCase):
extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}') extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}')
self.printers.append(extra) self.printers.append(extra)
package = generate(self.printers) package = generate(self.printers)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count) self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count + 1)
self.assertEqual(len(package['script']), expected_count) self.assertEqual(len(package['script']), expected_count + 1)
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']} subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress') self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
for page in package['lvgl']['pages']: for page in package['lvgl']['pages']:
@@ -147,6 +147,30 @@ class FleetTests(unittest.TestCase):
self.assertEqual(config['packages']['fleet'], 'bambu-display/generated.yaml') self.assertEqual(config['packages']['fleet'], 'bambu-display/generated.yaml')
self.assertEqual(len(names), 2) self.assertEqual(len(names), 2)
def test_fleet_overview_lists_every_printer(self):
package = generate(self.printers)
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],
[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']
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)
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')
numbers = [next(iter(widget.values())).get('text') for widget in charming['widgets']]
self.assertIn(f'2 / {len(self.printers) + 1}', numbers)
def test_diagnostic_color_page_is_skipped_during_normal_navigation(self): def test_diagnostic_color_page_is_skipped_during_normal_navigation(self):
package = generate(self.printers) package = generate(self.printers)
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page') page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page')