diff --git a/bambu_run/management/commands/bambu_collector.py b/bambu_run/management/commands/bambu_collector.py index c12029c..bdec54a 100644 --- a/bambu_run/management/commands/bambu_collector.py +++ b/bambu_run/management/commands/bambu_collector.py @@ -554,6 +554,8 @@ class Command(BaseCommand): printer_metric=printer_metric, filament=filament, tray_id=tray_id, + ams_unit_id=unit_id_int, + ams_type=tray_data.get('ams_type', '') or '', slot_name=tray_data.get('slot'), type=tray_data.get('type'), sub_type=tray_data.get('sub_type'), @@ -641,31 +643,40 @@ class Command(BaseCommand): elif not session.trays_used: logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage") else: + # A bare tray_id (from `tray_now`) doesn't identify which physical AMS + # unit was active when multiple units share the same slot numbering — + # so create one usage row per (unit, tray) that had a tracked filament + # loaded at job start, rather than guessing a single "correct" unit. + created_usages = [] for tray_id in session.trays_used: - start_snap = start_metric.filament_snapshots.filter( + start_snaps = start_metric.filament_snapshots.filter( tray_id=tray_id, filament__isnull=False - ).first() - if not start_snap: - continue - - end_snap = metric.filament_snapshots.filter( - filament=start_snap.filament, tray_id=tray_id - ).first() - - usage = FilamentUsage.objects.create( - print_job=job, - filament=start_snap.filament, - tray_id=tray_id, - starting_percent=start_snap.remain_percent or 100, - ending_percent=end_snap.remain_percent if end_snap else None, - is_primary=(len(session.trays_used) == 1), ) - usage.calculate_consumed() + for start_snap in start_snaps: + end_snap = metric.filament_snapshots.filter( + filament=start_snap.filament, + tray_id=tray_id, + ams_unit_id=start_snap.ams_unit_id, + ).first() + + usage = FilamentUsage.objects.create( + print_job=job, + filament=start_snap.filament, + tray_id=tray_id, + ams_unit_id=start_snap.ams_unit_id, + starting_percent=start_snap.remain_percent or 100, + ending_percent=end_snap.remain_percent if end_snap else None, + ) + usage.calculate_consumed() + created_usages.append(usage) + + for usage in created_usages: + usage.is_primary = len(created_usages) == 1 usage.save() if self.verbose: logger.debug( - f"Filament usage for {start_snap.filament} (tray {tray_id}): " + f"Filament usage for {usage.filament} (unit {usage.ams_unit_id}, tray {usage.tray_id}): " f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%" ) diff --git a/bambu_run/migrations/0006_alter_filamentsnapshot_options_and_more.py b/bambu_run/migrations/0006_alter_filamentsnapshot_options_and_more.py new file mode 100644 index 0000000..4565013 --- /dev/null +++ b/bambu_run/migrations/0006_alter_filamentsnapshot_options_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.8 on 2026-06-20 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bambu_run", "0005_printermetrics_vortek_raw"), + ] + + operations = [ + migrations.AlterModelOptions( + name="filamentsnapshot", + options={ + "ordering": ["printer_metric", "ams_unit_id", "tray_id"], + "verbose_name": "Filament Snapshot", + "verbose_name_plural": "Filament Snapshots", + }, + ), + migrations.AddField( + model_name="filamentsnapshot", + name="ams_type", + field=models.CharField( + blank=True, + choices=[ + ("AMS", "AMS"), + ("AMS 2 Pro", "AMS 2 Pro"), + ("AMS HT", "AMS HT"), + ], + default="", + help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)", + max_length=32, + ), + ), + migrations.AddField( + model_name="filamentsnapshot", + name="ams_unit_id", + field=models.PositiveSmallIntegerField( + blank=True, + db_index=True, + help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)", + null=True, + ), + ), + migrations.AddField( + model_name="filamentusage", + name="ams_unit_id", + field=models.PositiveSmallIntegerField( + blank=True, + db_index=True, + help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)", + null=True, + ), + ), + migrations.AddIndex( + model_name="filamentsnapshot", + index=models.Index( + fields=["printer_metric", "ams_unit_id", "tray_id"], + name="infrastruct_printer_2ad168_idx", + ), + ), + ] diff --git a/bambu_run/models.py b/bambu_run/models.py index 91da214..fb27df4 100644 --- a/bambu_run/models.py +++ b/bambu_run/models.py @@ -21,12 +21,15 @@ AMS_TYPE_CHOICES = [ def ams_type_from_info(info_code) -> str: """Resolve an AMS unit's `info` model code to a human label. - The HT unit reports its `id` with the 0x80 bit set (e.g. 128) — when the info - code is unknown, that bit is a reasonable secondary hint for HT identification. + Real MQTT `info` codes are 8 characters (e.g. "10001003") with the type encoded + in the last 4 digits — confirmed against a live H2C with AMS 2 Pro / AMS / AMS HT. + Fall back to an exact match for the bare 4-digit form in case other firmware + reports it short. """ - if info_code is None: + if not info_code: return "" - return AMS_INFO_TO_TYPE.get(str(info_code), "") + code = str(info_code) + return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "") class Printer(models.Model): @@ -498,6 +501,15 @@ class FilamentSnapshot(models.Model): max_length=20, null=True, blank=True, help_text="Slot identifier like A00-W1" ) + ams_unit_id = models.PositiveSmallIntegerField( + null=True, blank=True, db_index=True, + help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)" + ) + ams_type = models.CharField( + max_length=32, blank=True, default="", + choices=AMS_TYPE_CHOICES, + help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)" + ) type = models.CharField(max_length=50, null=True, blank=True) sub_type = models.CharField( @@ -545,9 +557,10 @@ class FilamentSnapshot(models.Model): db_table = "infrastructure_filament_snapshot" verbose_name = "Filament Snapshot" verbose_name_plural = "Filament Snapshots" - ordering = ['printer_metric', 'tray_id'] + ordering = ['printer_metric', 'ams_unit_id', 'tray_id'] indexes = [ models.Index(fields=['printer_metric', 'tray_id']), + models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']), models.Index(fields=['filament']), ] @@ -686,6 +699,10 @@ class FilamentUsage(models.Model): ) tray_id = models.IntegerField(help_text="Which AMS slot was used") + ams_unit_id = models.PositiveSmallIntegerField( + null=True, blank=True, db_index=True, + help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)" + ) starting_percent = models.IntegerField(help_text="Filament remaining % at job start") ending_percent = models.IntegerField( diff --git a/bambu_run/static/bambu_run/css/dashboard.css b/bambu_run/static/bambu_run/css/dashboard.css index bda76e6..1f826e1 100644 --- a/bambu_run/static/bambu_run/css/dashboard.css +++ b/bambu_run/static/bambu_run/css/dashboard.css @@ -64,3 +64,93 @@ opacity: 0.9; color: rgba(255, 255, 255, 0.9); } + +/* AMS unit type colors — CSS variables so RAE/standalone can override per theme */ +:root { + --ams-badge-ams: #6c757d; + --ams-badge-ams-2-pro: #0d6efd; + --ams-badge-ams-ht: #fd7e14; + --ams-group-border-color: rgba(0, 0, 0, 0.15); +} + +[data-coreui-theme="dark"] { + --ams-group-border-color: rgba(255, 255, 255, 0.2); +} + +.ams-badge-ams { + background-color: var(--ams-badge-ams); + color: #fff; +} + +.ams-badge-ams-2-pro { + background-color: var(--ams-badge-ams-2-pro); + color: #fff; +} + +.ams-badge-ams-ht { + background-color: var(--ams-badge-ams-ht); + color: #fff; +} + +.ams-filter-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.ams-filter-pill { + border-radius: 50rem; + padding: 0.25rem 0.9rem; + font-size: 0.85rem; + border: 1px solid var(--ams-group-border-color); + background-color: transparent; + opacity: 0.6; +} + +.ams-filter-pill.active { + opacity: 1; + font-weight: 600; + border-color: currentColor; +} + +/* Grouped AMS unit panels — wide (multi-slot) units stack one per row, + compact (single-slot, e.g. AMS HT) units flow side-by-side and wrap. */ +.ams-groups { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.ams-group { + border-radius: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--ams-group-border-color); +} + +.ams-group--wide { + flex: 1 1 100%; +} + +.ams-group--compact { + flex: 0 1 auto; + min-width: 220px; +} + +.ams-badge-bg-ams { + background-color: color-mix(in srgb, var(--ams-badge-ams) 8%, transparent); + border-left: 3px solid var(--ams-badge-ams); +} + +.ams-badge-bg-ams-2-pro { + background-color: color-mix(in srgb, var(--ams-badge-ams-2-pro) 8%, transparent); + border-left: 3px solid var(--ams-badge-ams-2-pro); +} + +.ams-badge-bg-ams-ht { + background-color: color-mix(in srgb, var(--ams-badge-ams-ht) 8%, transparent); + border-left: 3px solid var(--ams-badge-ams-ht); +} + +.ams-badge-bg- { + border-left: 3px solid var(--ams-group-border-color); +} diff --git a/bambu_run/static/bambu_run/js/printer_charts.js b/bambu_run/static/bambu_run/js/printer_charts.js index a40a015..ecce8c4 100644 --- a/bambu_run/static/bambu_run/js/printer_charts.js +++ b/bambu_run/static/bambu_run/js/printer_charts.js @@ -625,8 +625,23 @@ function createFilamentDatasets(filamentTimeline, timestamps) { data: filamentTimeline[key] })); - // Sort by tray_id (numeric first, External last), then by start_idx (chronological) + // Distinct (non-null/undefined) AMS units present in this timeline — used to decide + // whether labels need an AMS unit prefix (avoid noise for the common single-AMS case). + const distinctUnits = new Set( + filamentEntries + .map(e => e.data.ams_unit_id) + .filter(uid => uid !== null && uid !== undefined) + ); + const showUnitPrefix = distinctUnits.size > 1; + + // Sort by AMS unit, then tray_id (numeric first, External last), then by start_idx filamentEntries.sort((a, b) => { + const unitA = a.data.ams_unit_id ?? -1; + const unitB = b.data.ams_unit_id ?? -1; + if (unitA !== unitB) { + return unitA - unitB; + } + const trayA = a.data.tray_id; const trayB = b.data.tray_id; @@ -659,6 +674,10 @@ function createFilamentDatasets(filamentTimeline, timestamps) { displayLabel = `Tray ${filament.tray_id} (${filament.type})`; } + if (showUnitPrefix && filament.ams_type) { + displayLabel = `${filament.ams_type} · ${displayLabel}`; + } + // Add brand if it's different from type (avoid redundancy) if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') { displayLabel += ` - ${filament.brand}`; diff --git a/bambu_run/templates/bambu_run/printer_dashboard.html b/bambu_run/templates/bambu_run/printer_dashboard.html index 4837c19..0040219 100644 --- a/bambu_run/templates/bambu_run/printer_dashboard.html +++ b/bambu_run/templates/bambu_run/printer_dashboard.html @@ -37,32 +37,38 @@
{{ filament.type }} - {{ filament.brand }}
- {% if filament.color_name %}{{ filament.color_name }}
{% endif %} -{{ filament.type }} - {{ filament.brand }}
+ {% if filament.color_name %}{{ filament.color_name }}
{% endif %} +No filament data available
{% endif %} @@ -467,4 +498,27 @@ }); {% endif %} + {% endblock %} diff --git a/bambu_run/views.py b/bambu_run/views.py index b76f2b7..15c5c0d 100644 --- a/bambu_run/views.py +++ b/bambu_run/views.py @@ -148,6 +148,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): 'brand': snapshot.sub_type or 'Unknown', 'color': snapshot.color or 'FFFFFFFF', 'remain_percent': snapshot.remain_percent or 0, + 'ams_unit_id': snapshot.ams_unit_id, + 'ams_type': snapshot.ams_type or '', } if snapshot.filament: filament_dict['color_name'] = snapshot.filament.color @@ -157,6 +159,37 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): except Exception: filaments_list = [] + # Distinct AMS units represented in this snapshot, for the unit + # filter/badges in the template. Sort numeric unit ids first + # (AMS / AMS 2 Pro), HT (id 128 / bit 0x80 set) last. + seen_units = {} + for f in filaments_list: + uid = f.get('ams_unit_id') + if uid is not None and uid not in seen_units: + seen_units[uid] = f.get('ams_type') or '' + ams_units_list = [ + {'ams_unit_id': uid, 'ams_type': label} + for uid, label in sorted(seen_units.items()) + ] + + # Group trays by physical AMS unit for the panel-style dashboard layout — + # one tinted panel per unit, full-width for multi-slot units (AMS/AMS 2 Pro), + # compact for single-slot units (AMS HT) so several can flow side-by-side. + units_meta = { + u.get('unit_id'): u for u in (latest_metric.ams_units or []) + } + ams_groups = [] + for uid, label in sorted(seen_units.items()): + unit_meta = units_meta.get(str(uid), {}) + ams_groups.append({ + 'unit_id': uid, + 'ams_type': label, + 'label': f"{label or 'AMS'} (Unit {uid})", + 'humidity': unit_meta.get('humidity'), + 'temp': unit_meta.get('temp'), + 'filaments': [f for f in filaments_list if f.get('ams_unit_id') == uid], + }) + subtask_name = latest_metric.subtask_name or "No active print" # Look up active PrintJob for a better display name (cloud design_title) job_display_name = subtask_name @@ -196,6 +229,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): "ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None, "ams_humidity": latest_metric.ams_humidity, "filaments": filaments_list, + "ams_units": ams_units_list, + "ams_groups": ams_groups, "external_spool": latest_metric.external_spool or {}, "timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"), } @@ -278,15 +313,19 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView): for snapshot in snapshots: tray_id = snapshot.tray_id + ams_unit_id = snapshot.ams_unit_id + ams_type = snapshot.ams_type or '' fil_type = snapshot.type or 'Unknown' fil_sub_type = snapshot.sub_type or 'Unknown' fil_color = snapshot.color or 'FFFFFFFF' - unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}" + unique_key = f"{ams_unit_id}_{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}" if unique_key not in filament_data: filament_data[unique_key] = { 'tray_id': tray_id, + 'ams_unit_id': ams_unit_id, + 'ams_type': ams_type, 'type': fil_type, 'brand': fil_sub_type, 'color': fil_color, diff --git a/tests/test_ams_type_from_info.py b/tests/test_ams_type_from_info.py new file mode 100644 index 0000000..1b7b8da --- /dev/null +++ b/tests/test_ams_type_from_info.py @@ -0,0 +1,25 @@ +import pytest + +from bambu_run.models import ams_type_from_info + + +@pytest.mark.parametrize( + "info_code,expected", + [ + # Real-world 8-char info codes captured from a live H2C with + # AMS 2 Pro (unit 0), AMS (unit 1), AMS HT (unit 128). + ("10001003", "AMS 2 Pro"), + ("10001001", "AMS"), + ("11002104", "AMS HT"), + # Bare 4-digit codes (original assumption) still resolve. + ("1001", "AMS"), + ("1003", "AMS 2 Pro"), + ("2104", "AMS HT"), + # Unknown/missing codes resolve to empty string, not an error. + ("99999999", ""), + ("", ""), + (None, ""), + ], +) +def test_ams_type_from_info(info_code, expected): + assert ams_type_from_info(info_code) == expected diff --git a/tests/test_filament_context.py b/tests/test_filament_context.py new file mode 100644 index 0000000..c4f9cd0 --- /dev/null +++ b/tests/test_filament_context.py @@ -0,0 +1,180 @@ +import pytest +from decimal import Decimal +from django.urls import reverse +from django.utils import timezone + +from bambu_run.models import Printer, PrinterMetrics, FilamentSnapshot + + +@pytest.fixture +def logged_in_client(client, django_user_model): + user = django_user_model.objects.create_user(username="tester", password="pw") + client.force_login(user) + return client + + +@pytest.mark.django_db +def test_dashboard_filaments_carry_ams_unit_info(logged_in_client): + printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True) + metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now()) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS", + type="PLA", remain_percent=80, + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT", + type="PA-CF", remain_percent=50, + ) + + resp = logged_in_client.get( + reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk}) + ) + + filaments = resp.context["stats"]["filaments"] + assert len(filaments) == 2 + units = {(f["ams_unit_id"], f["ams_type"]) for f in filaments} + assert units == {(0, "AMS"), (128, "AMS HT")} + + ams_units = resp.context["stats"]["ams_units"] + assert ams_units == [ + {"ams_unit_id": 0, "ams_type": "AMS"}, + {"ams_unit_id": 128, "ams_type": "AMS HT"}, + ] + + +@pytest.mark.django_db +def test_filament_timeline_keeps_same_tray_id_units_separate(logged_in_client): + from bambu_run.views import PrinterDashboardView + + printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True) + metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now()) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS", + type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=80, + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT", + type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=50, + ) + + view = PrinterDashboardView() + timeline = view._prepare_filament_timeline(PrinterMetrics.objects.filter(pk=metric.pk)) + + assert len(timeline) == 2 + + +@pytest.mark.django_db +def test_dashboard_renders_unit_pills_and_badges_with_multiple_units(logged_in_client): + printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True) + metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now()) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS", + type="PLA", color="FF0000FF", remain_percent=80, + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT", + type="PA-CF", color="00FF00FF", remain_percent=50, + ) + + resp = logged_in_client.get( + reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk}) + ) + + assert resp.status_code == 200 + html = resp.content.decode() + assert "ams-filter-pills" in html + assert "ams-badge-ams" in html + assert "ams-badge-ams-ht" in html + assert 'data-ams-unit-id="0"' in html + assert 'data-ams-unit-id="128"' in html + + +@pytest.mark.django_db +def test_dashboard_groups_filaments_by_ams_unit(logged_in_client): + printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True) + metric = PrinterMetrics.objects.create( + device=printer, timestamp=timezone.now(), + ams_units=[ + {"unit_id": "0", "ams_type": "AMS 2 Pro", "humidity": 5, "temp": 22.5}, + {"unit_id": "128", "ams_type": "AMS HT", "humidity": 8, "temp": 60.0}, + ], + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS 2 Pro", + type="ABS", remain_percent=80, + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=1, ams_unit_id=0, ams_type="AMS 2 Pro", + type="ABS", remain_percent=60, + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT", + type="PA-CF", remain_percent=50, + ) + + resp = logged_in_client.get( + reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk}) + ) + + groups = resp.context["stats"]["ams_groups"] + assert len(groups) == 2 + + ams2pro_group, ht_group = groups + assert ams2pro_group["unit_id"] == 0 + assert ams2pro_group["label"] == "AMS 2 Pro (Unit 0)" + assert ams2pro_group["humidity"] == 5 + assert ams2pro_group["temp"] == 22.5 + assert len(ams2pro_group["filaments"]) == 2 + + assert ht_group["unit_id"] == 128 + assert ht_group["label"] == "AMS HT (Unit 128)" + assert ht_group["humidity"] == 8 + assert len(ht_group["filaments"]) == 1 + + +@pytest.mark.django_db +def test_dashboard_renders_wide_and_compact_panels(logged_in_client): + printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True) + metric = PrinterMetrics.objects.create( + device=printer, timestamp=timezone.now(), + ams_units=[ + {"unit_id": "0", "ams_type": "AMS 2 Pro", "humidity": 5, "temp": 22.5}, + {"unit_id": "128", "ams_type": "AMS HT", "humidity": 8, "temp": 60.0}, + ], + ) + for tray_id in range(4): + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=tray_id, ams_unit_id=0, ams_type="AMS 2 Pro", + type="ABS", remain_percent=80, + ) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT", + type="PA-CF", remain_percent=50, + ) + + resp = logged_in_client.get( + reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk}) + ) + + html = resp.content.decode() + assert "ams-group--wide" in html + assert "ams-group--compact" in html + assert "AMS 2 Pro (Unit 0)" in html + assert "AMS HT (Unit 128)" in html + + +@pytest.mark.django_db +def test_dashboard_hides_unit_pills_with_single_unit(logged_in_client): + printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True) + metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now()) + FilamentSnapshot.objects.create( + printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS", + type="PLA", color="FF0000FF", remain_percent=80, + ) + + resp = logged_in_client.get( + reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk}) + ) + + assert resp.status_code == 200 + assert "ams-filter-pills" not in resp.content.decode() diff --git a/tests/test_multi_ams_collection.py b/tests/test_multi_ams_collection.py new file mode 100644 index 0000000..df366ae --- /dev/null +++ b/tests/test_multi_ams_collection.py @@ -0,0 +1,104 @@ +import pytest + +from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device +from bambu_run.models import Filament, FilamentSnapshot, FilamentUsage, PrinterMetrics + + +class FakeClient: + """Stub in place of BambuPrinter — returns canned snapshots, no real MQTT.""" + + def __init__(self, snapshots): + self._snapshots = snapshots + self._index = 0 + self._client = None + + def get_snapshot(self): + snap = self._snapshots[min(self._index, len(self._snapshots) - 1)] + self._index += 1 + return snap + + +def make_session(device_id, name, snapshots): + printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"}) + return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer) + + +def two_unit_tray0_snapshot(): + """Two AMS units (AMS unit_id=0, AMS HT unit_id=128) both report tray_id=0, + with different filament types loaded — these must not collide.""" + return { + "gcode_state": "IDLE", + "ams_units": [ + {"unit_id": "0", "ams_type": "AMS", "humidity": 30, "temp": 25.0}, + {"unit_id": "128", "ams_type": "AMS HT", "humidity": 20, "temp": 60.0}, + ], + "filaments": [ + { + "tray_id": 0, "type": "PLA", "sub_type": "PLA Basic", "color": "FF0000FF", + "tray_uuid": "UUID-UNIT0-TRAY0", + "remain_percent": 80, "ams_unit_id": 0, "ams_type": "AMS", + }, + { + "tray_id": 0, "type": "PA-CF", "sub_type": "PA6-CF", "color": "00FF00FF", + "tray_uuid": "UUID-UNIT128-TRAY0", + "remain_percent": 50, "ams_unit_id": 128, "ams_type": "AMS HT", + }, + ], + } + + +@pytest.mark.django_db +def test_two_ams_units_with_same_tray_id_create_distinct_snapshots(): + session = make_session("SERIAL-A", "Printer A", [two_unit_tray0_snapshot()]) + + cmd = Command() + cmd.verbose = False + cmd._collect_printer_data(session) + + metric = PrinterMetrics.objects.get(device=session.printer) + snapshots = FilamentSnapshot.objects.filter(printer_metric=metric).order_by("ams_unit_id") + + assert snapshots.count() == 2 + + ams_snap, ht_snap = snapshots + assert ams_snap.tray_id == 0 + assert ams_snap.ams_unit_id == 0 + assert ams_snap.ams_type == "AMS" + assert ams_snap.type == "PLA" + + assert ht_snap.tray_id == 0 + assert ht_snap.ams_unit_id == 128 + assert ht_snap.ams_type == "AMS HT" + assert ht_snap.type == "PA-CF" + + +@pytest.mark.django_db +def test_filament_usage_matches_correct_unit_when_tray_ids_collide(): + start_snapshot = two_unit_tray0_snapshot() + start_snapshot.update({"gcode_state": "RUNNING", "subtask_name": "job_1", "print_percent": 1, "tray_now": "0"}) + + end_snapshot = two_unit_tray0_snapshot() + end_snapshot["filaments"][0]["remain_percent"] = 70 # AMS unit 0 consumed + end_snapshot["filaments"][1]["remain_percent"] = 50 # AMS HT unit 128 untouched + end_snapshot.update({"gcode_state": "FINISH", "subtask_name": "job_1", "print_percent": 100}) + + session = make_session("SERIAL-A", "Printer A", [start_snapshot, end_snapshot]) + + cmd = Command() + cmd.verbose = False + cmd._collect_printer_data(session) + cmd._collect_printer_data(session) + + usages = FilamentUsage.objects.filter(print_job__device=session.printer).order_by("ams_unit_id") + # Both units reported tray_id=0 with a tracked filament loaded throughout the + # job — usage is recorded per physical unit, not collapsed into one ambiguous row. + assert usages.count() == 2 + + ams_usage, ht_usage = usages + assert ams_usage.ams_unit_id == 0 + assert ams_usage.starting_percent == 80 + assert ams_usage.ending_percent == 70 + + assert ht_usage.ams_unit_id == 128 + assert ht_usage.starting_percent == 50 + assert ht_usage.ending_percent == 50