2 Commits

17 changed files with 1526 additions and 66 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask, Hotend, HotendSnapshot
@admin.register(Printer) @admin.register(Printer)
@@ -107,6 +107,21 @@ class FilamentUsageAdmin(admin.ModelAdmin):
readonly_fields = ('consumed_percent', 'consumed_grams') readonly_fields = ('consumed_percent', 'consumed_grams')
@admin.register(Hotend)
class HotendAdmin(admin.ModelAdmin):
list_display = ('printer', 'serial_number', 'nozzle_type', 'is_toolhead', 'slot_number', 'used_time_seconds', 'wear_percent', 'last_seen_at')
list_filter = ('printer', 'is_toolhead', 'nozzle_type')
search_fields = ('serial_number',)
readonly_fields = ('last_seen_at', 'created_at')
@admin.register(HotendSnapshot)
class HotendSnapshotAdmin(admin.ModelAdmin):
list_display = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'timestamp')
list_filter = ('hotend',)
readonly_fields = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'stat', 'timestamp')
@admin.register(BambuCloudTask) @admin.register(BambuCloudTask)
class BambuCloudTaskAdmin(admin.ModelAdmin): class BambuCloudTaskAdmin(admin.ModelAdmin):
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at') list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')

View File

@@ -554,6 +554,8 @@ class Command(BaseCommand):
printer_metric=printer_metric, printer_metric=printer_metric,
filament=filament, filament=filament,
tray_id=tray_id, tray_id=tray_id,
ams_unit_id=unit_id_int,
ams_type=tray_data.get('ams_type', '') or '',
slot_name=tray_data.get('slot'), slot_name=tray_data.get('slot'),
type=tray_data.get('type'), type=tray_data.get('type'),
sub_type=tray_data.get('sub_type'), sub_type=tray_data.get('sub_type'),
@@ -569,6 +571,38 @@ class Command(BaseCommand):
match_method=match_method match_method=match_method
) )
def _update_hotends(self, printer, printer_metric, hotends_data):
from bambu_run.models import Hotend, HotendSnapshot
for h in hotends_data:
if h.get("is_empty"):
continue
hotend, _ = Hotend.objects.update_or_create(
printer=printer,
serial_number=h.get("serial_number"),
defaults={
"raw_id": h.get("raw_id", 0),
"nozzle_type": h.get("nozzle_type", ""),
"diameter": self._to_decimal(h.get("diameter")),
"slot_number": h.get("slot_number"),
"is_toolhead": bool(h.get("is_toolhead")),
"last_filament_profile_id": h.get("fila_id", ""),
"last_color": h.get("color") or "",
"used_time_seconds": h.get("used_time_seconds", 0),
"wear_percent": h.get("wear_percent", 0),
},
)
HotendSnapshot.objects.create(
printer_metric=printer_metric,
hotend=hotend,
raw_id=h.get("raw_id", 0),
used_time_seconds=h.get("used_time_seconds", 0),
wear_percent=h.get("wear_percent", 0),
stat=h.get("stat"),
)
def _track_print_job(self, session, metric, snapshot): def _track_print_job(self, session, metric, snapshot):
from bambu_run.models import PrintJob from bambu_run.models import PrintJob
@@ -641,31 +675,40 @@ class Command(BaseCommand):
elif not session.trays_used: elif not session.trays_used:
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage") logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
else: 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: 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 tray_id=tray_id, filament__isnull=False
).first() )
if not start_snap: for start_snap in start_snaps:
continue
end_snap = metric.filament_snapshots.filter( end_snap = metric.filament_snapshots.filter(
filament=start_snap.filament, tray_id=tray_id filament=start_snap.filament,
tray_id=tray_id,
ams_unit_id=start_snap.ams_unit_id,
).first() ).first()
usage = FilamentUsage.objects.create( usage = FilamentUsage.objects.create(
print_job=job, print_job=job,
filament=start_snap.filament, filament=start_snap.filament,
tray_id=tray_id, tray_id=tray_id,
ams_unit_id=start_snap.ams_unit_id,
starting_percent=start_snap.remain_percent or 100, starting_percent=start_snap.remain_percent or 100,
ending_percent=end_snap.remain_percent if end_snap else None, ending_percent=end_snap.remain_percent if end_snap else None,
is_primary=(len(session.trays_used) == 1),
) )
usage.calculate_consumed() usage.calculate_consumed()
created_usages.append(usage)
for usage in created_usages:
usage.is_primary = len(created_usages) == 1
usage.save() usage.save()
if self.verbose: if self.verbose:
logger.debug( 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}%" f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
) )
@@ -746,12 +789,17 @@ class Command(BaseCommand):
external_spool=snapshot.get("external_spool", {}), external_spool=snapshot.get("external_spool", {}),
lights_report=snapshot.get("lights_report", []), lights_report=snapshot.get("lights_report", []),
vortek_raw=snapshot.get("vortek_raw", {}), vortek_raw=snapshot.get("vortek_raw", {}),
nozzle_info=snapshot.get("hotends", []),
) )
filaments_data = snapshot.get('filaments', []) filaments_data = snapshot.get('filaments', [])
if filaments_data: if filaments_data:
self._create_filament_snapshots(metric, filaments_data, snapshot) self._create_filament_snapshots(metric, filaments_data, snapshot)
hotends_data = snapshot.get('hotends', [])
if hotends_data:
self._update_hotends(session.printer, metric, hotends_data)
self._track_print_job(session, metric, snapshot) self._track_print_job(session, metric, snapshot)
session.success_count += 1 session.success_count += 1

