This commit is contained in:
root
2026-10-17 17:10:03 -04:00
15 changed files with 4184 additions and 0 deletions
+3
View File
@@ -61,6 +61,9 @@
### ESPHome Folders ###
!esphome/
!esphome/fonts/**
!esphome/bambu-display/
!esphome/bambu-display/**
esphome/bambu-display/**/__pycache__/
!esphome/ha-remote/
!esphome/ha-remote/components/
!esphome/ha-remote/components/**
+127
View File
@@ -0,0 +1,127 @@
# Bambu fleet display
ESPHome/LVGL monitor for the **Sunton ESP32-8048S043C**, with an 800×480 landscape RGB
display, GT911 capacitive touch, 16 MB flash and 8 MB octal PSRAM. The hardware pins
and LCD timings follow the [ESPHome device reference](https://devices.esphome.io/devices/sunton-esp32-8048s043c/).
This configuration is for the **C** capacitive-touch version.
Inspired by [BambuHelper](https://github.com/Keralots/BambuHelper), this implementation
subscribes to the existing Home Assistant Bambu Lab entities over a single encrypted
ESPHome API connection. Home Assistant handles the printer connections. No Bambu
access codes, cloud credentials or additional Home Assistant automations are needed.
Home Assistant must be running and its Bambu integration must have current printer data.
## Pages and navigation
![LVGL preview with synthetic printer telemetry](previews/prints_charming.png)
The configured swipe order is:
1. PrintsCharming — P1S, Office, both four-slot AMS units.
2. Polly-Pocket — A1 mini, Office, external spool.
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
5. Constipation-Orion — Office, left/right nozzles and four-slot AMS.
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
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
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.
When Home Assistant disconnects or a printer is offline, its live metrics are cleared
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.
This version provides monitoring and local page navigation. Camera feeds, cover
images, printer pause/stop controls, power switching, cloud login and automatic
printer rotation are outside its current scope.
## Install
1. Keep `esphome/bambu-printer-display.yaml` and the entire `bambu-display/` folder
together in the ESPHome configuration directory. The generated package is checked
in, so the ESPHome dashboard can compile it without running the generator first.
2. Supply the existing `api_encryption_key`, `wifi_iot_ssid` and `wifi_password`
entries in that directory's `secrets.yaml`.
3. Use ESPHome **2026.9.0 or newer**. Compile and install
`bambu-printer-display.yaml` over the board's USB-to-UART bridge for the first
flash; subsequent installs can use encrypted OTA.
4. Add **Bambu Printer Display** through Home Assistant's ESPHome integration using
the same API encryption key. The display only subscribes to entities, so it does
not require permission to perform Home Assistant actions.
5. Confirm all pages receive data and verify touch direction and color order on the
physical panel. Entity IDs match the repository's `ha-entities.txt` snapshot;
renamed or disabled entities in live HA must be corrected/enabled there.
The USB D+/D− GPIO19/20 pins are used by the board's touch bus. Logging therefore
uses UART0. ESPHome may still warn about these two pins; they are intentional for
this board. If GT911 is not found at `0x5D`, inspect the I²C scan and use its reported
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.
CLI checks from the repository root:
```sh
esphome config esphome/bambu-printer-display.yaml
esphome compile esphome/bambu-printer-display.yaml
```
## Change or expand the fleet
Edit **`printers.yaml`**, then regenerate and flash the display:
```sh
python esphome/bambu-display/generate.py
python esphome/bambu-display/generate.py --check
```
The generator requires PyYAML (already included in ESPHome's Python environment).
Do not hand-edit `generated.yaml`. There is no two-printer limit in the generator;
adding a printer creates its own subscriptions, widgets and swipe page. The practical
limit depends on the board's available RAM and flash, and adding printers requires
regenerating and installing the firmware. Five printers are configured; generation
is also tested with seven. Each printer's filament strip supports up to eight slots.
```yaml
- id: another_printer
name: Another Printer
subtitle: Workshop
entity_prefix: my_bambu_printer
entities:
progress: sensor.renamed_progress
time_left: sensor.renamed_remaining_time
filament_banks:
- label: A
entity_prefix: my_bambu_printer_ams_1
```
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.
Filament entries use the Bambu integration's `type`, `color` and `active` attributes.
## Development checks and screenshots
```sh
python -m unittest discover -s esphome/bambu-display -v
clang++ -std=c++17 -Wall -Wextra -Werror esphome/bambu-display/test_dashboard.cpp -o /tmp/bambu-dashboard-test
/tmp/bambu-dashboard-test
python esphome/bambu-display/preview.py
```
`preview.py` compiles the same generated LVGL widgets for ESPHome's SDL host with
clearly synthetic telemetry, then captures all five pages into `previews/`. It needs
ESPHome, Pillow, a C++ compiler and SDL2 (`sdl2-config` on PATH). It runs headlessly,
without connecting to Home Assistant or any printer. It also injects LVGL pointer
samples to check left/right swipes, page wraparound and consumption of a swipe that
starts on a navigation button. These are software layout
previews; physical panel timings, touch and Wi-Fi still need a hardware check.
+172
View File
@@ -0,0 +1,172 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdint>
#include <string>
#include <vector>
namespace bambu_display {
constexpr uint32_t GREEN = 0x60DE9A;
constexpr uint32_t MUTED = 0x91A3B5;
constexpr uint32_t RED = 0xFF737B;
constexpr uint32_t AMBER = 0xF2C572;
constexpr uint32_t TRACK = 0x263442;
inline std::string lower(std::string text) {
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return text;
}
inline bool valid_text(const std::string &text) {
const auto value = lower(text);
return !value.empty() && value != "unknown" && value != "unavailable" && value != "none";
}
inline bool is_on(const std::string &text) {
const auto value = lower(text);
return value == "on" || value == "true" || value == "1";
}
inline std::string humanize(std::string text) {
if (!valid_text(text)) return "--";
std::replace(text.begin(), text.end(), '_', ' ');
if (!text.empty()) text[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(text[0])));
return text;
}
inline int gauge_value(float value, int maximum) {
return std::isfinite(value) ? static_cast<int>(std::clamp(value, 0.0f, float(maximum))) : 0;
}
inline std::string number(float value) {
if (!std::isfinite(value) || value < 0 || value > 1000000) return "--";
char buffer[24];
snprintf(buffer, sizeof(buffer), "%.0f", value);
return buffer;
}
inline std::string temperature(float current, float target, const std::string &unit) {
return number(current) + " / " + number(target) + (valid_text(unit) ? " " + unit : "");
}
inline std::string remaining(float value, const std::string &unit) {
if (!std::isfinite(value) || value < 0) return "--";
double minutes;
if (unit == "h") minutes = double(value) * 60;
else if (unit == "min") minutes = value;
else if (unit == "s") minutes = double(value) / 60;
else return "--"; // Never silently interpret hours as minutes.
if (minutes > 1000000) return "--";
const auto rounded = static_cast<int>(std::ceil(minutes - 0.001));
char buffer[32];
snprintf(buffer, sizeof(buffer), "%dh %02dm", rounded / 60, rounded % 60);
return buffer;
}
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.
if (value.size() != 6 && value.size() != 8) return TRACK;
uint32_t rgb = 0;
for (size_t i = 0; i < value.size(); ++i) {
const char c = static_cast<char>(std::tolower(static_cast<unsigned char>(value[i])));
const int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
if (digit < 0) return TRACK;
if (i < 6) rgb = (rgb << 4) | digit;
}
return rgb;
}
struct Tray {
std::string label, type, color, active;
};
struct Snapshot {
bool connected = false;
std::string online, status, task, stage, print_error, hms_error;
std::string remaining_unit, nozzle_unit, bed_unit;
float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN;
float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN;
bool dual_nozzle = false;
float left_nozzle = NAN, left_target = NAN, right_nozzle = NAN, right_target = NAN;
std::vector<Tray> trays;
bool live() const { return connected && is_on(online) && valid_text(status); }
std::string status_text() const {
if (!connected) return "HA OFFLINE";
if (lower(online) == "off" || lower(online) == "false") return "PRINTER OFFLINE";
if (!live()) return "SYNCING";
if (is_on(print_error)) return "PRINT ERROR";
if (is_on(hms_error)) return "HMS ALERT";
const auto state = lower(status);
if (state == "running") return "PRINTING";
if (state == "pause" || state == "paused") return "PAUSED";
if (state == "finish" || state == "finished") return "COMPLETE";
auto text = humanize(status);
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return text;
}
uint32_t status_color() const {
if (!live()) return MUTED;
const auto state = lower(status);
if (is_on(print_error) || is_on(hms_error) || state == "failed") return RED;
if (state == "pause" || state == "paused") return AMBER;
return GREEN;
}
};
} // namespace bambu_display
#ifdef USE_LVGL
#include <lvgl.h>
namespace bambu_display {
struct TrayWidgets { lv_obj_t *card, *swatch, *label; };
struct Widgets {
lv_obj_t *status, *task, *arc, *progress, *nozzle, *bed, *remaining, *layers, *stage;
std::vector<TrayWidgets> trays;
};
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(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)) + "%" : "--");
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) : "--");
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 : "--"));
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);
}
}
} // namespace bambu_display
#endif
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Generate the ESPHome fleet package from printers.yaml. Requires PyYAML."""
import argparse
import json
from pathlib import Path
import re
import yaml
HERE = Path(__file__).resolve().parent
NUMERIC = {
'progress': 'print_progress', 'nozzle': 'nozzle_temperature',
'nozzle_target': 'nozzle_target_temperature', 'bed': 'bed_temperature',
'bed_target': 'bed_target_temperature', 'time_left': 'remaining_time',
'layer': 'current_layer', 'total_layers': 'total_layer_count',
'fan': 'cooling_fan_speed',
}
DUAL = {
'left_nozzle': 'left_nozzle_temperature', 'left_target': 'left_nozzle_target_temperature',
'right_nozzle': 'right_nozzle_temperature', 'right_target': 'right_nozzle_target_temperature',
}
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)
class Lambda(str):
"""A C++ lambda serialized with ESPHome's YAML tag."""
class Dumper(yaml.SafeDumper):
def ignore_aliases(self, data):
return True
Dumper.add_representer(Lambda, lambda dumper, text: dumper.represent_scalar('!lambda', text, style='|'))
def entity(printer, key, suffix, domain='sensor'):
return printer.get('entities', {}).get(key, f'{domain}.{printer["entity_prefix"]}_{suffix}')
def trays_for(printer):
result = []
for bank in printer.get('filament_banks', []):
for slot in range(1, 5):
result.append({'label': f'{bank["label"]}{slot}',
'entity': f'sensor.{bank["entity_prefix"]}_tray_{slot}'})
return result + printer.get('external_spools', [])
def validate_printers(printers):
if not isinstance(printers, list) or not printers:
raise ValueError('printers must be a nonempty list')
ids = set()
for printer in printers:
for key in ('id', 'name', 'subtitle', 'entity_prefix'):
if not isinstance(printer.get(key), str) or not printer[key]:
raise ValueError(f'Each printer needs a nonempty {key}')
pid = printer['id']
if not re.fullmatch(r'[a-z][a-z0-9_]*', pid) or pid in ids:
raise ValueError(f'Invalid or duplicate printer id: {pid}')
ids.add(pid)
if len(trays_for(printer)) > 8:
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')
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}')
def label(text, x, y, width, font=18, **kwargs):
return {'label': {'text': text, 'x': x, 'y': y, 'width': width,
'height': font + 8, 'text_color': TEXT_COLOR,
'text_font': f'montserrat_{font}', 'long_mode': 'DOT',
'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 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),
]
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,
'pad_all': 0, 'gesture_bubble': True,
'widgets': [{'label': {'text': text, 'align': 'CENTER',
'text_font': 'montserrat_12'}}],
'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))
return {'id': f'{pid}_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': f'{pid}_refresh'}], 'widgets': widgets}
def generate(printers):
validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [],
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
for index, printer in enumerate(printers):
pid = printer['id']
fields = dict(NUMERIC)
if printer.get('dual_nozzle'):
fields.update(DUAL)
expressions = {}
for key, suffix in fields.items():
sid = f'{pid}_{key}'
eid = entity(printer, key, suffix)
package['sensor'].append({'platform': 'homeassistant', 'id': sid,
'entity_id': eid, 'internal': True})
expressions[key] = f'id({sid}).state'
text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in TEXT.items()}
text_fields.update({key: (entity(printer, key, suffix, 'binary_sensor'), None)
for key, suffix in BINARY.items()})
for key, numeric_key in [('remaining_unit', 'time_left'), ('nozzle_unit', 'nozzle'), ('bed_unit', 'bed')]:
text_fields[key] = (entity(printer, numeric_key, NUMERIC[numeric_key]), 'unit_of_measurement')
trays = trays_for(printer)
for i, tray in enumerate(trays):
for key, attr in [('type', 'type'), ('color', 'color'), ('active', 'active')]:
text_fields[f'tray_{i}_{key}'] = (tray['entity'], attr)
for key, (eid, attribute) in text_fields.items():
sid = f'{pid}_{key}'
subscription = {'platform': 'homeassistant', 'id': sid, 'entity_id': eid, 'internal': True}
if attribute:
subscription['attribute'] = attribute
package['text_sensor'].append(subscription)
expressions[key] = f'id({sid}).state'
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith('tray_'):
lines.append(f'data.{key} = {expr};')
if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
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 (
'status_label', 'task_label', 'progress_arc', 'progress_label', 'nozzle_label',
'bed_label', 'remaining_label', 'layers_label', 'stage_label')]
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))}]})
package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
'then': [{'script.execute': f'{pid}_refresh'}]}})
package['lvgl']['pages'].append(page_for(printer, index, len(printers)))
return package
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true', help='Fail if generated.yaml is stale')
args = parser.parse_args()
printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
package = generate(printers)
content = ('# AUTO-GENERATED by bambu-display/generate.py. Edit printers.yaml or generate.py.\n'
+ yaml.dump(package, Dumper=Dumper, sort_keys=False, allow_unicode=True, width=110))
destination = HERE / 'generated.yaml'
if args.check:
if not destination.exists() or destination.read_text() != content:
raise SystemExit('generated.yaml is stale; run generate.py')
print(f'Generated configuration is current ({len(printers)} printer pages).')
else:
destination.write_text(content)
print(f'Generated {destination.name} with {len(printers)} printer pages.')
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Render the real LVGL pages with demo telemetry using ESPHome's headless SDL host."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
from PIL import Image
import yaml
from generate import Dumper, HERE, Lambda, generate
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=HERE / 'previews')
args = parser.parse_args()
printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
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,
'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',
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C'}
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
for printer in printers:
prefix = printer['id'] + '_'
for section in ('sensor', 'text_sensor'):
for entry in package[section]:
if not entry['id'].startswith(prefix):
continue
key = entry['id'][len(prefix):]
if section == 'sensor':
value = str(demo_values[key])
else:
if key.startswith('tray_'):
_, index, attribute = key.split('_')
text = {'type': 'PLA', 'color': colors[int(index) % len(colors)],
'active': 'on' if index == '0' else 'off'}[attribute]
else:
text = demo_text[key]
value = 'std::string(' + json.dumps(text, ensure_ascii=False) + ')'
entry.clear()
entry.update({'platform': 'template', 'id': prefix + key, 'internal': True,
'lambda': Lambda('return ' + value + ';'), 'update_interval': '60s'})
for script in package['script']:
code = script['then'][0]['lambda']
script['then'][0]['lambda'] = Lambda(code.replace(
'id(ha_api).is_connected_with_state_subscription()', 'true'))
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='bambu-lvgl-preview-') as work:
root = Path(work)
# Inject real LVGL pointer samples; test bubbling through cards/buttons
# without a GUI window or a physical touchscreen.
touch_header = root / 'preview_touch.h'
touch_header.write_text('''#pragma once
#include <lvgl.h>
namespace bambu_preview {
inline int x = 0, y = 0;
inline bool pressed = false;
inline void setup() {
auto *input = lv_indev_create();
lv_indev_set_type(input, LV_INDEV_TYPE_POINTER);
lv_indev_set_display(input, lv_display_get_default());
lv_indev_set_read_cb(input, [](lv_indev_t *, lv_indev_data_t *data) {
data->point.x = x;
data->point.y = y;
data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
});
}
}
''')
steps = [{'component.update': entry['id']}
for section in ('sensor', 'text_sensor') for entry in package[section]]
steps += [{'delay': '1s'}]
for printer in printers:
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': printers[0]['id'] + '_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}]
if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV.
gestures = [(650, 180, 180, printers[1]['id']),
(180, 650, 180, printers[0]['id']),
(180, 650, 180, printers[-1]['id']),
(650, 180, 180, printers[0]['id']),
(730, 60, 460, printers[1]['id'])]
for start, end, y, expected in gestures:
steps += [
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
{'delay': '70ms'},
{'lambda': Lambda(f'bambu_preview::x = {end};')}, {'delay': '70ms'},
{'lambda': Lambda('bambu_preview::pressed = false;')}, {'delay': '350ms'},
{'lambda': Lambda(f'if (!id({expected}_page).is_showing()) {{ ESP_LOGE("preview", "Swipe navigation failed"); exit(2); }}')},
]
steps += [{'lambda': Lambda('exit(0);')}]
config = {'esphome': {'name': 'bambu-display-preview',
'includes': [str(HERE / 'dashboard.h'), str(touch_header)],
'on_boot': {'priority': -100, 'then': steps}},
'host': {}, 'logger': {'level': 'WARN'},
'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,
'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')
with Image.open(bmp) as image:
image.save(args.output / (printer['id'] + '.png'))
print(f'Saved {len(printers)} demo screenshots to {args.output}')
print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.')
if __name__ == '__main__':
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+46
View File
@@ -0,0 +1,46 @@
# Order here is the swipe order. Add/remove entries and rerun generate.py.
# entity_prefix is the HA entity ID between the domain and the metric suffix.
# Optional entities: {metric: sensor.custom_entity} overrides individual mappings.
printers:
- id: prints_charming
name: PrintsCharming
subtitle: P1S / Office
entity_prefix: p1s_01p09c470102673
filament_banks:
- label: A
entity_prefix: p1s_01p09c470102673_ams_1
- label: B
entity_prefix: p1s_01p09c470102673_ams_2
- id: polly_pocket
name: Polly-Pocket
subtitle: A1 mini / Office
entity_prefix: a1mini_0309ca580403653
external_spools:
- label: EXT
entity: sensor.a1mini_0309ca580403653_externalspool_external_spool
- id: ds_turd_flinger
name: Ds-Turd-Flinger
subtitle: A1 / Upstairs
entity_prefix: a1_03919d540806387
filament_banks:
- label: A
entity_prefix: a1_03919d540806387_ams_1
- id: jordyns_layer_slayer
name: Jordyns-Layer-Slayer
subtitle: A1 / Upstairs
entity_prefix: jordyns_layer_slayer
filament_banks:
- label: A
entity_prefix: jordyns_layer_slayer_ams
- id: constipation_orion
name: Constipation-Orion
subtitle: Dual nozzle / Office
entity_prefix: constipation_orion
dual_nozzle: true
filament_banks:
- label: A
entity_prefix: constipation_orion_ams_1
+54
View File
@@ -0,0 +1,54 @@
#include "dashboard.h"
#include <cassert>
int main() {
using namespace bambu_display;
assert(remaining(1.75f, "h") == "1h 45m");
assert(remaining(105.0f, "min") == "1h 45m");
assert(remaining(6300.0f, "s") == "1h 45m");
assert(remaining(0.15f, "h") == "0h 09m");
assert(remaining(NAN, "h") == "--");
assert(remaining(-1, "min") == "--");
assert(remaining(10, "") == "--");
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("#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);
Snapshot data;
data.online = "on";
data.status = "running";
assert(!data.live());
assert(data.status_text() == "HA OFFLINE");
data.connected = true;
assert(data.live());
assert(data.status_text() == "PRINTING");
data.online = "off";
assert(!data.live());
assert(data.status_text() == "PRINTER OFFLINE");
data.online = "unavailable";
assert(data.status_text() == "SYNCING");
data.online = "on";
data.status = "unknown";
assert(!data.live());
data.status = "pause";
assert(data.status_text() == "PAUSED");
assert(data.status_color() == AMBER);
data.print_error = "on";
assert(data.status_text() == "PRINT ERROR");
assert(data.status_color() == RED);
data.print_error = "off";
data.hms_error = "True";
assert(data.status_text() == "HMS ALERT");
assert(data.status_color() == RED);
data.hms_error = "off";
data.status = "finish";
assert(data.status_text() == "COMPLETE");
assert(data.status_color() == GREEN);
}
+67
View File
@@ -0,0 +1,67 @@
"""Run with: python -m unittest discover -s esphome/bambu-display -v."""
import copy
import unittest
import yaml
from generate import HERE, generate, validate_printers
class FleetTests(unittest.TestCase):
def setUp(self):
self.printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
def test_all_subscriptions_match_repository_entities(self):
known = {line.split(' | ')[0] for line in
(HERE.parent / 'ha-entities.txt').read_text().splitlines()}
package = generate(self.printers)
subscribed = {entry['entity_id'] for domain in ('sensor', 'text_sensor')
for entry in package[domain]}
self.assertFalse(subscribed - known)
def test_seven_printers_have_independent_pages_and_metrics(self):
for index in (6, 7):
extra = copy.deepcopy(self.printers[0])
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(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')
for page in package['lvgl']['pages']:
self.assertIn('lvgl.page.next', page['on_swipe_left'][-1])
self.assertIn('lvgl.page.previous', page['on_swipe_right'][-1])
def test_overrides_apply_to_values_and_unit_subscriptions(self):
self.printers[0]['entities'] = {'time_left': 'sensor.custom_remaining'}
package = generate(self.printers)
for domain, key in [('sensor', 'time_left'), ('text_sensor', 'remaining_unit')]:
entry = next(row for row in package[domain] if row['id'] == f'prints_charming_{key}')
self.assertEqual(entry['entity_id'], 'sensor.custom_remaining')
def test_reject_empty_duplicate_and_malformed_printer_ids(self):
for invalid in ([], self.printers + [self.printers[0]],
[{**self.printers[0], 'id': 'invalid-id'}]):
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
validate_printers(invalid)
def test_reject_overflowing_filament_strip(self):
self.printers[0]['external_spools'] = [{'label': 'EXT', 'entity': 'sensor.external_spool'}]
with self.assertRaisesRegex(ValueError, 'eight slots'):
generate(self.printers)
def test_progress_and_dual_nozzle_widgets_fit_the_panel(self):
package = generate(self.printers)
for page in package['lvgl']['pages']:
for widget in page['widgets']:
props = next(iter(widget.values()))
self.assertGreaterEqual(props.get('x', 0), 0)
self.assertGreaterEqual(props.get('y', 0), 0)
self.assertLessEqual(props.get('x', 0) + props.get('width', 0), 800)
self.assertLessEqual(props.get('y', 0) + props.get('height', 0), 480)
if __name__ == '__main__':
unittest.main()
+114
View File
@@ -0,0 +1,114 @@
# Sunton ESP32-8048S043C: 4.3" 800x480 RGB LCD + GT911 capacitive touch.
# Edit bambu-display/printers.yaml, then run its generate.py to change the fleet.
substitutions:
device_name: bambu-printer-display
friendly_name: Bambu Printer Display
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
min_version: 2026.9.0
includes:
- bambu-display/dashboard.h
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
flash_mode: dio
cpu_frequency: 240MHz
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
CONFIG_SPIRAM_RODATA: "y"
psram:
mode: octal
speed: 80MHz
logger:
hardware_uart: UART0 # GPIO19/20 are the touch bus, not native USB.
api:
id: ha_api
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
encryption:
key: !secret api_encryption_key
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
i2c:
id: touch_bus
sda: GPIO19
scl: GPIO20
scan: true
display:
- platform: mipi_rgb
id: panel_display
model: RPI
dimensions: 800x480
color_order: RGB
byte_order: little_endian
de_pin: GPIO40
hsync_pin: GPIO39
vsync_pin: GPIO41
pclk_pin: GPIO42
pclk_inverted: true
pclk_frequency: 16MHz
hsync_front_porch: 8
hsync_pulse_width: 4
hsync_back_porch: 8
vsync_front_porch: 8
vsync_pulse_width: 4
vsync_back_porch: 8
data_pins:
red: [GPIO45, GPIO48, GPIO47, GPIO21, GPIO14]
green: [GPIO5, GPIO6, GPIO7, GPIO15, GPIO16, GPIO4]
blue: [GPIO8, GPIO3, GPIO46, GPIO9, GPIO1]
auto_clear_enabled: false
update_interval: never
touchscreen:
- platform: gt911
id: panel_touch
display: panel_display
i2c_id: touch_bus
address: 0x5D
# Polling avoids the GT911 INT pin's boot/address-selection behaviour.
update_interval: 20ms
output:
- platform: ledc
id: backlight_output
pin: GPIO2
light:
- platform: monochromatic
id: panel_backlight
name: Backlight
output: backlight_output
restore_mode: ALWAYS_ON
default_transition_length: 0s
packages:
fleet: !include bambu-display/generated.yaml
lvgl:
id: dashboard
displays: [panel_display]
touchscreens: [panel_touch]
buffer_size: 12%
color_depth: 16
byte_order: little_endian
bg_color: 0x0D131B
text_color: 0xEDF3F7
default_font: montserrat_18