updates
This commit is contained in:
@@ -61,6 +61,9 @@
|
|||||||
### ESPHome Folders ###
|
### ESPHome Folders ###
|
||||||
!esphome/
|
!esphome/
|
||||||
!esphome/fonts/**
|
!esphome/fonts/**
|
||||||
|
!esphome/bambu-display/
|
||||||
|
!esphome/bambu-display/**
|
||||||
|
esphome/bambu-display/**/__pycache__/
|
||||||
!esphome/ha-remote/
|
!esphome/ha-remote/
|
||||||
!esphome/ha-remote/components/
|
!esphome/ha-remote/components/
|
||||||
!esphome/ha-remote/components/**
|
!esphome/ha-remote/components/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|

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