#pragma once #include #include #include #include #include #include #include #include #include namespace bambu_display { constexpr uint32_t GREEN = 0x00FF00; constexpr uint32_t MUTED = 0xAEB3B7; constexpr uint32_t RED = 0xFF737B; constexpr uint32_t AMBER = 0xF2C572; constexpr uint32_t TRACK = 0x19232B; constexpr uint32_t RING_BACKING = 0x6E767E; inline std::string lower(std::string text) { for (char &c : text) c = static_cast(std::tolower(static_cast(c))); return text; } inline bool valid_text(const std::string &text) { const auto value = lower(text); return !value.empty() && value != "unknown" && value != "unavailable" && value != "none"; } inline bool is_on(const std::string &text) { const auto value = lower(text); return value == "on" || value == "true" || value == "1"; } inline 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) { if (!valid_text(text)) return "--"; std::replace(text.begin(), text.end(), '_', ' '); if (!text.empty()) text[0] = static_cast(std::toupper(static_cast(text[0]))); return text; } inline int gauge_value(float value, int maximum) { return std::isfinite(value) ? static_cast(std::clamp(value, 0.0f, float(maximum))) : 0; } inline std::string number(float value) { if (!std::isfinite(value) || value < 0 || value > 1000000) return "--"; char buffer[24]; snprintf(buffer, sizeof(buffer), "%.0f", value); return buffer; } inline std::string temperature(float current, float target, const std::string &unit) { return number(current) + " / " + number(target) + (valid_text(unit) ? " " + unit : ""); } inline std::string remaining(float value, const std::string &unit) { if (!std::isfinite(value) || value < 0) return "--"; double minutes; if (unit == "h") minutes = double(value) * 60; else if (unit == "min") minutes = value; else if (unit == "s") minutes = double(value) / 60; else if (unit == "ms") minutes = double(value) / 60000; else return "--"; // Never silently interpret hours as minutes. if (minutes > 1000000) return "--"; const auto rounded = static_cast(std::ceil(minutes - 0.001)); char buffer[32]; snprintf(buffer, sizeof(buffer), "%dh %02dm", rounded / 60, rounded % 60); return buffer; } inline int duration_seconds(float value, const std::string &unit) { if (!std::isfinite(value) || value < 0) return -1; double seconds; if (unit == "h") seconds = double(value) * 3600; else if (unit == "min") seconds = double(value) * 60; else if (unit == "s") seconds = value; else if (unit == "ms") seconds = double(value) / 1000; else return -1; return seconds <= 60000000 ? static_cast(std::round(seconds)) : -1; } inline float celsius(float value, const std::string &unit) { return unit == "°F" || unit == "F" ? (value - 32.0f) * 5.0f / 9.0f : value; } inline uint32_t filament_color(std::string value) { if (!value.empty() && value[0] == '#') value.erase(0, 1); // Bambu publishes RGB or RGBA, with alpha at the end. if (value.size() != 6 && value.size() != 8) return TRACK; uint32_t rgb = 0; for (size_t i = 0; i < value.size(); ++i) { const char c = static_cast(std::tolower(static_cast(value[i]))); const int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; if (digit < 0) return TRACK; if (i < 6) rgb = (rgb << 4) | digit; } return rgb; } // A black ring disappears on the black page. Anything this dark needs a grey arc behind it. inline bool filament_is_black(uint32_t color) { return ((color >> 16) & 0xFF) <= 0x28 && ((color >> 8) & 0xFF) <= 0x28 && (color & 0xFF) <= 0x28; } struct Tray { std::string label, type, color, active; }; struct HealthNotice { std::string code, message, severity; }; struct Snapshot { bool connected = false; std::string online, status, task, stage, print_error, hms_error; 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 health_notices; std::string remaining_unit, nozzle_unit, bed_unit; std::string finish_time; float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN; float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN; float aux_fan = NAN, exhaust_fan = NAN; bool dual_nozzle = false; float left_nozzle = NAN, left_target = NAN, right_nozzle = NAN, right_target = NAN; std::vector trays; 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 loaded = 0; for (const auto ¬ice : health_notices) if (valid_text(notice.code) || valid_text(notice.message)) ++loaded; char *end; const long count = std::strtol(hms_count.c_str(), &end, 10); return end != hms_count.c_str() && *end == '\0' && count >= 0 && count < 10000 ? std::max(loaded, static_cast(count)) : loaded; } int health_severity_rank() const { int rank = 1, loaded = 0; for (const auto ¬ice : health_notices) { if (!valid_text(notice.code) && !valid_text(notice.message)) continue; ++loaded; const auto severity = lower(notice.severity); rank = std::max(rank, severity == "fatal" || severity == "serious" ? 3 : severity == "info" ? 1 : 2); } // Missing/unloaded messages cannot establish an info-only notification. return loaded == 0 || notice_count() > loaded ? std::max(rank, 2) : rank; } std::string alert_summary() const { if (has_print_error()) return error_message(); if (is_on(hms_error)) { for (const auto ¬ice : health_notices) if (valid_text(notice.message)) return notice.message; return "Printer health notification; tap status for details"; } return ""; } std::string health_details() const { if (!live()) return "Printer health data is unavailable. Waiting for a live connection."; std::string details; if (has_print_error()) { details = "PRINT ERROR\n"; if (valid_text(print_error_code)) details += print_error_code + "\n"; details += error_message(); details += "\n\n"; } if (is_on(hms_error)) { details += "HMS = Health Management System\n"; int loaded = 0; for (const auto ¬ice : health_notices) { if (!valid_text(notice.code) && !valid_text(notice.message)) continue; details += "\nNotice " + std::to_string(++loaded); if (valid_text(notice.severity)) details += " / " + notice.severity; details += "\n"; if (valid_text(notice.code)) details += notice.code + "\n"; details += valid_text(notice.message) ? notice.message : "Message not supplied by Home Assistant."; details += "\n"; } if (loaded == 0) details += "\nNotification is active; details have not arrived from Home Assistant.\n"; if (notice_count() > loaded) details += "\n" + std::to_string(notice_count() - loaded) + " more notification(s). View all in Home Assistant.\n"; } return details.empty() ? "No current printer health notifications." : details; } std::string status_text() const { if (!connected) return "HA OFFLINE"; if (lower(online) == "off" || lower(online) == "false") return "PRINTER OFFLINE"; if (!live()) return "SYNCING"; if (has_print_error()) return "PRINT ERROR"; if (is_on(hms_error)) return health_severity_rank() >= 3 ? "HEALTH ERROR" : health_severity_rank() == 1 ? "HEALTH INFO" : "HEALTH NOTICE"; const auto state = lower(status); if (state == "running" || state == "printing") return "PRINTING"; if (state == "pause" || state == "paused") return "PAUSED"; if (state == "finish" || state == "finished" || state == "complete" || state == "completed") return "COMPLETE"; auto text = humanize(status); for (char &c : text) c = static_cast(std::toupper(static_cast(c))); return text; } std::string overview_task() const { if (!live()) return connected ? "Waiting for printer data" : "Waiting for Home Assistant"; return valid_text(task) ? task : "No active print"; } std::string overview_meta() const { if (!live()) return "--"; const std::string progress_text = std::isfinite(progress) ? number(std::clamp(progress, 0.0f, 100.0f)) + "%" : "--"; const std::string time_line = remaining(time_left, remaining_unit); if (!finish_time.empty() && time_line != "--") return progress_text + " " + finish_time + " " + time_line; return progress_text + " " + time_line; } // Active tray is the spool the printer loaded most recently. Without one, keep green. uint32_t active_filament_color() const { for (const auto &tray : trays) if (is_on(tray.active)) { const uint32_t color = filament_color(tray.color); if (color != TRACK) return color; } return GREEN; } uint32_t status_color() const { if (!live()) return MUTED; const auto state = lower(status); if (has_print_error() || state == "failed") return RED; if (is_on(hms_error)) return health_severity_rank() >= 3 ? RED : health_severity_rank() == 1 ? 0x00E5E5 : AMBER; if (state == "pause" || state == "paused" || state == "pausing") return AMBER; return GREEN; } }; struct FleetCounts { int attention = 0, printing = 0, paused = 0, idle = 0, offline = 0, syncing = 0; }; inline void tally(FleetCounts &counts, const Snapshot &data) { if (!data.connected || lower(data.online) == "off" || lower(data.online) == "false") { ++counts.offline; return; } if (!data.live()) { ++counts.syncing; return; } if (data.has_print_error() || is_on(data.hms_error) || lower(data.status) == "failed") { ++counts.attention; return; } const auto state = lower(data.status); if (state == "running" || state == "printing") ++counts.printing; else if (state == "pause" || state == "paused" || state == "pausing") ++counts.paused; else ++counts.idle; } inline std::string fleet_summary(const FleetCounts &counts) { const std::pair items[] = { {counts.attention, "attention"}, {counts.printing, "printing"}, {counts.paused, "paused"}, {counts.idle, "idle"}, {counts.offline, "offline"}, {counts.syncing, "syncing"}}; std::string text; for (const auto &item : items) { if (!item.first) continue; if (!text.empty()) text += ", "; text += std::to_string(item.first) + " " + item.second; } return text.empty() ? "Syncing" : text; } } // namespace bambu_display #ifdef USE_LVGL #include namespace bambu_display { struct TrayWidgets { lv_obj_t *card, *swatch, *label; }; struct GaugeWidgets { lv_obj_t *arc, *value, *detail; }; struct Widgets { lv_obj_t *status, *task, *top_progress, *remaining, *layers, *stage, *health_details; GaugeWidgets progress, nozzle, bed, fan, aux, exhaust; std::vector trays; GaugeWidgets uv{}, layer{}; lv_obj_t *elapsed = nullptr; }; struct OverviewWidgets { lv_obj_t *arc, *value, *finish, *backing; }; inline void set_text(lv_obj_t *label, const std::string &text) { if (text != lv_label_get_text(label)) lv_label_set_text(label, text.c_str()); } inline void render_gauge(const GaugeWidgets &widgets, float value, float position, int maximum, const std::string &detail) { lv_arc_set_value(widgets.arc, gauge_value(position, maximum)); set_text(widgets.value, number(value)); set_text(widgets.detail, detail); } inline void render(const Snapshot &data, const Widgets &widgets) { const bool live = data.live(); const uint32_t color = data.status_color(); set_text(widgets.status, data.status_text()); lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0); lv_obj_set_style_arc_color(widgets.progress.arc, lv_color_hex(color), LV_PART_INDICATOR); lv_bar_set_value(widgets.top_progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF); render_gauge(widgets.progress, live && std::isfinite(data.progress) ? std::clamp(data.progress, 0.0f, 100.0f) : NAN, live ? data.progress : NAN, 100, live ? remaining(data.time_left, data.remaining_unit) : "--"); set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print") : data.connected ? "Waiting for printer data" : "Waiting for Home Assistant"); if (data.resin) { render_gauge(widgets.uv, live ? data.uv : NAN, live ? celsius(data.uv, data.uv_unit) : NAN, 100, live && valid_text(data.uv_unit) ? data.uv_unit : "--"); const float layer_percent = std::isfinite(data.layer) && std::isfinite(data.total_layers) && data.layer >= 0 && data.total_layers > 0 ? 100 * data.layer / data.total_layers : NAN; render_gauge(widgets.layer, live ? data.layer : NAN, live ? layer_percent : NAN, 100, live ? "/ " + number(data.total_layers) : "--"); set_text(widgets.elapsed, "Elapsed: " + (live ? remaining(data.elapsed, data.elapsed_unit) : "--")); } else { const float nozzle = data.dual_nozzle ? data.left_nozzle : data.nozzle; const float target = data.dual_nozzle ? data.left_target : data.nozzle_target; render_gauge(widgets.nozzle, live ? nozzle : NAN, live ? celsius(nozzle, data.nozzle_unit) : NAN, 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) : "--"; if (live && !data.finish_time.empty()) time_line = data.finish_time + " | " + time_line; set_text(widgets.remaining, time_line); set_text(widgets.layers, live ? "Layer " + number(data.layer) + " / " + number(data.total_layers) : "--"); std::string stage = live ? humanize(data.stage) : "Telemetry unavailable"; if (live && (data.has_print_error() || is_on(data.hms_error))) stage = data.alert_summary(); set_text(widgets.stage, stage); 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()); for (size_t i = 0; i < std::min(data.trays.size(), widgets.trays.size()); ++i) { const auto &tray = data.trays[i]; const auto &widget = widgets.trays[i]; set_text(widget.label, tray.label + "\n" + (live && valid_text(tray.type) ? tray.type : "--")); lv_obj_set_style_bg_color(widget.swatch, lv_color_hex(live ? filament_color(tray.color) : TRACK), 0); lv_obj_set_style_border_color(widget.card, lv_color_hex(live && is_on(tray.active) ? GREEN : TRACK), 0); } } inline void render_overview(const Snapshot &data, const OverviewWidgets &widgets) { const bool live = data.live(); const uint32_t filament = data.active_filament_color(); const float shown = live && std::isfinite(data.progress) ? std::clamp(data.progress, 0.0f, 100.0f) : NAN; lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(filament), LV_PART_MAIN); lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(filament), LV_PART_INDICATOR); lv_arc_set_value(widgets.arc, 100); if (filament_is_black(filament)) { lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_MAIN); lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_INDICATOR); lv_obj_clear_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN); } set_text(widgets.value, number(shown)); lv_obj_set_style_text_color(widgets.value, lv_color_hex(0xFFFFFF), 0); set_text(widgets.finish, live && !data.finish_time.empty() ? data.finish_time : "--"); } inline void render_fleet_summary(lv_obj_t *label, const FleetCounts &counts) { set_text(label, fleet_summary(counts)); const uint32_t color = counts.attention ? RED : counts.printing ? GREEN : MUTED; lv_obj_set_style_text_color(label, lv_color_hex(color), 0); } } // namespace bambu_display #endif