382 lines
16 KiB
C++
382 lines
16 KiB
C++
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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;
|
|
|
|
inline std::string lower(std::string text) {
|
|
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
return text;
|
|
}
|
|
|
|
inline bool valid_text(const std::string &text) {
|
|
const auto value = lower(text);
|
|
return !value.empty() && value != "unknown" && value != "unavailable" && value != "none";
|
|
}
|
|
|
|
inline bool is_on(const std::string &text) {
|
|
const auto value = lower(text);
|
|
return value == "on" || value == "true" || value == "1";
|
|
}
|
|
|
|
inline 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<char>(std::toupper(static_cast<unsigned char>(text[0])));
|
|
return text;
|
|
}
|
|
|
|
inline int gauge_value(float value, int maximum) {
|
|
return std::isfinite(value) ? static_cast<int>(std::clamp(value, 0.0f, float(maximum))) : 0;
|
|
}
|
|
|
|
inline std::string number(float value) {
|
|
if (!std::isfinite(value) || value < 0 || value > 1000000) return "--";
|
|
char buffer[24];
|
|
snprintf(buffer, sizeof(buffer), "%.0f", value);
|
|
return buffer;
|
|
}
|
|
|
|
inline std::string temperature(float current, float target, const std::string &unit) {
|
|
return number(current) + " / " + number(target) + (valid_text(unit) ? " " + unit : "");
|
|
}
|
|
|
|
inline std::string remaining(float value, const std::string &unit) {
|
|
if (!std::isfinite(value) || value < 0) return "--";
|
|
double minutes;
|
|
if (unit == "h") minutes = double(value) * 60;
|
|
else if (unit == "min") minutes = value;
|
|
else if (unit == "s") minutes = double(value) / 60;
|
|
else if (unit == "ms") minutes = double(value) / 60000;
|
|
else return "--"; // Never silently interpret hours as minutes.
|
|
if (minutes > 1000000) return "--";
|
|
const auto rounded = static_cast<int>(std::ceil(minutes - 0.001));
|
|
char buffer[32];
|
|
snprintf(buffer, sizeof(buffer), "%dh %02dm", rounded / 60, rounded % 60);
|
|
return buffer;
|
|
}
|
|
|
|
inline 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<int>(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<char>(std::tolower(static_cast<unsigned char>(value[i])));
|
|
const int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
|
|
if (digit < 0) return TRACK;
|
|
if (i < 6) rgb = (rgb << 4) | digit;
|
|
}
|
|
return rgb;
|
|
}
|
|
|
|
struct Tray {
|
|
std::string label, type, color, active;
|
|
};
|
|
|
|
struct 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<HealthNotice> 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<Tray> 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<int>(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<char>(std::toupper(static_cast<unsigned char>(c)));
|
|
return text;
|
|
}
|
|
std::string overview_task() const {
|
|
if (!live()) return connected ? "Waiting for printer data" : "Waiting for Home Assistant";
|
|
return valid_text(task) ? task : "No active print";
|
|
}
|
|
std::string overview_meta() const {
|
|
if (!live()) return "--";
|
|
const 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;
|
|
}
|
|
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<int, const char *> items[] = {
|
|
{counts.attention, "attention"}, {counts.printing, "printing"}, {counts.paused, "paused"},
|
|
{counts.idle, "idle"}, {counts.offline, "offline"}, {counts.syncing, "syncing"}};
|
|
std::string text;
|
|
for (const auto &item : items) {
|
|
if (!item.first) continue;
|
|
if (!text.empty()) text += ", ";
|
|
text += std::to_string(item.first) + " " + item.second;
|
|
}
|
|
return text.empty() ? "Syncing" : text;
|
|
}
|
|
|
|
} // namespace bambu_display
|
|
|
|
#ifdef USE_LVGL
|
|
#include <lvgl.h>
|
|
|
|
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<TrayWidgets> trays;
|
|
GaugeWidgets uv{}, layer{};
|
|
lv_obj_t *elapsed = nullptr;
|
|
};
|
|
struct OverviewWidgets {
|
|
lv_obj_t *status, *task, *meta, *progress, *accent;
|
|
};
|
|
|
|
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 color = data.status_color();
|
|
set_text(widgets.status, data.status_text());
|
|
lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0);
|
|
if (widgets.task) set_text(widgets.task, data.overview_task());
|
|
set_text(widgets.meta, data.overview_meta());
|
|
lv_bar_set_value(widgets.progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF);
|
|
lv_obj_set_style_bg_color(widgets.progress, lv_color_hex(color), LV_PART_INDICATOR);
|
|
lv_obj_set_style_bg_color(widgets.accent, lv_color_hex(color), 0);
|
|
}
|
|
|
|
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
|