View File

@@ -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",
),
),
]

View File

@@ -0,0 +1,172 @@
# Generated by Django 5.2.8 on 2026-06-20 14:07
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0006_alter_filamentsnapshot_options_and_more"),
]
operations = [
migrations.CreateModel(
name="Hotend",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("serial_number", models.CharField(db_index=True, max_length=100)),
(
"nozzle_type",
models.CharField(blank=True, default="", max_length=50),
),
(
"diameter",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
(
"raw_id",
models.PositiveSmallIntegerField(
help_text="Last-seen MQTT device.nozzle.info[].id"
),
),
(
"slot_number",
models.PositiveSmallIntegerField(
blank=True,
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel).",
null=True,
),
),
(
"is_toolhead",
models.BooleanField(
default=False,
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0).",
),
),
(
"last_filament_profile_id",
models.CharField(
blank=True,
default="",
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')",
max_length=20,
),
),
(
"last_color",
models.CharField(
blank=True,
default="",
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)",
max_length=6,
),
),
("used_time_seconds", models.PositiveIntegerField(default=0)),
(
"wear_percent",
models.DecimalField(
decimal_places=2,
default=0,
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent",
max_digits=5,
),
),
("last_seen_at", models.DateTimeField(auto_now=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"printer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="hotends",
to="bambu_run.printer",
),
),
],
options={
"verbose_name": "Hotend",
"verbose_name_plural": "Hotends",
"db_table": "infrastructure_hotend",
"ordering": ["printer", "-is_toolhead", "slot_number", "serial_number"],
"unique_together": {("printer", "serial_number")},
},
),
migrations.CreateModel(
name="HotendSnapshot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("raw_id", models.PositiveSmallIntegerField()),
("used_time_seconds", models.PositiveIntegerField(default=0)),
(
"wear_percent",
models.DecimalField(decimal_places=2, default=0, max_digits=5),
),
(
"stat",
models.IntegerField(
blank=True,
help_text="Raw MQTT status code for this hotend",
null=True,
),
),
(
"timestamp",
models.DateTimeField(
db_index=True, default=django.utils.timezone.now
),
),
(
"hotend",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="snapshots",
to="bambu_run.hotend",
),
),
(
"printer_metric",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="hotend_snapshots",
to="bambu_run.printermetrics",
),
),
],
options={
"verbose_name": "Hotend Snapshot",
"verbose_name_plural": "Hotend Snapshots",
"db_table": "infrastructure_hotend_snapshot",
"ordering": ["printer_metric", "hotend"],
"indexes": [
models.Index(
fields=["printer_metric", "hotend"],
name="infrastruct_printer_b528aa_idx",
),
models.Index(
fields=["hotend", "-timestamp"],
name="infrastruct_hotend__691f7e_idx",
),
],
},
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.8 on 2026-06-20 14:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0007_hotend_hotendsnapshot"),
]
operations = [
migrations.AddField(
model_name="printermetrics",
name="nozzle_info",
field=models.JSONField(
blank=True,
default=list,
help_text="Parsed per-poll nozzle/hotend info list",
),
),
]

View File

