This commit is contained in:
Joshua King
2026-10-17 17:26:25 -04:00
parent 19adc71fef
commit f672de484e
14 changed files with 2946 additions and 1661 deletions
+25 -4
View File
@@ -25,12 +25,16 @@ The configured swipe order is:
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 metric cards and filament slots. A swipe consumes the
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.
Each page shows print status, job name, progress, nozzle and bed current/target
temperatures, remaining time, current/total layers, print stage, part fan speed and
filament colors/types. The active filament slot has a green outline. Print errors
temperatures, remaining time, estimated finish time, current/total layers, print
stage, fan speeds and filament colors/types. The layout follows BambuHelper's
classic gauges: black background, bright green accents, six arc gauges and a
vertical AMS column. Nozzle arcs are orange and the bed arc is cyan. For dual-nozzle
printers the auxiliary-fan position becomes the right-nozzle gauge. The active
filament slot has a green outline. Print errors
and HMS alerts change the status badge to red; paused prints use amber. The display
shows an alert badge rather than the detailed HMS diagnostic text.
@@ -39,6 +43,10 @@ to `--` rather than continuing to show cached values as live. Unknown individual
values also show `--`. Remaining time respects the HA sensor's `h`, `min` or `s`
unit, and temperature labels use the imported HA temperature unit. Only the visible
page is refreshed each second; every printer's subscriptions remain connected.
Auxiliary and exhaust fans are imported only for printers with those sensors;
unsupported fan readings show `--`. Finish time is estimated from HA's remaining
time and the HA-synchronized clock. Set `time.timezone` in the main YAML if your
timezone differs from `America/New_York`.
This version provides monitoring and local page navigation. Camera feeds, cover
images, printer pause/stop controls, power switching, cloud login and automatic
@@ -67,6 +75,18 @@ this board. If GT911 is not found at `0x5D`, inspect the I²C scan and use its r
address (some hardware revisions select `0x14`). Backlight is GPIO2; RGB data pins
and timings are explicit in the main YAML rather than borrowed from a 5″ board.
The display and LVGL both use **big-endian RGB565**. With `mipi_rgb`'s named
`red`/`green`/`blue` pin groups, its generated bus pin map already exchanges the
two bytes. Selecting little-endian pixels corrupts colors on the physical panel
even though a host preview can look correct. Keep both byte-order settings aligned
with the bus mapping; changing the RGB/BGR label alone is insufficient for this
RPI panel's direct framebuffer path.
To check the physical colors, open the device's **Display Color Test** diagnostic
button in Home Assistant. It shows labeled red, green, blue, white, cyan and magenta
blocks. Swipe to return to the printers. This test page is skipped during normal
printer navigation. Its expected image is [previews/color_test.png](previews/color_test.png).
CLI checks from the repository root:
```sh
@@ -106,7 +126,8 @@ is also tested with seven. Each printer's filament strip supports up to eight sl
For a dual-nozzle printer add `dual_nozzle: true`. For external filament add
`external_spools: [{label: EXT, entity: sensor.my_external_spool}]`. Per-metric
`entities` overrides use the keys in `NUMERIC`, `DUAL`, `TEXT` and `BINARY` inside
`generate.py`. A remaining-time override also updates its unit subscription.
`generate.py`. Use `extra_fans: [aux_fan, exhaust_fan]` only where those HA entities
exist; their mappings are in `EXTRA_FANS`. A remaining-time override also updates its unit subscription.
Filament entries use the Bambu integration's `type`, `color` and `active` attributes.
## Development checks and screenshots
+53 -19
View File
@@ -10,11 +10,11 @@
namespace bambu_display {
constexpr uint32_t GREEN = 0x60DE9A;
constexpr uint32_t MUTED = 0x91A3B5;
constexpr uint32_t GREEN = 0x00FF00;
constexpr uint32_t MUTED = 0xAEB3B7;
constexpr uint32_t RED = 0xFF737B;
constexpr uint32_t AMBER = 0xF2C572;
constexpr uint32_t TRACK = 0x263442;
constexpr uint32_t TRACK = 0x19232B;
inline std::string lower(std::string text) {
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
@@ -67,6 +67,20 @@ inline std::string remaining(float value, const std::string &unit) {
return buffer;
}
inline int duration_seconds(float value, const std::string &unit) {
if (!std::isfinite(value) || value < 0) return -1;
double seconds;
if (unit == "h") seconds = double(value) * 3600;
else if (unit == "min") seconds = double(value) * 60;
else if (unit == "s") seconds = value;
else return -1;
return seconds <= 60000000 ? static_cast<int>(std::round(seconds)) : -1;
}
inline float celsius(float value, const std::string &unit) {
return unit == "°F" || unit == "F" ? (value - 32.0f) * 5.0f / 9.0f : value;
}
inline uint32_t filament_color(std::string value) {
if (!value.empty() && value[0] == '#') value.erase(0, 1);
// Bambu publishes RGB or RGBA, with alpha at the end.
@@ -89,8 +103,10 @@ struct Snapshot {
bool connected = false;
std::string online, status, task, stage, print_error, hms_error;
std::string remaining_unit, nozzle_unit, bed_unit;
std::string finish_time;
float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN;
float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN;
float aux_fan = NAN, exhaust_fan = NAN;
bool dual_nozzle = false;
float left_nozzle = NAN, left_target = NAN, right_nozzle = NAN, right_target = NAN;
std::vector<Tray> trays;
@@ -127,8 +143,10 @@ struct Snapshot {
namespace bambu_display {
struct TrayWidgets { lv_obj_t *card, *swatch, *label; };
struct GaugeWidgets { lv_obj_t *arc, *value, *detail; };
struct Widgets {
lv_obj_t *status, *task, *arc, *progress, *nozzle, *bed, *remaining, *layers, *stage;
lv_obj_t *status, *task, *top_progress, *remaining, *layers, *stage;
GaugeWidgets progress, nozzle, bed, fan, aux, exhaust;
std::vector<TrayWidgets> trays;
};
@@ -136,33 +154,49 @@ 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());
}
inline void render_gauge(const GaugeWidgets &widgets, float value, float position,
int maximum, const std::string &detail) {
lv_arc_set_value(widgets.arc, gauge_value(position, maximum));
set_text(widgets.value, number(value));
set_text(widgets.detail, detail);
}
inline void render(const Snapshot &data, const Widgets &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);
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(color), LV_PART_INDICATOR);
lv_arc_set_value(widgets.arc, live ? gauge_value(data.progress, 100) : 0);
set_text(widgets.progress, live && std::isfinite(data.progress)
? number(std::clamp(data.progress, 0.0f, 100.0f)) + "%" : "--");
lv_obj_set_style_arc_color(widgets.progress.arc, lv_color_hex(color), LV_PART_INDICATOR);
lv_bar_set_value(widgets.top_progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF);
render_gauge(widgets.progress, live && std::isfinite(data.progress)
? std::clamp(data.progress, 0.0f, 100.0f) : NAN, live ? data.progress : NAN, 100,
live ? remaining(data.time_left, data.remaining_unit) : "--");
set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print")
: data.connected ? "Waiting for printer data" : "Waiting for Home Assistant");
if (live && data.dual_nozzle) {
set_text(widgets.nozzle, "L " + temperature(data.left_nozzle, data.left_target, data.nozzle_unit)
+ "\nR " + temperature(data.right_nozzle, data.right_target, data.nozzle_unit));
} else {
set_text(widgets.nozzle, live ? temperature(data.nozzle, data.nozzle_target, data.nozzle_unit) : "--");
}
set_text(widgets.bed, live ? temperature(data.bed, data.bed_target, data.bed_unit) : "--");
set_text(widgets.remaining, live ? remaining(data.time_left, data.remaining_unit) : "--");
set_text(widgets.layers, live ? number(data.layer) + " / " + number(data.total_layers) : "--");
const float nozzle = data.dual_nozzle ? data.left_nozzle : data.nozzle;
const float target = data.dual_nozzle ? data.left_target : data.nozzle_target;
render_gauge(widgets.nozzle, live ? nozzle : NAN, live ? celsius(nozzle, data.nozzle_unit) : NAN,
300, live ? "/ " + number(target) + " " + data.nozzle_unit : "--");
render_gauge(widgets.bed, live ? data.bed : NAN, live ? celsius(data.bed, data.bed_unit) : NAN,
120, live ? "/ " + number(data.bed_target) + " " + data.bed_unit : "--");
render_gauge(widgets.fan, live ? data.fan : NAN, live ? data.fan : NAN, 100, "%");
if (data.dual_nozzle)
render_gauge(widgets.aux, live ? data.right_nozzle : NAN,
live ? celsius(data.right_nozzle, data.nozzle_unit) : NAN,
300, live ? "/ " + number(data.right_target) + " " + data.nozzle_unit : "--");
else
render_gauge(widgets.aux, live ? data.aux_fan : NAN, live ? data.aux_fan : NAN, 100, "%");
render_gauge(widgets.exhaust, live ? data.exhaust_fan : NAN, live ? data.exhaust_fan : NAN, 100, "%");
std::string time_line = live ? remaining(data.time_left, data.remaining_unit) : "--";
if (live && !data.finish_time.empty()) time_line = data.finish_time + " | " + time_line;
set_text(widgets.remaining, time_line);
set_text(widgets.layers, live ? "Layer " + number(data.layer) + " / " + number(data.total_layers) : "--");
std::string stage = live ? humanize(data.stage) : "Telemetry unavailable";
if (live && std::isfinite(data.fan)) stage += " | Part fan " + number(data.fan) + "%";
set_text(widgets.stage, stage);
for (size_t i = 0; i < std::min(data.trays.size(), widgets.trays.size()); ++i) {
const auto &tray = data.trays[i];
const auto &widget = widgets.trays[i];
set_text(widget.label, tray.label + " " + (live && valid_text(tray.type) ? tray.type : "--"));
set_text(widget.label, tray.label + "\n" + (live && valid_text(tray.type) ? tray.type : "--"));
lv_obj_set_style_bg_color(widget.swatch, lv_color_hex(live ? filament_color(tray.color) : TRACK), 0);
lv_obj_set_style_border_color(widget.card, lv_color_hex(live && is_on(tray.active) ? GREEN : TRACK), 0);
}
+98 -57
View File
@@ -20,10 +20,12 @@ DUAL = {
'left_nozzle': 'left_nozzle_temperature', 'left_target': 'left_nozzle_target_temperature',
'right_nozzle': 'right_nozzle_temperature', 'right_target': 'right_nozzle_target_temperature',
}
EXTRA_FANS = {'aux_fan': 'aux_fan_speed', 'exhaust_fan': 'chamber_fan_speed'}
TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'}
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'}
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
0x0D131B, 0x17222E, 0x263442, 0xEDF3F7, 0x91A3B5, 0x60DE9A)
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
class Lambda(str):
@@ -67,6 +69,8 @@ def validate_printers(printers):
raise ValueError(f'{pid}: the filament strip fits up to eight slots')
if 'dual_nozzle' in printer and not isinstance(printer['dual_nozzle'], bool):
raise ValueError(f'{pid}: dual_nozzle must be true or false')
if any(key not in EXTRA_FANS for key in printer.get('extra_fans', [])):
raise ValueError(f'{pid}: extra_fans supports aux_fan and exhaust_fan')
for value in printer.get('entities', {}).values():
if not isinstance(value, str) or not re.fullmatch(r'(sensor|binary_sensor)\.[a-z0-9_]+', value):
raise ValueError(f'{pid}: invalid entity override: {value}')
@@ -79,70 +83,76 @@ def label(text, x, y, width, font=18, **kwargs):
'gesture_bubble': True, **kwargs}}
def card(pid, name, title, x, y, width=238, height=104, font=26):
return {'obj': {'x': x, 'y': y, 'width': width, 'height': height,
'bg_color': CARD, 'radius': 16, 'border_width': 0,
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True,
'widgets': [
label(title, 16, 12, width - 32, 14, text_color=MUTED),
label('--', 16, 38, width - 32, font, id=f'{pid}_{name}_label', height=height - 42),
]}}
def gauge(pid, key, title, x, y, color, maximum):
return [
{'arc': {'id': f'{pid}_{key}_arc', 'x': x + 8, 'y': y, 'width': 154, 'height': 154,
'min_value': 0, 'max_value': maximum, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': 13,
'arc_color': TRACK, 'indicator': {'arc_color': color, 'arc_width': 13},
'adjustable': False, 'gesture_bubble': True}},
label('--', x + 14, y + 47, 142, 32, id=f'{pid}_{key}_label', text_align='CENTER'),
label('--', x + 14, y + 86, 142, 16, id=f'{pid}_{key}_detail', text_align='CENTER', text_color=MUTED),
label(title, x, y + 137, 170, 18, text_align='CENTER', text_color=color),
]
def page_for(printer, index, total):
pid = printer['id']
widgets = [
label(printer['name'], 24, 16, 545, 28),
label(printer['subtitle'], 24, 53, 545, 14, text_color=MUTED),
{'obj': {'x': 586, 'y': 20, 'width': 190, 'height': 42,
'bg_color': CARD, 'radius': 21, 'border_width': 0, 'pad_all': 0,
'scrollable': False, 'gesture_bubble': True,
'widgets': [label('SYNCING', 0, 11, 190, 16,
id=f'{pid}_status_label', text_align='CENTER', text_color=MUTED)]}},
label('Waiting for Home Assistant', 24, 88, 752, 20, id=f'{pid}_task_label'),
{'arc': {'id': f'{pid}_progress_arc', 'x': 28, 'y': 132, 'width': 224, 'height': 224,
'min_value': 0, 'max_value': 100, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': 14,
'arc_color': TRACK, 'indicator': {'arc_color': GREEN, 'arc_width': 14},
'adjustable': False, 'gesture_bubble': True}},
label('--', 42, 207, 196, 48, id=f'{pid}_progress_label', text_align='CENTER'),
label('PRINT PROGRESS', 42, 266, 196, 14, text_align='CENTER', text_color=MUTED),
card(pid, 'nozzle', 'NOZZLES L/R / TARGET' if printer.get('dual_nozzle')
else 'NOZZLE / TARGET', 282, 132, height=118,
font=18 if printer.get('dual_nozzle') else 26),
card(pid, 'bed', 'BED / TARGET', 538, 132, height=118),
card(pid, 'remaining', 'REMAINING', 282, 262, height=86),
card(pid, 'layers', 'LAYER', 538, 262, height=86),
label('Telemetry unavailable', 24, 358, 752, 16, id=f'{pid}_stage_label', text_color=MUTED),
label('FILAMENT / ACTIVE SLOT OUTLINED IN GREEN', 24, 383, 752, 12, text_color=MUTED),
{'bar': {'id': f'{pid}_top_progress', 'x': 0, 'y': 0, 'width': 800, 'height': 5,
'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('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED),
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
'border_width': 0, 'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
]
widgets += gauge(pid, 'progress', 'Progress', 16, 62, GREEN, 100)
widgets += gauge(pid, 'nozzle', 'Nozzle L' if printer.get('dual_nozzle') else 'Nozzle',
202, 62, ORANGE, 300)
widgets += gauge(pid, 'bed', 'Bed', 388, 62, CYAN, 120)
widgets += gauge(pid, 'fan', 'Part', 16, 229, 0xEEEEEE, 100)
widgets += gauge(pid, 'aux', 'Nozzle R' if printer.get('dual_nozzle') else 'Aux',
202, 229, ORANGE if printer.get('dual_nozzle') else 0xEEEEEE,
300 if printer.get('dual_nozzle') else 100)
widgets += gauge(pid, 'exhaust', 'Exhaust', 388, 229, GREEN, 100)
widgets += [
label('--', 16, 401, 544, 26, id=f'{pid}_remaining_label', text_color=GREEN, text_align='CENTER'),
label('Telemetry unavailable', 16, 430, 544, 12, id=f'{pid}_stage_label', text_color=MUTED, text_align='CENTER'),
label('--', 594, 397, 190, 16, id=f'{pid}_layers_label', text_color=MUTED, text_align='CENTER'),
]
trays = trays_for(printer)
if trays:
width = (752 - 8 * (len(trays) - 1)) // len(trays)
for i, tray in enumerate(trays):
widgets.append({'obj': {
'id': f'{pid}_tray_{i}_card', 'x': 24 + (width + 8) * i, 'y': 401,
'width': width, 'height': 32, 'bg_color': CARD, 'radius': 6,
'border_width': 1, 'border_color': TRACK, 'pad_all': 0,
'scrollable': False, 'gesture_bubble': True,
'widgets': [
{'obj': {'id': f'{pid}_tray_{i}_swatch', 'x': 1, 'y': 1, 'width': width - 4,
'height': 5, 'bg_color': TRACK, 'radius': 2, 'border_width': 0,
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
label(f'{tray["label"]} --', 6, 11, width - 12, 12,
id=f'{pid}_tray_{i}_label'),
]}})
else:
widgets.append(label('No filament slots configured', 24, 403, 752, 14, text_color=MUTED))
for direction, x, text in (('previous', 24, '< PREV'), ('next', 688, 'NEXT >')):
widgets.append({'button': {'x': x, 'y': 444, 'width': 88, 'height': 30,
'bg_color': CARD, 'radius': 8, 'border_width': 0,
for i, tray in enumerate(trays):
bank = i // 4
slot = i % 4
if slot == 0:
title = 'AMS ' + tray['label'][:-1] if tray['label'][-1:].isdigit() else 'EXTERNAL SPOOL'
widgets.append(label(title, 594, 70 + bank * 145, 190, 20,
text_align='CENTER', text_color=MUTED))
widgets.append({'obj': {
'id': f'{pid}_tray_{i}_card', 'x': 594 + 48 * slot, 'y': 102 + bank * 145,
'width': 44, 'height': 82, 'bg_color': BG, 'radius': 5,
'border_width': 1, 'border_color': TRACK, 'pad_all': 0,
'scrollable': False, 'gesture_bubble': True,
'widgets': [
{'obj': {'id': f'{pid}_tray_{i}_swatch', 'x': 3, 'y': 3, 'width': 36,
'height': 40, 'bg_color': TRACK, 'radius': 4, 'border_width': 0,
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
label(f'{tray["label"]}\n--', 1, 46, 40, 12, height=34,
id=f'{pid}_tray_{i}_label', text_align='CENTER'),
]}})
if not trays:
widgets.append(label('No AMS', 594, 150, 190, 20, text_color=MUTED, text_align='CENTER'))
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_font': 'montserrat_12'}}],
'text_color': TEXT_COLOR, 'text_font': 'montserrat_18'}}],
'on_click': [{f'lvgl.page.{direction}': {}}]}})
widgets.append(label(f'{index + 1} / {total} SWIPE LEFT / RIGHT', 130, 451, 540, 14,
text_align='CENTER', text_color=MUTED))
widgets.append(label('Waiting for Home Assistant', 94, 449, 612, 14,
id=f'{pid}_task_label', text_align='CENTER'))
return {'id': f'{pid}_page', 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
'on_swipe_left': [
@@ -154,6 +164,25 @@ def page_for(printer, index, total):
'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets}
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.',
16, 62, 768, 18)]
colors = [('RED', 0xFF0000), ('GREEN', 0x00FF00), ('BLUE', 0x0000FF),
('WHITE', 0xFFFFFF), ('CYAN', 0x00FFFF), ('MAGENTA', 0xFF00FF)]
for i, (name, color) in enumerate(colors):
widgets += [
{'obj': {'x': 16 + i * 129, 'y': 118, 'width': 122, 'height': 200,
'bg_color': color, 'bg_opa': 'COVER', 'border_width': 0,
'scrollable': False, 'gesture_bubble': True}},
label(name, 16 + i * 129, 332, 122, 18, text_align='CENTER'),
]
return {'id': 'color_test_page', 'skip': True, 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False,
'on_swipe_left': [{'lvgl.page.next': {}}],
'on_swipe_right': [{'lvgl.page.previous': {}}], 'widgets': widgets}
def generate(printers):
validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [],
@@ -163,6 +192,7 @@ def generate(printers):
fields = dict(NUMERIC)
if printer.get('dual_nozzle'):
fields.update(DUAL)
fields.update({key: EXTRA_FANS[key] for key in printer.get('extra_fans', [])})
expressions = {}
for key, suffix in fields.items():
sid = f'{pid}_{key}'
@@ -193,6 +223,12 @@ def generate(printers):
lines.append(f'data.{key} = {expr};')
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([
@@ -201,8 +237,9 @@ def generate(printers):
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}')
refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'progress_arc', 'progress_label', 'nozzle_label',
'bed_label', 'remaining_label', 'layers_label', 'stage_label')]
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label')]
for key in ('progress', 'nozzle', 'bed', 'fan', 'aux', 'exhaust'):
refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}')
refs.append('{' + ', '.join(tray_widgets) + '}')
lines.append('bambu_display::render(data, {' + ', '.join(refs) + '});')
package['script'].append({'id': f'{pid}_refresh', 'then': [{'lambda': Lambda('\n'.join(lines))}]})
@@ -210,6 +247,10 @@ def generate(printers):
'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(color_test_page())
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
'entity_category': 'diagnostic',
'on_press': [{'lvgl.page.show': 'color_test_page'}]}]
return package
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -21,7 +21,7 @@ def main():
package = generate(printers)
demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60,
'bed_target': 60, 'time_left': 1.75, 'layer': 134, 'total_layers': 200,
'fan': 80, 'left_nozzle': 219, 'left_target': 220,
'fan': 80, 'aux_fan': 45, 'exhaust_fan': 30, 'left_nozzle': 219, 'left_target': 220,
'right_nozzle': 37, 'right_target': 0}
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
@@ -83,6 +83,8 @@ inline void setup() {
steps += [{'lvgl.page.show': printer['id'] + '_page'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': printer['id'] + '.bmp'}}]
steps += [{'lvgl.page.show': 'color_test_page'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'color_test.bmp'}}]
steps += [{'lvgl.page.show': printers[0]['id'] + '_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}]
if len(printers) > 1:
@@ -106,20 +108,21 @@ inline void setup() {
'includes': [str(HERE / 'dashboard.h'), str(touch_header)],
'on_boot': {'priority': -100, 'then': steps}},
'host': {}, 'logger': {'level': 'WARN'},
'time': [{'platform': 'host', 'id': 'ha_time', 'timezone': 'America/New_York'}],
'display': [{'platform': 'sdl', 'id': 'panel_display', 'dimensions': '800x480',
'headless': True, 'auto_clear_enabled': False, 'update_interval': 'never'}],
'lvgl': {'id': 'dashboard', 'displays': ['panel_display'], 'buffer_size': '100%',
'color_depth': 16, 'bg_color': 0x0D131B, 'text_color': 0xEDF3F7,
'color_depth': 16, 'bg_color': 0x000000, 'text_color': 0xEEEEEE,
'default_font': 'montserrat_18', **package.pop('lvgl')}, **package}
filename = root / 'preview.yaml'
filename.write_text(yaml.dump(config, Dumper=Dumper, sort_keys=False, allow_unicode=True))
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 printer in printers:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (printer['id'] + '.bmp')
for name in [printer['id'] for printer in printers] + ['color_test']:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
with Image.open(bmp) as image:
image.save(args.output / (printer['id'] + '.png'))
image.save(args.output / (name + '.png'))
print(f'Saved {len(printers)} demo screenshots to {args.output}')
print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+2
View File
@@ -6,6 +6,7 @@ printers:
name: PrintsCharming
subtitle: P1S / Office
entity_prefix: p1s_01p09c470102673
extra_fans: [aux_fan, exhaust_fan]
filament_banks:
- label: A
entity_prefix: p1s_01p09c470102673_ams_1
@@ -41,6 +42,7 @@ printers:
subtitle: Dual nozzle / Office
entity_prefix: constipation_orion
dual_nozzle: true
extra_fans: [exhaust_fan]
filament_banks:
- label: A
entity_prefix: constipation_orion_ams_1
+7 -2
View File
@@ -13,13 +13,18 @@ int main() {
assert(remaining(INFINITY, "h") == "--");
assert(number(NAN) == "--");
assert(temperature(219, 220, "°C") == "219 / 220 °C");
assert(filament_color("#60DE9AFF") == GREEN);
assert(filament_color("60de9a") == GREEN);
assert(filament_color("#00FF00FF") == GREEN);
assert(filament_color("60de9a") == 0x60DE9A);
assert(filament_color("#60DE9AZZ") == TRACK);
assert(filament_color("unknown") == TRACK);
assert(gauge_value(NAN, 100) == 0);
assert(gauge_value(110, 100) == 100);
assert(gauge_value(-10, 100) == 0);
assert(duration_seconds(1.75f, "h") == 6300);
assert(duration_seconds(105, "min") == 6300);
assert(duration_seconds(NAN, "h") == -1);
assert(duration_seconds(1, "") == -1);
assert(std::abs(celsius(392, "°F") - 200) < 0.01f);
Snapshot data;
data.online = "on";
+26 -1
View File
@@ -26,7 +26,7 @@ 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(package['lvgl']['pages']), 7)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), 7)
self.assertEqual(len(package['script']), 7)
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
self.assertEqual(subscriptions['printer_7_progress'], 'sensor.new_printer_7_print_progress')
@@ -62,6 +62,31 @@ class FleetTests(unittest.TestCase):
self.assertLessEqual(props.get('x', 0) + props.get('width', 0), 800)
self.assertLessEqual(props.get('y', 0) + props.get('height', 0), 480)
def test_rgb565_primaries_reach_the_correct_panel_bits(self):
# mipi_rgb emits named RGB data pins with bytes exchanged, then copies
# LVGL RGB565 bytes straight into the ESP32 little-endian framebuffer.
# Exercise red, green, blue and combined primaries through that path.
class Loader(yaml.SafeLoader):
pass
for tag in ('!secret', '!include'):
Loader.add_constructor(tag, lambda loader, node: loader.construct_scalar(node))
config = yaml.load((HERE.parent / 'bambu-printer-display.yaml').read_text(), Loader=Loader)
display = config['display'][0]
pins = display['data_pins']
natural = pins['blue'] + pins['green'] + pins['red']
driver = natural[8:] + natural[:8]
order = {'big_endian': 'big', 'little_endian': 'little'}[config['lvgl']['byte_order']]
for color in (0xF800, 0x07E0, 0x001F, 0xFFFF, 0x07FF, 0xF81F):
memory_word = int.from_bytes(color.to_bytes(2, order), 'little')
panel_word = sum(((memory_word >> i) & 1) << natural.index(pin)
for i, pin in enumerate(driver))
self.assertEqual(panel_word, color, f'RGB565 color {color:#06x} is wired incorrectly')
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')
self.assertTrue(page['skip'])
if __name__ == '__main__':
unittest.main()
+11 -4
View File
@@ -57,7 +57,9 @@ display:
model: RPI
dimensions: 800x480
color_order: RGB
byte_order: little_endian
# Named RGB data_pins are byte-swapped by mipi_rgb's pin mapping.
# Send big-endian RGB565 pixels to match that wiring transformation.
byte_order: big_endian
de_pin: GPIO40
hsync_pin: GPIO39
vsync_pin: GPIO41
@@ -99,6 +101,11 @@ light:
restore_mode: ALWAYS_ON
default_transition_length: 0s
time:
- platform: homeassistant
id: ha_time
timezone: America/New_York
packages:
fleet: !include bambu-display/generated.yaml
@@ -108,7 +115,7 @@ lvgl:
touchscreens: [panel_touch]
buffer_size: 12%
color_depth: 16
byte_order: little_endian
bg_color: 0x0D131B
text_color: 0xEDF3F7
byte_order: big_endian # Must match mipi_rgb's named RGB pin mapping above.
bg_color: 0x000000
text_color: 0xEEEEEE
default_font: montserrat_18