Files
Bambu-Run/bambu_run/models.py
RunLit 5c56711c57 Color base add support for transparent color (#5)
* added db model is transparent and fixed PETG translucent showing black

* js and filament form for transparent color

* bumped version to v0.1.2
2026-03-27 23:30:27 +11:00

604 lines
21 KiB
Python

from django.db import models
from django.utils import timezone
class Printer(models.Model):
"""Represents a Bambu Lab 3D printer device"""
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)")
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)
class Meta:
db_table = "infrastructure_device"
verbose_name = "Printer"
verbose_name_plural = "Printers"
ordering = ["name"]
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
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)
# 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}]"
)
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="Which AMS slot (0-3) if loaded"
)
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"
)
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', 'tray_id']
indexes = [
models.Index(fields=['printer_metric', '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 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)
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')}"
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")
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)
)