diff --git a/bambu_run/admin.py b/bambu_run/admin.py index 03d7989..4873f13 100644 --- a/bambu_run/admin.py +++ b/bambu_run/admin.py @@ -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') diff --git a/bambu_run/management/commands/bambu_collector.py b/bambu_run/management/commands/bambu_collector.py index bdec54a..ce1bcdd 100644 --- a/bambu_run/management/commands/bambu_collector.py +++ b/bambu_run/management/commands/bambu_collector.py @@ -571,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 @@ -757,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 diff --git a/bambu_run/migrations/0007_hotend_hotendsnapshot.py b/bambu_run/migrations/0007_hotend_hotendsnapshot.py new file mode 100644 index 0000000..adf7a75 --- /dev/null +++ b/bambu_run/migrations/0007_hotend_hotendsnapshot.py @@ -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", + ), + ], + }, + ), + ] diff --git a/bambu_run/migrations/0008_printermetrics_nozzle_info.py b/bambu_run/migrations/0008_printermetrics_nozzle_info.py new file mode 100644 index 0000000..4880437 --- /dev/null +++ b/bambu_run/migrations/0008_printermetrics_nozzle_info.py @@ -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", + ), + ), + ] diff --git a/bambu_run/models.py b/bambu_run/models.py index fb27df4..85dbff3 100644 --- a/bambu_run/models.py +++ b/bambu_run/models.py @@ -242,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" @@ -740,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')}" diff --git a/bambu_run/mqtt_client.py b/bambu_run/mqtt_client.py index 0ddc815..e8474d3 100644 --- a/bambu_run/mqtt_client.py +++ b/bambu_run/mqtt_client.py @@ -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, diff --git a/bambu_run/templates/bambu_run/printer_dashboard.html b/bambu_run/templates/bambu_run/printer_dashboard.html index 0040219..a171f74 100644 --- a/bambu_run/templates/bambu_run/printer_dashboard.html +++ b/bambu_run/templates/bambu_run/printer_dashboard.html @@ -282,6 +282,64 @@ + + {% if stats.hotends or stats.nozzle_positions %} +
SN {{ hotend.serial_number }}
+{{ hotend.nozzle_type }}{% if hotend.diameter %} · {{ hotend.diameter }}mm{% endif %}
+ {% if hotend.last_filament_profile_id %}Last: {{ hotend.last_filament_profile_id }}
{% endif %} +{{ nozzle.nozzle_type }}{% if nozzle.diameter %} · {{ nozzle.diameter }}mm{% endif %}
+No induction chip data
+