Add Elegoo printers to ESPHome fleet display

This commit is contained in:
Joshua King
2026-10-19 11:24:02 -04:00
parent 3c940f8027
commit c469aa4aae
16 changed files with 1523 additions and 75 deletions
+45 -9
View File
@@ -1,4 +1,4 @@
# Bambu fleet display # Bambu and Elegoo fleet display
ESPHome/LVGL monitor for the **Sunton ESP32-8048S043C**, with an 800×480 landscape RGB 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 display, GT911 capacitive touch, 16 MB flash and 8 MB octal PSRAM. The hardware pins
@@ -6,10 +6,10 @@ and LCD timings follow the [ESPHome device reference](https://devices.esphome.io
This configuration is for the **C** capacitive-touch version. This configuration is for the **C** capacitive-touch version.
Inspired by [BambuHelper](https://github.com/Keralots/BambuHelper), this implementation Inspired by [BambuHelper](https://github.com/Keralots/BambuHelper), this implementation
subscribes to the existing Home Assistant Bambu Lab entities over a single encrypted subscribes to the existing Home Assistant Bambu Lab and Elegoo Printer entities over
ESPHome API connection. Home Assistant handles the printer connections. No Bambu 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. 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. Home Assistant must be running and its printer integrations must have current printer data.
## Pages and navigation ## Pages and navigation
@@ -24,13 +24,19 @@ The configured swipe order is:
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units. 5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
AMS A uses `constipation_orion_ams_1`; AMS B retains its original AMS A uses `constipation_orion_ams_1`; AMS B retains its original
`h2c_31b8ap5a2200288_ams_2` entity prefix. `h2c_31b8ap5a2200288_ams_2` entity prefix.
6. 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,
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 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. 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 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. 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 FDM pages show print status, job name, progress, nozzle and bed current/target
temperatures, remaining time, estimated finish time, current/total layers, print temperatures, remaining time, estimated finish time, current/total layers, print
stage, fan speeds and filament colors/types. The layout follows BambuHelper's stage, fan speeds and filament colors/types. The layout follows BambuHelper's
classic gauges: black background, bright green accents, six arc gauges and a classic gauges: black background, bright green accents, six arc gauges and a
@@ -47,6 +53,12 @@ three current HMS notices, plus print-error details. Scroll vertically for long
messages and tap Close to return. If more than three notices exist, the panel says messages and tap Close to return. If more than three notices exist, the panel says
how many additional notices are available in Home Assistant. how many additional notices are available in Home Assistant.
Elegoo pages use the same status-tap details panel for `print_error` and
`error_status_reason` enum values. `none` and `ok` are normal states; unknown or
unavailable errors do not imply a fault. Elegoo pages use `sdcp_status` for printer
connectivity and do not subscribe to Bambu HMS or AMS entities. The Saturn page
omits nozzle, bed, fan and filament widgets.
The status uses **HEALTH INFO** (cyan) for information-only notifications, The status uses **HEALTH INFO** (cyan) for information-only notifications,
**HEALTH NOTICE** (amber) for common/unknown severities or incomplete details, and **HEALTH NOTICE** (amber) for common/unknown severities or incomplete details, and
**HEALTH ERROR** (red) for fatal/serious severities. Severity comes from the Bambu **HEALTH ERROR** (red) for fatal/serious severities. Severity comes from the Bambu
@@ -58,7 +70,7 @@ fields for subsequent notices. Stale attributes are ignored when the sensor is o
When Home Assistant disconnects or a printer is offline, its live metrics are cleared 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 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` values also show `--`. Remaining time respects the HA sensor's `h`, `min`, `s` or `ms`
unit, and temperature labels use the imported HA temperature unit. Only the visible unit, and temperature labels use the imported HA temperature unit. Only the visible
page is refreshed each second; every printer's subscriptions remain connected. page is refreshed each second; every printer's subscriptions remain connected.
Auxiliary and exhaust fans are imported only for printers with those sensors; Auxiliary and exhaust fans are imported only for printers with those sensors;
@@ -125,8 +137,8 @@ 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; 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 and swipe page. The practical
limit depends on the board's available RAM and flash, and adding printers requires limit depends on the board's available RAM and flash, and adding printers requires
regenerating and installing the firmware. Five printers are configured; generation regenerating and installing the firmware. Seven printers are configured; generation
is also tested with seven. Each printer's filament strip supports up to eight slots. is also tested with nine. Each Bambu printer's filament strip supports up to eight slots.
```yaml ```yaml
- id: another_printer - id: another_printer
@@ -148,6 +160,29 @@ For a dual-nozzle printer add `dual_nozzle: true`. For external filament add
exist; their mappings are in `EXTRA_FANS`. A remaining-time override also updates its unit subscription. exist; their mappings are in `EXTRA_FANS`. A remaining-time override also updates its unit subscription.
Filament entries use the Bambu integration's `type`, `color` and `active` attributes. 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
`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
`entities` overrides support `sensor`, `binary_sensor` and `number` domains.
Sensor types, duration units and enum states follow the
[Elegoo integration definitions](https://github.com/danielcherubini/elegoo-homeassistant/blob/main/custom_components/elegoo_printer/definitions.py).
The configured Elegoo prefixes are `centauri_carbon` and `saturn`, matching the
repository entity snapshot. Some older automations refer to `saturn_iv`; if your
live integration still uses that prefix, change the Saturn `entity_prefix` before
regenerating. If individual entities have different names in your integration
version, use `entities` overrides, for example:
```yaml
entities:
print_error_reason: sensor.saturn_current_print_error_status_reason
```
No new HA helpers are required. `dual_nozzle`, filament slots and `extra_fans` are
Bambu options; the Elegoo FDM profile includes its three fan subscriptions.
## Development checks and screenshots ## Development checks and screenshots
```sh ```sh
@@ -158,10 +193,11 @@ python esphome/bambu-display/preview.py
``` ```
`preview.py` compiles the same generated LVGL widgets for ESPHome's SDL host with `preview.py` compiles the same generated LVGL widgets for ESPHome's SDL host with
clearly synthetic telemetry, then captures all five pages and a synthetic health clearly synthetic telemetry, then captures all seven pages and a synthetic health
notice into `previews/`. The demo notice is not a diagnosis of any real printer. It needs notice into `previews/`. The demo notice is not a diagnosis of any real printer. It needs
ESPHome, Pillow, a C++ compiler and SDL2 (`sdl2-config` on PATH). It runs headlessly, 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 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 samples to check left/right swipes, page wraparound and consumption of a swipe that
starts on a navigation button. These are software layout starts on a navigation button. These are software layout
previews; physical panel timings, touch and Wi-Fi still need a hardware check. previews; physical panel timings, touch and Wi-Fi still need a hardware check.
The preview also checks Elegoo telemetry, error text and clearing of offline data.
+60 -24
View File
@@ -32,6 +32,11 @@ inline bool is_on(const std::string &text) {
return value == "on" || value == "true" || value == "1"; return value == "on" || value == "true" || value == "1";
} }
inline bool elegoo_fault(const std::string &text) {
const auto value = lower(text);
return valid_text(value) && value != "ok" && value != "0" && value != "off" && value != "false";
}
inline std::string humanize(std::string text) { inline std::string humanize(std::string text) {
if (!valid_text(text)) return "--"; if (!valid_text(text)) return "--";
std::replace(text.begin(), text.end(), '_', ' '); std::replace(text.begin(), text.end(), '_', ' ');
@@ -60,6 +65,7 @@ inline std::string remaining(float value, const std::string &unit) {
if (unit == "h") minutes = double(value) * 60; if (unit == "h") minutes = double(value) * 60;
else if (unit == "min") minutes = value; else if (unit == "min") minutes = value;
else if (unit == "s") minutes = double(value) / 60; else if (unit == "s") minutes = double(value) / 60;
else if (unit == "ms") minutes = double(value) / 60000;
else return "--"; // Never silently interpret hours as minutes. else return "--"; // Never silently interpret hours as minutes.
if (minutes > 1000000) return "--"; if (minutes > 1000000) return "--";
const auto rounded = static_cast<int>(std::ceil(minutes - 0.001)); const auto rounded = static_cast<int>(std::ceil(minutes - 0.001));
@@ -74,6 +80,7 @@ inline int duration_seconds(float value, const std::string &unit) {
if (unit == "h") seconds = double(value) * 3600; if (unit == "h") seconds = double(value) * 3600;
else if (unit == "min") seconds = double(value) * 60; else if (unit == "min") seconds = double(value) * 60;
else if (unit == "s") seconds = value; else if (unit == "s") seconds = value;
else if (unit == "ms") seconds = double(value) / 1000;
else return -1; else return -1;
return seconds <= 60000000 ? static_cast<int>(std::round(seconds)) : -1; return seconds <= 60000000 ? static_cast<int>(std::round(seconds)) : -1;
} }
@@ -106,6 +113,9 @@ struct Snapshot {
bool connected = false; bool connected = false;
std::string online, status, task, stage, print_error, hms_error; std::string online, status, task, stage, print_error, hms_error;
std::string hms_count, print_error_message, print_error_code; 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;
std::vector<HealthNotice> health_notices; std::vector<HealthNotice> health_notices;
std::string remaining_unit, nozzle_unit, bed_unit; std::string remaining_unit, nozzle_unit, bed_unit;
std::string finish_time; std::string finish_time;
@@ -117,6 +127,20 @@ struct Snapshot {
std::vector<Tray> trays; std::vector<Tray> trays;
bool live() const { return connected && is_on(online) && valid_text(status); } bool live() const { return connected && is_on(online) && valid_text(status); }
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";
}
std::string error_message() const {
if (!elegoo) return valid_text(print_error_message) ? print_error_message : "Print error reported; tap status for details";
std::string message;
for (const auto &text : {print_error, print_error_reason}) {
if (!elegoo_fault(text)) continue;
if (!message.empty()) message += " / ";
message += humanize(text);
}
return message.empty() ? "Print error reported; tap status for details" : message;
}
int notice_count() const { int notice_count() const {
int loaded = 0; int loaded = 0;
for (const auto &notice : health_notices) for (const auto &notice : health_notices)
@@ -138,8 +162,7 @@ struct Snapshot {
return loaded == 0 || notice_count() > loaded ? std::max(rank, 2) : rank; return loaded == 0 || notice_count() > loaded ? std::max(rank, 2) : rank;
} }
std::string alert_summary() const { std::string alert_summary() const {
if (is_on(print_error)) if (has_print_error()) return error_message();
return valid_text(print_error_message) ? print_error_message : "Print error reported; tap status for details";
if (is_on(hms_error)) { if (is_on(hms_error)) {
for (const auto &notice : health_notices) for (const auto &notice : health_notices)
if (valid_text(notice.message)) return notice.message; if (valid_text(notice.message)) return notice.message;
@@ -150,10 +173,10 @@ struct Snapshot {
std::string health_details() const { std::string health_details() const {
if (!live()) return "Printer health data is unavailable. Waiting for a live connection."; if (!live()) return "Printer health data is unavailable. Waiting for a live connection.";
std::string details; std::string details;
if (is_on(print_error)) { if (has_print_error()) {
details = "PRINT ERROR\n"; details = "PRINT ERROR\n";
if (valid_text(print_error_code)) details += print_error_code + "\n"; if (valid_text(print_error_code)) details += print_error_code + "\n";
details += valid_text(print_error_message) ? print_error_message : "Message not supplied by Home Assistant."; details += error_message();
details += "\n\n"; details += "\n\n";
} }
if (is_on(hms_error)) { if (is_on(hms_error)) {
@@ -178,13 +201,13 @@ struct Snapshot {
if (!connected) return "HA OFFLINE"; if (!connected) return "HA OFFLINE";
if (lower(online) == "off" || lower(online) == "false") return "PRINTER OFFLINE"; if (lower(online) == "off" || lower(online) == "false") return "PRINTER OFFLINE";
if (!live()) return "SYNCING"; if (!live()) return "SYNCING";
if (is_on(print_error)) return "PRINT ERROR"; if (has_print_error()) return "PRINT ERROR";
if (is_on(hms_error)) if (is_on(hms_error))
return health_severity_rank() >= 3 ? "HEALTH ERROR" : health_severity_rank() == 1 ? "HEALTH INFO" : "HEALTH NOTICE"; return health_severity_rank() >= 3 ? "HEALTH ERROR" : health_severity_rank() == 1 ? "HEALTH INFO" : "HEALTH NOTICE";
const auto state = lower(status); const auto state = lower(status);
if (state == "running") return "PRINTING"; if (state == "running" || state == "printing") return "PRINTING";
if (state == "pause" || state == "paused") return "PAUSED"; if (state == "pause" || state == "paused") return "PAUSED";
if (state == "finish" || state == "finished") return "COMPLETE"; if (state == "finish" || state == "finished" || state == "complete" || state == "completed") return "COMPLETE";
auto text = humanize(status); auto text = humanize(status);
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c))); for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return text; return text;
@@ -192,10 +215,10 @@ struct Snapshot {
uint32_t status_color() const { uint32_t status_color() const {
if (!live()) return MUTED; if (!live()) return MUTED;
const auto state = lower(status); const auto state = lower(status);
if (is_on(print_error) || state == "failed") return RED; if (has_print_error() || state == "failed") return RED;
if (is_on(hms_error)) if (is_on(hms_error))
return health_severity_rank() >= 3 ? RED : health_severity_rank() == 1 ? 0x00E5E5 : AMBER; return health_severity_rank() >= 3 ? RED : health_severity_rank() == 1 ? 0x00E5E5 : AMBER;
if (state == "pause" || state == "paused") return AMBER; if (state == "pause" || state == "paused" || state == "pausing") return AMBER;
return GREEN; return GREEN;
} }
}; };
@@ -213,6 +236,8 @@ struct Widgets {
lv_obj_t *status, *task, *top_progress, *remaining, *layers, *stage, *health_details; lv_obj_t *status, *task, *top_progress, *remaining, *layers, *stage, *health_details;
GaugeWidgets progress, nozzle, bed, fan, aux, exhaust; GaugeWidgets progress, nozzle, bed, fan, aux, exhaust;
std::vector<TrayWidgets> trays; std::vector<TrayWidgets> trays;
GaugeWidgets uv{}, layer{};
lv_obj_t *elapsed = nullptr;
}; };
inline void set_text(lv_obj_t *label, const std::string &text) { inline void set_text(lv_obj_t *label, const std::string &text) {
@@ -238,26 +263,37 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
live ? remaining(data.time_left, data.remaining_unit) : "--"); live ? remaining(data.time_left, data.remaining_unit) : "--");
set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print") set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print")
: data.connected ? "Waiting for printer data" : "Waiting for Home Assistant"); : data.connected ? "Waiting for printer data" : "Waiting for Home Assistant");
const float nozzle = data.dual_nozzle ? data.left_nozzle : data.nozzle; if (data.resin) {
const float target = data.dual_nozzle ? data.left_target : data.nozzle_target; render_gauge(widgets.uv, live ? data.uv : NAN,
render_gauge(widgets.nozzle, live ? nozzle : NAN, live ? celsius(nozzle, data.nozzle_unit) : NAN, live ? celsius(data.uv, data.uv_unit) : NAN, 100,
300, live ? "/ " + number(target) + " " + data.nozzle_unit : "--"); live && valid_text(data.uv_unit) ? data.uv_unit : "--");
render_gauge(widgets.bed, live ? data.bed : NAN, live ? celsius(data.bed, data.bed_unit) : NAN, const float layer_percent = std::isfinite(data.layer) && std::isfinite(data.total_layers)
120, live ? "/ " + number(data.bed_target) + " " + data.bed_unit : "--"); && data.layer >= 0 && data.total_layers > 0 ? 100 * data.layer / data.total_layers : NAN;
render_gauge(widgets.fan, live ? data.fan : NAN, live ? data.fan : NAN, 100, "%"); render_gauge(widgets.layer, live ? data.layer : NAN, live ? layer_percent : NAN, 100,
if (data.dual_nozzle) live ? "/ " + number(data.total_layers) : "--");
render_gauge(widgets.aux, live ? data.right_nozzle : NAN, set_text(widgets.elapsed, "Elapsed: " + (live ? remaining(data.elapsed, data.elapsed_unit) : "--"));
live ? celsius(data.right_nozzle, data.nozzle_unit) : NAN, } else {
300, live ? "/ " + number(data.right_target) + " " + data.nozzle_unit : "--"); const float nozzle = data.dual_nozzle ? data.left_nozzle : data.nozzle;
else const float target = data.dual_nozzle ? data.left_target : data.nozzle_target;
render_gauge(widgets.aux, live ? data.aux_fan : NAN, live ? data.aux_fan : NAN, 100, "%"); render_gauge(widgets.nozzle, live ? nozzle : NAN, live ? celsius(nozzle, data.nozzle_unit) : NAN,
render_gauge(widgets.exhaust, live ? data.exhaust_fan : NAN, live ? data.exhaust_fan : NAN, 100, "%"); 300, live ? "/ " + number(target) + " " + data.nozzle_unit : "--");
render_gauge(widgets.bed, live ? data.bed : NAN, live ? celsius(data.bed, data.bed_unit) : NAN,
120, live ? "/ " + number(data.bed_target) + " " + data.bed_unit : "--");
render_gauge(widgets.fan, live ? data.fan : NAN, live ? data.fan : NAN, 100, "%");
if (data.dual_nozzle)
render_gauge(widgets.aux, live ? data.right_nozzle : NAN,
live ? celsius(data.right_nozzle, data.nozzle_unit) : NAN,
300, live ? "/ " + number(data.right_target) + " " + data.nozzle_unit : "--");
else
render_gauge(widgets.aux, live ? data.aux_fan : NAN, live ? data.aux_fan : NAN, 100, "%");
render_gauge(widgets.exhaust, live ? data.exhaust_fan : NAN, live ? data.exhaust_fan : NAN, 100, "%");
}
std::string time_line = live ? remaining(data.time_left, data.remaining_unit) : "--"; std::string time_line = live ? remaining(data.time_left, data.remaining_unit) : "--";
if (live && !data.finish_time.empty()) time_line = data.finish_time + " | " + time_line; if (live && !data.finish_time.empty()) time_line = data.finish_time + " | " + time_line;
set_text(widgets.remaining, time_line); set_text(widgets.remaining, time_line);
set_text(widgets.layers, live ? "Layer " + number(data.layer) + " / " + number(data.total_layers) : "--"); set_text(widgets.layers, live ? "Layer " + number(data.layer) + " / " + number(data.total_layers) : "--");
std::string stage = live ? humanize(data.stage) : "Telemetry unavailable"; std::string stage = live ? humanize(data.stage) : "Telemetry unavailable";
if (live && (is_on(data.print_error) || is_on(data.hms_error))) stage = data.alert_summary(); if (live && (data.has_print_error() || is_on(data.hms_error))) stage = data.alert_summary();
set_text(widgets.stage, stage); set_text(widgets.stage, stage);
lv_obj_set_style_text_color(widgets.stage, lv_color_hex(live && !data.alert_summary().empty() ? color : MUTED), 0); lv_obj_set_style_text_color(widgets.stage, lv_color_hex(live && !data.alert_summary().empty() ? color : MUTED), 0);
set_text(widgets.health_details, data.health_details()); set_text(widgets.health_details, data.health_details());
+84 -30
View File
@@ -23,6 +23,13 @@ DUAL = {
EXTRA_FANS = {'aux_fan': 'aux_fan_speed', 'exhaust_fan': 'chamber_fan_speed'} EXTRA_FANS = {'aux_fan': 'aux_fan_speed', 'exhaust_fan': 'chamber_fan_speed'}
TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'} TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'}
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'} 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'}
ELEGOO_FDM = {'nozzle': 'nozzle_temperature', 'bed': 'bed_temperature',
'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 = ( BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00) 0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
ORANGE, CYAN = 0xFFAA00, 0x00E5E5 ORANGE, CYAN = 0xFFAA00, 0x00E5E5
@@ -65,6 +72,11 @@ def validate_printers(printers):
if not re.fullmatch(r'[a-z][a-z0-9_]*', pid) or pid in ids: if not re.fullmatch(r'[a-z][a-z0-9_]*', pid) or pid in ids:
raise ValueError(f'Invalid or duplicate printer id: {pid}') raise ValueError(f'Invalid or duplicate printer id: {pid}')
ids.add(pid) ids.add(pid)
profile = printer.get('profile', 'bambu')
if profile not in ('bambu', 'elegoo_fdm', 'elegoo_resin'):
raise ValueError(f'{pid}: unsupported profile: {profile}')
if profile != 'bambu' and (printer.get('dual_nozzle') or trays_for(printer) or printer.get('extra_fans')):
raise ValueError(f'{pid}: dual_nozzle, filament slots and extra_fans require the bambu profile')
if len(trays_for(printer)) > 8: if len(trays_for(printer)) > 8:
raise ValueError(f'{pid}: the filament strip fits up to eight slots') raise ValueError(f'{pid}: the filament strip fits up to eight slots')
if 'dual_nozzle' in printer and not isinstance(printer['dual_nozzle'], bool): if 'dual_nozzle' in printer and not isinstance(printer['dual_nozzle'], bool):
@@ -72,7 +84,7 @@ def validate_printers(printers):
if any(key not in EXTRA_FANS for key in printer.get('extra_fans', [])): if any(key not in EXTRA_FANS for key in printer.get('extra_fans', [])):
raise ValueError(f'{pid}: extra_fans supports aux_fan and exhaust_fan') raise ValueError(f'{pid}: extra_fans supports aux_fan and exhaust_fan')
for value in printer.get('entities', {}).values(): for value in printer.get('entities', {}).values():
if not isinstance(value, str) or not re.fullmatch(r'(sensor|binary_sensor)\.[a-z0-9_]+', value): if not isinstance(value, str) or not re.fullmatch(r'(sensor|binary_sensor|number)\.[a-z0-9_]+', value):
raise ValueError(f'{pid}: invalid entity override: {value}') raise ValueError(f'{pid}: invalid entity override: {value}')
@@ -96,8 +108,22 @@ def gauge(pid, key, title, x, y, color, maximum):
] ]
def fdm_gauges(printer):
pid = printer['id']
widgets = gauge(pid, 'nozzle', 'Nozzle L' if printer.get('dual_nozzle') else 'Nozzle',
202, 62, ORANGE, 300)
widgets += gauge(pid, 'bed', 'Bed', 388, 62, CYAN, 120)
widgets += gauge(pid, 'fan', 'Part', 16, 229, 0xEEEEEE, 100)
widgets += gauge(pid, 'aux', 'Nozzle R' if printer.get('dual_nozzle') else 'Aux',
202, 229, ORANGE if printer.get('dual_nozzle') else 0xEEEEEE,
300 if printer.get('dual_nozzle') else 100)
widgets += gauge(pid, 'exhaust', 'Exhaust', 388, 229, GREEN, 100)
return widgets
def page_for(printer, index, total): def page_for(printer, index, total):
pid = printer['id'] pid = printer['id']
resin = printer.get('profile') == 'elegoo_resin'
widgets = [ widgets = [
{'bar': {'id': f'{pid}_top_progress', 'x': 0, 'y': 0, 'width': 800, 'height': 5, {'bar': {'id': f'{pid}_top_progress', 'x': 0, 'y': 0, 'width': 800, 'height': 5,
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK, 'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
@@ -110,16 +136,17 @@ def page_for(printer, index, total):
'border_width': 0, 'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}}, 'border_width': 0, 'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
] ]
widgets += gauge(pid, 'progress', 'Progress', 16, 62, GREEN, 100) widgets += gauge(pid, 'progress', 'Progress', 16, 62, GREEN, 100)
widgets += gauge(pid, 'nozzle', 'Nozzle L' if printer.get('dual_nozzle') else 'Nozzle', if resin:
202, 62, ORANGE, 300) widgets += gauge(pid, 'uv', 'UV LED', 202, 62, ORANGE, 100)
widgets += gauge(pid, 'bed', 'Bed', 388, 62, CYAN, 120) widgets += gauge(pid, 'layer', 'Layer', 388, 62, CYAN, 100)
widgets += gauge(pid, 'fan', 'Part', 16, 229, 0xEEEEEE, 100) widgets += [label('FINISH / TIME REMAINING', 16, 260, 544, 18,
widgets += gauge(pid, 'aux', 'Nozzle R' if printer.get('dual_nozzle') else 'Aux', text_align='CENTER', text_color=MUTED),
202, 229, ORANGE if printer.get('dual_nozzle') else 0xEEEEEE, label('--', 16, 354, 544, 20, id=f'{pid}_elapsed_label',
300 if printer.get('dual_nozzle') else 100) text_align='CENTER', text_color=MUTED)]
widgets += gauge(pid, 'exhaust', 'Exhaust', 388, 229, GREEN, 100) else:
widgets += fdm_gauges(printer)
widgets += [ widgets += [
label('--', 16, 401, 544, 26, id=f'{pid}_remaining_label', text_color=GREEN, text_align='CENTER'), label('--', 16, 300 if resin else 401, 544, 26, id=f'{pid}_remaining_label', text_color=GREEN, text_align='CENTER'),
label('Telemetry unavailable', 16, 430, 544, 12, id=f'{pid}_stage_label', text_color=MUTED, label('Telemetry unavailable', 16, 430, 544, 12, id=f'{pid}_stage_label', text_color=MUTED,
text_align='CENTER', long_mode='SCROLL_CIRCULAR', clickable=True, text_align='CENTER', long_mode='SCROLL_CIRCULAR', clickable=True,
on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]), on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
@@ -146,7 +173,10 @@ def page_for(printer, index, total):
id=f'{pid}_tray_{i}_label', text_align='CENTER'), id=f'{pid}_tray_{i}_label', text_align='CENTER'),
]}}) ]}})
if not trays: if not trays:
widgets.append(label('No AMS', 594, 150, 190, 20, text_color=MUTED, text_align='CENTER')) widgets.append(label('ELEGOO' if printer.get('profile', '').startswith('elegoo_') else 'No AMS', 594, 150, 190, 20,
text_color=MUTED, text_align='CENTER'))
widgets.append(label(printer['subtitle'], 594, 185, 190, 16,
text_color=MUTED, text_align='CENTER'))
for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')): for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')):
widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30, widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30,
'bg_color': TRACK, 'radius': 8, 'border_width': 0, 'bg_color': TRACK, 'radius': 8, 'border_width': 0,
@@ -208,30 +238,44 @@ def generate(printers):
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}} 'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
for index, printer in enumerate(printers): for index, printer in enumerate(printers):
pid = printer['id'] pid = printer['id']
fields = dict(NUMERIC) profile = printer.get('profile', 'bambu')
elegoo = profile.startswith('elegoo_')
resin = profile == 'elegoo_resin'
fields = {**ELEGOO_COMMON, **(ELEGOO_RESIN if resin else ELEGOO_FDM)} if elegoo else dict(NUMERIC)
if printer.get('dual_nozzle'): if printer.get('dual_nozzle'):
fields.update(DUAL) fields.update(DUAL)
fields.update({key: EXTRA_FANS[key] for key in printer.get('extra_fans', [])}) fields.update({key: EXTRA_FANS[key] for key in printer.get('extra_fans', [])})
expressions = {} expressions = {}
for key, suffix in fields.items(): for key, suffix in fields.items():
sid = f'{pid}_{key}' sid = f'{pid}_{key}'
eid = entity(printer, key, suffix) domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
eid = entity(printer, key, suffix, domain)
package['sensor'].append({'platform': 'homeassistant', 'id': sid, package['sensor'].append({'platform': 'homeassistant', 'id': sid,
'entity_id': eid, 'internal': True}) 'entity_id': eid, 'internal': True})
expressions[key] = f'id({sid}).state' expressions[key] = f'id({sid}).state'
text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in TEXT.items()} if elegoo:
text_fields.update({key: (entity(printer, key, suffix, 'binary_sensor'), None) text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in {
for key, suffix in BINARY.items()}) 'status': 'print_status', 'task': 'file_name', 'stage': 'current_status',
hms_entity = entity(printer, 'hms_error', BINARY['hms_error'], 'binary_sensor') 'print_error': 'print_error', 'print_error_reason': 'error_status_reason'}.items()}
text_fields['hms_count'] = (hms_entity, 'Count') text_fields['online'] = (entity(printer, 'online', 'sdcp_status', 'binary_sensor'), None)
for number in range(1, 4): else:
for key, attribute in [('message', 'Error'), ('code', 'Code'), ('severity', 'Severity')]: text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in TEXT.items()}
text_fields[f'hms_{number}_{key}'] = (hms_entity, f'{number}-{attribute}') text_fields.update({key: (entity(printer, key, suffix, 'binary_sensor'), None)
print_error_entity = entity(printer, 'print_error', BINARY['print_error'], 'binary_sensor') for key, suffix in BINARY.items()})
text_fields['print_error_message'] = (print_error_entity, 'error') hms_entity = entity(printer, 'hms_error', BINARY['hms_error'], 'binary_sensor')
text_fields['print_error_code'] = (print_error_entity, 'code') text_fields['hms_count'] = (hms_entity, 'Count')
for key, numeric_key in [('remaining_unit', 'time_left'), ('nozzle_unit', 'nozzle'), ('bed_unit', 'bed')]: for number in range(1, 4):
text_fields[key] = (entity(printer, numeric_key, NUMERIC[numeric_key]), 'unit_of_measurement') for key, attribute in [('message', 'Error'), ('code', 'Code'), ('severity', 'Severity')]:
text_fields[f'hms_{number}_{key}'] = (hms_entity, f'{number}-{attribute}')
print_error_entity = entity(printer, 'print_error', BINARY['print_error'], 'binary_sensor')
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')]:
if numeric_key in fields:
suffix = fields[numeric_key]
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
text_fields[key] = (entity(printer, numeric_key, suffix, domain), 'unit_of_measurement')
trays = trays_for(printer) trays = trays_for(printer)
for i, tray in enumerate(trays): for i, tray in enumerate(trays):
for key, attr in [('type', 'type'), ('color', 'color'), ('active', 'active')]: for key, attr in [('type', 'type'), ('color', 'color'), ('active', 'active')]:
@@ -248,9 +292,14 @@ def generate(printers):
for key, expr in expressions.items(): for key, expr in expressions.items():
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')): if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};') lines.append(f'data.{key} = {expr};')
for number in range(1, 4): if elegoo:
lines.append('data.health_notices.push_back({' + ', '.join( lines.append('data.elegoo = true;')
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});') 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'): if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;') lines.append('data.dual_nozzle = true;')
lines += [ lines += [
@@ -270,8 +319,13 @@ def generate(printers):
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label', 'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
'health_details')] 'health_details')]
for key in ('progress', 'nozzle', 'bed', 'fan', 'aux', 'exhaust'): for key in ('progress', 'nozzle', 'bed', 'fan', 'aux', 'exhaust'):
refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}') refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}'
if not resin or key == 'progress' else '{}')
refs.append('{' + ', '.join(tray_widgets) + '}') refs.append('{' + ', '.join(tray_widgets) + '}')
if resin:
for key in ('uv', 'layer'):
refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}')
refs.append(f'id({pid}_elapsed_label)')
lines.append('bambu_display::render(data, {' + ', '.join(refs) + '});') lines.append('bambu_display::render(data, {' + ', '.join(refs) + '});')
package['script'].append({'id': f'{pid}_refresh', 'then': [{'lambda': Lambda('\n'.join(lines))}]}) package['script'].append({'id': f'{pid}_refresh', 'then': [{'lambda': Lambda('\n'.join(lines))}]})
package['interval'][0]['then'].append({'if': { package['interval'][0]['then'].append({'if': {
File diff suppressed because it is too large Load Diff
+28 -2
View File
@@ -22,11 +22,12 @@ def main():
demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60, demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60,
'bed_target': 60, 'time_left': 1.75, 'layer': 134, 'total_layers': 200, '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, 'fan': 80, 'aux_fan': 45, 'exhaust_fan': 30, 'left_nozzle': 219, 'left_target': 220,
'right_nozzle': 37, 'right_target': 0} 'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30}
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2', demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off', 'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
'hms_count': '0', 'print_error_message': '', 'print_error_code': '', 'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C'} 'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C',
'uv_unit': '°C', 'elapsed_unit': 'min', 'print_error_reason': 'ok'}
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF', colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF'] '#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
for printer in printers: for printer in printers:
@@ -38,6 +39,8 @@ def main():
key = entry['id'][len(prefix):] key = entry['id'][len(prefix):]
if section == 'sensor': if section == 'sensor':
value = str(demo_values[key]) value = str(demo_values[key])
if key == 'time_left' and printer.get('profile', '').startswith('elegoo_'):
value = '105'
else: else:
if key.startswith('tray_'): if key.startswith('tray_'):
_, index, attribute = key.split('_') _, index, attribute = key.split('_')
@@ -47,6 +50,9 @@ def main():
text = '' text = ''
else: else:
text = demo_text[key] text = demo_text[key]
if printer.get('profile', '').startswith('elegoo_'):
text = {'status': 'printing', 'print_error': 'none',
'remaining_unit': 'min'}.get(key, text)
value = 'std::string(' + json.dumps(text, ensure_ascii=False) + ')' value = 'std::string(' + json.dumps(text, ensure_ascii=False) + ')'
entry.clear() entry.clear()
entry.update({'platform': 'template', 'id': prefix + key, 'internal': True, entry.update({'platform': 'template', 'id': prefix + key, 'internal': True,
@@ -86,6 +92,25 @@ inline void setup() {
steps += [{'lvgl.page.show': printer['id'] + '_page'}, steps += [{'lvgl.page.show': printer['id'] + '_page'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'}, {'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': printer['id'] + '.bmp'}}] {'snapshot.take': {'id': 'panel_display', 'filename': printer['id'] + '.bmp'}}]
if printer.get('profile', '').startswith('elegoo_'):
pid = printer['id']
checks = {f'{pid}_status_label': 'PRINTING', f'{pid}_progress_detail': '1h 45m'}
if printer['profile'] == 'elegoo_resin':
checks.update({f'{pid}_uv_label': '42', f'{pid}_layer_label': '134',
f'{pid}_elapsed_label': 'Elapsed: 0h 30m'})
for widget, expected in checks.items():
steps.append({'lambda': Lambda(f'if (std::string(lv_label_get_text(id({widget}))) != {json.dumps(expected)}) {{ ESP_LOGE("preview", "Elegoo telemetry render failed: {widget}"); exit(4); }}')})
steps += [
{'text_sensor.template.publish': {'id': f'{pid}_print_error_reason', 'state': 'filament_runout'}},
{'script.execute': f'{pid}_refresh'},
{'lambda': Lambda(f'if (std::string(lv_label_get_text(id({pid}_status_label))) != "PRINT ERROR" || std::string(lv_label_get_text(id({pid}_health_details))).find("Filament runout") == std::string::npos) {{ ESP_LOGE("preview", "Elegoo error render failed"); exit(5); }}')},
{'text_sensor.template.publish': {'id': f'{pid}_online', 'state': 'off'}},
{'script.execute': f'{pid}_refresh'},
{'lambda': Lambda(f'if (std::string(lv_label_get_text(id({pid}_status_label))) != "PRINTER OFFLINE" || std::string(lv_label_get_text(id({pid}_progress_label))) != "--") {{ ESP_LOGE("preview", "Elegoo offline render failed"); exit(6); }}')},
{'text_sensor.template.publish': {'id': f'{pid}_print_error_reason', 'state': 'ok'}},
{'text_sensor.template.publish': {'id': f'{pid}_online', 'state': 'on'}},
{'script.execute': f'{pid}_refresh'},
]
steps += [{'lvgl.page.show': 'color_test_page'}, {'delay': '300ms'}, steps += [{'lvgl.page.show': 'color_test_page'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'color_test.bmp'}}] {'snapshot.take': {'id': 'panel_display', 'filename': 'color_test.bmp'}}]
demo_printer = printers[0]['id'] demo_printer = printers[0]['id']
@@ -147,6 +172,7 @@ inline void setup() {
print(f'Saved {len(printers)} demo screenshots to {args.output}') print(f'Saved {len(printers)} demo screenshots to {args.output}')
print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.') print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.')
print('Passed status-tap health panel open and Close-button tests.') print('Passed status-tap health panel open and Close-button tests.')
print('Passed Elegoo telemetry, text-error and offline rendering checks.')
if __name__ == '__main__': if __name__ == '__main__':
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+12
View File
@@ -49,3 +49,15 @@ printers:
- label: B - label: B
# This unit retains its original hardware-based HA entity names. # This unit retains its original hardware-based HA entity names.
entity_prefix: h2c_31b8ap5a2200288_ams_2 entity_prefix: h2c_31b8ap5a2200288_ams_2
- id: centauri_carbon
name: Centauri Carbon
subtitle: FDM / Office
entity_prefix: centauri_carbon
profile: elegoo_fdm
- id: saturn
name: Saturn
subtitle: Resin / Garage
entity_prefix: saturn
profile: elegoo_resin
+31
View File
@@ -6,6 +6,7 @@ int main() {
assert(remaining(1.75f, "h") == "1h 45m"); assert(remaining(1.75f, "h") == "1h 45m");
assert(remaining(105.0f, "min") == "1h 45m"); assert(remaining(105.0f, "min") == "1h 45m");
assert(remaining(6300.0f, "s") == "1h 45m"); assert(remaining(6300.0f, "s") == "1h 45m");
assert(remaining(6300000.0f, "ms") == "1h 45m");
assert(remaining(0.15f, "h") == "0h 09m"); assert(remaining(0.15f, "h") == "0h 09m");
assert(remaining(NAN, "h") == "--"); assert(remaining(NAN, "h") == "--");
assert(remaining(-1, "min") == "--"); assert(remaining(-1, "min") == "--");
@@ -22,6 +23,7 @@ int main() {
assert(gauge_value(-10, 100) == 0); assert(gauge_value(-10, 100) == 0);
assert(duration_seconds(1.75f, "h") == 6300); assert(duration_seconds(1.75f, "h") == 6300);
assert(duration_seconds(105, "min") == 6300); assert(duration_seconds(105, "min") == 6300);
assert(duration_seconds(6300000, "ms") == 6300);
assert(duration_seconds(NAN, "h") == -1); assert(duration_seconds(NAN, "h") == -1);
assert(duration_seconds(1, "") == -1); assert(duration_seconds(1, "") == -1);
assert(std::abs(celsius(392, "°F") - 200) < 0.01f); assert(std::abs(celsius(392, "°F") - 200) < 0.01f);
@@ -72,4 +74,33 @@ int main() {
data.status = "finish"; data.status = "finish";
assert(data.status_text() == "COMPLETE"); assert(data.status_text() == "COMPLETE");
assert(data.status_color() == GREEN); assert(data.status_color() == GREEN);
// Elegoo exposes error enums rather than Bambu binary errors/HMS notices.
data.elegoo = true;
data.print_error = "none";
data.print_error_reason = "ok";
data.status = "printing";
assert(data.live());
assert(!data.has_print_error());
assert(data.status_text() == "PRINTING");
data.print_error_reason = "filament_runout";
assert(data.status_text() == "PRINT ERROR");
assert(data.status_color() == RED);
assert(data.health_details().find("Filament runout") != std::string::npos);
data.print_error = "invalid_resolution";
assert(data.alert_summary() == "Invalid resolution / Filament runout");
data.print_error_reason = "unavailable";
assert(data.alert_summary() == "Invalid resolution");
data.print_error = "unknown";
assert(!data.has_print_error());
data.status = "error";
assert(data.has_print_error());
data.status = "pausing";
assert(data.status_color() == AMBER);
data.status = "complete";
assert(data.status_text() == "COMPLETE");
data.online = "off";
assert(!data.live());
assert(data.status_text() == "PRINTER OFFLINE");
assert(data.health_details().find("unavailable") != std::string::npos);
} }
+42 -5
View File
@@ -20,20 +20,57 @@ class FleetTests(unittest.TestCase):
for entry in package[domain]} for entry in package[domain]}
self.assertFalse(subscribed - known) self.assertFalse(subscribed - known)
def test_seven_printers_have_independent_pages_and_metrics(self): def test_fleet_can_expand_beyond_configured_printers(self):
for index in (6, 7): expected_count = len(self.printers) + 2
for index in (8, 9):
extra = copy.deepcopy(self.printers[0]) extra = copy.deepcopy(self.printers[0])
extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}') extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}')
self.printers.append(extra) self.printers.append(extra)
package = generate(self.printers) package = generate(self.printers)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), 7) self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count)
self.assertEqual(len(package['script']), 7) self.assertEqual(len(package['script']), expected_count)
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']} subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
self.assertEqual(subscriptions['printer_7_progress'], 'sensor.new_printer_7_print_progress') self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
for page in package['lvgl']['pages']: for page in package['lvgl']['pages']:
self.assertIn('lvgl.page.next', page['on_swipe_left'][-1]) self.assertIn('lvgl.page.next', page['on_swipe_left'][-1])
self.assertIn('lvgl.page.previous', page['on_swipe_right'][-1]) self.assertIn('lvgl.page.previous', page['on_swipe_right'][-1])
def test_elegoo_profiles_use_available_metrics_and_domains(self):
package = generate(self.printers)
subscriptions = {entry['id']: entry for domain in ('sensor', 'text_sensor')
for entry in package[domain]}
self.assertEqual(subscriptions['centauri_carbon_nozzle_target']['entity_id'],
'number.centauri_carbon_target_nozzle_temp')
self.assertEqual(subscriptions['centauri_carbon_fan']['entity_id'],
'sensor.centauri_carbon_model_fan_speed')
for pid in ('centauri_carbon', 'saturn'):
self.assertEqual(subscriptions[f'{pid}_online']['entity_id'], f'binary_sensor.{pid}_sdcp_status')
self.assertEqual(subscriptions[f'{pid}_remaining_unit']['entity_id'], f'sensor.{pid}_remaining_print_time')
self.assertEqual(subscriptions[f'{pid}_print_error']['entity_id'], f'sensor.{pid}_print_error')
self.assertEqual(subscriptions[f'{pid}_print_error_reason']['entity_id'], f'sensor.{pid}_error_status_reason')
self.assertNotIn(f'{pid}_hms_error', subscriptions)
self.assertNotIn('saturn_nozzle', subscriptions)
self.assertNotIn('saturn_fan', subscriptions)
self.assertEqual(subscriptions['saturn_uv_unit']['entity_id'], 'sensor.saturn_uv_led_temp')
self.assertEqual(subscriptions['saturn_elapsed_unit']['entity_id'], 'sensor.saturn_current_print_time')
def test_resin_page_contains_only_resin_gauges(self):
package = generate(self.printers)
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'saturn_page')
arcs = {widget['arc']['id'] for widget in page['widgets'] if 'arc' in widget}
self.assertEqual(arcs, {'saturn_progress_arc', 'saturn_uv_arc', 'saturn_layer_arc'})
def test_number_overrides_are_supported(self):
self.printers[-2]['entities'] = {'nozzle_target': 'number.custom_target'}
package = generate(self.printers)
entry = next(row for row in package['sensor'] if row['id'] == 'centauri_carbon_nozzle_target')
self.assertEqual(entry['entity_id'], 'number.custom_target')
def test_reject_unknown_profiles(self):
self.printers[0]['profile'] = 'unsupported'
with self.assertRaisesRegex(ValueError, 'unsupported profile'):
generate(self.printers)
def test_overrides_apply_to_values_and_unit_subscriptions(self): def test_overrides_apply_to_values_and_unit_subscriptions(self):
self.printers[0]['entities'] = {'time_left': 'sensor.custom_remaining'} self.printers[0]['entities'] = {'time_left': 'sensor.custom_remaining'}
package = generate(self.printers) package = generate(self.printers)