@@ -21,12 +21,15 @@ AMS_TYPE_CHOICES = [
def ams_type_from_info(info_code) -> str: def ams_type_from_info(info_code) -> str:
"""Resolve an AMS unit's `info` model code to a human label. """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 Real MQTT `info` codes are 8 characters (e.g. "10001003") with the type encoded
code is unknown, that bit is a reasonable secondary hint for HT identification. 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 ""
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): class Printer(models.Model):
@@ -239,6 +242,15 @@ class PrinterMetrics(models.Model):
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)" default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
) )
# Parsed device.nozzle.info[] from this poll, one dict per entry (mirrors
# HotendInfo.to_dict()). Includes induction-chip hotends *and* non-inductive
# nozzle positions (e.g. H2C's fixed left nozzle) that have no stable serial
# number to key a Hotend registry row on — kept here so the dashboard can show
# their readable type/diameter without claiming an identity/history we don't have.
nozzle_info = models.JSONField(
default=list, blank=True, help_text="Parsed per-poll nozzle/hotend info list"
)
class Meta: class Meta:
db_table = "infrastructure_printer_metrics" db_table = "infrastructure_printer_metrics"
verbose_name = "Printer Metric" verbose_name = "Printer Metric"
@@ -498,6 +510,15 @@ class FilamentSnapshot(models.Model):
max_length=20, null=True, blank=True, max_length=20, null=True, blank=True,
help_text="Slot identifier like A00-W1" 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) type = models.CharField(max_length=50, null=True, blank=True)
sub_type = models.CharField( sub_type = models.CharField(
@@ -545,9 +566,10 @@ class FilamentSnapshot(models.Model):
db_table = "infrastructure_filament_snapshot" db_table = "infrastructure_filament_snapshot"
verbose_name = "Filament Snapshot" verbose_name = "Filament Snapshot"
verbose_name_plural = "Filament Snapshots" verbose_name_plural = "Filament Snapshots"
ordering = ['printer_metric', 'tray_id'] ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
indexes = [ indexes = [
models.Index(fields=['printer_metric', 'tray_id']), models.Index(fields=['printer_metric', 'tray_id']),
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
models.Index(fields=['filament']), models.Index(fields=['filament']),
] ]
@@ -686,6 +708,10 @@ class FilamentUsage(models.Model):
) )
tray_id = models.IntegerField(help_text="Which AMS slot was used") 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") starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
ending_percent = models.IntegerField( ending_percent = models.IntegerField(
@@ -723,3 +749,108 @@ class FilamentUsage(models.Model):
self.consumed_grams = int( self.consumed_grams = int(
self.filament.initial_weight_grams * (self.consumed_percent / 100.0) self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
) )
class Hotend(models.Model):
"""Registry of individual Vortek hotends, keyed by serial number.
A Vortek rack holds up to 6 swappable hotends (bays, MQTT `id` 16-21) plus
1 mounted on the toolhead at a time (MQTT `id` 0). `raw_id` reflects whichever
address was last seen on the wire for this hotend; `slot_number` is only set
when that address falls in the 16-21 rack-bay range — confirmed by watching
a "Read All" MQTT capture reassign a toolhead-mounted hotend's id from 0 to
its true bay id.
"""
printer = models.ForeignKey(
'Printer', on_delete=models.CASCADE, related_name='hotends'
)
serial_number = models.CharField(max_length=100, db_index=True)
nozzle_type = models.CharField(max_length=50, blank=True, default="")
diameter = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
)
raw_id = models.PositiveSmallIntegerField(
help_text="Last-seen MQTT device.nozzle.info[].id"
)
slot_number = models.PositiveSmallIntegerField(
null=True, blank=True,
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel)."
)
is_toolhead = models.BooleanField(
default=False,
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0)."
)
last_filament_profile_id = models.CharField(
max_length=20, blank=True, default="",
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')"
)
last_color = models.CharField(
max_length=6, blank=True, default="",
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)"
)
used_time_seconds = models.PositiveIntegerField(default=0)
wear_percent = models.DecimalField(
max_digits=5, decimal_places=2, default=0,
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent"
)
last_seen_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "infrastructure_hotend"
verbose_name = "Hotend"
verbose_name_plural = "Hotends"
ordering = ['printer', '-is_toolhead', 'slot_number', 'serial_number']
unique_together = [['printer', 'serial_number']]
def __str__(self):
location = "Toolhead" if self.is_toolhead else (
f"Slot {self.slot_number}" if self.slot_number else "Rack"
)
return f"{self.serial_number} ({location})"
@property
def used_time_display(self) -> str:
hours, remainder = divmod(self.used_time_seconds, 3600)
minutes = remainder // 60
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
class HotendSnapshot(models.Model):
"""Point-in-time reading of a Hotend, one row per collector poll."""
printer_metric = models.ForeignKey(
'PrinterMetrics', on_delete=models.CASCADE,
related_name='hotend_snapshots'
)
hotend = models.ForeignKey(
'Hotend', on_delete=models.CASCADE,
related_name='snapshots'
)
raw_id = models.PositiveSmallIntegerField()
used_time_seconds = models.PositiveIntegerField(default=0)
wear_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
stat = models.IntegerField(
null=True, blank=True, help_text="Raw MQTT status code for this hotend"
)
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
class Meta:
db_table = "infrastructure_hotend_snapshot"
verbose_name = "Hotend Snapshot"
verbose_name_plural = "Hotend Snapshots"
ordering = ['printer_metric', 'hotend']
indexes = [
models.Index(fields=['printer_metric', 'hotend']),
models.Index(fields=['hotend', '-timestamp']),
]
def __str__(self):
return f"{self.hotend.serial_number} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"

View File

