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:
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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'))
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user