2 Commits

17 changed files with 1526 additions and 66 deletions

View File

@@ -1,5 +1,5 @@
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)
@@ -107,6 +107,21 @@ class FilamentUsageAdmin(admin.ModelAdmin):
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)
class BambuCloudTaskAdmin(admin.ModelAdmin):
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,
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'),
@@ -569,6 +571,38 @@ class Command(BaseCommand):
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):
from bambu_run.models import PrintJob
@@ -641,31 +675,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}%"
)
@@ -746,12 +789,17 @@ class Command(BaseCommand):
external_spool=snapshot.get("external_spool", {}),
lights_report=snapshot.get("lights_report", []),
vortek_raw=snapshot.get("vortek_raw", {}),
nozzle_info=snapshot.get("hotends", []),
)
filaments_data = snapshot.get('filaments', [])
if filaments_data:
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)
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:
"""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):
@@ -239,6 +242,15 @@ class PrinterMetrics(models.Model):
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:
db_table = "infrastructure_printer_metrics"
verbose_name = "Printer Metric"
@@ -498,6 +510,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 +566,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 +708,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(
@@ -723,3 +749,108 @@ class FilamentUsage(models.Model):
self.consumed_grams = int(
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
@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
class PrinterState:
"""Complete printer state parsed from MQTT data"""
@@ -388,6 +455,9 @@ class PrinterState:
# External spool (virtual tray)
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: Dict[str, Any] = field(default_factory=dict, repr=False)
@@ -431,6 +501,12 @@ class PrinterState:
nozzle_target_temp_left = float((t >> 16) & 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(
timestamp=timestamp,
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", "")),
lifecycle=print_data.get("lifecycle", ""),
vt_tray=print_data.get("vt_tray"),
hotends=hotends,
_raw_data=data,
)
@@ -533,6 +610,7 @@ class PrinterState:
# 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.
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
"hotends": [h.to_dict() for h in self.hotends],
"hms": self.hms,
"stg_cur": self.stg_cur,
"lights_report": self.lights_report,

View File

@@ -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);
}

View File

@@ -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}`;

View File

@@ -37,32 +37,38 @@
<!-- Summary Cards Row -->
<div class="row g-3 mb-4">
{% 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="card infra-card-warning">
<div class="card infra-card-warning h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Right Nozzle</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 %}· {{ stats.nozzle_type }}{% endif %}</div>
<div class="stat-label d-flex align-items-center gap-1">
<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>
Left Nozzle
</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>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div>
<!-- Left Nozzle -->
<!-- Right Nozzle -->
<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="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Left Nozzle</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 %}· {{ stats.nozzle_type_left }}{% endif %}</div>
<div class="stat-label d-flex align-items-center gap-1">
Right Nozzle
<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>
</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>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div>
@@ -72,7 +78,7 @@
{% else %}
<!-- Nozzle Temperature Card (single-nozzle printers) -->
<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="d-flex justify-content-between align-items-start">
<div>
@@ -88,7 +94,7 @@
<!-- Bed Temperature Card -->
<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="d-flex justify-content-between align-items-start">
<div>
@@ -103,7 +109,7 @@
<!-- Print Progress Card -->
<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="d-flex justify-content-between align-items-start">
<div>
@@ -118,7 +124,7 @@
<!-- Chamber Light Card -->
<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="d-flex justify-content-between align-items-start">
<div>
@@ -198,34 +204,59 @@
</div>
<div class="card-body">
{% if stats.filaments %}
<div class="row g-3">
{% for filament in stats.filaments %}
<div class="col-12 col-md-6 col-lg-3">
<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="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
{% if filament.filament_pk %}
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
</a>
{% endif %}
</div>
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small">Remaining</span>
<span class="badge filament-badge">{{ filament.remain_percent }}%</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: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
{% 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">
{% for filament in group.filaments %}
<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-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
{% if filament.filament_pk %}
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
</a>
{% endif %}
</div>
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small">Remaining</span>
<span class="badge filament-badge">{{ filament.remain_percent }}%</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: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>
{% 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="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
<div class="card-body">
@@ -241,8 +272,8 @@
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-body-secondary">No filament data available</p>
{% endif %}
@@ -251,6 +282,64 @@
</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 -->
{% if not is_basic_user %}
<div class="row mb-4">
@@ -467,4 +556,27 @@
});
</script>
{% 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 %}

View File

@@ -11,7 +11,7 @@ import json
import zoneinfo
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
_METRICS_API_FIELDS = [
@@ -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,21 @@ 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,
"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 {},
"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:
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,

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