updates
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 38 KiB |
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
|
||||