Files
Bambu-Run/bambu_run/models.py
RNL 6f19560842 perf(printer): remove deferred-field N+1 and scope Printer to real printers
The printer chart API was issuing one extra SELECT per row per field that the
serializer read but .only() omitted. nozzle_temp_left/nozzle_target_temp_left
were added to the serialization loop without being added to _METRICS_API_FIELDS,
so a single-day request ran 5,507 queries and took 34s; the UI's default 48h
range took 87s. Against a remote Postgres this is pure round-trip latency.

- Add the two left-nozzle fields to _METRICS_API_FIELDS.
- Always apply both time bounds in PrinterDataAPIView. Missing or partial date
  params previously left the range open, so a bare API call scanned the whole
  metrics table.
- Give PrinterDashboardView the same treatment the API already had: .only(),
  sampling to _MAX_CHART_POINTS, and a targeted snapshot fetch. It also
  evaluated its queryset twice, because .last() on an unevaluated queryset
  issues its own query plus its own prefetch.
- Extract sample_metrics() and fetch_snapshots_by_metric() for reuse.

Printer shares the infrastructure_device table with a host project's other
devices and had no category field, so Printer.objects.filter(is_active=True)
could return a NAS. Add a category field and a category-scoped default manager,
keeping all_objects as the unfiltered base manager so related descriptors still
resolve every row. Migration 0009 creates the column in standalone deployments
and skips the DDL where the host project already owns it.

Measured: API 87s -> 0.67s, dashboard 3.4s -> 0.9s. Query counts are now
independent of row count, asserted by tests.
2026-07-28 07:14:10 +10:00

888 lines
34 KiB
Python

