Fix Orion AMS mapping and show printer health details

This commit is contained in:
Joshua King
2026-10-18 09:29:07 -04:00
parent f672de484e
commit 3c940f8027
15 changed files with 1104 additions and 24 deletions
+24 -5
View File
@@ -21,7 +21,9 @@ The configured swipe order is:
2. Polly-Pocket — A1 mini, Office, external spool.
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
5. Constipation-Orion — Office, left/right nozzles and four-slot AMS.
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
`h2c_31b8ap5a2200288_ams_2` entity prefix.
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.
@@ -34,9 +36,25 @@ stage, fan speeds and filament colors/types. The layout follows BambuHelper's
classic gauges: black background, bright green accents, six arc gauges and a
vertical AMS column. Nozzle arcs are orange and the bed arc is cyan. For dual-nozzle
printers the auxiliary-fan position becomes the right-nozzle gauge. The active
filament slot has a green outline. Print errors
and HMS alerts change the status badge to red; paused prints use amber. The display
shows an alert badge rather than the detailed HMS diagnostic text.
filament slot has a green outline. Print errors use red; paused prints use amber.
**HMS means Health Management System.** Its binary sensor means the Bambu integration
has one or more printer health notifications, which can include information or
maintenance messages as well as faults. Tap the status in the upper-right corner
or the scrolling message above the footer to open the health-details panel. It
shows the codes, severities and descriptions reported by Home Assistant, for up to
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
how many additional notices are available in Home Assistant.
The status uses **HEALTH INFO** (cyan) for information-only notifications,
**HEALTH NOTICE** (amber) for common/unknown severities or incomplete details, and
**HEALTH ERROR** (red) for fatal/serious severities. Severity comes from the Bambu
integration's `N-Severity` attributes. Missing or unloaded messages never establish
an information-only status. The display does not dismiss or clear printer alerts.
The [`hms_errors` sensor attributes](https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py)
provide `Count`, `1-Code`, `1-Error`, `1-Severity`, and the corresponding numbered
fields for subsequent notices. Stale attributes are ignored when the sensor is off.
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
@@ -140,7 +158,8 @@ python esphome/bambu-display/preview.py
```
`preview.py` compiles the same generated LVGL widgets for ESPHome's SDL host with
clearly synthetic telemetry, then captures all five pages into `previews/`. It needs
clearly synthetic telemetry, then captures all five pages and a synthetic health
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,
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
+71 -3
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <vector>
@@ -99,9 +100,13 @@ 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::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;
@@ -112,12 +117,70 @@ struct Snapshot {
std::vector<Tray> trays;
bool live() const { return connected && is_on(online) && valid_text(status); }
int notice_count() const {
int loaded = 0;
for (const auto &notice : 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 &notice : 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 (is_on(print_error))
return valid_text(print_error_message) ? print_error_message : "Print error reported; tap status for details";
if (is_on(hms_error)) {
for (const auto &notice : 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 (is_on(print_error)) {
details = "PRINT ERROR\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 += "\n\n";
}
if (is_on(hms_error)) {
details += "HMS = Health Management System\n";
int loaded = 0;
for (const auto &notice : 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 (is_on(print_error)) return "PRINT ERROR";
if (is_on(hms_error)) return "HMS ALERT";
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") return "PRINTING";
if (state == "pause" || state == "paused") return "PAUSED";
@@ -129,7 +192,9 @@ struct Snapshot {
uint32_t status_color() const {
if (!live()) return MUTED;
const auto state = lower(status);
if (is_on(print_error) || is_on(hms_error) || state == "failed") return RED;
if (is_on(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") return AMBER;
return GREEN;
}
@@ -145,7 +210,7 @@ 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;
lv_obj_t *status, *task, *top_progress, *remaining, *layers, *stage, *health_details;
GaugeWidgets progress, nozzle, bed, fan, aux, exhaust;
std::vector<TrayWidgets> trays;
};
@@ -192,7 +257,10 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
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 && (is_on(data.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];
+35 -4
View File
@@ -104,7 +104,8 @@ def page_for(printer, index, total):
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
label(printer['name'], 16, 17, 485, 26, text_color=GREEN),
label(f'{index + 1} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED),
label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED,
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
'border_width': 0, 'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
]
@@ -119,7 +120,9 @@ def page_for(printer, index, total):
widgets += gauge(pid, 'exhaust', 'Exhaust', 388, 229, GREEN, 100)
widgets += [
label('--', 16, 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, text_align='CENTER'),
label('Telemetry unavailable', 16, 430, 544, 12, id=f'{pid}_stage_label', text_color=MUTED,
text_align='CENTER', long_mode='SCROLL_CIRCULAR', clickable=True,
on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
label('--', 594, 397, 190, 16, id=f'{pid}_layers_label', text_color=MUTED, text_align='CENTER'),
]
trays = trays_for(printer)
@@ -153,6 +156,22 @@ def page_for(printer, index, total):
'on_click': [{f'lvgl.page.{direction}': {}}]}})
widgets.append(label('Waiting for Home Assistant', 94, 449, 612, 14,
id=f'{pid}_task_label', text_align='CENTER'))
widgets.append({'obj': {
'id': f'{pid}_health_panel', 'x': 24, 'y': 62, 'width': 752, 'height': 366,
'hidden': True, 'bg_color': BG, 'bg_opa': 'COVER', 'border_width': 2,
'border_color': MUTED, 'radius': 10, 'pad_all': 0, 'scrollable': False,
'gesture_bubble': False,
'widgets': [
label('Printer health / ' + printer['name'], 16, 14, 716, 20),
{'obj': {'x': 12, 'y': 50, 'width': 724, 'height': 246,
'bg_color': BG, 'border_width': 0, 'pad_all': 0,
'scroll_dir': 'VER', 'scrollbar_mode': 'AUTO', 'gesture_bubble': False,
'widgets': [label('No current health notifications.', 0, 0, 700, 18,
id=f'{pid}_health_details', long_mode='WRAP', height='SIZE_CONTENT')]}},
{'button': {'x': 624, 'y': 310, 'width': 108, 'height': 40, 'bg_color': TRACK,
'radius': 6, 'widgets': [{'label': {'text': 'CLOSE', 'align': 'CENTER'}}],
'on_click': [{'lvgl.widget.hide': f'{pid}_health_panel'}]}},
]}})
return {'id': f'{pid}_page', 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
'on_swipe_left': [
@@ -203,6 +222,14 @@ def generate(printers):
text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in TEXT.items()}
text_fields.update({key: (entity(printer, key, suffix, 'binary_sensor'), None)
for key, suffix in BINARY.items()})
hms_entity = entity(printer, 'hms_error', BINARY['hms_error'], 'binary_sensor')
text_fields['hms_count'] = (hms_entity, 'Count')
for number in range(1, 4):
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')]:
text_fields[key] = (entity(printer, numeric_key, NUMERIC[numeric_key]), 'unit_of_measurement')
trays = trays_for(printer)
@@ -219,8 +246,11 @@ def generate(printers):
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith('tray_'):
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};')
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 printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
lines += [
@@ -237,7 +267,8 @@ def generate(printers):
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}')
refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label')]
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
'health_details')]
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(tray_widgets) + '}')
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -25,6 +25,7 @@ def main():
'right_nozzle': 37, 'right_target': 0}
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C'}
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
@@ -42,6 +43,8 @@ def main():
_, index, attribute = key.split('_')
text = {'type': 'PLA', 'color': colors[int(index) % len(colors)],
'active': 'on' if index == '0' else 'off'}[attribute]
elif key.startswith(('hms_1_', 'hms_2_', 'hms_3_')):
text = ''
else:
text = demo_text[key]
value = 'std::string(' + json.dumps(text, ensure_ascii=False) + ')'
@@ -85,8 +88,26 @@ inline void setup() {
{'snapshot.take': {'id': 'panel_display', 'filename': printer['id'] + '.bmp'}}]
steps += [{'lvgl.page.show': 'color_test_page'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'color_test.bmp'}}]
demo_printer = printers[0]['id']
for key, value in {'hms_error': 'on', 'hms_count': '1', 'hms_1_code': 'DEMO_CODE',
'hms_1_severity': 'common',
'hms_1_message': 'DEMO: A printer maintenance notification. This is synthetic preview data, not an actual fault diagnosis.'}.items():
steps.append({'text_sensor.template.publish': {'id': f'{demo_printer}_{key}', 'state': value}})
steps += [{'lvgl.page.show': demo_printer + '_page'},
{'script.execute': demo_printer + '_refresh'},
{'lvgl.widget.show': demo_printer + '_health_panel'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'health_notice.bmp'}},
{'lvgl.widget.hide': demo_printer + '_health_panel'},
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
{'script.execute': demo_printer + '_refresh'}]
steps += [{'lvgl.page.show': printers[0]['id'] + '_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}]
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
steps += [
{'lambda': Lambda(f'bambu_preview::x = {x}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
{'delay': '70ms'}, {'lambda': Lambda('bambu_preview::pressed = false;')}, {'delay': '150ms'},
{'lambda': Lambda(f'if (lv_obj_has_flag(id({demo_printer}_health_panel), LV_OBJ_FLAG_HIDDEN) != {str(expect_hidden).lower()}) {{ ESP_LOGE("preview", "Health panel tap failed"); exit(3); }}')},
]
if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV.
@@ -119,12 +140,13 @@ inline void setup() {
subprocess.run(['esphome', 'compile', str(filename)], check=True)
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
for name in [printer['id'] for printer in printers] + ['color_test']:
for name in [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
with Image.open(bmp) as image:
image.save(args.output / (name + '.png'))
print(f'Saved {len(printers)} demo screenshots to {args.output}')
print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.')
print('Passed status-tap health panel open and Close-button tests.')
if __name__ == '__main__':
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 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.

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

+3
View File
@@ -46,3 +46,6 @@ printers:
filament_banks:
- label: A
entity_prefix: constipation_orion_ams_1
- label: B
# This unit retains its original hardware-based HA entity names.
entity_prefix: h2c_31b8ap5a2200288_ams_2
+17 -1
View File
@@ -50,9 +50,25 @@ int main() {
assert(data.status_color() == RED);
data.print_error = "off";
data.hms_error = "True";
assert(data.status_text() == "HMS ALERT");
assert(data.status_text() == "HEALTH NOTICE");
assert(data.status_color() == AMBER);
assert(data.health_details().find("have not arrived") != std::string::npos);
data.hms_count = "1";
data.health_notices = {{"DEMO_CODE", "A synthetic information message", "info"}};
assert(data.status_text() == "HEALTH INFO");
assert(data.health_details().find("DEMO_CODE") != std::string::npos);
assert(data.alert_summary() == "A synthetic information message");
data.health_notices.push_back({"DEMO_2", "A synthetic serious message", "serious"});
data.hms_count = "2";
assert(data.status_text() == "HEALTH ERROR");
assert(data.status_color() == RED);
assert(data.health_details().find("A synthetic serious message") != std::string::npos);
data.health_notices.pop_back();
data.hms_count = "5";
assert(data.status_text() == "HEALTH NOTICE");
assert(data.health_details().find("4 more") != std::string::npos);
data.hms_error = "off";
assert(data.health_details() == "No current printer health notifications.");
data.status = "finish";
assert(data.status_text() == "COMPLETE");
assert(data.status_color() == GREEN);
+16
View File
@@ -41,6 +41,22 @@ class FleetTests(unittest.TestCase):
entry = next(row for row in package[domain] if row['id'] == f'prints_charming_{key}')
self.assertEqual(entry['entity_id'], 'sensor.custom_remaining')
def test_orion_has_both_ams_units_with_their_actual_entity_prefixes(self):
package = generate(self.printers)
types = [entry['entity_id'] for entry in package['text_sensor']
if entry['id'].startswith('constipation_orion_tray_') and entry.get('attribute') == 'type']
self.assertEqual(len(types), 8)
self.assertIn('sensor.constipation_orion_ams_1_tray_1', types)
self.assertIn('sensor.h2c_31b8ap5a2200288_ams_2_tray_4', types)
def test_health_messages_import_numbered_ha_attributes(self):
package = generate(self.printers)
subscriptions = {entry['id']: entry for entry in package['text_sensor']}
message = subscriptions['prints_charming_hms_1_message']
self.assertEqual(message['entity_id'], 'binary_sensor.p1s_01p09c470102673_hms_errors')
self.assertEqual(message['attribute'], '1-Error')
self.assertEqual(subscriptions['prints_charming_hms_3_severity']['attribute'], '3-Severity')
def test_reject_empty_duplicate_and_malformed_printer_ids(self):
for invalid in ([], self.printers + [self.printers[0]],
[{**self.printers[0], 'id': 'invalid-id'}]):