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:
1. PrintsCharming — P1S, Office, both four-slot AMS units.
2. Polly-Pocket — A1 mini, Office, external spool.
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
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.
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.
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
`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.
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.
![Saturn resin page with synthetic telemetry](previews/saturn.png)
Swipe left for the next printer or right for the previous printer. Navigation wraps
from the last page to the first. Previous/Next buttons provide the same navigation.
Swipes propagate through the arc gauges and filament slots. A swipe consumes the
rest of that touch so dragging across a navigation button does not also click it.
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
navigation button does not also click it.
FDM pages show print status, job name, progress, nozzle and bed current/target
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).
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
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.
+71
View File
@@ -7,6 +7,7 @@
#include <cstdint>
#include <cstdlib>
#include <string>
#include <utility>
#include <vector>
namespace bambu_display {
@@ -212,6 +213,19 @@ struct Snapshot {
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
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 {
if (!live()) return MUTED;
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
#ifdef USE_LVGL
@@ -239,6 +289,9 @@ struct Widgets {
GaugeWidgets uv{}, layer{};
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) {
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
#endif
+128 -33
View File
@@ -121,7 +121,7 @@ def fdm_gauges(printer):
return widgets
def page_for(printer, index, total):
def page_for(printer, display_index, total):
pid = printer['id']
resin = printer.get('profile') == 'elegoo_resin'
widgets = [
@@ -129,7 +129,7 @@ def page_for(printer, index, total):
'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(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,
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
{'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}
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():
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
label('Each block must match its label. Swipe to return to the printers.',
@@ -236,6 +346,8 @@ def generate(printers):
validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [],
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
snapshots = []
page_total = len(printers) + 1
for index, printer in enumerate(printers):
pid = printer['id']
profile = printer.get('profile', 'bambu')
@@ -287,34 +399,11 @@ def generate(printers):
subscription['attribute'] = attribute
package['text_sensor'].append(subscription)
expressions[key] = f'id({sid}).state'
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");',
]
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')) + '}')
lines = snapshot_lines(printer, expressions, trays)
snapshots.append((pid, list(lines)))
tray_widgets = ['{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}'
for i in range(len(trays))]
refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
'health_details')]
@@ -331,7 +420,13 @@ def generate(printers):
package['interval'][0]['then'].append({'if': {
'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(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['button'] = [{'platform': 'template', 'name': 'Display Color Test',
'entity_category': 'diagnostic',
@@ -351,10 +446,10 @@ def main():
if args.check:
if not destination.exists() or destination.read_text() != content:
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:
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__':
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']}
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:
steps += [{'lvgl.page.show': printer['id'] + '_page'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
@@ -125,7 +137,7 @@ inline void setup() {
{'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': printers[0]['id'] + '_page'}, {'delay': '250ms'},
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 += [
@@ -136,11 +148,13 @@ inline void setup() {
if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV.
gestures = [(650, 180, 180, printers[1]['id']),
(180, 650, 180, printers[0]['id']),
# 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, printers[0]['id']),
(730, 60, 460, 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;')},
@@ -165,7 +179,7 @@ inline void setup() {
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 [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')
with Image.open(bmp) as image:
image.save(args.output / (name + '.png'))
+37
View File
@@ -103,4 +103,41 @@ int main() {
assert(!data.live());
assert(data.status_text() == "PRINTER OFFLINE");
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}')
self.printers.append(extra)
package = generate(self.printers)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count)
self.assertEqual(len(package['script']), 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 + 1)
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
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(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):
package = generate(self.printers)
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page')