Add Elegoo printers to ESPHome fleet display
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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 ¬ice : health_notices)
|
for (const auto ¬ice : 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 ¬ice : health_notices)
|
for (const auto ¬ice : 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());
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||