mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
Add Vortek hotend rack tracking: per-SN registry with slot mapping confirmed against live MQTT capture, plus a fallback for non-inductive nozzles (e.g. H2C's fixed left nozzle) shown read-only without fabricated identity. New dashboard card hides entirely on printers with no Vortek/nozzle-info data at all.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask
|
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask, Hotend, HotendSnapshot
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Printer)
|
@admin.register(Printer)
|
||||||
@@ -107,6 +107,21 @@ class FilamentUsageAdmin(admin.ModelAdmin):
|
|||||||
readonly_fields = ('consumed_percent', 'consumed_grams')
|
readonly_fields = ('consumed_percent', 'consumed_grams')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Hotend)
|
||||||
|
class HotendAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('printer', 'serial_number', 'nozzle_type', 'is_toolhead', 'slot_number', 'used_time_seconds', 'wear_percent', 'last_seen_at')
|
||||||
|
list_filter = ('printer', 'is_toolhead', 'nozzle_type')
|
||||||
|
search_fields = ('serial_number',)
|
||||||
|
readonly_fields = ('last_seen_at', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(HotendSnapshot)
|
||||||
|
class HotendSnapshotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'timestamp')
|
||||||
|
list_filter = ('hotend',)
|
||||||
|
readonly_fields = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'stat', 'timestamp')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BambuCloudTask)
|
@admin.register(BambuCloudTask)
|
||||||
class BambuCloudTaskAdmin(admin.ModelAdmin):
|
class BambuCloudTaskAdmin(admin.ModelAdmin):
|
||||||
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
|
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
|
||||||
|
|||||||
@@ -571,6 +571,38 @@ class Command(BaseCommand):
|
|||||||
match_method=match_method
|
match_method=match_method
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _update_hotends(self, printer, printer_metric, hotends_data):
|
||||||
|
from bambu_run.models import Hotend, HotendSnapshot
|
||||||
|
|
||||||
|
for h in hotends_data:
|
||||||
|
if h.get("is_empty"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
hotend, _ = Hotend.objects.update_or_create(
|
||||||
|
printer=printer,
|
||||||
|
serial_number=h.get("serial_number"),
|
||||||
|
defaults={
|
||||||
|
"raw_id": h.get("raw_id", 0),
|
||||||
|
"nozzle_type": h.get("nozzle_type", ""),
|
||||||
|
"diameter": self._to_decimal(h.get("diameter")),
|
||||||
|
"slot_number": h.get("slot_number"),
|
||||||
|
"is_toolhead": bool(h.get("is_toolhead")),
|
||||||
|
"last_filament_profile_id": h.get("fila_id", ""),
|
||||||
|
"last_color": h.get("color") or "",
|
||||||
|
"used_time_seconds": h.get("used_time_seconds", 0),
|
||||||
|
"wear_percent": h.get("wear_percent", 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
HotendSnapshot.objects.create(
|
||||||
|
printer_metric=printer_metric,
|
||||||
|
hotend=hotend,
|
||||||
|
raw_id=h.get("raw_id", 0),
|
||||||
|
used_time_seconds=h.get("used_time_seconds", 0),
|
||||||
|
wear_percent=h.get("wear_percent", 0),
|
||||||
|
stat=h.get("stat"),
|
||||||
|
)
|
||||||
|
|
||||||
def _track_print_job(self, session, metric, snapshot):
|
def _track_print_job(self, session, metric, snapshot):
|
||||||
from bambu_run.models import PrintJob
|
from bambu_run.models import PrintJob
|
||||||
|
|
||||||
@@ -757,12 +789,17 @@ class Command(BaseCommand):
|
|||||||
external_spool=snapshot.get("external_spool", {}),
|
external_spool=snapshot.get("external_spool", {}),
|
||||||
lights_report=snapshot.get("lights_report", []),
|
lights_report=snapshot.get("lights_report", []),
|
||||||
vortek_raw=snapshot.get("vortek_raw", {}),
|
vortek_raw=snapshot.get("vortek_raw", {}),
|
||||||
|
nozzle_info=snapshot.get("hotends", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
filaments_data = snapshot.get('filaments', [])
|
filaments_data = snapshot.get('filaments', [])
|
||||||
if filaments_data:
|
if filaments_data:
|
||||||
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
||||||
|
|
||||||
|
hotends_data = snapshot.get('hotends', [])
|
||||||
|
if hotends_data:
|
||||||
|
self._update_hotends(session.printer, metric, hotends_data)
|
||||||
|
|
||||||
self._track_print_job(session, metric, snapshot)
|
self._track_print_job(session, metric, snapshot)
|
||||||
|
|
||||||
session.success_count += 1
|
session.success_count += 1
|
||||||
|
|||||||
172
bambu_run/migrations/0007_hotend_hotendsnapshot.py
Normal file
172
bambu_run/migrations/0007_hotend_hotendsnapshot.py
Normal 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",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
22
bambu_run/migrations/0008_printermetrics_nozzle_info.py
Normal file
22
bambu_run/migrations/0008_printermetrics_nozzle_info.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -242,6 +242,15 @@ class PrinterMetrics(models.Model):
|
|||||||
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
|
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parsed device.nozzle.info[] from this poll, one dict per entry (mirrors
|
||||||
|
# HotendInfo.to_dict()). Includes induction-chip hotends *and* non-inductive
|
||||||
|
# nozzle positions (e.g. H2C's fixed left nozzle) that have no stable serial
|
||||||
|
# number to key a Hotend registry row on — kept here so the dashboard can show
|
||||||
|
# their readable type/diameter without claiming an identity/history we don't have.
|
||||||
|
nozzle_info = models.JSONField(
|
||||||
|
default=list, blank=True, help_text="Parsed per-poll nozzle/hotend info list"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "infrastructure_printer_metrics"
|
db_table = "infrastructure_printer_metrics"
|
||||||
verbose_name = "Printer Metric"
|
verbose_name = "Printer Metric"
|
||||||
@@ -740,3 +749,108 @@ class FilamentUsage(models.Model):
|
|||||||
self.consumed_grams = int(
|
self.consumed_grams = int(
|
||||||
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
|
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Hotend(models.Model):
|
||||||
|
"""Registry of individual Vortek hotends, keyed by serial number.
|
||||||
|
|
||||||
|
A Vortek rack holds up to 6 swappable hotends (bays, MQTT `id` 16-21) plus
|
||||||
|
1 mounted on the toolhead at a time (MQTT `id` 0). `raw_id` reflects whichever
|
||||||
|
address was last seen on the wire for this hotend; `slot_number` is only set
|
||||||
|
when that address falls in the 16-21 rack-bay range — confirmed by watching
|
||||||
|
a "Read All" MQTT capture reassign a toolhead-mounted hotend's id from 0 to
|
||||||
|
its true bay id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
printer = models.ForeignKey(
|
||||||
|
'Printer', on_delete=models.CASCADE, related_name='hotends'
|
||||||
|
)
|
||||||
|
serial_number = models.CharField(max_length=100, db_index=True)
|
||||||
|
|
||||||
|
nozzle_type = models.CharField(max_length=50, blank=True, default="")
|
||||||
|
diameter = models.DecimalField(
|
||||||
|
max_digits=3, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_id = models.PositiveSmallIntegerField(
|
||||||
|
help_text="Last-seen MQTT device.nozzle.info[].id"
|
||||||
|
)
|
||||||
|
slot_number = models.PositiveSmallIntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel)."
|
||||||
|
)
|
||||||
|
is_toolhead = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0)."
|
||||||
|
)
|
||||||
|
|
||||||
|
last_filament_profile_id = models.CharField(
|
||||||
|
max_length=20, blank=True, default="",
|
||||||
|
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')"
|
||||||
|
)
|
||||||
|
last_color = models.CharField(
|
||||||
|
max_length=6, blank=True, default="",
|
||||||
|
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)"
|
||||||
|
)
|
||||||
|
|
||||||
|
used_time_seconds = models.PositiveIntegerField(default=0)
|
||||||
|
wear_percent = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, default=0,
|
||||||
|
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_seen_at = models.DateTimeField(auto_now=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_hotend"
|
||||||
|
verbose_name = "Hotend"
|
||||||
|
verbose_name_plural = "Hotends"
|
||||||
|
ordering = ['printer', '-is_toolhead', 'slot_number', 'serial_number']
|
||||||
|
unique_together = [['printer', 'serial_number']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
location = "Toolhead" if self.is_toolhead else (
|
||||||
|
f"Slot {self.slot_number}" if self.slot_number else "Rack"
|
||||||
|
)
|
||||||
|
return f"{self.serial_number} ({location})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def used_time_display(self) -> str:
|
||||||
|
hours, remainder = divmod(self.used_time_seconds, 3600)
|
||||||
|
minutes = remainder // 60
|
||||||
|
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
class HotendSnapshot(models.Model):
|
||||||
|
"""Point-in-time reading of a Hotend, one row per collector poll."""
|
||||||
|
|
||||||
|
printer_metric = models.ForeignKey(
|
||||||
|
'PrinterMetrics', on_delete=models.CASCADE,
|
||||||
|
related_name='hotend_snapshots'
|
||||||
|
)
|
||||||
|
hotend = models.ForeignKey(
|
||||||
|
'Hotend', on_delete=models.CASCADE,
|
||||||
|
related_name='snapshots'
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_id = models.PositiveSmallIntegerField()
|
||||||
|
used_time_seconds = models.PositiveIntegerField(default=0)
|
||||||
|
wear_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||||
|
stat = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Raw MQTT status code for this hotend"
|
||||||
|
)
|
||||||
|
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_hotend_snapshot"
|
||||||
|
verbose_name = "Hotend Snapshot"
|
||||||
|
verbose_name_plural = "Hotend Snapshots"
|
||||||
|
ordering = ['printer_metric', 'hotend']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['printer_metric', 'hotend']),
|
||||||
|
models.Index(fields=['hotend', '-timestamp']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.hotend.serial_number} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
|||||||
@@ -296,6 +296,73 @@ class AMSState:
|
|||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HotendInfo:
|
||||||
|
"""A single hotend reported in `device.nozzle.info[]` (Vortek rack).
|
||||||
|
|
||||||
|
`raw_id` semantics (confirmed by watching a live "Read All" MQTT capture):
|
||||||
|
0 = currently mounted on the (swappable) toolhead — the sentinel hides the
|
||||||
|
true bay address until "Read All" resolves it; 1 = the fixed left nozzle on
|
||||||
|
dual-nozzle printers (no RFID chip, always reports sn="N/A"); 16-21 = rack
|
||||||
|
bay address, slot_number = raw_id - 15 (1-6).
|
||||||
|
"""
|
||||||
|
raw_id: int = 0
|
||||||
|
serial_number: str = ""
|
||||||
|
nozzle_type: str = ""
|
||||||
|
diameter: float = 0.4
|
||||||
|
fila_id: str = ""
|
||||||
|
color: Optional[str] = None
|
||||||
|
used_time_seconds: int = 0
|
||||||
|
wear_percent: float = 0.0
|
||||||
|
stat: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "HotendInfo":
|
||||||
|
from .utils import strip_color_padding
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
raw_id=int(data.get("id", 0)),
|
||||||
|
serial_number=data.get("sn", ""),
|
||||||
|
nozzle_type=data.get("type", ""),
|
||||||
|
diameter=float(data.get("diameter", 0.4)),
|
||||||
|
fila_id=data.get("fila_id", ""),
|
||||||
|
color=strip_color_padding(data.get("color_m")),
|
||||||
|
used_time_seconds=int(data.get("p_t", 0)),
|
||||||
|
wear_percent=round(float(data.get("wear", 0.0)) / 128.0 * 100, 2),
|
||||||
|
stat=int(data.get("stat", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_toolhead(self) -> bool:
|
||||||
|
return self.raw_id == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return self.serial_number in ("", "N/A")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slot_number(self) -> Optional[int]:
|
||||||
|
if 16 <= self.raw_id <= 21:
|
||||||
|
return self.raw_id - 15
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"raw_id": self.raw_id,
|
||||||
|
"serial_number": self.serial_number,
|
||||||
|
"nozzle_type": self.nozzle_type,
|
||||||
|
"diameter": self.diameter,
|
||||||
|
"fila_id": self.fila_id,
|
||||||
|
"color": self.color,
|
||||||
|
"used_time_seconds": self.used_time_seconds,
|
||||||
|
"wear_percent": self.wear_percent,
|
||||||
|
"stat": self.stat,
|
||||||
|
"is_toolhead": self.is_toolhead,
|
||||||
|
"is_empty": self.is_empty,
|
||||||
|
"slot_number": self.slot_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PrinterState:
|
class PrinterState:
|
||||||
"""Complete printer state parsed from MQTT data"""
|
"""Complete printer state parsed from MQTT data"""
|
||||||
@@ -388,6 +455,9 @@ class PrinterState:
|
|||||||
# External spool (virtual tray)
|
# External spool (virtual tray)
|
||||||
vt_tray: Optional[Dict[str, Any]] = None
|
vt_tray: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Vortek hotend rack (device.nozzle.info[])
|
||||||
|
hotends: List[HotendInfo] = field(default_factory=list)
|
||||||
|
|
||||||
# Raw data for any additional fields
|
# Raw data for any additional fields
|
||||||
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
@@ -431,6 +501,12 @@ class PrinterState:
|
|||||||
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
|
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
|
||||||
nozzle_temp_left = float(t & 0xFFFF)
|
nozzle_temp_left = float(t & 0xFFFF)
|
||||||
|
|
||||||
|
# Vortek hotend rack: device.nozzle.info[] — one entry per hotend.
|
||||||
|
hotends = [
|
||||||
|
HotendInfo.from_dict(h)
|
||||||
|
for h in (device.get("nozzle") or {}).get("info") or []
|
||||||
|
]
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
sequence_id=str(print_data.get("sequence_id", "")),
|
sequence_id=str(print_data.get("sequence_id", "")),
|
||||||
@@ -487,6 +563,7 @@ class PrinterState:
|
|||||||
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
||||||
lifecycle=print_data.get("lifecycle", ""),
|
lifecycle=print_data.get("lifecycle", ""),
|
||||||
vt_tray=print_data.get("vt_tray"),
|
vt_tray=print_data.get("vt_tray"),
|
||||||
|
hotends=hotends,
|
||||||
_raw_data=data,
|
_raw_data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -533,6 +610,7 @@ class PrinterState:
|
|||||||
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
|
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
|
||||||
# here so no data is lost once the real Vortek MQTT schema is confirmed.
|
# here so no data is lost once the real Vortek MQTT schema is confirmed.
|
||||||
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
|
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
|
||||||
|
"hotends": [h.to_dict() for h in self.hotends],
|
||||||
"hms": self.hms,
|
"hms": self.hms,
|
||||||
"stg_cur": self.stg_cur,
|
"stg_cur": self.stg_cur,
|
||||||
"lights_report": self.lights_report,
|
"lights_report": self.lights_report,
|
||||||
|
|||||||
@@ -282,6 +282,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hotends Section (Vortek rack + any plain/non-inductive nozzles) -->
|
||||||
|
{% if stats.hotends or stats.nozzle_positions %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Hotends</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for hotend in stats.hotends %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card filament-card" data-filament-color="{{ hotend.last_color|default:'888888' }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
{% if hotend.is_toolhead %}Toolhead{% elif hotend.slot_number %}Slot {{ hotend.slot_number }}{% else %}Rack{% endif %}
|
||||||
|
</h6>
|
||||||
|
{% if hotend.is_toolhead %}<span class="badge filament-badge">Toolhead</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 small text-body-secondary">SN {{ hotend.serial_number }}</p>
|
||||||
|
<p class="mb-1 small"><strong>{{ hotend.nozzle_type }}</strong>{% if hotend.diameter %} · {{ 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 %} · {{ nozzle.diameter }}mm{% endif %}</p>
|
||||||
|
<p class="mb-0 small text-body-secondary">No induction chip data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Date/Time Filter Controls -->
|
<!-- Date/Time Filter Controls -->
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import json
|
|||||||
import zoneinfo
|
import zoneinfo
|
||||||
|
|
||||||
from .conf import app_settings
|
from .conf import app_settings
|
||||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
|
||||||
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
||||||
|
|
||||||
_METRICS_API_FIELDS = [
|
_METRICS_API_FIELDS = [
|
||||||
@@ -231,6 +231,19 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
"filaments": filaments_list,
|
"filaments": filaments_list,
|
||||||
"ams_units": ams_units_list,
|
"ams_units": ams_units_list,
|
||||||
"ams_groups": ams_groups,
|
"ams_groups": ams_groups,
|
||||||
|
"hotends": list(
|
||||||
|
Hotend.objects.filter(printer=printer_device)
|
||||||
|
.order_by('-is_toolhead', 'slot_number', 'serial_number')
|
||||||
|
),
|
||||||
|
# Nozzle positions with no induction chip (no stable serial number to
|
||||||
|
# key a Hotend registry row on, e.g. H2C's fixed left nozzle) — shown
|
||||||
|
# read-only from the latest poll, not persisted/historical. Entries with
|
||||||
|
# no readable type/diameter at all (i.e. genuinely nothing there) are
|
||||||
|
# dropped rather than shown as an empty placeholder.
|
||||||
|
"nozzle_positions": [
|
||||||
|
h for h in (latest_metric.nozzle_info or [])
|
||||||
|
if h.get('is_empty') and (h.get('nozzle_type') or h.get('diameter'))
|
||||||
|
],
|
||||||
"external_spool": latest_metric.external_spool or {},
|
"external_spool": latest_metric.external_spool or {},
|
||||||
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
|
|||||||
121
tests/test_hotend_collection.py
Normal file
121
tests/test_hotend_collection.py
Normal 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
|
||||||
128
tests/test_hotend_dashboard.py
Normal file
128
tests/test_hotend_dashboard.py
Normal 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
|
||||||
100
tests/test_hotend_parsing.py
Normal file
100
tests/test_hotend_parsing.py
Normal 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"] == []
|
||||||
Reference in New Issue
Block a user