mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-24 15:00:18 +01:00
Feature/multi printer support (#12)
* Initial implementation of multi-printer support. * Always show device dropdown and add bambu_diagnose for multi-printer troubleshooting. * 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. * 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:
@@ -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):
|
||||
@@ -231,6 +234,23 @@ class PrinterMetrics(models.Model):
|
||||
default=list, help_text="Light status report [{node, mode}]"
|
||||
)
|
||||
|
||||
# Groundwork for H2C's Vortek nozzle-changer rack (6 swappable hotends + 1 fixed
|
||||
# left nozzle) — the full MQTT schema for per-slot state isn't confirmed yet, so
|
||||
# the raw `print.device` payload is captured here unfiltered to avoid losing data
|
||||
# ahead of proper per-slot modeling.
|
||||
vortek_raw = models.JSONField(
|
||||
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"
|
||||
@@ -490,6 +510,15 @@ class FilamentSnapshot(models.Model):
|
||||
max_length=20, null=True, blank=True,
|
||||
help_text="Slot identifier like A00-W1"
|
||||
)
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
ams_type = models.CharField(
|
||||
max_length=32, blank=True, default="",
|
||||
choices=AMS_TYPE_CHOICES,
|
||||
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)"
|
||||
)
|
||||
|
||||
type = models.CharField(max_length=50, null=True, blank=True)
|
||||
sub_type = models.CharField(
|
||||
@@ -537,9 +566,10 @@ class FilamentSnapshot(models.Model):
|
||||
db_table = "infrastructure_filament_snapshot"
|
||||
verbose_name = "Filament Snapshot"
|
||||
verbose_name_plural = "Filament Snapshots"
|
||||
ordering = ['printer_metric', 'tray_id']
|
||||
ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
|
||||
indexes = [
|
||||
models.Index(fields=['printer_metric', 'tray_id']),
|
||||
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
|
||||
models.Index(fields=['filament']),
|
||||
]
|
||||
|
||||
@@ -678,6 +708,10 @@ class FilamentUsage(models.Model):
|
||||
)
|
||||
|
||||
tray_id = models.IntegerField(help_text="Which AMS slot was used")
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
|
||||
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
|
||||
ending_percent = models.IntegerField(
|
||||
@@ -715,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')}"
|
||||
|
||||
Reference in New Issue
Block a user