@@ -296,6 +296,73 @@ class AMSState:
return loaded return loaded
@dataclass
class HotendInfo:
"""A single hotend reported in `device.nozzle.info[]` (Vortek rack).
`raw_id` semantics (confirmed by watching a live "Read All" MQTT capture):
0 = currently mounted on the (swappable) toolhead — the sentinel hides the
true bay address until "Read All" resolves it; 1 = the fixed left nozzle on
dual-nozzle printers (no RFID chip, always reports sn="N/A"); 16-21 = rack
bay address, slot_number = raw_id - 15 (1-6).
"""
raw_id: int = 0
serial_number: str = ""
nozzle_type: str = ""
diameter: float = 0.4
fila_id: str = ""
color: Optional[str] = None
used_time_seconds: int = 0
wear_percent: float = 0.0
stat: int = 0
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HotendInfo":
from .utils import strip_color_padding
return cls(
raw_id=int(data.get("id", 0)),
serial_number=data.get("sn", ""),
nozzle_type=data.get("type", ""),
diameter=float(data.get("diameter", 0.4)),
fila_id=data.get("fila_id", ""),
color=strip_color_padding(data.get("color_m")),
used_time_seconds=int(data.get("p_t", 0)),
wear_percent=round(float(data.get("wear", 0.0)) / 128.0 * 100, 2),
stat=int(data.get("stat", 0)),
)
@property
def is_toolhead(self) -> bool:
return self.raw_id == 0
@property
def is_empty(self) -> bool:
return self.serial_number in ("", "N/A")
@property
def slot_number(self) -> Optional[int]:
if 16 <= self.raw_id <= 21:
return self.raw_id - 15
return None
def to_dict(self) -> Dict[str, Any]:
return {
"raw_id": self.raw_id,
"serial_number": self.serial_number,
"nozzle_type": self.nozzle_type,
"diameter": self.diameter,
"fila_id": self.fila_id,
"color": self.color,
"used_time_seconds": self.used_time_seconds,
"wear_percent": self.wear_percent,
"stat": self.stat,
"is_toolhead": self.is_toolhead,
"is_empty": self.is_empty,
"slot_number": self.slot_number,
}
@dataclass @dataclass
class PrinterState: class PrinterState:
"""Complete printer state parsed from MQTT data""" """Complete printer state parsed from MQTT data"""
@@ -388,6 +455,9 @@ class PrinterState:
# External spool (virtual tray) # External spool (virtual tray)
vt_tray: Optional[Dict[str, Any]] = None vt_tray: Optional[Dict[str, Any]] = None
# Vortek hotend rack (device.nozzle.info[])
hotends: List[HotendInfo] = field(default_factory=list)
# Raw data for any additional fields # Raw data for any additional fields
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False) _raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
@@ -431,6 +501,12 @@ class PrinterState:
nozzle_target_temp_left = float((t >> 16) & 0xFFFF) nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
nozzle_temp_left = float(t & 0xFFFF) nozzle_temp_left = float(t & 0xFFFF)
# Vortek hotend rack: device.nozzle.info[] — one entry per hotend.
hotends = [
HotendInfo.from_dict(h)
for h in (device.get("nozzle") or {}).get("info") or []
]
return cls( return cls(
timestamp=timestamp, timestamp=timestamp,
sequence_id=str(print_data.get("sequence_id", "")), sequence_id=str(print_data.get("sequence_id", "")),
@@ -487,6 +563,7 @@ class PrinterState:
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")), gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
lifecycle=print_data.get("lifecycle", ""), lifecycle=print_data.get("lifecycle", ""),
vt_tray=print_data.get("vt_tray"), vt_tray=print_data.get("vt_tray"),
hotends=hotends,
_raw_data=data, _raw_data=data,
) )
@@ -533,6 +610,7 @@ class PrinterState:
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything # hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
# here so no data is lost once the real Vortek MQTT schema is confirmed. # here so no data is lost once the real Vortek MQTT schema is confirmed.
"vortek_raw": self._raw_data.get("print", {}).get("device", {}), "vortek_raw": self._raw_data.get("print", {}).get("device", {}),
"hotends": [h.to_dict() for h in self.hotends],
"hms": self.hms, "hms": self.hms,
"stg_cur": self.stg_cur, "stg_cur": self.stg_cur,
"lights_report": self.lights_report, "lights_report": self.lights_report,

View File

@@ -64,3 +64,93 @@
opacity: 0.9; opacity: 0.9;
color: rgba(255, 255, 255, 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);
}

View File

@@ -625,8 +625,23 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
data: filamentTimeline[key] 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) => { 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 trayA = a.data.tray_id;
const trayB = b.data.tray_id; const trayB = b.data.tray_id;
@@ -659,6 +674,10 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
displayLabel = `Tray ${filament.tray_id} (${filament.type})`; 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) // Add brand if it's different from type (avoid redundancy)
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') { if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
displayLabel += ` - ${filament.brand}`; displayLabel += ` - ${filament.brand}`;

