Compare commits

..
10 Commits
Author SHA1 Message Date
Joshua KingandCursor af7f268888 Derive Centauri progress from layers or print time when percent stays at zero.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 14:02:50 -04:00
Joshua KingandCursor 994cfb6b60 Fill overview gauges to each printer's actual progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 21:00:03 -04:00
Joshua KingandCursor 12f8ba2def Show a grey ring behind black filament gauges so they stay visible.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 20:46:41 -04:00
Joshua KingandCursor 5b8a3f24c2 Format the overview clock through a mutable ESPTime so ESPHome 2026.9.1 compiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 19:34:52 -04:00
Joshua KingandCursor c5b7408b18 Color overview gauges by active filament and show finish times.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 19:22:04 -04:00
Joshua KingandCursor 360e86e46a Show each printer on the fleet page as a status gauge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 15:13:24 -04:00
root 4626019a3e automation updates 2026-10-20 14:30:20 -04:00
Joshua KingandCursor 43f416ab69 Add a fleet overview page that shows every printer's status at once.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 13:05:32 -04:00
Joshua KingandCursor 0ec94a909e Add a second ESPHome fleet display that shares the existing printer pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 11:24:47 -04:00
Joshua King 5e50c9a296 Refactor automation configurations by removing obsolete NestQuest access controls and updating automation inclusion in configuration.yaml 2026-10-19 21:08:59 -04:00
13 changed files with 1785 additions and 252 deletions
+16 -56
View File
@@ -1,35 +1,7 @@
- id: '1792441676327'
alias: NestQuest UniFi quest access
description: ''
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
client_switches:
- switch.default_blocked
- switch.iphone_blocked_2
- id: '1792441738406'
alias: NestQuest UniFi quest access
description: ''
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
client_switches:
- switch.iphone_blocked
- id: '1792453243454'
alias: NestQuest UniFi quest access
description: ''
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_declan_quests_remaining_today
client_switches:
- switch.default_blocked_2
- id: '1792453338330'
alias: Declan NestQuest Access Control
description: Manages internet access for Declan by unblocking devices based on the
number of remaining daily quests.
- id: '1792458749185'
alias: Declan - NestQuest Access Block
description: Blocks network access for Declan's devices once the remaining daily
quest sensor reaches zero.
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
input:
@@ -37,36 +9,24 @@
client_switches:
- switch.declan_iphone_blocked
- switch.default_blocked_2
- id: '1792453462434'
alias: Jordyn NestQuest Access Control
description: Manages network access for Jordyn's devices based on the number of
remaining daily quests.
- id: '1792458829477'
alias: Jordyn - NestQuest Access Block
description: Automatically blocks network access for Jordyn's devices once the remaining
daily quests sensor reaches zero.
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
path: nestquest/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_jordyn_quests_remaining_today
client_switches:
- switch.jordyn_iphone_blocked
- switch.default_blocked_3
- id: '1792453576643'
alias: Jordyn Quest Access Control
description: Manages network access for Jordyn's devices based on the number of
remaining daily quests.
- id: '1792458886560'
alias: Chloe - NestQuest Access Block
description: Automatically blocks network access for Chloe's device once the daily
NestQuest allowance has been exhausted.
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
path: nestquest/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_jordyn_quests_remaining_today
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
client_switches:
- switch.jordyn_iphone_blocked
- switch.default_blocked_3
- id: '1792453896291'
alias: Declan NestQuest Access Control
description: Monitors Declan's remaining daily quests and automatically blocks his
iPhone and the default device once the limit is reached.
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_declan_quests_remaining_today
client_switches:
- switch.declan_iphone_blocked
- switch.default_blocked_2
- switch.switch_v1_blocked
+2 -1
View File
@@ -27,7 +27,8 @@ frontend:
lovelace:
dashboards: !include dashboards/dashboards.yaml
automation: !include_dir_merge_list automations/
automation manual: !include_dir_merge_list automations/
automation ui: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
input_boolean: !include helpers/input_boolean.yaml
+34
View File
@@ -13,6 +13,37 @@
"mac_address": "A0:85:E3:0F:DC:F4"
},
"_firmware_jobs": [
{
"job_id": "b5a2e3675005",
"configuration": "bambu-printer-display-2.yaml",
"job_type": "compile",
"status": "completed",
"created_at": "2026-10-20T18:14:44.512443+00:00",
"started_at": "2026-10-20T18:14:44.512752+00:00",
"completed_at": "2026-10-20T18:15:31.213919+00:00",
"compile_started_at": "2026-10-20T18:14:50.121007+00:00",
"compile_ended_at": "2026-10-20T18:15:30.642966+00:00",
"exit_code": 0,
"error": null,
"port": "",
"flash_bootloader": false,
"is_deferred_install": false,
"new_name": "",
"depends_on": "",
"dependency_released": false,
"progress": 83,
"remote_peer": "",
"remote_job_id": "",
"remote_peer_label": "",
"device_name": "",
"device_friendly_name": "",
"source": "local",
"source_pin_sha256": "",
"source_label": "",
"source_esphome_version": "",
"target_esphome_version": "",
"failure_reason": ""
},
{
"job_id": "bb19d223ab5c",
"configuration": "bambu-printer-display.yaml",
@@ -830,5 +861,8 @@
},
"family-room-control.yaml": {
"mac_address": "A4:F0:0F:68:A1:8C"
},
"bambu-printer-display-2.yaml": {
"mac_address": "34:CD:B0:0C:3D:CC"
}
}
+39 -23
View File
@@ -17,24 +17,31 @@ Home Assistant must be running and its printer integrations must have current pr
The configured swipe order is:
1. PrintsCharming — P1S, Office, both four-slot AMS units.
2. Polly-Pocket — A1 mini, Office, external spool.
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
1. Print Status Overview — one steam gauge per printer. The filled part of
each ring is that printer's print progress, drawn in the active filament
color. Black filament keeps a grey ring behind the gauge so it stays visible
on the black page. The center shows the percent complete in white, with the
printer name and estimated finish time underneath.
The local time is in the top-right corner. Tap a gauge to open that printer.
2. PrintsCharming — P1S, Office, both four-slot AMS units.
3. Polly-Pocket — A1 mini, Office, external spool.
4. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
5. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
6. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
AMS A uses `constipation_orion_ams_1`; AMS B retains its original
`h2c_31b8ap5a2200288_ams_2` entity prefix.
6. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
7. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
and model/auxiliary/enclosure fan speeds.
7. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
8. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
elapsed time, remaining time and estimated finish.
![Saturn resin page with synthetic telemetry](previews/saturn.png)
Swipe left for the next printer or right for the previous printer. Navigation wraps
from the last page to the first. Previous/Next buttons provide the same navigation.
Swipes propagate through the arc gauges and filament slots. A swipe consumes the
rest of that touch so dragging across a navigation button does not also click it.
Swipe left for the next page or right for the previous page. Navigation wraps
from the last printer back to the fleet page. Previous/Next buttons provide the
same navigation. Swipes propagate through the fleet gauges and filament
slots. A swipe consumes the rest of that touch so dragging across a gauge or a
navigation button does not also click it.
FDM pages show print status, job name, progress, nozzle and bed current/target
temperatures, remaining time, estimated finish time, current/total layers, print
@@ -84,17 +91,22 @@ 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.
1. Keep both device files and the entire `bambu-display/` folder together in the
ESPHome configuration directory. `bambu-printer-display.yaml` is the first board
and `bambu-printer-display-2.yaml` is the second. They share `board.yaml` and the
generated fleet package, which is checked in, so the ESPHome dashboard can compile
either file 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.
3. Use ESPHome **2026.9.0 or newer**. Compile and install the matching device file
over that board's USB-to-UART bridge for the first flash; subsequent installs can
use encrypted OTA. Flash `bambu-printer-display.yaml` to the first panel and
`bambu-printer-display-2.yaml` to the second. Each file has its own ESPHome name,
so both panels can be online together.
4. Add **Bambu Printer Display** or **Bambu Printer Display 2** through Home
Assistant's ESPHome integration using the same API encryption key. Each 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.
@@ -103,7 +115,7 @@ The USB D+/D− GPIO19/20 pins are used by the board's touch bus. Logging theref
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.
and timings are explicit in `board.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
@@ -121,7 +133,9 @@ CLI checks from the repository root:
```sh
esphome config esphome/bambu-printer-display.yaml
esphome config esphome/bambu-printer-display-2.yaml
esphome compile esphome/bambu-printer-display.yaml
esphome compile esphome/bambu-printer-display-2.yaml
```
## Change or expand the fleet
@@ -135,7 +149,7 @@ python esphome/bambu-display/generate.py --check
The generator requires PyYAML (already included in ESPHome's Python environment).
Do not hand-edit `generated.yaml`. There is no two-printer limit in the generator;
adding a printer creates its own subscriptions, widgets and swipe page. The practical
adding a printer creates its own subscriptions, widgets, swipe page and fleet gauge. The practical
limit depends on the board's available RAM and flash, and adding printers requires
regenerating and installing the firmware. Seven printers are configured; generation
is also tested with nine. Each Bambu printer's filament strip supports up to eight slots.
@@ -161,7 +175,9 @@ exist; their mappings are in `EXTRA_FANS`. A remaining-time override also update
Filament entries use the Bambu integration's `type`, `color` and `active` attributes.
For Elegoo printers, set `profile: elegoo_fdm` or `profile: elegoo_resin` (the default
profile is `bambu`). These profiles map progress to `percent_complete`, job name to
profile is `bambu`). These profiles map progress to `percent_complete`. The Centauri Carbon leaves
that sensor at 0 for most of a job, so an active Elegoo print falls back to the
layer count and then to elapsed time divided by `total_print_time`. Job name maps to
`file_name`, remaining time to `remaining_print_time`, and total layers to
`total_layers`. The FDM profile reads targets from the HA `number` entities
`target_nozzle_temp` and `target_bed_temp`; it only monitors their state. Per-metric
+114
View File
@@ -0,0 +1,114 @@
# Shared Sunton ESP32-8048S043C board for every fleet display.
# Device name and friendly name come from the top-level YAML substitutions.
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
# 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
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
time:
- platform: homeassistant
id: ha_time
timezone: America/New_York
lvgl:
id: dashboard
displays: [panel_display]
touchscreens: [panel_touch]
buffer_size: 12%
color_depth: 16
byte_order: big_endian # Must match mipi_rgb's named RGB pin mapping above.
bg_color: 0x000000
text_color: 0xEEEEEE
default_font: montserrat_18
+133 -5
View File
@@ -7,6 +7,7 @@
#include <cstdint>
#include <cstdlib>
#include <string>
#include <utility>
#include <vector>
namespace bambu_display {
@@ -16,6 +17,7 @@ constexpr uint32_t MUTED = 0xAEB3B7;
constexpr uint32_t RED = 0xFF737B;
constexpr uint32_t AMBER = 0xF2C572;
constexpr uint32_t TRACK = 0x19232B;
constexpr uint32_t RING_BACKING = 0x6E767E;
inline std::string lower(std::string text) {
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
@@ -103,6 +105,11 @@ inline uint32_t filament_color(std::string value) {
return rgb;
}
// A black ring disappears on the black page. Anything this dark needs a grey arc behind it.
inline bool filament_is_black(uint32_t color) {
return ((color >> 16) & 0xFF) <= 0x28 && ((color >> 8) & 0xFF) <= 0x28 && (color & 0xFF) <= 0x28;
}
struct Tray {
std::string label, type, color, active;
};
@@ -115,9 +122,9 @@ struct Snapshot {
std::string hms_count, print_error_message, print_error_code;
std::string print_error_reason, uv_unit, elapsed_unit;
bool elegoo = false, resin = false;
float uv = NAN, elapsed = NAN;
float uv = NAN, elapsed = NAN, total_time = NAN;
std::vector<HealthNotice> health_notices;
std::string remaining_unit, nozzle_unit, bed_unit;
std::string remaining_unit, nozzle_unit, bed_unit, total_time_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;
@@ -127,6 +134,36 @@ struct Snapshot {
std::vector<Tray> trays;
bool live() const { return connected && is_on(online) && valid_text(status); }
// Centauri firmware parks on milestone names such as preheating_completed for
// most of a job, so any status that is not a known idle or finished state is active.
bool job_active() const {
auto idle = [](const std::string &value) {
return value.empty() || value == "unknown" || value == "unavailable" || value == "none" ||
value == "idle" || value == "standby" || value == "ready" || value == "complete" ||
value == "completed" || value == "finish" || value == "finished" || value == "cancelled" ||
value == "canceled" || value == "stopped" || value == "stop" || value == "failed";
};
const auto state = lower(status);
const auto machine = lower(stage);
if (machine == "printing" || machine == "paused" || machine == "busy" || machine == "heating" ||
machine == "preheating" || machine == "running")
return true;
return !idle(state);
}
// The Centauri reports percent_complete as 0 while CurrentLayer stays 0.
// Layers, then elapsed time against the total, still describe the job.
float completion() const {
if (!elegoo) return progress;
if (std::isfinite(progress) && progress > 0.0f) return std::clamp(progress, 0.0f, 100.0f);
if (!job_active()) return std::isfinite(progress) ? 0.0f : NAN;
if (std::isfinite(layer) && std::isfinite(total_layers) && layer > 0.0f && total_layers > 0.0f)
return std::clamp(100.0f * layer / total_layers, 0.0f, 100.0f);
const int elapsed_s = duration_seconds(elapsed, elapsed_unit);
const int total_s = duration_seconds(total_time, total_time_unit);
if (elapsed_s > 0 && total_s > 0)
return std::clamp(100.0f * static_cast<float>(elapsed_s) / static_cast<float>(total_s), 0.0f, 100.0f);
return std::isfinite(progress) ? 0.0f : NAN;
}
bool has_print_error() const {
if (!elegoo) return is_on(print_error);
return elegoo_fault(print_error) || elegoo_fault(print_error_reason) || lower(status) == "error";
@@ -212,6 +249,29 @@ struct Snapshot {
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return text;
}
std::string overview_task() const {
if (!live()) return connected ? "Waiting for printer data" : "Waiting for Home Assistant";
return valid_text(task) ? task : "No active print";
}
std::string overview_meta() const {
if (!live()) return "--";
const float percent = completion();
const std::string progress_text = std::isfinite(percent)
? number(std::clamp(percent, 0.0f, 100.0f)) + "%" : "--";
const std::string time_line = remaining(time_left, remaining_unit);
if (!finish_time.empty() && time_line != "--")
return progress_text + " " + finish_time + " " + time_line;
return progress_text + " " + time_line;
}
// Active tray is the spool the printer loaded most recently. Without one, keep green.
uint32_t active_filament_color() const {
for (const auto &tray : trays)
if (is_on(tray.active)) {
const uint32_t color = filament_color(tray.color);
if (color != TRACK) return color;
}
return GREEN;
}
uint32_t status_color() const {
if (!live()) return MUTED;
const auto state = lower(status);
@@ -223,6 +283,42 @@ struct Snapshot {
}
};
struct FleetCounts {
int attention = 0, printing = 0, paused = 0, idle = 0, offline = 0, syncing = 0;
};
inline void tally(FleetCounts &counts, const Snapshot &data) {
if (!data.connected || lower(data.online) == "off" || lower(data.online) == "false") {
++counts.offline;
return;
}
if (!data.live()) {
++counts.syncing;
return;
}
if (data.has_print_error() || is_on(data.hms_error) || lower(data.status) == "failed") {
++counts.attention;
return;
}
const auto state = lower(data.status);
if (state == "running" || state == "printing") ++counts.printing;
else if (state == "pause" || state == "paused" || state == "pausing") ++counts.paused;
else ++counts.idle;
}
inline std::string fleet_summary(const FleetCounts &counts) {
const std::pair<int, const char *> items[] = {
{counts.attention, "attention"}, {counts.printing, "printing"}, {counts.paused, "paused"},
{counts.idle, "idle"}, {counts.offline, "offline"}, {counts.syncing, "syncing"}};
std::string text;
for (const auto &item : items) {
if (!item.first) continue;
if (!text.empty()) text += ", ";
text += std::to_string(item.first) + " " + item.second;
}
return text.empty() ? "Syncing" : text;
}
} // namespace bambu_display
#ifdef USE_LVGL
@@ -239,6 +335,9 @@ struct Widgets {
GaugeWidgets uv{}, layer{};
lv_obj_t *elapsed = nullptr;
};
struct OverviewWidgets {
lv_obj_t *arc, *value, *finish, *backing;
};
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());
@@ -257,9 +356,10 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
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.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,
const float percent = data.completion();
lv_bar_set_value(widgets.top_progress, live ? gauge_value(percent, 100) : 0, LV_ANIM_OFF);
render_gauge(widgets.progress, live && std::isfinite(percent)
? std::clamp(percent, 0.0f, 100.0f) : NAN, live ? percent : 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");
@@ -306,5 +406,33 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
}
}
inline void render_overview(const Snapshot &data, const OverviewWidgets &widgets) {
const bool live = data.live();
const uint32_t filament = data.active_filament_color();
const float percent = data.completion();
const float shown = live && std::isfinite(percent)
? std::clamp(percent, 0.0f, 100.0f) : NAN;
const bool black = filament_is_black(filament);
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(black ? RING_BACKING : TRACK), LV_PART_MAIN);
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(filament), LV_PART_INDICATOR);
lv_arc_set_value(widgets.arc, live ? gauge_value(percent, 100) : 0);
if (black) {
lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_MAIN);
lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_INDICATOR);
lv_obj_clear_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN);
}
set_text(widgets.value, number(shown));
lv_obj_set_style_text_color(widgets.value, lv_color_hex(0xFFFFFF), 0);
set_text(widgets.finish, live && !data.finish_time.empty() ? data.finish_time : "--");
}
inline void render_fleet_summary(lv_obj_t *label, const FleetCounts &counts) {
set_text(label, fleet_summary(counts));
const uint32_t color = counts.attention ? RED : counts.printing ? GREEN : MUTED;
lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
}
} // namespace bambu_display
#endif
+159 -35
View File
@@ -24,14 +24,17 @@ 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'}
ELEGOO_COMMON = {'progress': 'percent_complete', 'time_left': 'remaining_print_time',
'layer': 'current_layer', 'total_layers': 'total_layers'}
'layer': 'current_layer', 'total_layers': 'total_layers',
'total_time': 'total_print_time'}
ELEGOO_FDM = {'nozzle': 'nozzle_temperature', 'bed': 'bed_temperature',
'elapsed': 'current_print_time',
'nozzle_target': ('number', 'target_nozzle_temp'),
'bed_target': ('number', 'target_bed_temp'), 'fan': 'model_fan_speed',
'aux_fan': 'auxiliary_fan_speed', 'exhaust_fan': 'enclosure_fan_speed'}
ELEGOO_RESIN = {'uv': 'uv_led_temp', 'elapsed': 'current_print_time'}
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
RING_BACKING = 0x6E767E
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
@@ -121,7 +124,7 @@ def fdm_gauges(printer):
return widgets
def page_for(printer, index, total):
def page_for(printer, display_index, total):
pid = printer['id']
resin = printer.get('profile') == 'elegoo_resin'
widgets = [
@@ -129,7 +132,7 @@ def page_for(printer, index, total):
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
label(printer['name'], 16, 17, 485, 26, text_color=GREEN),
label(f'{index + 1} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
label(f'{display_index} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED,
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
@@ -213,6 +216,141 @@ def page_for(printer, index, total):
'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets}
def snapshot_lines(printer, expressions, trays):
profile = printer.get('profile', 'bambu')
elegoo = profile.startswith('elegoo_')
resin = profile == 'elegoo_resin'
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};')
if elegoo:
lines.append('data.elegoo = true;')
else:
for number in range(1, 4):
lines.append('data.health_notices.push_back({' + ', '.join(
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
if resin:
lines.append('data.resin = true;')
if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
lines += [
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
'const auto clock = id(ha_time).now();',
'if (clock.is_valid() && seconds_left >= 0)',
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
]
for i, tray in enumerate(trays):
lines.append('data.trays.push_back({' + ', '.join([
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
return lines
def overview_slots(count):
"""Place one steam gauge per printer under the page title."""
columns = 4 if count > 4 else max(count, 1)
rows = (count + columns - 1) // columns
top, bottom = 46, 432
row_h = (bottom - top) // rows
arc = min(154, (800 // columns) - 24, row_h - 48)
col_w = 800 // columns
slots = []
for index in range(count):
row, col = divmod(index, columns)
in_row = min(columns, count - row * columns)
x0 = (800 - in_row * col_w) // 2
gauge_w = arc + 16
slots.append((x0 + col * col_w + (col_w - gauge_w) // 2,
top + row * row_h, arc))
return slots
def overview_gauge(printer, x, y, arc):
pid = printer['id']
scale = arc / 154
value_font = 32 if arc >= 130 else 18
open_page = [{'lvgl.page.show': f'{pid}_page'}]
inner = max(10, int(round(13 * scale)))
inner_x = x + int(round(8 * scale))
number_y = y + int(round(arc * 0.30))
pad = 8
return [
{'arc': {'id': f'{pid}_overview_ring', 'x': inner_x - pad // 2, 'y': y - pad // 2,
'width': arc + pad, 'height': arc + pad, 'min_value': 0, 'max_value': 100,
'value': 100, 'start_angle': 135, 'end_angle': 45,
'arc_width': inner + pad, 'arc_color': RING_BACKING,
'indicator': {'arc_color': RING_BACKING, 'arc_width': inner + pad},
'adjustable': False, 'hidden': True, 'gesture_bubble': True, 'clickable': True,
'on_click': open_page}},
{'arc': {'id': f'{pid}_overview_arc', 'x': inner_x, 'y': y,
'width': arc, 'height': arc, 'min_value': 0, 'max_value': 100, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': inner, 'arc_color': TRACK,
'indicator': {'arc_color': GREEN, 'arc_width': inner}, 'adjustable': False,
'gesture_bubble': True, 'clickable': True, 'on_click': open_page}},
label('--', inner_x, number_y, arc, value_font, id=f'{pid}_overview_value',
text_align='CENTER', text_color=0xFFFFFF, clickable=True, on_click=open_page),
label('%', inner_x, number_y + (34 if value_font >= 28 else 20), arc, 16,
id=f'{pid}_overview_percent', text_align='CENTER', text_color=0xFFFFFF,
clickable=True, on_click=open_page),
label(printer['name'], x, y + arc + 2, arc + 16, 14, text_align='CENTER',
text_color=TEXT_COLOR, clickable=True, on_click=open_page),
label('--', x, y + arc + 22, arc + 16, 14, id=f'{pid}_overview_finish',
text_align='CENTER', text_color=TEXT_COLOR, clickable=True, on_click=open_page),
]
def overview_page(printers):
widgets = [
label('Print Status Overview', 16, 10, 520, 22, text_color=GREEN),
label('--:--', 548, 12, 236, 18, id='fleet_clock', text_align='RIGHT', text_color=TEXT_COLOR),
]
for printer, (x, y, arc) in zip(printers, overview_slots(len(printers))):
widgets += overview_gauge(printer, x, y, arc)
for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')):
widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30,
'bg_color': TRACK, 'radius': 8, 'border_width': 0,
'pad_all': 0, 'gesture_bubble': True,
'widgets': [{'label': {'text': text, 'align': 'CENTER',
'text_color': TEXT_COLOR, 'text_font': 'montserrat_18'}}],
'on_click': [{f'lvgl.page.{direction}': {}}]}})
widgets.append(label(f'1 / {len(printers) + 1}', 94, 449, 612, 14,
text_align='CENTER', text_color=MUTED))
return {'id': 'fleet_page', 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
'on_swipe_left': [
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
{'lvgl.page.next': {'animation': 'MOVE_LEFT', 'time': '180ms'}}],
'on_swipe_right': [
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
{'lvgl.page.previous': {'animation': 'MOVE_RIGHT', 'time': '180ms'}}],
'on_load': [{'script.execute': 'fleet_refresh'}], 'widgets': widgets}
def fleet_refresh_lines(snapshots):
lines = []
for pid, snapshot in snapshots:
lines.append('{')
lines.extend(snapshot)
lines.append('bambu_display::render_overview(data, {' + ', '.join([
f'id({pid}_overview_arc)', f'id({pid}_overview_value)',
f'id({pid}_overview_finish)', f'id({pid}_overview_ring)']) + '});')
lines.append('}')
lines += [
'{',
'const auto clock = id(ha_time).now();',
'std::string clock_text = "--:--";',
'if (clock.is_valid()) {',
' clock_text = esphome::ESPTime::from_epoch_local(clock.timestamp).strftime("%I:%M %p");',
' if (!clock_text.empty() && clock_text[0] == \'0\') clock_text.erase(0, 1);',
'}',
'bambu_display::set_text(id(fleet_clock), clock_text);',
'}',
]
return lines
def color_test_page():
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
label('Each block must match its label. Swipe to return to the printers.',
@@ -236,6 +374,8 @@ def generate(printers):
validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [],
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
snapshots = []
page_total = len(printers) + 1
for index, printer in enumerate(printers):
pid = printer['id']
profile = printer.get('profile', 'bambu')
@@ -271,7 +411,8 @@ def generate(printers):
text_fields['print_error_message'] = (print_error_entity, 'error')
text_fields['print_error_code'] = (print_error_entity, 'code')
for key, numeric_key in [('remaining_unit', 'time_left'), ('nozzle_unit', 'nozzle'),
('bed_unit', 'bed'), ('uv_unit', 'uv'), ('elapsed_unit', 'elapsed')]:
('bed_unit', 'bed'), ('uv_unit', 'uv'), ('elapsed_unit', 'elapsed'),
('total_time_unit', 'total_time')]:
if numeric_key in fields:
suffix = fields[numeric_key]
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
@@ -287,34 +428,11 @@ def generate(printers):
subscription['attribute'] = attribute
package['text_sensor'].append(subscription)
expressions[key] = f'id({sid}).state'
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};')
if elegoo:
lines.append('data.elegoo = true;')
else:
for number in range(1, 4):
lines.append('data.health_notices.push_back({' + ', '.join(
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
if resin:
lines.append('data.resin = true;')
if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
lines += [
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
'const auto clock = id(ha_time).now();',
'if (clock.is_valid() && seconds_left >= 0)',
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
]
tray_widgets = []
for i, tray in enumerate(trays):
lines.append('data.trays.push_back({' + ', '.join([
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}')
lines = snapshot_lines(printer, expressions, trays)
snapshots.append((pid, list(lines)))
tray_widgets = ['{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}'
for i in range(len(trays))]
refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
'health_details')]
@@ -331,7 +449,13 @@ def generate(printers):
package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
'then': [{'script.execute': f'{pid}_refresh'}]}})
package['lvgl']['pages'].append(page_for(printer, index, len(printers)))
package['lvgl']['pages'].append(page_for(printer, index + 2, page_total))
package['script'].append({'id': 'fleet_refresh', 'then': [
{'lambda': Lambda('\n'.join(fleet_refresh_lines(snapshots)))}]})
package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': 'fleet_page'},
'then': [{'script.execute': 'fleet_refresh'}]}})
package['lvgl']['pages'].insert(0, overview_page(printers))
package['lvgl']['pages'].append(color_test_page())
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
'entity_category': 'diagnostic',
@@ -351,10 +475,10 @@ def main():
if args.check:
if not destination.exists() or destination.read_text() != content:
raise SystemExit('generated.yaml is stale; run generate.py')
print(f'Generated configuration is current ({len(printers)} printer pages).')
print(f'Generated configuration is current ({len(printers)} printer pages plus fleet overview).')
else:
destination.write_text(content)
print(f'Generated {destination.name} with {len(printers)} printer pages.')
print(f'Generated {destination.name} with {len(printers)} printer pages plus fleet overview.')
if __name__ == '__main__':
File diff suppressed because it is too large Load Diff
+27 -9
View File
@@ -22,12 +22,14 @@ def main():
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, 'aux_fan': 45, 'exhaust_fan': 30, 'left_nozzle': 219, 'left_target': 220,
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30}
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30,
'total_time': 200}
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C',
'uv_unit': '°C', 'elapsed_unit': 'min', 'print_error_reason': 'ok'}
'uv_unit': '°C', 'elapsed_unit': 'min', 'total_time_unit': 'min',
'print_error_reason': 'ok'}
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
for printer in printers:
@@ -87,7 +89,21 @@ inline void setup() {
''')
steps = [{'component.update': entry['id']}
for section in ('sensor', 'text_sensor') for entry in package[section]]
steps += [{'delay': '1s'}]
steps += [{'delay': '1s'},
{'lvgl.page.show': 'fleet_page'},
{'script.execute': 'fleet_refresh'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'fleet.bmp'}},
{'lambda': Lambda(
'if (std::string(lv_label_get_text(id(fleet_clock))) == "--:--") '
'{ ESP_LOGE("preview", "Fleet clock render failed"); exit(7); }')}]
for printer in printers:
pid = printer['id']
steps.append({'lambda': Lambda(
f'if (std::string(lv_label_get_text(id({pid}_overview_value))) != "67" || '
f'lv_arc_get_value(id({pid}_overview_arc)) != 67 || '
f'std::string(lv_label_get_text(id({pid}_overview_percent))) != "%" || '
f'std::string(lv_label_get_text(id({pid}_overview_finish))) == "--") '
f'{{ ESP_LOGE("preview", "Fleet gauge render failed: {pid}"); exit(7); }}')})
for printer in printers:
steps += [{'lvgl.page.show': printer['id'] + '_page'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
@@ -125,7 +141,7 @@ inline void setup() {
{'lvgl.widget.hide': demo_printer + '_health_panel'},
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
{'script.execute': demo_printer + '_refresh'}]
steps += [{'lvgl.page.show': printers[0]['id'] + '_page'}, {'delay': '250ms'},
steps += [{'lvgl.page.show': 'fleet_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}]
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
steps += [
@@ -136,11 +152,13 @@ inline void setup() {
if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV.
gestures = [(650, 180, 180, printers[1]['id']),
(180, 650, 180, printers[0]['id']),
# Fleet -> first printer -> fleet -> last printer -> fleet, then a
# swipe starting on NEXT advances exactly once.
gestures = [(650, 180, 180, printers[0]['id']),
(180, 650, 180, 'fleet'),
(180, 650, 180, printers[-1]['id']),
(650, 180, 180, printers[0]['id']),
(730, 60, 460, printers[1]['id'])]
(650, 180, 180, 'fleet'),
(730, 60, 460, printers[0]['id'])]
for start, end, y, expected in gestures:
steps += [
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
@@ -165,7 +183,7 @@ inline void setup() {
subprocess.run(['esphome', 'compile', str(filename)], check=True)
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
for name in [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
for name in ['fleet'] + [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
with Image.open(bmp) as image:
image.save(args.output / (name + '.png'))
+69
View File
@@ -99,8 +99,77 @@ int main() {
assert(data.status_color() == AMBER);
data.status = "complete";
assert(data.status_text() == "COMPLETE");
data.status = "preheating_completed";
data.stage = "printing";
data.progress = 0;
data.layer = 0;
data.total_layers = 400;
data.elapsed = 30;
data.elapsed_unit = "min";
data.total_time = 60;
data.total_time_unit = "min";
assert(std::fabs(data.completion() - 50.0f) < 0.01f);
data.layer = 100;
assert(std::fabs(data.completion() - 25.0f) < 0.01f);
data.progress = 67;
assert(std::fabs(data.completion() - 67.0f) < 0.01f);
data.progress = 0;
data.layer = 0;
data.status = "idle";
data.stage = "idle";
assert(data.completion() == 0.0f);
data.online = "off";
assert(!data.live());
assert(data.status_text() == "PRINTER OFFLINE");
assert(data.health_details().find("unavailable") != std::string::npos);
data.connected = true;
data.online = "on";
data.elegoo = false;
data.print_error = "off";
data.print_error_reason = "ok";
data.hms_error = "off";
data.status = "running";
data.task = "Workshop organizer";
data.progress = 67;
data.time_left = 1.75f;
data.remaining_unit = "h";
data.finish_time = "16:05";
assert(data.overview_task() == "Workshop organizer");
assert(data.overview_meta() == "67% 16:05 1h 45m");
data.task = "";
assert(data.overview_task() == "No active print");
data.connected = false;
assert(data.overview_task() == "Waiting for Home Assistant");
assert(data.overview_meta() == "--");
data.trays = {{"A1", "PLA", "#FF8800FF", "off"}, {"A2", "PETG", "00FF00", "on"}};
assert(data.active_filament_color() == GREEN);
data.trays[1].active = "off";
data.trays[0].active = "on";
assert(data.active_filament_color() == 0xFF8800);
data.trays.clear();
assert(data.active_filament_color() == GREEN);
assert(filament_is_black(0x000000));
assert(filament_is_black(filament_color("#000000FF")));
assert(filament_is_black(0x202020));
assert(!filament_is_black(0xFFFFFF));
assert(!filament_is_black(GREEN));
assert(!filament_is_black(0xFF8800));
FleetCounts counts;
data.connected = true;
data.online = "on";
data.status = "printing";
tally(counts, data);
data.status = "pause";
tally(counts, data);
data.print_error = "on";
tally(counts, data);
data.print_error = "off";
data.online = "off";
tally(counts, data);
data.online = "unavailable";
data.connected = true;
tally(counts, data);
assert(fleet_summary(counts) == "1 attention, 1 printing, 1 paused, 1 offline, 1 syncing");
}
+59 -3
View File
@@ -27,8 +27,8 @@ class FleetTests(unittest.TestCase):
extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}')
self.printers.append(extra)
package = generate(self.printers)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count)
self.assertEqual(len(package['script']), expected_count)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count + 1)
self.assertEqual(len(package['script']), expected_count + 1)
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
for page in package['lvgl']['pages']:
@@ -123,7 +123,7 @@ class FleetTests(unittest.TestCase):
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)
config = yaml.load((HERE / 'board.yaml').read_text(), Loader=Loader)
display = config['display'][0]
pins = display['data_pins']
natural = pins['blue'] + pins['green'] + pins['red']
@@ -135,6 +135,62 @@ class FleetTests(unittest.TestCase):
for i, pin in enumerate(driver))
self.assertEqual(panel_word, color, f'RGB565 color {color:#06x} is wired incorrectly')
def test_both_boards_share_one_hardware_package(self):
class Loader(yaml.SafeLoader):
pass
Loader.add_constructor('!include', lambda loader, node: loader.construct_scalar(node))
names = set()
for filename in ('bambu-printer-display.yaml', 'bambu-printer-display-2.yaml'):
config = yaml.load((HERE.parent / filename).read_text(), Loader=Loader)
names.add(config['substitutions']['device_name'])
self.assertEqual(config['packages']['board'], 'bambu-display/board.yaml')
self.assertEqual(config['packages']['fleet'], 'bambu-display/generated.yaml')
self.assertEqual(len(names), 2)
def test_fleet_overview_lists_every_printer(self):
package = generate(self.printers)
page = package['lvgl']['pages'][0]
self.assertEqual(page['id'], 'fleet_page')
self.assertFalse(page.get('skip'))
arcs = [widget['arc'] for widget in page['widgets']
if widget.get('arc', {}).get('id', '').endswith('_overview_arc')]
self.assertEqual([arc['id'][:-len('_overview_arc')] for arc in arcs],
[printer['id'] for printer in self.printers])
backings = [widget['arc'] for widget in page['widgets']
if widget.get('arc', {}).get('id', '').endswith('_overview_ring')]
self.assertEqual([arc['id'][:-len('_overview_ring')] for arc in backings],
[printer['id'] for printer in self.printers])
for backing, arc in zip(backings, arcs):
self.assertTrue(backing['hidden'])
self.assertEqual(backing['arc_color'], 0x6E767E)
self.assertGreater(backing['width'], arc['width'])
self.assertGreaterEqual(backing['x'], 0)
self.assertGreaterEqual(backing['y'], 0)
self.assertLessEqual(backing['x'] + backing['width'], 800)
self.assertLessEqual(backing['y'] + backing['height'], 480)
for arc, printer in zip(arcs, self.printers):
self.assertEqual(arc['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}])
self.assertGreaterEqual(arc['width'], 120)
self.assertLessEqual(arc['width'], 154)
self.assertEqual(arc['value'], 0)
self.assertEqual(arc['arc_color'], 0x19232B)
self.assertEqual(arc['start_angle'], 135)
self.assertLessEqual(arc['x'] + arc['width'], 800)
self.assertLessEqual(arc['y'] + arc['height'], 480)
code = next(script['then'][0]['lambda'] for script in package['script'] if script['id'] == 'fleet_refresh')
for printer in self.printers:
self.assertIn(f'id({printer["id"]}_overview_arc)', code)
self.assertIn(f'id({printer["id"]}_overview_finish)', code)
self.assertIn(f'id({printer["id"]}_overview_ring)', code)
titles = [next(iter(widget.values())).get('text') for widget in page['widgets']]
self.assertIn('Print Status Overview', titles)
self.assertNotIn('fleet_summary', {next(iter(widget.values())).get('id') for widget in page['widgets']})
shown = [step['if']['condition']['lvgl.page.is_showing'] for step in package['interval'][0]['then']]
self.assertIn('fleet_page', shown)
charming = next(item for item in package['lvgl']['pages'] if item['id'] == 'prints_charming_page')
numbers = [next(iter(widget.values())).get('text') for widget in charming['widgets']]
self.assertIn(f'2 / {len(self.printers) + 1}', numbers)
def test_diagnostic_color_page_is_skipped_during_normal_navigation(self):
package = generate(self.printers)
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page')
+10
View File
@@ -0,0 +1,10 @@
# Second Sunton ESP32-8048S043C fleet display. Same pages and hardware as
# bambu-printer-display.yaml, with its own ESPHome name so both boards can
# join Home Assistant. Edit bambu-display/printers.yaml, then run generate.py.
substitutions:
device_name: bambu-printer-display-2
friendly_name: Bambu Printer Display 2
packages:
board: !include bambu-display/board.yaml
fleet: !include bambu-display/generated.yaml
+2 -113
View File
@@ -1,121 +1,10 @@
# 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.
# The second board is bambu-printer-display-2.yaml; both include bambu-display/board.yaml.
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
# 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
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
time:
- platform: homeassistant
id: ha_time
timezone: America/New_York
packages:
board: !include bambu-display/board.yaml
fleet: !include bambu-display/generated.yaml
lvgl:
id: dashboard
displays: [panel_display]
touchscreens: [panel_touch]
buffer_size: 12%
color_depth: 16
byte_order: big_endian # Must match mipi_rgb's named RGB pin mapping above.
bg_color: 0x000000
text_color: 0xEEEEEE
default_font: montserrat_18