mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
Add multi-AMS support: per-unit snapshot/usage tracking, grouped dashboard panels with real type labels, and dual-nozzle card UX fixes. Fixes a real-world AMS info-code parsing bug found by inspecting live H2C data.
This commit is contained in:
@@ -554,6 +554,8 @@ class Command(BaseCommand):
|
||||
printer_metric=printer_metric,
|
||||
filament=filament,
|
||||
tray_id=tray_id,
|
||||
ams_unit_id=unit_id_int,
|
||||
ams_type=tray_data.get('ams_type', '') or '',
|
||||
slot_name=tray_data.get('slot'),
|
||||
type=tray_data.get('type'),
|
||||
sub_type=tray_data.get('sub_type'),
|
||||
@@ -641,31 +643,40 @@ class Command(BaseCommand):
|
||||
elif not session.trays_used:
|
||||
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
|
||||
else:
|
||||
# A bare tray_id (from `tray_now`) doesn't identify which physical AMS
|
||||
# unit was active when multiple units share the same slot numbering —
|
||||
# so create one usage row per (unit, tray) that had a tracked filament
|
||||
# loaded at job start, rather than guessing a single "correct" unit.
|
||||
created_usages = []
|
||||
for tray_id in session.trays_used:
|
||||
start_snap = start_metric.filament_snapshots.filter(
|
||||
start_snaps = start_metric.filament_snapshots.filter(
|
||||
tray_id=tray_id, filament__isnull=False
|
||||
).first()
|
||||
if not start_snap:
|
||||
continue
|
||||
|
||||
end_snap = metric.filament_snapshots.filter(
|
||||
filament=start_snap.filament, tray_id=tray_id
|
||||
).first()
|
||||
|
||||
usage = FilamentUsage.objects.create(
|
||||
print_job=job,
|
||||
filament=start_snap.filament,
|
||||
tray_id=tray_id,
|
||||
starting_percent=start_snap.remain_percent or 100,
|
||||
ending_percent=end_snap.remain_percent if end_snap else None,
|
||||
is_primary=(len(session.trays_used) == 1),
|
||||
)
|
||||
usage.calculate_consumed()
|
||||
for start_snap in start_snaps:
|
||||
end_snap = metric.filament_snapshots.filter(
|
||||
filament=start_snap.filament,
|
||||
tray_id=tray_id,
|
||||
ams_unit_id=start_snap.ams_unit_id,
|
||||
).first()
|
||||
|
||||
usage = FilamentUsage.objects.create(
|
||||
print_job=job,
|
||||
filament=start_snap.filament,
|
||||
tray_id=tray_id,
|
||||
ams_unit_id=start_snap.ams_unit_id,
|
||||
starting_percent=start_snap.remain_percent or 100,
|
||||
ending_percent=end_snap.remain_percent if end_snap else None,
|
||||
)
|
||||
usage.calculate_consumed()
|
||||
created_usages.append(usage)
|
||||
|
||||
for usage in created_usages:
|
||||
usage.is_primary = len(created_usages) == 1
|
||||
usage.save()
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(
|
||||
f"Filament usage for {start_snap.filament} (tray {tray_id}): "
|
||||
f"Filament usage for {usage.filament} (unit {usage.ams_unit_id}, tray {usage.tray_id}): "
|
||||
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -21,12 +21,15 @@ AMS_TYPE_CHOICES = [
|
||||
def ams_type_from_info(info_code) -> str:
|
||||
"""Resolve an AMS unit's `info` model code to a human label.
|
||||
|
||||
The HT unit reports its `id` with the 0x80 bit set (e.g. 128) — when the info
|
||||
code is unknown, that bit is a reasonable secondary hint for HT identification.
|
||||
Real MQTT `info` codes are 8 characters (e.g. "10001003") with the type encoded
|
||||
in the last 4 digits — confirmed against a live H2C with AMS 2 Pro / AMS / AMS HT.
|
||||
Fall back to an exact match for the bare 4-digit form in case other firmware
|
||||
reports it short.
|
||||
"""
|
||||
if info_code is None:
|
||||
if not info_code:
|
||||
return ""
|
||||
return AMS_INFO_TO_TYPE.get(str(info_code), "")
|
||||
code = str(info_code)
|
||||
return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "")
|
||||
|
||||
|
||||
class Printer(models.Model):
|
||||
@@ -498,6 +501,15 @@ class FilamentSnapshot(models.Model):
|
||||
max_length=20, null=True, blank=True,
|
||||
help_text="Slot identifier like A00-W1"
|
||||
)
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
ams_type = models.CharField(
|
||||
max_length=32, blank=True, default="",
|
||||
choices=AMS_TYPE_CHOICES,
|
||||
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)"
|
||||
)
|
||||
|
||||
type = models.CharField(max_length=50, null=True, blank=True)
|
||||
sub_type = models.CharField(
|
||||
@@ -545,9 +557,10 @@ class FilamentSnapshot(models.Model):
|
||||
db_table = "infrastructure_filament_snapshot"
|
||||
verbose_name = "Filament Snapshot"
|
||||
verbose_name_plural = "Filament Snapshots"
|
||||
ordering = ['printer_metric', 'tray_id']
|
||||
ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
|
||||
indexes = [
|
||||
models.Index(fields=['printer_metric', 'tray_id']),
|
||||
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
|
||||
models.Index(fields=['filament']),
|
||||
]
|
||||
|
||||
@@ -686,6 +699,10 @@ class FilamentUsage(models.Model):
|
||||
)
|
||||
|
||||
tray_id = models.IntegerField(help_text="Which AMS slot was used")
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
|
||||
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
|
||||
ending_percent = models.IntegerField(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 }}°C</div>
|
||||
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°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 }}°C</div>
|
||||
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°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 }}°C</div>
|
||||
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°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 }}°C</div>
|
||||
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°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 %}· {{ group.temp }}°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 %}
|
||||
@@ -467,4 +498,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 %}
|
||||
|
||||
@@ -148,6 +148,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
'brand': snapshot.sub_type or 'Unknown',
|
||||
'color': snapshot.color or 'FFFFFFFF',
|
||||
'remain_percent': snapshot.remain_percent or 0,
|
||||
'ams_unit_id': snapshot.ams_unit_id,
|
||||
'ams_type': snapshot.ams_type or '',
|
||||
}
|
||||
if snapshot.filament:
|
||||
filament_dict['color_name'] = snapshot.filament.color
|
||||
@@ -157,6 +159,37 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
except Exception:
|
||||
filaments_list = []
|
||||
|
||||
# Distinct AMS units represented in this snapshot, for the unit
|
||||
# filter/badges in the template. Sort numeric unit ids first
|
||||
# (AMS / AMS 2 Pro), HT (id 128 / bit 0x80 set) last.
|
||||
seen_units = {}
|
||||
for f in filaments_list:
|
||||
uid = f.get('ams_unit_id')
|
||||
if uid is not None and uid not in seen_units:
|
||||
seen_units[uid] = f.get('ams_type') or ''
|
||||
ams_units_list = [
|
||||
{'ams_unit_id': uid, 'ams_type': label}
|
||||
for uid, label in sorted(seen_units.items())
|
||||
]
|
||||
|
||||
# Group trays by physical AMS unit for the panel-style dashboard layout —
|
||||
# one tinted panel per unit, full-width for multi-slot units (AMS/AMS 2 Pro),
|
||||
# compact for single-slot units (AMS HT) so several can flow side-by-side.
|
||||
units_meta = {
|
||||
u.get('unit_id'): u for u in (latest_metric.ams_units or [])
|
||||
}
|
||||
ams_groups = []
|
||||
for uid, label in sorted(seen_units.items()):
|
||||
unit_meta = units_meta.get(str(uid), {})
|
||||
ams_groups.append({
|
||||
'unit_id': uid,
|
||||
'ams_type': label,
|
||||
'label': f"{label or 'AMS'} (Unit {uid})",
|
||||
'humidity': unit_meta.get('humidity'),
|
||||
'temp': unit_meta.get('temp'),
|
||||
'filaments': [f for f in filaments_list if f.get('ams_unit_id') == uid],
|
||||
})
|
||||
|
||||
subtask_name = latest_metric.subtask_name or "No active print"
|
||||
# Look up active PrintJob for a better display name (cloud design_title)
|
||||
job_display_name = subtask_name
|
||||
@@ -196,6 +229,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||
"ams_humidity": latest_metric.ams_humidity,
|
||||
"filaments": filaments_list,
|
||||
"ams_units": ams_units_list,
|
||||
"ams_groups": ams_groups,
|
||||
"external_spool": latest_metric.external_spool or {},
|
||||
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
@@ -278,15 +313,19 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
for snapshot in snapshots:
|
||||
tray_id = snapshot.tray_id
|
||||
ams_unit_id = snapshot.ams_unit_id
|
||||
ams_type = snapshot.ams_type or ''
|
||||
fil_type = snapshot.type or 'Unknown'
|
||||
fil_sub_type = snapshot.sub_type or 'Unknown'
|
||||
fil_color = snapshot.color or 'FFFFFFFF'
|
||||
|
||||
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||
unique_key = f"{ams_unit_id}_{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||
|
||||
if unique_key not in filament_data:
|
||||
filament_data[unique_key] = {
|
||||
'tray_id': tray_id,
|
||||
'ams_unit_id': ams_unit_id,
|
||||
'ams_type': ams_type,
|
||||
'type': fil_type,
|
||||
'brand': fil_sub_type,
|
||||
'color': fil_color,
|
||||
|
||||
25
tests/test_ams_type_from_info.py
Normal file
25
tests/test_ams_type_from_info.py
Normal 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
|
||||
180
tests/test_filament_context.py
Normal file
180
tests/test_filament_context.py
Normal 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()
|
||||
104
tests/test_multi_ams_collection.py
Normal file
104
tests/test_multi_ams_collection.py
Normal 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
|
||||
Reference in New Issue
Block a user