View File

@@ -37,32 +37,38 @@
<!-- Summary Cards Row --> <!-- Summary Cards Row -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
{% if stats.is_dual_nozzle %} {% if stats.is_dual_nozzle %}
<!-- Right Nozzle (dual-nozzle printers, e.g. H2C) --> <!-- Left Nozzle (dual-nozzle printers, e.g. H2C) -->
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning"> <div class="card infra-card-warning h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<div class="stat-label">Right Nozzle</div> <div class="stat-label d-flex align-items-center gap-1">
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}&deg;C</div> <svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-left"></use></svg>
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}&deg;C Left Nozzle
{% if stats.nozzle_type %}· {{ stats.nozzle_type }}{% endif %}</div> </div>
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}&deg;C</div>
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}&deg;C
{% if stats.nozzle_type_left %}· Nozzle {{ stats.nozzle_type_left }}{% endif %}</div>
</div> </div>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i> <i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Left Nozzle --> <!-- Right Nozzle -->
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning"> <div class="card infra-card-warning h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<div class="stat-label">Left Nozzle</div> <div class="stat-label d-flex align-items-center gap-1">
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}&deg;C</div> Right Nozzle
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}&deg;C <svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-right"></use></svg>
{% if stats.nozzle_type_left %}· {{ stats.nozzle_type_left }}{% endif %}</div> </div>
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}&deg;C</div>
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}&deg;C
{% if stats.nozzle_type %}· Nozzle {{ stats.nozzle_type }}{% endif %}</div>
</div> </div>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i> <i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div> </div>
@@ -72,7 +78,7 @@
{% else %} {% else %}
<!-- Nozzle Temperature Card (single-nozzle printers) --> <!-- Nozzle Temperature Card (single-nozzle printers) -->
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning"> <div class="card infra-card-warning h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -88,7 +94,7 @@
<!-- Bed Temperature Card --> <!-- Bed Temperature Card -->
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-danger"> <div class="card infra-card-danger h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -103,7 +109,7 @@
<!-- Print Progress Card --> <!-- Print Progress Card -->
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-info"> <div class="card infra-card-info h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -118,7 +124,7 @@
<!-- Chamber Light Card --> <!-- Chamber Light Card -->
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}"> <div class="card h-100 {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -198,9 +204,29 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if stats.filaments %} {% if stats.filaments %}
{% if stats.ams_units|length > 1 %}
<div class="ams-filter-pills mb-3" id="amsFilterPills">
<button type="button" class="btn ams-filter-pill active" data-ams-filter="all">All</button>
{% for unit in stats.ams_units %}
<button type="button" class="btn ams-filter-pill ams-badge-{{ unit.ams_type|slugify }}" data-ams-filter="{{ unit.ams_unit_id }}">{{ unit.ams_type|default:"AMS" }}</button>
{% endfor %}
</div>
{% endif %}
<div class="ams-groups">
{% for group in stats.ams_groups %}
<div class="ams-group ams-badge-bg-{{ group.ams_type|slugify }} {% if group.filaments|length > 1 %}ams-group--wide{% else %}ams-group--compact{% endif %}" data-ams-unit-id="{{ group.unit_id }}">
<div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
<strong class="small">{{ group.label }}</strong>
{% if group.humidity is not None or group.temp is not None %}
<span class="small text-body-secondary">
{% if group.humidity is not None %}{{ group.humidity }}%RH{% endif %}
{% if group.temp is not None %}&middot; {{ group.temp }}&deg;C{% endif %}
</span>
{% endif %}
</div>
<div class="row g-3"> <div class="row g-3">
{% for filament in stats.filaments %} {% for filament in group.filaments %}
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 {% if group.filaments|length > 1 %}col-md-6 col-lg-3{% endif %}">
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}> <div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}>
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
@@ -224,8 +250,13 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
</div>
{% endfor %}
</div>
{% if stats.external_spool.type %} {% if stats.external_spool.type %}
<div class="row g-3 mt-1">
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}"> <div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
<div class="card-body"> <div class="card-body">
@@ -241,8 +272,8 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% endif %}
{% else %} {% else %}
<p class="text-body-secondary">No filament data available</p> <p class="text-body-secondary">No filament data available</p>
{% endif %} {% endif %}
@@ -251,6 +282,64 @@
</div> </div>
</div> </div>
<!-- Hotends Section (Vortek rack + any plain/non-inductive nozzles) -->
{% if stats.hotends or stats.nozzle_positions %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Hotends</h5>
</div>
<div class="card-body">
<div class="row g-3">
{% for hotend in stats.hotends %}
<div class="col-12 col-md-6 col-lg-3">
<div class="card filament-card" data-filament-color="{{ hotend.last_color|default:'888888' }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
{% if hotend.is_toolhead %}Toolhead{% elif hotend.slot_number %}Slot {{ hotend.slot_number }}{% else %}Rack{% endif %}
</h6>
{% if hotend.is_toolhead %}<span class="badge filament-badge">Toolhead</span>{% endif %}
</div>
<p class="mb-1 small text-body-secondary">SN {{ hotend.serial_number }}</p>
<p class="mb-1 small"><strong>{{ hotend.nozzle_type }}</strong>{% if hotend.diameter %} &middot; {{ hotend.diameter }}mm{% endif %}</p>
{% if hotend.last_filament_profile_id %}<p class="mb-1 small text-body-secondary">Last: {{ hotend.last_filament_profile_id }}</p>{% endif %}
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small">Used time</span>
<span class="small">{{ hotend.used_time_display }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small">Wear</span>
<span class="badge filament-badge">{{ hotend.wear_percent|floatformat:0 }}%</span>
</div>
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ hotend.wear_percent }}%;" aria-valuenow="{{ hotend.wear_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>
{% endfor %}
{% for nozzle in stats.nozzle_positions %}
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">{% if nozzle.is_toolhead %}Toolhead{% else %}Fixed Nozzle{% endif %}</h6>
</div>
<p class="mb-1 small"><strong>{{ nozzle.nozzle_type }}</strong>{% if nozzle.diameter %} &middot; {{ nozzle.diameter }}mm{% endif %}</p>
<p class="mb-0 small text-body-secondary">No induction chip data</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Date/Time Filter Controls --> <!-- Date/Time Filter Controls -->
{% if not is_basic_user %} {% if not is_basic_user %}
<div class="row mb-4"> <div class="row mb-4">
@@ -467,4 +556,27 @@
}); });
</script> </script>
{% endif %} {% endif %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const pillsContainer = document.getElementById('amsFilterPills');
if (!pillsContainer) return;
const items = document.querySelectorAll('.ams-groups .ams-group');
pillsContainer.addEventListener('click', function(e) {
const pill = e.target.closest('.ams-filter-pill');
if (!pill) return;
pillsContainer.querySelectorAll('.ams-filter-pill').forEach(function(p) {
p.classList.remove('active');
});
pill.classList.add('active');
const filter = pill.dataset.amsFilter;
items.forEach(function(item) {
const show = filter === 'all' || item.dataset.amsUnitId === filter;
item.classList.toggle('d-none', !show);
});
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -11,7 +11,7 @@ import json
import zoneinfo import zoneinfo
from .conf import app_settings from .conf import app_settings
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
_METRICS_API_FIELDS = [ _METRICS_API_FIELDS = [
@@ -148,6 +148,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
'brand': snapshot.sub_type or 'Unknown', 'brand': snapshot.sub_type or 'Unknown',
'color': snapshot.color or 'FFFFFFFF', 'color': snapshot.color or 'FFFFFFFF',
'remain_percent': snapshot.remain_percent or 0, 'remain_percent': snapshot.remain_percent or 0,
'ams_unit_id': snapshot.ams_unit_id,
'ams_type': snapshot.ams_type or '',
} }
if snapshot.filament: if snapshot.filament:
filament_dict['color_name'] = snapshot.filament.color filament_dict['color_name'] = snapshot.filament.color
@@ -157,6 +159,37 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
except Exception: except Exception:
filaments_list = [] 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" subtask_name = latest_metric.subtask_name or "No active print"
# Look up active PrintJob for a better display name (cloud design_title) # Look up active PrintJob for a better display name (cloud design_title)
job_display_name = subtask_name job_display_name = subtask_name
@@ -196,6 +229,21 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None, "ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
"ams_humidity": latest_metric.ams_humidity, "ams_humidity": latest_metric.ams_humidity,
"filaments": filaments_list, "filaments": filaments_list,
"ams_units": ams_units_list,
"ams_groups": ams_groups,
"hotends": list(
Hotend.objects.filter(printer=printer_device)
.order_by('-is_toolhead', 'slot_number', 'serial_number')
),
# Nozzle positions with no induction chip (no stable serial number to
# key a Hotend registry row on, e.g. H2C's fixed left nozzle) — shown
# read-only from the latest poll, not persisted/historical. Entries with
# no readable type/diameter at all (i.e. genuinely nothing there) are
# dropped rather than shown as an empty placeholder.
"nozzle_positions": [
h for h in (latest_metric.nozzle_info or [])
if h.get('is_empty') and (h.get('nozzle_type') or h.get('diameter'))
],
"external_spool": latest_metric.external_spool or {}, "external_spool": latest_metric.external_spool or {},
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"), "timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
} }
@@ -278,15 +326,19 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
for snapshot in snapshots: for snapshot in snapshots:
tray_id = snapshot.tray_id 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_type = snapshot.type or 'Unknown'
fil_sub_type = snapshot.sub_type or 'Unknown' fil_sub_type = snapshot.sub_type or 'Unknown'
fil_color = snapshot.color or 'FFFFFFFF' 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: if unique_key not in filament_data:
filament_data[unique_key] = { filament_data[unique_key] = {
'tray_id': tray_id, 'tray_id': tray_id,
'ams_unit_id': ams_unit_id,
'ams_type': ams_type,
'type': fil_type, 'type': fil_type,
'brand': fil_sub_type, 'brand': fil_sub_type,
'color': fil_color, 'color': fil_color,

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1,121 @@
import pytest
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
from bambu_run.models import Hotend, HotendSnapshot, 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 hotends_snapshot(used_time=11472, wear=100.0):
return {
"gcode_state": "IDLE",
"hotends": [
{
"raw_id": 21, "serial_number": "20D06A5B2918952", "nozzle_type": "HS01",
"diameter": 0.4, "fila_id": "GFA01", "color": "FFFFFF",
"used_time_seconds": used_time, "wear_percent": wear, "stat": 0,
"is_toolhead": False, "is_empty": False, "slot_number": 6,
},
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01",
"diameter": 0.4, "fila_id": "", "color": None,
"used_time_seconds": 0, "wear_percent": 0.0, "stat": 0,
"is_toolhead": False, "is_empty": True, "slot_number": None,
},
{
"raw_id": 0, "serial_number": "20D06A5C0426280", "nozzle_type": "HS01",
"diameter": 0.4, "fila_id": "GFA00", "color": "FEC600",
"used_time_seconds": 93490, "wear_percent": 100.0, "stat": 0,
"is_toolhead": True, "is_empty": False, "slot_number": None,
},
],
}
@pytest.mark.django_db
def test_first_poll_creates_one_hotend_per_non_empty_entry():
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
hotends = Hotend.objects.filter(printer=session.printer)
assert hotends.count() == 2 # empty bay (sn="N/A") skipped
rack = hotends.get(serial_number="20D06A5B2918952")
assert rack.raw_id == 21
assert rack.slot_number == 6
assert rack.is_toolhead is False
assert rack.used_time_seconds == 11472
assert rack.wear_percent == 100.0
assert rack.nozzle_type == "HS01"
assert rack.last_filament_profile_id == "GFA01"
assert rack.last_color == "FFFFFF"
toolhead = hotends.get(serial_number="20D06A5C0426280")
assert toolhead.is_toolhead is True
assert toolhead.slot_number is None
@pytest.mark.django_db
def test_first_poll_creates_one_snapshot_per_non_empty_hotend():
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
metric = PrinterMetrics.objects.get(device=session.printer)
assert HotendSnapshot.objects.filter(printer_metric=metric).count() == 2
@pytest.mark.django_db
def test_collector_persists_raw_nozzle_info_including_non_inductive_entries():
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
metric = PrinterMetrics.objects.get(device=session.printer)
assert len(metric.nozzle_info) == 3 # all entries, including the empty/non-inductive one
serials = {h["serial_number"] for h in metric.nozzle_info}
assert serials == {"20D06A5B2918952", "N/A", "20D06A5C0426280"}
@pytest.mark.django_db
def test_second_poll_updates_existing_hotend_instead_of_duplicating():
session = make_session(
"SERIAL-A", "Printer A",
[hotends_snapshot(used_time=11472, wear=100.0), hotends_snapshot(used_time=11500, wear=100.0)],
)
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
cmd._collect_printer_data(session)
hotends = Hotend.objects.filter(printer=session.printer, serial_number="20D06A5B2918952")
assert hotends.count() == 1
assert hotends.first().used_time_seconds == 11500
snapshots = HotendSnapshot.objects.filter(hotend=hotends.first())
assert snapshots.count() == 2

View File

@@ -0,0 +1,128 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from bambu_run.models import Printer, PrinterMetrics, Hotend
@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_context_includes_hotends_toolhead_first(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
Hotend.objects.create(
printer=printer, serial_number="RACK-SN", raw_id=16, slot_number=1,
is_toolhead=False, nozzle_type="HS01", used_time_seconds=3600, wear_percent=50,
)
Hotend.objects.create(
printer=printer, serial_number="TOOLHEAD-SN", raw_id=0, slot_number=None,
is_toolhead=True, nozzle_type="HS01", used_time_seconds=7200, wear_percent=80,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
hotends = resp.context["stats"]["hotends"]
assert len(hotends) == 2
assert hotends[0].serial_number == "TOOLHEAD-SN"
assert hotends[1].serial_number == "RACK-SN"
@pytest.mark.django_db
def test_dashboard_context_includes_non_inductive_nozzle_positions(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
nozzle_info=[
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01", "diameter": 0.4,
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
},
],
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
positions = resp.context["stats"]["nozzle_positions"]
assert len(positions) == 1
assert positions[0]["nozzle_type"] == "HS01"
@pytest.mark.django_db
def test_dashboard_omits_nozzle_positions_with_no_readable_data(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
nozzle_info=[
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "", "diameter": 0,
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
},
],
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
assert resp.context["stats"]["nozzle_positions"] == []
assert "<h5>Hotends</h5>" not in resp.content.decode()
@pytest.mark.django_db
def test_dashboard_renders_nozzle_position_without_serial_or_wear(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
nozzle_info=[
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01", "diameter": 0.4,
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
},
],
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
html = resp.content.decode()
assert "Hotends" in html
assert "HS01" in html
assert "SN: N/A" not in html
assert "SN N/A" not in html
@pytest.mark.django_db
def test_dashboard_renders_hotends_card(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
Hotend.objects.create(
printer=printer, serial_number="RACK-SN", raw_id=18, slot_number=3,
is_toolhead=False, nozzle_type="HS01", diameter=0.4,
used_time_seconds=3661, wear_percent=50, last_filament_profile_id="GFA01",
last_color="DE4343",
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
html = resp.content.decode()
assert "Hotends" in html
assert "RACK-SN" in html
assert "Slot 3" in html

View File

@@ -0,0 +1,100 @@
from bambu_run.mqtt_client import PrinterState
def real_nozzle_payload():
"""Real captured device.nozzle payload from a live H2C with a Vortek rack
(1x AMS, 1x AMS 2 Pro, 1x AMS HT physically connected — unrelated here).
SN/used-time cross-checked against the user's Bambu Studio Hotends Info table."""
return {
"exist": 3997699,
"src_id": 17,
"tar_id": 17,
"state": 0,
"info": [
{"id": 21, "sn": "20D06A5B2918952", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "FFFFFFFF", "p_t": 11472, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 1, "sn": "N/A", "type": "HS01", "diameter": 0.4,
"fila_id": "", "color_m": "00000000", "p_t": 0, "wear": 0.0, "stat": 0, "tm": 0},
{"id": 16, "sn": "20D06A5B2919219", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "A3D8E1FF", "p_t": 105386, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 20, "sn": "20D06A590610257", "type": "HS01", "diameter": 0.4,
"fila_id": "GFG01", "color_m": "00000000", "p_t": 81506, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 18, "sn": "20D06A591506263", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "DE4343FF", "p_t": 30962, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 0, "sn": "20D06A5C0426280", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA00", "color_m": "FEC600FF", "p_t": 93490, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 19, "sn": "20D06A5C0207881", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "DE4343FF", "p_t": 1430, "wear": 128.0, "stat": 0, "tm": 350},
],
}
def make_data(nozzle_payload):
return {"print": {"gcode_state": "IDLE", "device": {"nozzle": nozzle_payload}}}
def test_snapshot_includes_one_hotend_per_nozzle_info_entry():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
assert len(snapshot["hotends"]) == 7
def test_hotend_fields_extracted_correctly():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
h = by_sn["20D06A5B2919219"]
assert h["raw_id"] == 16
assert h["nozzle_type"] == "HS01"
assert h["diameter"] == 0.4
assert h["fila_id"] == "GFA01"
assert h["color"] == "A3D8E1" # alpha stripped
assert h["used_time_seconds"] == 105386
assert h["wear_percent"] == 100.0 # 128/128*100
assert h["is_empty"] is False
def test_id_zero_is_toolhead_and_resolves_slot_number():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
toolhead = by_sn["20D06A5C0426280"]
assert toolhead["raw_id"] == 0
assert toolhead["is_toolhead"] is True
assert toolhead["slot_number"] is None # true bay address hidden while id==0 sentinel
def test_rack_bay_ids_resolve_to_slot_numbers_one_through_six():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
assert by_sn["20D06A5B2919219"]["slot_number"] == 1 # raw_id 16
assert by_sn["20D06A591506263"]["slot_number"] == 3 # raw_id 18
assert by_sn["20D06A5C0207881"]["slot_number"] == 4 # raw_id 19
assert by_sn["20D06A590610257"]["slot_number"] == 5 # raw_id 20
assert by_sn["20D06A5B2918952"]["slot_number"] == 6 # raw_id 21
def test_empty_bay_with_na_serial_is_flagged_empty():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
empty = by_sn["N/A"]
assert empty["is_empty"] is True
assert empty["is_toolhead"] is False
def test_snapshot_hotends_empty_list_when_no_nozzle_payload():
state = PrinterState.from_mqtt_data({"print": {"gcode_state": "IDLE"}})
snapshot = state.get_snapshot()
assert snapshot["hotends"] == []

View File

@@ -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