from django.db import models
from django.utils import timezone
# Bambu AMS model-code → human-readable type label.
# Source: live H2C MQTT probe — `print.ams.ams[i].info` field.
# Add new codes as they are observed (e.g. AMS Lite, future variants).
AMS_INFO_TO_TYPE = {
"1001": "AMS",
"1003": "AMS 2 Pro",
"1104": "AMS HT", # observed on production H2C (last 4 of info code 11001104)
"2104": "AMS HT", # observed in dev capture (last 4 of info code 11002104)
}
AMS_TYPE_CHOICES = [
("AMS", "AMS"),
("AMS 2 Pro", "AMS 2 Pro"),
("AMS HT", "AMS HT"),
]
def ams_type_from_info(info_code) -> str:
"""Resolve an AMS unit's `info` model code to a human label.
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 not info_code:
return ""
code = str(info_code)
return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "")
class PrinterManager(models.Manager):
"""Default manager — scopes every query to actual 3D printers.
`Printer` shares the `infrastructure_device` table with a host project's other
device rows (RAE stores its NAS, routers and cameras there too). Without this
scoping, `Printer.objects.filter(is_active=True).first()` can return a NAS.
"""
def get_queryset(self):
return super().get_queryset().filter(category=Printer.CATEGORY_3D_PRINTER)
class Printer(models.Model):
"""Represents a Bambu Lab 3D printer device"""
CATEGORY_3D_PRINTER = "threed_printer"
name = models.CharField(max_length=200, help_text="Friendly device name")
model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)")
category = models.CharField(
max_length=50,
default=CATEGORY_3D_PRINTER,
help_text=(
"Device category. Always 'threed_printer' for printers — present because "
"host projects may share this table with other device types."
),
)
manufacturer = models.CharField(
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
)
description = models.TextField(blank=True, null=True)
serial_number = models.CharField(max_length=100, blank=True, null=True, unique=True)
ip_address = models.GenericIPAddressField(blank=True, null=True)
is_active = models.BooleanField(default=True)
location = models.CharField(
max_length=200, blank=True, help_text="Physical location"
)
first_seen = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
# `all_objects` is declared first so it serves as the base manager for related
# descriptors (PrinterMetrics.device etc.) — those must never filter, or rows
# attached to a mis-categorised device become unreachable.
all_objects = models.Manager()
objects = PrinterManager()
class Meta:
db_table = "infrastructure_device"
verbose_name = "Printer"
verbose_name_plural = "Printers"
ordering = ["name"]
base_manager_name = "all_objects"
default_manager_name = "objects"
def __str__(self):
return f"{self.name} ({self.model})"
class PrinterMetrics(models.Model):
"""Time-series metrics for 3D Printer devices (Bambu Lab)"""
device = models.ForeignKey(
Printer, on_delete=models.CASCADE, related_name="printer_metrics", db_index=True
)
timestamp = models.DateTimeField(
default=timezone.now, db_index=True, help_text="When this reading was taken"
)
# Temperature metrics
nozzle_temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
nozzle_target_temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
bed_temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
bed_target_temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
chamber_temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
# Nozzle info — single-nozzle / right-side back-compat fields. On dual-nozzle
# printers (H2C) these mirror the right extruder; the left extruder uses the
# `_left` columns below.
nozzle_diameter = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
)
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
# H2C dual-nozzle: left-side fields (NULL on single-nozzle printers).
nozzle_temp_left = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Left extruder current temperature (°C). H2C only."
)
nozzle_target_temp_left = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Left extruder target temperature (°C). H2C only."
)
nozzle_diameter_left = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True,
help_text="Left nozzle diameter (mm). H2C only."
)
nozzle_type_left = models.CharField(
max_length=50, null=True, blank=True,
help_text="Left nozzle type (e.g. HS01-0.4). H2C only."
)
# Print job status
gcode_state = models.CharField(
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
)
print_type = models.CharField(
max_length=50, null=True, blank=True, help_text="idle, printing, etc."
)
print_percent = models.IntegerField(
null=True, blank=True, help_text="Print progress percentage"
)
remaining_time_min = models.IntegerField(
null=True, blank=True, help_text="Estimated remaining time in minutes"
)
layer_num = models.IntegerField(
null=True, blank=True, help_text="Current layer number"
)
total_layer_num = models.IntegerField(
null=True, blank=True, help_text="Total layers in print"
)
print_line_number = models.IntegerField(null=True, blank=True)
subtask_name = models.CharField(max_length=200, null=True, blank=True)
gcode_file = models.CharField(max_length=200, null=True, blank=True)
# Fan speeds (0-100%)
cooling_fan_speed = models.IntegerField(null=True, blank=True)
heatbreak_fan_speed = models.IntegerField(null=True, blank=True)
big_fan1_speed = models.IntegerField(
null=True, blank=True, help_text="Auxiliary/chamber fan 1 speed"
)
big_fan2_speed = models.IntegerField(
null=True, blank=True, help_text="Auxiliary/chamber fan 2 speed"
)
# Speed settings
spd_lvl = models.IntegerField(
null=True, blank=True,
help_text="Speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)",
)
spd_mag = models.IntegerField(
null=True, blank=True, help_text="Speed magnitude percentage"
)
# Network & connectivity
wifi_signal_dbm = models.IntegerField(null=True, blank=True)
# Error tracking
print_error = models.IntegerField(default=0)
has_errors = models.BooleanField(default=False)
# Chamber light & camera
chamber_light = models.CharField(
max_length=20, null=True, blank=True, help_text="on/off"
)
ipcam_record = models.CharField(
max_length=20, null=True, blank=True, help_text="enable/disable"
)
timelapse = models.CharField(
max_length=20, null=True, blank=True, help_text="enable/disable"
)
# System info
stg_cur = models.IntegerField(
null=True, blank=True, help_text="Current print stage"
)
sdcard = models.BooleanField(
null=True, blank=True, help_text="SD card present"
)
gcode_file_prepare_percent = models.CharField(
max_length=10, null=True, blank=True, help_text="File preparation progress"
)
lifecycle = models.CharField(
max_length=50, null=True, blank=True, help_text="Product lifecycle state"
)
# HMS (Health Management System)
hms = models.JSONField(
default=list, help_text="Health management system messages (errors/warnings)"
)
# AMS (Automatic Material System) status
ams_unit_count = models.IntegerField(null=True, blank=True)
ams_status = models.IntegerField(null=True, blank=True)
ams_rfid_status = models.IntegerField(null=True, blank=True)
ams_humidity = models.IntegerField(
null=True, blank=True, help_text="AMS humidity level (processed)"
)
ams_humidity_raw = models.IntegerField(
null=True, blank=True, help_text="AMS raw humidity reading"
)
ams_temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
ams_version = models.IntegerField(
null=True, blank=True, help_text="AMS firmware version"
)
tray_is_bbl_bits = models.CharField(
max_length=20, null=True, blank=True,
help_text="Which trays have Bambu Lab (OEM) filament",
)
tray_read_done_bits = models.CharField(
max_length=20, null=True, blank=True,
help_text="RFID read completion status bits",
)
# JSON fields for complex nested data
filaments = models.JSONField(
default=list,
help_text="List of filament info [{tray_id, slot, type, sub_type, color, remain_percent, k, ...}]",
)
ams_units = models.JSONField(
default=list,
help_text="AMS unit info [{unit_id, ams_id, chip_id, humidity, temp, ...}]",
)
external_spool = models.JSONField(
default=dict, help_text="External spool info {type, color, remain}"
)
lights_report = models.JSONField(
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"
verbose_name_plural = "Printer Metrics"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["device", "-timestamp"], name="printer_dev_time_idx"),
models.Index(fields=["-timestamp"], name="printer_time_idx"),
]
def __str__(self):
return f"{self.device.name} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
class FilamentType(models.Model):
"""Central registry of filament types (material + sub-type + brand)"""
type = models.CharField(max_length=50, help_text="Base material: PLA, PETG, ABS, etc.")
sub_type = models.CharField(
max_length=100, null=True, blank=True,
help_text="Sub-type: PLA Basic, PLA Matte, etc."
)
brand = models.CharField(
max_length=100, default='Bambu Lab',
help_text="Manufacturer name"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "infrastructure_filament_type"
verbose_name = "Filament Type"
verbose_name_plural = "Filament Types"
ordering = ['type', 'sub_type', 'brand']
unique_together = [['type', 'sub_type', 'brand']]
def __str__(self):
sub = f" {self.sub_type}" if self.sub_type else ""
return f"{self.type}{sub} ({self.brand})"
class FilamentColor(models.Model):
"""Master database of Bambu Lab filament colors for auto-matching"""
color_code = models.CharField(
max_length=6,
help_text="Hex color code without padding (e.g., '000000' not '000000FF')"
)
color_name = models.CharField(
max_length=100,
help_text="Human-readable color name (e.g., 'Black', 'Orange')"
)
filament_type_fk = models.ForeignKey(
'FilamentType', null=True, blank=True, on_delete=models.SET_NULL,
related_name='colors',
help_text="Link to FilamentType registry"
)
filament_type = models.CharField(
max_length=50,
help_text="Base material type: PLA, PETG, ABS, TPU, etc."
)
filament_sub_type = models.CharField(
max_length=100,
null=True,
blank=True,
help_text="Material sub-type: 'PLA Basic', 'PLA Matte', 'ABS GF', etc."
)
brand = models.CharField(
max_length=100,
default='Bambu Lab',
help_text="Manufacturer name"
)
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "infrastructure_filament_color"
verbose_name = "Filament Color"
verbose_name_plural = "Filament Colors"
ordering = ['filament_type', 'filament_sub_type', 'color_name']
indexes = [
models.Index(fields=['color_code', 'filament_type', 'filament_sub_type', 'brand']),
models.Index(fields=['filament_type']),
]
unique_together = [['color_code', 'filament_type', 'filament_sub_type', 'brand']]
def __str__(self):
sub_type_info = f" {self.filament_sub_type}" if self.filament_sub_type else ""
return f"{self.filament_type}{sub_type_info}: {self.color_name} (#{self.color_code})"
def get_hex_color(self):
"""Return color code with # prefix for display"""
return f"#{self.color_code}"
class Filament(models.Model):
"""Master inventory of filament spools owned by user"""
# Unique identification
tray_uuid = models.CharField(
max_length=100, unique=True, null=True, blank=True, db_index=True,
help_text="Spool serial number from MQTT"
)
tag_uid = models.CharField(
max_length=100, null=True, blank=True, db_index=True,
help_text="RFID chip unique identifier"
)
tag_id = models.CharField(
max_length=100, null=True, blank=True,
help_text="User-defined unique identifier (barcode, label, etc.)"
)
# Creation tracking
created_by = models.CharField(
max_length=20, default='Manual',
choices=[
('Auto Detection', 'Auto Detection'),
('Manual', 'Manual'),
],
help_text="How this filament was added to inventory"
)
# FK to FilamentType registry
filament_type = models.ForeignKey(
'FilamentType', null=True, blank=True, on_delete=models.SET_NULL,
related_name='filaments',
help_text="Link to FilamentType registry"
)
# Filament specifications
type = models.CharField(max_length=50, help_text="PLA, PETG, ABS, TPU, etc.")
sub_type = models.CharField(
max_length=100, null=True, blank=True,
help_text="Material sub-type from MQTT: 'PLA Matte', 'PLA Basic', etc."
)
brand = models.CharField(max_length=100, help_text="Manufacturer name")
color = models.CharField(max_length=50, help_text="Color name")
color_hex = models.CharField(
max_length=7, null=True, blank=True,
help_text="Color hex code for display (#RRGGBB)"
)
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
# Physical properties
diameter = models.DecimalField(
max_digits=4, decimal_places=2, default=1.75,
help_text="Filament diameter in mm (1.75 or 2.85)"
)
initial_weight_grams = models.IntegerField(
null=True, blank=True,
help_text="Spool weight when new (typically 1000g)"
)
# Current status
remaining_percent = models.IntegerField(
default=100,
help_text="Estimated remaining filament (0-100%)"
)
remaining_weight_grams = models.IntegerField(
null=True, blank=True,
help_text="Calculated remaining weight"
)
# Current location in AMS
is_loaded_in_ams = models.BooleanField(
default=False,
help_text="Is this spool currently loaded in AMS?"
)
current_tray_id = models.IntegerField(
null=True, blank=True,
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)"
)
ams_unit_id = models.PositiveSmallIntegerField(
null=True, blank=True, db_index=True,
help_text="Which physical AMS unit this spool is loaded in (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 spool is loaded in (AMS / AMS 2 Pro / AMS HT)"
)
last_loaded_date = models.DateTimeField(
null=True, blank=True,
help_text="When was this spool loaded into AMS"
)
# Purchase/inventory tracking
purchase_date = models.DateField(null=True, blank=True)
purchase_price = models.DecimalField(
max_digits=8, decimal_places=2, null=True, blank=True
)
supplier = models.CharField(max_length=100, null=True, blank=True)
notes = models.TextField(blank=True, help_text="Custom notes about this spool")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_used = models.DateTimeField(
null=True, blank=True,
help_text="Last time this spool was used in a print"
)
class Meta:
db_table = "infrastructure_filament"
verbose_name = "Filament Spool"
verbose_name_plural = "Filament Spools"
ordering = ['type', 'brand', 'color', '-remaining_percent']
indexes = [
models.Index(fields=['type', 'brand', 'color']),
models.Index(fields=['tray_uuid']),
models.Index(fields=['tag_uid']),
models.Index(fields=['tag_id']),
models.Index(fields=['is_loaded_in_ams', 'current_tray_id']),
models.Index(fields=['remaining_percent']),
models.Index(fields=['created_by']),
]
def __str__(self):
sn_info = f"[SN:{self.tray_uuid[:8]}...] " if self.tray_uuid else ""
return f"{sn_info}{self.brand} {self.type} - {self.color} ({self.remaining_percent}%)"
def update_remaining_weight(self):
"""Calculate remaining weight based on percentage"""
if self.initial_weight_grams:
self.remaining_weight_grams = int(
self.initial_weight_grams * (self.remaining_percent / 100.0)
)
class FilamentSnapshot(models.Model):
"""Links PrinterMetrics to Filament inventory with point-in-time AMS data"""
printer_metric = models.ForeignKey(
'PrinterMetrics', on_delete=models.CASCADE,
related_name='filament_snapshots'
)
filament = models.ForeignKey(
'Filament', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='usage_snapshots',
help_text="Matched filament from inventory (null if no match)"
)
tray_id = models.IntegerField(help_text="AMS slot number (0-3)")
slot_name = models.CharField(
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(
max_length=100, null=True, blank=True,
help_text="Material sub-type from MQTT (PLA Basic, PLA Matte, etc.)"
)
brand = models.CharField(
max_length=100, null=True, blank=True,
help_text="Deprecated: MQTT doesn't provide brand. Use Filament.brand instead."
)
color = models.CharField(max_length=50, null=True, blank=True)
remain_percent = models.IntegerField(null=True, blank=True)
k_value = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True
)
tag_uid = models.CharField(
max_length=100, null=True, blank=True, db_index=True,
help_text="RFID chip unique identifier"
)
tray_uuid = models.CharField(
max_length=100, null=True, blank=True,
help_text="Tray UUID from MQTT"
)
state = models.IntegerField(
null=True, blank=True,
help_text="Tray state from MQTT"
)
temp = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
humidity = models.IntegerField(null=True, blank=True)
auto_matched = models.BooleanField(
default=True,
help_text="Was this auto-matched to inventory or manually set?"
)
match_method = models.CharField(
max_length=20, default='none',
help_text="tag_id, lowest_remaining, manual, or none"
)
class Meta:
db_table = "infrastructure_filament_snapshot"
verbose_name = "Filament Snapshot"
verbose_name_plural = "Filament Snapshots"
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']),
]
def __str__(self):
filament_info = str(self.filament) if self.filament else f"{self.brand} {self.type}"
return f"Tray {self.tray_id}: {filament_info}"
class BambuCloudTask(models.Model):
"""Cloud task record synced from Bambu Cloud API (v1/user-service/my/tasks)."""
task_id = models.BigIntegerField(unique=True, db_index=True, help_text="Bambu Cloud task ID (matches MQTT task_id)")
design_id = models.IntegerField(null=True, blank=True, help_text="Makerworld design ID")
design_title = models.CharField(max_length=500, blank=True, help_text="Human project name from Makerworld (designTitle)")
plate_title = models.CharField(max_length=500, blank=True, help_text="Plate/variant name (matches MQTT subtask_name)")
model_id = models.CharField(max_length=100, blank=True)
profile_id = models.BigIntegerField(null=True, blank=True, help_text="Bambu Cloud profile ID")
plate_index = models.SmallIntegerField(null=True, blank=True)
device_serial = models.CharField(max_length=100, blank=True, help_text="Printer serial number from cloud")
cover_url = models.URLField(max_length=1000, blank=True, help_text="Plate preview image URL from S3")
weight_grams = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="Actual filament weight reported by cloud")
length_mm = models.IntegerField(null=True, blank=True, help_text="Filament length in mm")
cost_time_seconds = models.IntegerField(null=True, blank=True, help_text="Cloud-measured print duration in seconds")
cloud_status = models.SmallIntegerField(null=True, blank=True, help_text="2=finish, 3=failed")
bed_type = models.CharField(max_length=50, blank=True)
use_ams = models.BooleanField(default=True)
print_mode = models.CharField(max_length=50, blank=True, help_text="cloud_file, local, etc.")
ams_detail_mapping = models.JSONField(default=list, help_text="Per-slot filament weight breakdown from cloud")
cloud_start_time = models.DateTimeField(null=True, blank=True)
cloud_end_time = models.DateTimeField(null=True, blank=True)
raw_data = models.JSONField(default=dict, help_text="Full task response — preserved for future use")
synced_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "infrastructure_cloud_task"
verbose_name = "Bambu Cloud Task"
verbose_name_plural = "Bambu Cloud Tasks"
ordering = ["-cloud_start_time"]
indexes = [
models.Index(fields=["task_id"]),
models.Index(fields=["design_id"]),
models.Index(fields=["-cloud_start_time"]),
]
def __str__(self):
name = self.design_title or self.plate_title or f"task-{self.task_id}"
return f"{name} ({self.cloud_start_time.strftime('%Y-%m-%d') if self.cloud_start_time else 'unknown date'})"
class PrintJob(models.Model):
"""Represents a single print job from start to finish"""
device = models.ForeignKey(
'Printer', on_delete=models.CASCADE,
related_name='print_jobs'
)
project_name = models.CharField(
max_length=200, help_text="From subtask_name field"
)
gcode_file = models.CharField(max_length=200, null=True, blank=True)
cloud_task = models.ForeignKey(
'BambuCloudTask', on_delete=models.SET_NULL,
null=True, blank=True, related_name='print_jobs',
help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)"
)
cloud_task_id_raw = models.BigIntegerField(
null=True, blank=True, db_index=True,
help_text="MQTT task_id — captured at job start, used to link cloud task"
)
start_time = models.DateTimeField(help_text="When print started")
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed")
duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
total_layers = models.IntegerField(null=True, blank=True)
final_status = models.CharField(
max_length=50, null=True, blank=True, help_text="FINISH, FAILED, CANCELLED"
)
completion_percent = models.IntegerField(
default=0, help_text="Final completion percentage"
)
start_metric = models.ForeignKey(
'PrinterMetrics', on_delete=models.SET_NULL,
null=True, related_name='started_jobs'
)
end_metric = models.ForeignKey(
'PrinterMetrics', on_delete=models.SET_NULL,
null=True, related_name='ended_jobs'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "infrastructure_print_job"
verbose_name = "Print Job"
verbose_name_plural = "Print Jobs"
ordering = ['-start_time']
indexes = [
models.Index(fields=['device', '-start_time']),
models.Index(fields=['project_name']),
models.Index(fields=['-start_time']),
]
def __str__(self):
status = self.final_status or 'In Progress'
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
@property
def display_name(self):
"""Human-readable job name: cloud design_title if available, else project_name."""
if self.cloud_task_id and self.cloud_task and self.cloud_task.design_title:
return self.cloud_task.design_title
return self.project_name
def calculate_duration(self):
"""Calculate print duration if end_time is set"""
if self.end_time and self.start_time:
delta = self.end_time - self.start_time
self.duration_minutes = int(delta.total_seconds() / 60)
class FilamentUsage(models.Model):
"""Tracks filament consumption during print jobs"""
print_job = models.ForeignKey(
'PrintJob', on_delete=models.CASCADE,
related_name='filament_usages'
)
filament = models.ForeignKey(
'Filament', on_delete=models.CASCADE,
related_name='print_usages'
)
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(
null=True, blank=True, help_text="Filament remaining % at job end"
)
consumed_percent = models.IntegerField(
null=True, blank=True, help_text="Amount consumed during print"
)
consumed_grams = models.IntegerField(
null=True, blank=True, help_text="Estimated grams consumed"
)
is_primary = models.BooleanField(
default=True, help_text="Primary filament vs multi-color"
)
class Meta:
db_table = "infrastructure_filament_usage"
verbose_name = "Filament Usage"
verbose_name_plural = "Filament Usages"
ordering = ['print_job', 'tray_id']
indexes = [
models.Index(fields=['print_job']),
models.Index(fields=['filament']),
]
def __str__(self):
return f"{self.filament} - {self.print_job.project_name} ({self.consumed_percent}%)"
def calculate_consumed(self):
"""Calculate consumed amount"""
if self.ending_percent is not None:
self.consumed_percent = self.starting_percent - self.ending_percent
if self.filament.initial_weight_grams:
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')}"