mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
Initial spin-off of bambu-run from my private project separation
This commit is contained in:
1
bambu_run/__init__.py
Normal file
1
bambu_run/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "bambu_run.apps.BambuRunConfig"
|
||||
107
bambu_run/admin.py
Normal file
107
bambu_run/admin.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from django.contrib import admin
|
||||
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||
|
||||
|
||||
@admin.register(Printer)
|
||||
class PrinterAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"name", "model", "manufacturer", "ip_address", "is_active", "first_seen",
|
||||
]
|
||||
list_filter = ["manufacturer", "is_active"]
|
||||
search_fields = ["name", "model", "serial_number", "ip_address"]
|
||||
readonly_fields = ["first_seen", "last_updated"]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Information", {"fields": ("name", "model", "manufacturer", "description")}),
|
||||
("Identification", {"fields": ("serial_number",)}),
|
||||
("Network", {"fields": ("ip_address",)}),
|
||||
("Status", {"fields": ("is_active", "location")}),
|
||||
("Metadata", {"fields": ("first_seen", "last_updated"), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PrinterMetrics)
|
||||
class PrinterMetricsAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"device", "timestamp", "nozzle_temp", "bed_temp",
|
||||
"print_percent", "gcode_state", "chamber_light",
|
||||
]
|
||||
list_filter = ["device", "gcode_state", "print_type", "chamber_light"]
|
||||
search_fields = ["device__name", "subtask_name", "gcode_file"]
|
||||
readonly_fields = ["timestamp"]
|
||||
date_hierarchy = "timestamp"
|
||||
|
||||
fieldsets = (
|
||||
("Device & Timestamp", {"fields": ("device", "timestamp")}),
|
||||
("Temperatures", {
|
||||
"fields": ("nozzle_temp", "nozzle_target_temp", "bed_temp", "bed_target_temp", "chamber_temp")
|
||||
}),
|
||||
("Print Status", {
|
||||
"fields": ("gcode_state", "print_type", "print_percent", "remaining_time_min",
|
||||
"layer_num", "total_layer_num", "subtask_name", "gcode_file")
|
||||
}),
|
||||
("AMS & Filaments", {
|
||||
"fields": ("ams_unit_count", "ams_status", "ams_temp", "ams_humidity",
|
||||
"ams_humidity_raw", "filaments", "external_spool")
|
||||
}),
|
||||
("System", {
|
||||
"fields": ("chamber_light", "wifi_signal_dbm", "cooling_fan_speed",
|
||||
"heatbreak_fan_speed", "has_errors", "print_error")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(FilamentType)
|
||||
class FilamentTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('type', 'sub_type', 'brand', 'created_at')
|
||||
search_fields = ('type', 'sub_type', 'brand')
|
||||
list_filter = ('type', 'brand')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
@admin.register(Filament)
|
||||
class FilamentAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'brand', 'type', 'sub_type', 'color', 'remaining_percent',
|
||||
'is_loaded_in_ams', 'current_tray_id', 'last_used'
|
||||
)
|
||||
list_filter = ('type', 'brand', 'is_loaded_in_ams')
|
||||
search_fields = ('brand', 'color', 'type', 'tag_id')
|
||||
readonly_fields = ('created_at', 'updated_at', 'last_used')
|
||||
|
||||
fieldsets = (
|
||||
('Identification', {'fields': ('tag_id',)}),
|
||||
('Specifications', {
|
||||
'fields': ('type', 'sub_type', 'brand', 'color', 'color_hex', 'diameter', 'initial_weight_grams')
|
||||
}),
|
||||
('Current Status', {
|
||||
'fields': ('remaining_percent', 'remaining_weight_grams',
|
||||
'is_loaded_in_ams', 'current_tray_id', 'last_loaded_date')
|
||||
}),
|
||||
('Purchase Info', {'fields': ('purchase_date', 'purchase_price', 'supplier', 'notes')}),
|
||||
('Timestamps', {'fields': ('created_at', 'updated_at', 'last_used')}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(FilamentSnapshot)
|
||||
class FilamentSnapshotAdmin(admin.ModelAdmin):
|
||||
list_display = ('printer_metric', 'tray_id', 'filament', 'type', 'sub_type', 'tag_uid', 'remain_percent', 'match_method')
|
||||
list_filter = ('match_method', 'auto_matched', 'tray_id', 'type')
|
||||
search_fields = ('type', 'sub_type', 'brand', 'color', 'tag_uid')
|
||||
readonly_fields = ('printer_metric', 'filament', 'auto_matched', 'match_method', 'tag_uid', 'tray_uuid', 'state')
|
||||
|
||||
|
||||
@admin.register(PrintJob)
|
||||
class PrintJobAdmin(admin.ModelAdmin):
|
||||
list_display = ('project_name', 'device', 'start_time', 'end_time', 'duration_minutes', 'final_status', 'completion_percent')
|
||||
list_filter = ('device', 'final_status')
|
||||
search_fields = ('project_name', 'gcode_file')
|
||||
readonly_fields = ('created_at', 'updated_at', 'duration_minutes')
|
||||
date_hierarchy = 'start_time'
|
||||
|
||||
|
||||
@admin.register(FilamentUsage)
|
||||
class FilamentUsageAdmin(admin.ModelAdmin):
|
||||
list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
|
||||
list_filter = ('is_primary', 'tray_id')
|
||||
readonly_fields = ('consumed_percent', 'consumed_grams')
|
||||
7
bambu_run/apps.py
Normal file
7
bambu_run/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BambuRunConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "bambu_run"
|
||||
verbose_name = "Bambu Run"
|
||||
55
bambu_run/conf.py
Normal file
55
bambu_run/conf.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
App-level settings with sensible defaults.
|
||||
|
||||
Override in your Django settings.py:
|
||||
BAMBU_RUN_TIMEZONE = 'Australia/Melbourne'
|
||||
BAMBU_RUN_BASE_TEMPLATE = 'base/base.html'
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_setting(name, default):
|
||||
return getattr(settings, name, default)
|
||||
|
||||
|
||||
# Timezone for all timestamp display and queries
|
||||
BAMBU_RUN_TIMEZONE = property(lambda self: get_setting("BAMBU_RUN_TIMEZONE", "UTC"))
|
||||
|
||||
# Base template that all bambu_run templates extend
|
||||
BAMBU_RUN_BASE_TEMPLATE = property(
|
||||
lambda self: get_setting("BAMBU_RUN_BASE_TEMPLATE", "bambu_run/base.html")
|
||||
)
|
||||
|
||||
# Login URL for @login_required redirects
|
||||
BAMBU_RUN_LOGIN_URL = property(
|
||||
lambda self: get_setting("BAMBU_RUN_LOGIN_URL", "/accounts/login/")
|
||||
)
|
||||
|
||||
# Default brand for auto-created filaments from MQTT
|
||||
BAMBU_RUN_AUTO_CREATE_BRAND = property(
|
||||
lambda self: get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||
)
|
||||
|
||||
|
||||
class _Settings:
|
||||
"""Lazy settings object that reads from Django settings with defaults."""
|
||||
|
||||
@property
|
||||
def TIMEZONE(self):
|
||||
return get_setting("BAMBU_RUN_TIMEZONE", "UTC")
|
||||
|
||||
@property
|
||||
def BASE_TEMPLATE(self):
|
||||
return get_setting("BAMBU_RUN_BASE_TEMPLATE", "bambu_run/base.html")
|
||||
|
||||
@property
|
||||
def LOGIN_URL(self):
|
||||
return get_setting("BAMBU_RUN_LOGIN_URL", "/accounts/login/")
|
||||
|
||||
@property
|
||||
def AUTO_CREATE_BRAND(self):
|
||||
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||
|
||||
|
||||
app_settings = _Settings()
|
||||
232
bambu_run/forms.py
Normal file
232
bambu_run/forms.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from django import forms
|
||||
from .models import Filament, FilamentColor, FilamentType
|
||||
|
||||
|
||||
class FilamentTypeForm(forms.ModelForm):
|
||||
"""Form for managing FilamentType registry"""
|
||||
|
||||
PRESET_TYPES = ['PLA', 'PETG', 'PET', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PPS']
|
||||
PRESET_SUB_TYPES = [
|
||||
'PLA Basic', 'PLA Matte', 'PLA Silk', 'PLA Metal', 'PLA Marble', 'PLA Glow', 'PLA-CF',
|
||||
'PETG Basic', 'PETG-CF', 'PETG-HF', 'ABS', 'TPU 95A', 'PA6-CF', 'ASA', 'PC', 'PPS-CF',
|
||||
'Support W', 'Support G',
|
||||
]
|
||||
PRESET_BRANDS = [
|
||||
'Bambu Lab', 'eSUN', 'Polymaker', 'Hatchbox', 'Prusament',
|
||||
'MatterHackers', 'Overture', '3DXTech', 'ColorFabb',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = FilamentType
|
||||
fields = ['type', 'sub_type', 'brand']
|
||||
widgets = {
|
||||
'type': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., PLA, PETG, ABS'
|
||||
}),
|
||||
'sub_type': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., PLA Basic, PLA Matte (optional)'
|
||||
}),
|
||||
'brand': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., Bambu Lab'
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class FilamentForm(forms.ModelForm):
|
||||
color_hex_text = forms.CharField(
|
||||
required=False,
|
||||
max_length=7,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '#000000',
|
||||
'pattern': '#[0-9A-Fa-f]{6}',
|
||||
'id': 'id_color_hex_text'
|
||||
}),
|
||||
label='Color Hex Code'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Filament
|
||||
fields = [
|
||||
'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
|
||||
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex',
|
||||
'diameter', 'initial_weight_grams',
|
||||
'remaining_percent', 'remaining_weight_grams',
|
||||
'is_loaded_in_ams', 'current_tray_id',
|
||||
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
'tray_uuid': forms.TextInput(attrs={
|
||||
'class': 'form-control font-monospace',
|
||||
'placeholder': 'Optional - Auto-filled by MQTT',
|
||||
'style': 'font-size: 0.9em;'
|
||||
}),
|
||||
'tag_uid': forms.TextInput(attrs={
|
||||
'class': 'form-control font-monospace',
|
||||
'placeholder': 'Optional - RFID chip ID',
|
||||
'style': 'font-size: 0.9em;'
|
||||
}),
|
||||
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
|
||||
'created_by': forms.Select(attrs={'class': 'form-select'}),
|
||||
'filament_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'type': forms.HiddenInput(),
|
||||
'sub_type': forms.HiddenInput(),
|
||||
'brand': forms.HiddenInput(),
|
||||
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
|
||||
'color_hex': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'color',
|
||||
'id': 'id_color_hex_picker'
|
||||
}),
|
||||
'diameter': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
|
||||
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
|
||||
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
|
||||
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance and self.instance.color_hex:
|
||||
self.fields['color_hex_text'].initial = self.instance.color_hex
|
||||
|
||||
self.fields['filament_type'].queryset = FilamentType.objects.all()
|
||||
self.fields['filament_type'].empty_label = '--- Select Filament Type ---'
|
||||
self.fields['filament_type'].required = False
|
||||
|
||||
self.fields['type'].required = False
|
||||
self.fields['sub_type'].required = False
|
||||
self.fields['brand'].required = False
|
||||
|
||||
self._populate_color_choices()
|
||||
|
||||
def _populate_color_choices(self):
|
||||
"""Populate color field choices from FilamentColor database with suggested colors"""
|
||||
from .utils import strip_color_padding, match_filament_color
|
||||
|
||||
color_choices = [('', '--- Select Color ---')]
|
||||
suggested_color = None
|
||||
|
||||
all_colors = FilamentColor.objects.all().order_by('filament_type', 'filament_sub_type', 'color_name')
|
||||
|
||||
if self.instance and self.instance.type and self.instance.color_hex:
|
||||
color_code = strip_color_padding(self.instance.color_hex.lstrip('#'))
|
||||
suggested = match_filament_color(
|
||||
filament_type=self.instance.type,
|
||||
filament_sub_type=self.instance.sub_type,
|
||||
color_code=color_code,
|
||||
brand=self.instance.brand or 'Bambu Lab'
|
||||
)
|
||||
if suggested:
|
||||
suggested_color = suggested
|
||||
|
||||
if suggested_color:
|
||||
color_choices.append((
|
||||
suggested_color.color_name,
|
||||
f"SUGGESTED: {suggested_color.filament_sub_type or suggested_color.filament_type}: {suggested_color.color_name}"
|
||||
))
|
||||
color_choices.append(('---separator---', '---' * 20))
|
||||
|
||||
for color in all_colors:
|
||||
if suggested_color and color.pk == suggested_color.pk:
|
||||
continue
|
||||
|
||||
display_name = f"{color.filament_sub_type or color.filament_type}: {color.color_name}"
|
||||
color_choices.append((color.color_name, display_name))
|
||||
|
||||
color_choices.append(('---separator2---', '---' * 20))
|
||||
color_choices.append(('custom', 'Custom (type in manually)'))
|
||||
|
||||
self.fields['color'].widget.choices = color_choices
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
is_loaded = cleaned_data.get('is_loaded_in_ams')
|
||||
tray_id = cleaned_data.get('current_tray_id')
|
||||
|
||||
color_hex_text = cleaned_data.get('color_hex_text')
|
||||
if color_hex_text:
|
||||
cleaned_data['color_hex'] = color_hex_text
|
||||
|
||||
color = cleaned_data.get('color')
|
||||
if color and 'separator' in color:
|
||||
cleaned_data['color'] = ''
|
||||
|
||||
ft = cleaned_data.get('filament_type')
|
||||
if ft:
|
||||
cleaned_data['type'] = ft.type
|
||||
cleaned_data['sub_type'] = ft.sub_type or ''
|
||||
cleaned_data['brand'] = ft.brand
|
||||
|
||||
if is_loaded and tray_id is None:
|
||||
raise forms.ValidationError('Tray ID required when filament is loaded in AMS')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class FilamentColorForm(forms.ModelForm):
|
||||
"""Form for managing FilamentColor database"""
|
||||
|
||||
color_code = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
|
||||
color_hex_input = forms.CharField(
|
||||
required=True,
|
||||
max_length=7,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '#000000',
|
||||
'pattern': '#[0-9A-Fa-f]{6}',
|
||||
}),
|
||||
label='Color Hex Code'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FilamentColor
|
||||
fields = ['color_code', 'color_name', 'filament_type_fk', 'filament_type', 'filament_sub_type', 'brand']
|
||||
widgets = {
|
||||
'color_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Black, Orange'}),
|
||||
'filament_type_fk': forms.Select(attrs={'class': 'form-select'}),
|
||||
'filament_type': forms.HiddenInput(),
|
||||
'filament_sub_type': forms.HiddenInput(),
|
||||
'brand': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance and self.instance.color_code:
|
||||
self.fields['color_hex_input'].initial = f"#{self.instance.color_code}"
|
||||
|
||||
self.fields['filament_type_fk'].queryset = FilamentType.objects.all()
|
||||
self.fields['filament_type_fk'].empty_label = '--- Select Filament Type ---'
|
||||
self.fields['filament_type_fk'].required = False
|
||||
|
||||
self.fields['filament_type'].required = False
|
||||
self.fields['filament_sub_type'].required = False
|
||||
self.fields['brand'].required = False
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
color_hex = cleaned_data.get('color_hex_input', '')
|
||||
if color_hex:
|
||||
color_code = color_hex.lstrip('#').upper()[:6]
|
||||
cleaned_data['color_code'] = color_code
|
||||
|
||||
ft_fk = cleaned_data.get('filament_type_fk')
|
||||
if ft_fk:
|
||||
cleaned_data['filament_type'] = ft_fk.type
|
||||
cleaned_data['filament_sub_type'] = ft_fk.sub_type or ''
|
||||
cleaned_data['brand'] = ft_fk.brand
|
||||
|
||||
return cleaned_data
|
||||
0
bambu_run/management/__init__.py
Normal file
0
bambu_run/management/__init__.py
Normal file
0
bambu_run/management/commands/__init__.py
Normal file
0
bambu_run/management/commands/__init__.py
Normal file
142
bambu_run/management/commands/bambu_cleanup.py
Normal file
142
bambu_run/management/commands/bambu_cleanup.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Management command to clean up old FilamentSnapshot records.
|
||||
|
||||
Usage:
|
||||
python manage.py bambu_cleanup --days 90 --dry-run
|
||||
python manage.py bambu_cleanup --days 180
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from bambu_run.models import FilamentSnapshot, PrinterMetrics
|
||||
|
||||
logger = logging.getLogger("bambu_run.cleanup")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clean up old FilamentSnapshot records to save database space"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--days", type=int, default=90,
|
||||
help="Delete snapshots older than X days (default: 90)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Show what would be deleted without actually deleting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-print-jobs", action="store_true",
|
||||
help="Keep snapshots linked to print jobs even if old",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options["days"]
|
||||
dry_run = options["dry_run"]
|
||||
keep_print_jobs = options["keep_print_jobs"]
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
self.stdout.write(f"Cleaning up FilamentSnapshots older than {days} days")
|
||||
self.stdout.write(f"Cutoff date: {cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
old_snapshots = FilamentSnapshot.objects.filter(
|
||||
printer_metric__timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
if keep_print_jobs:
|
||||
old_snapshots = old_snapshots.exclude(
|
||||
printer_metric__started_jobs__isnull=False
|
||||
).exclude(
|
||||
printer_metric__ended_jobs__isnull=False
|
||||
)
|
||||
|
||||
count = old_snapshots.count()
|
||||
|
||||
if count == 0:
|
||||
self.stdout.write(self.style.SUCCESS("No snapshots to delete."))
|
||||
return
|
||||
|
||||
space_mb = (count * 391) / (1024 * 1024)
|
||||
|
||||
self.stdout.write(f"\nSnapshots to delete: {count:,}")
|
||||
self.stdout.write(f"Estimated space saved: {space_mb:.2f} MB")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING("\nDRY RUN - Nothing deleted"))
|
||||
|
||||
sample = old_snapshots[:10]
|
||||
self.stdout.write("\nSample of snapshots to delete:")
|
||||
for snap in sample:
|
||||
self.stdout.write(
|
||||
f" - {snap.printer_metric.timestamp} | "
|
||||
f"Tray {snap.tray_id} | {snap.type or 'Empty'} | "
|
||||
f"{snap.remain_percent}%"
|
||||
)
|
||||
if count > 10:
|
||||
self.stdout.write(f" ... and {count - 10:,} more")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"\nThis will permanently delete {count:,} snapshot records!"
|
||||
)
|
||||
)
|
||||
confirm = input("Type 'DELETE' to confirm: ")
|
||||
|
||||
if confirm != "DELETE":
|
||||
self.stdout.write(self.style.ERROR("Cancelled."))
|
||||
return
|
||||
|
||||
batch_size = 1000
|
||||
deleted_total = 0
|
||||
|
||||
with transaction.atomic():
|
||||
while True:
|
||||
batch_ids = list(
|
||||
old_snapshots.values_list('id', flat=True)[:batch_size]
|
||||
)
|
||||
if not batch_ids:
|
||||
break
|
||||
|
||||
deleted = FilamentSnapshot.objects.filter(id__in=batch_ids).delete()
|
||||
deleted_count = deleted[0]
|
||||
deleted_total += deleted_count
|
||||
|
||||
self.stdout.write(
|
||||
f"Deleted {deleted_total:,} / {count:,} snapshots...",
|
||||
ending='\r'
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\nSuccessfully deleted {deleted_total:,} snapshots "
|
||||
f"({space_mb:.2f} MB freed)"
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write("\nChecking for orphaned PrinterMetrics...")
|
||||
orphaned_metrics = PrinterMetrics.objects.filter(
|
||||
timestamp__lt=cutoff_date,
|
||||
filament_snapshots__isnull=True
|
||||
)
|
||||
|
||||
metrics_count = orphaned_metrics.count()
|
||||
if metrics_count > 0:
|
||||
metrics_space_mb = (metrics_count * 1500) / (1024 * 1024)
|
||||
self.stdout.write(
|
||||
f"Found {metrics_count:,} orphaned PrinterMetrics "
|
||||
f"({metrics_space_mb:.2f} MB)"
|
||||
)
|
||||
|
||||
if input("Delete these too? (y/N): ").lower() == 'y':
|
||||
orphaned_metrics.delete()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Deleted {metrics_count:,} orphaned metrics"
|
||||
)
|
||||
)
|
||||
674
bambu_run/management/commands/bambu_collector.py
Normal file
674
bambu_run/management/commands/bambu_collector.py
Normal file
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
Management command to continuously collect 3D printer MQTT data.
|
||||
Collects printer metrics from Bambu Lab 3D printers.
|
||||
|
||||
Usage:
|
||||
python manage.py bambu_collector
|
||||
python manage.py bambu_collector --interval 60
|
||||
python manage.py bambu_collector --once
|
||||
python manage.py bambu_collector --verbose
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from bambu_run.conf import app_settings
|
||||
from bambu_run.models import Printer, PrinterMetrics
|
||||
|
||||
logger = logging.getLogger("bambu_run.collector")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
|
||||
"""
|
||||
help = "Continuously collect 3D printer MQTT data from Bambu Lab printer"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--interval", type=int, default=30,
|
||||
help="Data collection interval in seconds (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--once", action="store_true",
|
||||
help="Run once and exit (useful for testing/cron)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="Enable verbose logging"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-ssl-verify", action="store_true",
|
||||
help="Disable SSL certificate verification (use with caution)",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.printer_client = None
|
||||
self.printer_device = None
|
||||
self.verbose = False
|
||||
self.disable_ssl_verify = False
|
||||
self.error_count = 0
|
||||
self.success_count = 0
|
||||
self.mqtt_connect_errors = 0
|
||||
self.start_time = None
|
||||
self.current_print_job = None
|
||||
self.last_gcode_state = None
|
||||
self.last_subtask_name = None
|
||||
self.trays_used = set()
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.verbose = options["verbose"]
|
||||
self.disable_ssl_verify = options["disable_ssl_verify"]
|
||||
interval = options["interval"]
|
||||
run_once = options["once"]
|
||||
|
||||
if self.disable_ssl_verify:
|
||||
logger.warning("SSL verification disabled - use with caution!")
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
os.environ["PYTHONHTTPSVERIFY"] = "0"
|
||||
os.environ["CURL_CA_BUNDLE"] = ""
|
||||
os.environ["REQUESTS_CA_BUNDLE"] = ""
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt_client
|
||||
|
||||
original_tls_set = mqtt_client.Client.tls_set
|
||||
|
||||
def patched_tls_set(
|
||||
self, ca_certs=None, certfile=None, keyfile=None,
|
||||
cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLS, ciphers=None,
|
||||
):
|
||||
return original_tls_set(
|
||||
self, ca_certs, certfile, keyfile, ssl.CERT_NONE, tls_version, ciphers,
|
||||
)
|
||||
|
||||
mqtt_client.Client.tls_set = patched_tls_set
|
||||
logger.debug("Successfully patched paho-mqtt SSL verification")
|
||||
except ImportError:
|
||||
logger.debug("paho-mqtt not yet imported, will rely on SSL context")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not patch paho-mqtt: {e}")
|
||||
|
||||
self._configure_logging()
|
||||
|
||||
try:
|
||||
self._initialize_printer()
|
||||
except Exception as e:
|
||||
raise CommandError(f"Initialization failed: {e}")
|
||||
|
||||
self.start_time = timezone.now()
|
||||
logger.info(f"Bambu Run data collector started for printer: {self.printer_device.name}")
|
||||
logger.info(f"Collection interval: {interval} seconds")
|
||||
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
|
||||
|
||||
try:
|
||||
if run_once:
|
||||
self._collect_printer_data()
|
||||
logger.info("Single collection completed successfully")
|
||||
else:
|
||||
self._run_continuous_loop(interval)
|
||||
except KeyboardInterrupt:
|
||||
self._print_statistics()
|
||||
logger.info("Bambu Run data collector stopped by user")
|
||||
except Exception as e:
|
||||
logger.exception(f"Fatal error in main loop: {e}")
|
||||
raise CommandError(f"Runner failed: {e}")
|
||||
|
||||
def _configure_logging(self):
|
||||
log_level = logging.DEBUG if self.verbose else logging.INFO
|
||||
logger.setLevel(log_level)
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(log_level)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
def _initialize_printer(self):
|
||||
from bambu_run.mqtt_client import BambuPrinter
|
||||
|
||||
bambu_username = os.environ.get("BAMBU_USERNAME")
|
||||
bambu_password = os.environ.get("BAMBU_PASSWORD")
|
||||
bambu_token = os.environ.get("BAMBU_TOKEN")
|
||||
bambu_device_id = os.environ.get("BAMBU_DEVICE_ID")
|
||||
|
||||
if not bambu_token and not all([bambu_username, bambu_password]):
|
||||
raise CommandError(
|
||||
"Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD "
|
||||
"environment variables must be set"
|
||||
)
|
||||
|
||||
logger.info("Connecting to Bambu Lab printer...")
|
||||
try:
|
||||
if bambu_token:
|
||||
logger.info("Using saved BAMBU_TOKEN for authentication")
|
||||
self.printer_client = BambuPrinter(
|
||||
token=bambu_token, device_id=bambu_device_id
|
||||
)
|
||||
else:
|
||||
logger.info("Authenticating with username/password")
|
||||
self.printer_client = BambuPrinter(
|
||||
username=bambu_username,
|
||||
password=bambu_password,
|
||||
device_id=bambu_device_id,
|
||||
)
|
||||
|
||||
logger.info("Initiating MQTT connection...")
|
||||
self.printer_client.connect(blocking=False)
|
||||
logger.info("MQTT connection initiated (non-blocking)")
|
||||
|
||||
except Exception as e:
|
||||
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
||||
error_msg = (
|
||||
f"SSL certificate verification failed: {e}\n\n"
|
||||
"Solutions:\n"
|
||||
"1. Run with --disable-ssl-verify flag\n"
|
||||
"2. Install Python SSL certificates\n"
|
||||
"3. pip install --upgrade certifi\n"
|
||||
)
|
||||
raise CommandError(error_msg)
|
||||
raise CommandError(f"Failed to initialize printer client: {e}")
|
||||
|
||||
self.printer_device = self._ensure_printer_device_exists()
|
||||
logger.info(f"Initialized for printer device: {self.printer_device}")
|
||||
|
||||
def _ensure_printer_device_exists(self) -> Printer:
|
||||
try:
|
||||
snapshot = self.printer_client.get_snapshot()
|
||||
|
||||
if snapshot:
|
||||
device, created = Printer.objects.update_or_create(
|
||||
model="Bambu Lab",
|
||||
defaults={
|
||||
"name": "Bambu Lab Printer",
|
||||
"manufacturer": "Bambu Lab",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
action = "Created" if created else "Updated"
|
||||
logger.info(f"{action} printer device record: {device}")
|
||||
return device
|
||||
else:
|
||||
logger.warning("Snapshot returned None - MQTT not connected yet")
|
||||
device = Printer.objects.filter(is_active=True).first()
|
||||
if device:
|
||||
logger.info(f"Using existing device record: {device}")
|
||||
return device
|
||||
else:
|
||||
device = Printer.objects.create(
|
||||
name="Bambu Lab Printer",
|
||||
model="Bambu Lab",
|
||||
manufacturer="Bambu Lab",
|
||||
is_active=True,
|
||||
)
|
||||
logger.info(f"Created placeholder device: {device}")
|
||||
return device
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during device initialization: {e}")
|
||||
try:
|
||||
device = Printer.objects.filter(is_active=True).first()
|
||||
if device:
|
||||
logger.warning(f"Using existing device record from DB: {device}")
|
||||
return device
|
||||
else:
|
||||
raise CommandError(
|
||||
"No printer device found in database and initialization failed."
|
||||
)
|
||||
except Printer.DoesNotExist:
|
||||
raise CommandError("Failed to create or retrieve printer device.")
|
||||
|
||||
def _run_continuous_loop(self, interval: int):
|
||||
iteration = 0
|
||||
while True:
|
||||
iteration += 1
|
||||
loop_start = time.time()
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(f"=== Iteration {iteration} ===")
|
||||
|
||||
self._collect_printer_data()
|
||||
|
||||
elapsed = time.time() - loop_start
|
||||
sleep_time = max(0, interval - elapsed)
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(f"Collection took {elapsed:.2f}s, sleeping for {sleep_time:.2f}s")
|
||||
|
||||
if iteration % 100 == 0:
|
||||
self._print_statistics()
|
||||
|
||||
time.sleep(sleep_time)
|
||||
|
||||
def _convert_mqtt_color(self, mqtt_color):
|
||||
if not mqtt_color:
|
||||
return None
|
||||
color_hex = mqtt_color[:6] if len(mqtt_color) >= 6 else mqtt_color
|
||||
return f"#{color_hex.upper()}"
|
||||
|
||||
def _match_filament_to_inventory(self, tray_data):
|
||||
from bambu_run.models import Filament
|
||||
|
||||
tray_id = tray_data.get('tray_id')
|
||||
tray_uuid = tray_data.get('tray_uuid')
|
||||
tag_uid = tray_data.get('tag_uid')
|
||||
tag_id = tray_data.get('tag_id')
|
||||
type_val = tray_data.get('type')
|
||||
sub_type = tray_data.get('sub_type')
|
||||
color = tray_data.get('color')
|
||||
|
||||
if tray_uuid:
|
||||
filament = Filament.objects.filter(tray_uuid=tray_uuid).first()
|
||||
if filament:
|
||||
if self.verbose:
|
||||
logger.debug(f"Matched filament via tray_uuid: {tray_uuid[:16]}...")
|
||||
return filament, 'tray_uuid'
|
||||
|
||||
if tag_uid:
|
||||
filament = Filament.objects.filter(tag_uid=tag_uid).first()
|
||||
if filament:
|
||||
if self.verbose:
|
||||
logger.debug(f"Matched filament via tag_uid: {tag_uid}")
|
||||
return filament, 'tag_uid'
|
||||
|
||||
if tag_id:
|
||||
filament = Filament.objects.filter(tag_id=tag_id).first()
|
||||
if filament:
|
||||
if self.verbose:
|
||||
logger.debug(f"Matched filament via tag_id: {tag_id}")
|
||||
return filament, 'tag_id'
|
||||
|
||||
if type_val and color:
|
||||
query_filters = {'type': type_val, 'color': color}
|
||||
if sub_type:
|
||||
query_filters['sub_type'] = sub_type
|
||||
|
||||
filament = Filament.objects.filter(
|
||||
**query_filters, is_loaded_in_ams=False
|
||||
).order_by('remaining_percent', 'last_used').first()
|
||||
|
||||
if not filament:
|
||||
filament = Filament.objects.filter(
|
||||
**query_filters
|
||||
).order_by('remaining_percent', 'last_used').first()
|
||||
|
||||
if filament:
|
||||
if self.verbose:
|
||||
logger.debug(f"Matched filament via type+sub_type+color: {filament}")
|
||||
return filament, 'type_sub_type_color'
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"No match found for tray {tray_id}. Auto-creating new filament...")
|
||||
|
||||
filament = self._auto_create_filament(tray_data)
|
||||
return filament, 'auto_created'
|
||||
|
||||
def _auto_create_filament(self, tray_data):
|
||||
from bambu_run.models import Filament, FilamentType
|
||||
from bambu_run.utils import strip_color_padding, match_filament_color
|
||||
|
||||
tray_uuid = tray_data.get('tray_uuid')
|
||||
tag_uid = tray_data.get('tag_uid')
|
||||
type_val = tray_data.get('type', 'Unknown')
|
||||
sub_type = tray_data.get('sub_type', '')
|
||||
mqtt_color = tray_data.get('color')
|
||||
remain_percent = tray_data.get('remain_percent', 100)
|
||||
diameter = tray_data.get('tray_diameter', 1.75)
|
||||
initial_weight = tray_data.get('tray_weight', 1000)
|
||||
|
||||
default_brand = app_settings.AUTO_CREATE_BRAND
|
||||
|
||||
color_code = strip_color_padding(mqtt_color)
|
||||
color_hex = f"#{color_code}" if color_code else None
|
||||
|
||||
color_name = mqtt_color
|
||||
filament_color = match_filament_color(
|
||||
filament_type=type_val,
|
||||
filament_sub_type=sub_type,
|
||||
color_code=color_code,
|
||||
brand=default_brand
|
||||
)
|
||||
|
||||
if filament_color:
|
||||
color_name = filament_color.color_name
|
||||
if self.verbose:
|
||||
logger.info(f"Matched color from database: {color_name} (#{color_code})")
|
||||
else:
|
||||
color_name = mqtt_color
|
||||
if self.verbose:
|
||||
logger.warning(
|
||||
f"No color match in database for {type_val} {sub_type} #{color_code}. "
|
||||
f"Using hex code as color name."
|
||||
)
|
||||
|
||||
filament_type_obj, ft_created = FilamentType.objects.get_or_create(
|
||||
type=type_val,
|
||||
sub_type=sub_type or None,
|
||||
brand=default_brand,
|
||||
)
|
||||
if ft_created and self.verbose:
|
||||
logger.info(f"Auto-created FilamentType: {filament_type_obj}")
|
||||
|
||||
filament = Filament.objects.create(
|
||||
filament_type=filament_type_obj,
|
||||
tray_uuid=tray_uuid,
|
||||
tag_uid=tag_uid,
|
||||
type=type_val,
|
||||
sub_type=sub_type,
|
||||
brand=default_brand,
|
||||
color=color_name,
|
||||
color_hex=color_hex,
|
||||
diameter=diameter,
|
||||
initial_weight_grams=initial_weight,
|
||||
remaining_percent=remain_percent,
|
||||
created_by='Auto Detection',
|
||||
is_loaded_in_ams=True,
|
||||
current_tray_id=tray_data.get('tray_id'),
|
||||
last_loaded_date=timezone.now(),
|
||||
)
|
||||
|
||||
filament.update_remaining_weight()
|
||||
filament.save()
|
||||
|
||||
logger.info(
|
||||
f"Auto-created filament: {filament.brand} {filament.type} "
|
||||
f"{filament.sub_type} - {filament.color} (SN: {tray_uuid[:16] if tray_uuid else 'N/A'}...)"
|
||||
)
|
||||
|
||||
return filament
|
||||
|
||||
def _update_filament_status(self, filament, tray_id, remain_percent):
|
||||
from bambu_run.models import Filament
|
||||
|
||||
if filament.remaining_percent != remain_percent:
|
||||
filament.remaining_percent = remain_percent
|
||||
filament.update_remaining_weight()
|
||||
filament.last_used = timezone.now()
|
||||
if self.verbose:
|
||||
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
||||
|
||||
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
|
||||
previous_filament = Filament.objects.filter(
|
||||
is_loaded_in_ams=True, current_tray_id=tray_id
|
||||
).exclude(id=filament.id).first()
|
||||
|
||||
if previous_filament:
|
||||
previous_filament.is_loaded_in_ams = False
|
||||
previous_filament.current_tray_id = None
|
||||
previous_filament.save()
|
||||
logger.info(
|
||||
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
||||
f"(replaced by {filament.brand} {filament.type} - {filament.color})"
|
||||
)
|
||||
|
||||
filament.is_loaded_in_ams = True
|
||||
filament.current_tray_id = tray_id
|
||||
filament.last_loaded_date = timezone.now()
|
||||
if self.verbose:
|
||||
logger.debug(f"Updated filament location: Tray {tray_id}")
|
||||
|
||||
filament.save()
|
||||
|
||||
def _create_filament_snapshots(self, printer_metric, filaments_data, snapshot):
|
||||
from bambu_run.models import FilamentSnapshot
|
||||
|
||||
ams_units = {
|
||||
u.get('unit_id'): u for u in snapshot.get('ams_units', [])
|
||||
}
|
||||
|
||||
for tray_data in filaments_data:
|
||||
tray_id = tray_data.get('tray_id')
|
||||
if tray_id is None:
|
||||
continue
|
||||
|
||||
filament, match_method = self._match_filament_to_inventory(tray_data)
|
||||
|
||||
if filament:
|
||||
remain_percent = tray_data.get('remain_percent')
|
||||
if remain_percent is not None:
|
||||
self._update_filament_status(filament, tray_id, remain_percent)
|
||||
|
||||
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None
|
||||
unit_data = ams_units.get(unit_id, {})
|
||||
|
||||
FilamentSnapshot.objects.create(
|
||||
printer_metric=printer_metric,
|
||||
filament=filament,
|
||||
tray_id=tray_id,
|
||||
slot_name=tray_data.get('slot'),
|
||||
type=tray_data.get('type'),
|
||||
sub_type=tray_data.get('sub_type'),
|
||||
color=tray_data.get('color'),
|
||||
remain_percent=tray_data.get('remain_percent'),
|
||||
k_value=tray_data.get('k'),
|
||||
temp=self._to_decimal(unit_data.get('temp')),
|
||||
humidity=unit_data.get('humidity'),
|
||||
tag_uid=tray_data.get('tag_uid'),
|
||||
tray_uuid=tray_data.get('tray_uuid'),
|
||||
state=tray_data.get('state'),
|
||||
auto_matched=bool(filament),
|
||||
match_method=match_method
|
||||
)
|
||||
|
||||
def _track_print_job(self, metric, snapshot):
|
||||
from bambu_run.models import PrintJob, FilamentUsage
|
||||
|
||||
gcode_state = snapshot.get('gcode_state')
|
||||
subtask_name = snapshot.get('subtask_name')
|
||||
|
||||
if self._is_print_starting(gcode_state, subtask_name):
|
||||
if self.current_print_job:
|
||||
self._finalize_print_job(metric, snapshot)
|
||||
|
||||
self.current_print_job = PrintJob.objects.create(
|
||||
device=self.printer_device,
|
||||
project_name=subtask_name,
|
||||
gcode_file=snapshot.get('gcode_file'),
|
||||
start_time=metric.timestamp,
|
||||
start_metric=metric,
|
||||
total_layers=snapshot.get('total_layer_num'),
|
||||
completion_percent=snapshot.get('print_percent', 0)
|
||||
)
|
||||
self.trays_used = set()
|
||||
logger.info(f"Print job started: {subtask_name}")
|
||||
|
||||
if self.current_print_job:
|
||||
tray_now = snapshot.get('tray_now', '')
|
||||
if tray_now not in (None, '', '255'):
|
||||
try:
|
||||
tray_id = int(tray_now)
|
||||
if 0 <= tray_id <= 15:
|
||||
self.trays_used.add(tray_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if self._is_print_ending(gcode_state) and self.current_print_job:
|
||||
self._finalize_print_job(metric, snapshot)
|
||||
|
||||
self.last_gcode_state = gcode_state
|
||||
self.last_subtask_name = subtask_name
|
||||
|
||||
def _is_print_starting(self, gcode_state, subtask_name):
|
||||
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
|
||||
has_new_job = subtask_name and subtask_name != self.last_subtask_name
|
||||
return is_printing and has_new_job
|
||||
|
||||
def _is_print_ending(self, gcode_state):
|
||||
ending_states = ['FINISH', 'FAILED']
|
||||
return gcode_state in ending_states and self.last_gcode_state not in ending_states
|
||||
|
||||
def _finalize_print_job(self, metric, snapshot):
|
||||
from bambu_run.models import FilamentUsage
|
||||
|
||||
self.current_print_job.end_time = metric.timestamp
|
||||
self.current_print_job.end_metric = metric
|
||||
self.current_print_job.final_status = snapshot.get('gcode_state')
|
||||
self.current_print_job.completion_percent = snapshot.get('print_percent', 0)
|
||||
self.current_print_job.calculate_duration()
|
||||
self.current_print_job.save()
|
||||
|
||||
start_metric = self.current_print_job.start_metric
|
||||
if not start_metric:
|
||||
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")
|
||||
elif not self.trays_used:
|
||||
logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage")
|
||||
else:
|
||||
for tray_id in self.trays_used:
|
||||
start_snap = start_metric.filament_snapshots.filter(
|
||||
tray_id=tray_id, filament__isnull=False
|
||||
).first()
|
||||
if not start_snap:
|
||||
continue
|
||||
|
||||
end_snap = metric.filament_snapshots.filter(
|
||||
filament=start_snap.filament, tray_id=tray_id
|
||||
).first()
|
||||
|
||||
usage = FilamentUsage.objects.create(
|
||||
print_job=self.current_print_job,
|
||||
filament=start_snap.filament,
|
||||
tray_id=tray_id,
|
||||
starting_percent=start_snap.remain_percent or 100,
|
||||
ending_percent=end_snap.remain_percent if end_snap else None,
|
||||
is_primary=(len(self.trays_used) == 1),
|
||||
)
|
||||
usage.calculate_consumed()
|
||||
usage.save()
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(
|
||||
f"Filament usage for {start_snap.filament} (tray {tray_id}): "
|
||||
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Print job finished: {self.current_print_job.project_name} "
|
||||
f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, "
|
||||
f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}"
|
||||
)
|
||||
|
||||
self.current_print_job = None
|
||||
self.trays_used = set()
|
||||
|
||||
def _collect_printer_data(self):
|
||||
try:
|
||||
snapshot = self.printer_client.get_snapshot()
|
||||
|
||||
if snapshot is None:
|
||||
self.mqtt_connect_errors += 1
|
||||
if self.mqtt_connect_errors <= 5 or self.verbose:
|
||||
logger.warning(
|
||||
f"MQTT not connected yet or no data available "
|
||||
f"(attempt {self.mqtt_connect_errors})"
|
||||
)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
metric = PrinterMetrics.objects.create(
|
||||
device=self.printer_device,
|
||||
timestamp=timezone.now(),
|
||||
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
|
||||
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
|
||||
bed_temp=self._to_decimal(snapshot.get("bed_temp")),
|
||||
bed_target_temp=self._to_decimal(snapshot.get("bed_target_temp")),
|
||||
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
||||
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
||||
nozzle_type=snapshot.get("nozzle_type"),
|
||||
gcode_state=snapshot.get("gcode_state"),
|
||||
print_type=snapshot.get("print_type"),
|
||||
print_percent=snapshot.get("print_percent"),
|
||||
remaining_time_min=snapshot.get("remaining_time_min"),
|
||||
layer_num=snapshot.get("layer_num"),
|
||||
total_layer_num=snapshot.get("total_layer_num"),
|
||||
print_line_number=snapshot.get("print_line_number"),
|
||||
subtask_name=snapshot.get("subtask_name"),
|
||||
gcode_file=snapshot.get("gcode_file"),
|
||||
cooling_fan_speed=snapshot.get("cooling_fan_speed"),
|
||||
heatbreak_fan_speed=snapshot.get("heatbreak_fan_speed"),
|
||||
big_fan1_speed=snapshot.get("big_fan1_speed"),
|
||||
big_fan2_speed=snapshot.get("big_fan2_speed"),
|
||||
spd_lvl=snapshot.get("spd_lvl"),
|
||||
spd_mag=snapshot.get("spd_mag"),
|
||||
wifi_signal_dbm=snapshot.get("wifi_signal_dbm"),
|
||||
print_error=snapshot.get("print_error", 0),
|
||||
has_errors=snapshot.get("has_errors", False),
|
||||
chamber_light=snapshot.get("chamber_light"),
|
||||
ipcam_record=snapshot.get("ipcam_record"),
|
||||
timelapse=snapshot.get("timelapse"),
|
||||
stg_cur=snapshot.get("stg_cur"),
|
||||
sdcard=snapshot.get("sdcard"),
|
||||
gcode_file_prepare_percent=snapshot.get("gcode_file_prepare_percent"),
|
||||
lifecycle=snapshot.get("lifecycle"),
|
||||
hms=snapshot.get("hms", []),
|
||||
ams_unit_count=snapshot.get("ams_unit_count"),
|
||||
ams_status=snapshot.get("ams_status"),
|
||||
ams_rfid_status=snapshot.get("ams_rfid_status"),
|
||||
ams_humidity=snapshot.get("ams_humidity"),
|
||||
ams_humidity_raw=snapshot.get("ams_humidity_raw"),
|
||||
ams_temp=self._to_decimal(snapshot.get("ams_temp")),
|
||||
ams_version=snapshot.get("ams_version"),
|
||||
tray_is_bbl_bits=snapshot.get("tray_is_bbl_bits"),
|
||||
tray_read_done_bits=snapshot.get("tray_read_done_bits"),
|
||||
filaments=snapshot.get("filaments", []),
|
||||
ams_units=snapshot.get("ams_units", []),
|
||||
external_spool=snapshot.get("external_spool", {}),
|
||||
lights_report=snapshot.get("lights_report", []),
|
||||
)
|
||||
|
||||
filaments_data = snapshot.get('filaments', [])
|
||||
if filaments_data:
|
||||
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
||||
|
||||
self._track_print_job(metric, snapshot)
|
||||
|
||||
self.success_count += 1
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(
|
||||
f"Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
|
||||
f"Bed={snapshot.get('bed_temp')}C, "
|
||||
f"Progress={snapshot.get('print_percent')}%, "
|
||||
f"State={snapshot.get('gcode_state')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}")
|
||||
if self.verbose:
|
||||
logger.exception("Detailed traceback:")
|
||||
|
||||
def _to_decimal(self, value) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _print_statistics(self):
|
||||
if self.start_time:
|
||||
runtime = timezone.now() - self.start_time
|
||||
total_collections = self.success_count + self.error_count
|
||||
success_rate = (
|
||||
(self.success_count / total_collections * 100)
|
||||
if total_collections > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
logger.info("=== Statistics ===")
|
||||
logger.info(f"Runtime: {runtime}")
|
||||
logger.info(f"Successful collections: {self.success_count}")
|
||||
logger.info(f"Failed collections: {self.error_count}")
|
||||
logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}")
|
||||
logger.info(f"Success rate: {success_rate:.1f}%")
|
||||
208
bambu_run/migrations/0001_initial.py
Normal file
208
bambu_run/migrations/0001_initial.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Initial migration for bambu_run.
|
||||
|
||||
For STANDALONE deployments (fresh SQLite), this creates all tables from scratch.
|
||||
|
||||
For RAE integration, this migration should NOT be run directly — instead,
|
||||
use the SeparateDatabaseAndState migration in the infrastructure app
|
||||
to transfer model ownership without touching existing tables.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Printer",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(help_text="Printer display name", max_length=200)),
|
||||
("ip_address", models.GenericIPAddressField(blank=True, help_text="Local IP address", null=True)),
|
||||
("serial_number", models.CharField(blank=True, help_text="Printer serial number", max_length=100)),
|
||||
("model", models.CharField(blank=True, help_text="Printer model (e.g., X1C, P1S)", max_length=100)),
|
||||
("is_active", models.BooleanField(default=True, help_text="Whether the printer is actively monitored")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Printer",
|
||||
"verbose_name_plural": "Printers",
|
||||
"db_table": "infrastructure_device",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PrinterMetrics",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("timestamp", models.DateTimeField(db_index=True, help_text="When this metric was recorded")),
|
||||
("nozzle_temp", models.FloatField(blank=True, help_text="Nozzle temperature in Celsius", null=True)),
|
||||
("nozzle_target_temp", models.FloatField(blank=True, help_text="Nozzle target temperature", null=True)),
|
||||
("bed_temp", models.FloatField(blank=True, help_text="Bed temperature in Celsius", null=True)),
|
||||
("bed_target_temp", models.FloatField(blank=True, help_text="Bed target temperature", null=True)),
|
||||
("chamber_temp", models.FloatField(blank=True, help_text="Chamber temperature", null=True)),
|
||||
("print_percent", models.IntegerField(blank=True, help_text="Print progress percentage", null=True)),
|
||||
("wifi_signal_dbm", models.IntegerField(blank=True, help_text="WiFi signal strength in dBm", null=True)),
|
||||
("cooling_fan_speed", models.IntegerField(blank=True, help_text="Cooling fan speed (0-15)", null=True)),
|
||||
("heatbreak_fan_speed", models.IntegerField(blank=True, help_text="Heatbreak fan speed (0-15)", null=True)),
|
||||
("gcode_state", models.CharField(blank=True, help_text="Current GCode execution state", max_length=50, null=True)),
|
||||
("subtask_name", models.CharField(blank=True, help_text="Current print subtask name", max_length=255, null=True)),
|
||||
("layer_num", models.IntegerField(blank=True, help_text="Current layer number", null=True)),
|
||||
("total_layer_num", models.IntegerField(blank=True, help_text="Total layer count for current print", null=True)),
|
||||
("chamber_light", models.CharField(blank=True, help_text="Chamber light status (on/off)", max_length=10, null=True)),
|
||||
("ams_humidity_raw", models.IntegerField(blank=True, help_text="AMS raw humidity value", null=True)),
|
||||
("ams_temp", models.FloatField(blank=True, help_text="AMS temperature in Celsius", null=True)),
|
||||
("tray_now", models.CharField(blank=True, help_text="Currently active AMS tray", max_length=10, null=True)),
|
||||
("device", models.ForeignKey(help_text="The printer this metric belongs to", on_delete=django.db.models.deletion.CASCADE, related_name="printer_metrics", to="bambu_run.printer")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Printer Metrics",
|
||||
"verbose_name_plural": "Printer Metrics",
|
||||
"db_table": "infrastructure_printer_metrics",
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FilamentType",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("type", models.CharField(help_text="Base material type (PLA, PETG, ABS, etc.)", max_length=50)),
|
||||
("sub_type", models.CharField(blank=True, default="", help_text="Material variant (Basic, Matte, Silk, etc.)", max_length=50)),
|
||||
("brand", models.CharField(help_text="Filament manufacturer", max_length=100)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Filament Type",
|
||||
"verbose_name_plural": "Filament Types",
|
||||
"db_table": "infrastructure_filament_type",
|
||||
"ordering": ["type", "sub_type", "brand"],
|
||||
"unique_together": {("type", "sub_type", "brand")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FilamentColor",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("color_name", models.CharField(help_text="Human-readable color name", max_length=100)),
|
||||
("color_code", models.CharField(help_text="8-char hex color code from printer (RRGGBBFF)", max_length=8)),
|
||||
("filament_type", models.CharField(blank=True, default="", help_text="Material type (legacy field)", max_length=50)),
|
||||
("filament_sub_type", models.CharField(blank=True, default="", help_text="Sub type (legacy field)", max_length=50)),
|
||||
("brand", models.CharField(blank=True, default="", help_text="Brand (legacy field)", max_length=100)),
|
||||
("filament_type_fk", models.ForeignKey(blank=True, help_text="Link to filament type registry", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="colors", to="bambu_run.filamenttype")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Filament Color",
|
||||
"verbose_name_plural": "Filament Colors",
|
||||
"db_table": "infrastructure_filament_color",
|
||||
"ordering": ["filament_type", "color_name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Filament",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("tray_uuid", models.CharField(blank=True, db_index=True, help_text="Spool serial number from AMS (unique per spool)", max_length=100, null=True)),
|
||||
("tag_uid", models.CharField(blank=True, db_index=True, help_text="RFID chip UID from AMS tray", max_length=100, null=True)),
|
||||
("tag_id", models.CharField(blank=True, help_text="User-defined tag/barcode ID", max_length=100, null=True)),
|
||||
("type", models.CharField(help_text="Material type (PLA, PETG, ABS, etc.)", max_length=50)),
|
||||
("sub_type", models.CharField(blank=True, default="", help_text="Material sub-type (Basic, Matte, Silk, etc.)", max_length=50)),
|
||||
("brand", models.CharField(default="Unknown", help_text="Filament manufacturer/brand", max_length=100)),
|
||||
("color", models.CharField(help_text="Color name (e.g., Black, White, Red)", max_length=50)),
|
||||
("color_hex", models.CharField(blank=True, help_text="Hex color code (#RRGGBB format)", max_length=9, null=True)),
|
||||
("diameter", models.FloatField(default=1.75, help_text="Filament diameter in mm")),
|
||||
("initial_weight_grams", models.FloatField(blank=True, help_text="Initial spool weight in grams", null=True)),
|
||||
("remaining_percent", models.FloatField(default=100, help_text="Remaining filament percentage (0-100)")),
|
||||
("remaining_weight_grams", models.FloatField(blank=True, help_text="Remaining filament weight in grams", null=True)),
|
||||
("is_loaded_in_ams", models.BooleanField(default=False, help_text="Whether this filament is currently in an AMS tray")),
|
||||
("current_tray_id", models.IntegerField(blank=True, help_text="AMS tray slot (0-3) if loaded", null=True)),
|
||||
("last_loaded_date", models.DateTimeField(blank=True, help_text="When filament was last loaded into AMS", null=True)),
|
||||
("last_used", models.DateTimeField(blank=True, help_text="Last time this filament was used in a print", null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("created_by", models.CharField(default="Manual", help_text="How this filament was added (Manual or Auto Detection)", max_length=50)),
|
||||
("purchase_date", models.DateField(blank=True, help_text="When the filament was purchased", null=True)),
|
||||
("purchase_price", models.DecimalField(blank=True, decimal_places=2, help_text="Purchase price", max_digits=8, null=True)),
|
||||
("supplier", models.CharField(blank=True, help_text="Where the filament was purchased", max_length=200, null=True)),
|
||||
("notes", models.TextField(blank=True, help_text="Additional notes about this filament", null=True)),
|
||||
("filament_color", models.ForeignKey(blank=True, help_text="Matched color from database", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="filaments", to="bambu_run.filamentcolor")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Filament",
|
||||
"verbose_name_plural": "Filaments",
|
||||
"db_table": "infrastructure_filament",
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FilamentSnapshot",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("tray_id", models.IntegerField(help_text="AMS tray slot (0-3)")),
|
||||
("type", models.CharField(blank=True, help_text="Filament type at snapshot time", max_length=50, null=True)),
|
||||
("sub_type", models.CharField(blank=True, help_text="Filament sub-type at snapshot time", max_length=50, null=True)),
|
||||
("color", models.CharField(blank=True, help_text="Hex color code at snapshot time", max_length=20, null=True)),
|
||||
("remain_percent", models.IntegerField(blank=True, help_text="Remaining percentage at snapshot time", null=True)),
|
||||
("tray_uuid", models.CharField(blank=True, help_text="Spool serial number at snapshot time", max_length=100, null=True)),
|
||||
("tag_uid", models.CharField(blank=True, help_text="RFID tag UID at snapshot time", max_length=100, null=True)),
|
||||
("filament", models.ForeignKey(blank=True, help_text="Matched filament from inventory", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="snapshots", to="bambu_run.filament")),
|
||||
("printer_metric", models.ForeignKey(help_text="The printer metric this snapshot belongs to", on_delete=django.db.models.deletion.CASCADE, related_name="filament_snapshots", to="bambu_run.printermetrics")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Filament Snapshot",
|
||||
"verbose_name_plural": "Filament Snapshots",
|
||||
"db_table": "infrastructure_filament_snapshot",
|
||||
"ordering": ["-printer_metric__timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PrintJob",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("project_name", models.CharField(help_text="Name of the print project", max_length=255)),
|
||||
("gcode_file", models.CharField(blank=True, help_text="GCode filename", max_length=255, null=True)),
|
||||
("start_time", models.DateTimeField(db_index=True, help_text="When the print started")),
|
||||
("end_time", models.DateTimeField(blank=True, help_text="When the print ended", null=True)),
|
||||
("final_status", models.CharField(blank=True, help_text="Final status (FINISH, FAILED, etc.)", max_length=50, null=True)),
|
||||
("total_layers", models.IntegerField(blank=True, help_text="Total layers in the print", null=True)),
|
||||
("device", models.ForeignKey(help_text="Printer used for this job", on_delete=django.db.models.deletion.CASCADE, related_name="print_jobs", to="bambu_run.printer")),
|
||||
("start_metric", models.ForeignKey(blank=True, help_text="Metric snapshot at print start", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="started_jobs", to="bambu_run.printermetrics")),
|
||||
("end_metric", models.ForeignKey(blank=True, help_text="Metric snapshot at print end", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="ended_jobs", to="bambu_run.printermetrics")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Print Job",
|
||||
"verbose_name_plural": "Print Jobs",
|
||||
"db_table": "infrastructure_print_job",
|
||||
"ordering": ["-start_time"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FilamentUsage",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("tray_id", models.IntegerField(help_text="AMS tray slot used (0-3)")),
|
||||
("starting_percent", models.FloatField(blank=True, help_text="Filament remaining % at print start", null=True)),
|
||||
("ending_percent", models.FloatField(blank=True, help_text="Filament remaining % at print end", null=True)),
|
||||
("consumed_percent", models.FloatField(blank=True, help_text="Percentage of filament consumed", null=True)),
|
||||
("consumed_grams", models.FloatField(blank=True, help_text="Weight of filament consumed in grams", null=True)),
|
||||
("filament", models.ForeignKey(blank=True, help_text="Which filament spool was used", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="usage_records", to="bambu_run.filament")),
|
||||
("print_job", models.ForeignKey(help_text="The print job that used this filament", on_delete=django.db.models.deletion.CASCADE, related_name="filament_usages", to="bambu_run.printjob")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Filament Usage",
|
||||
"verbose_name_plural": "Filament Usages",
|
||||
"db_table": "infrastructure_filament_usage",
|
||||
"ordering": ["-print_job__start_time"],
|
||||
},
|
||||
),
|
||||
# Add indexes
|
||||
migrations.AddIndex(
|
||||
model_name="printermetrics",
|
||||
index=models.Index(fields=["device", "-timestamp"], name="infra_pm_device_ts_idx"),
|
||||
),
|
||||
]
|
||||
0
bambu_run/migrations/__init__.py
Normal file
0
bambu_run/migrations/__init__.py
Normal file
595
bambu_run/models.py
Normal file
595
bambu_run/models.py
Normal file
@@ -0,0 +1,595 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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)"
|
||||
)
|
||||
|
||||
# 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)
|
||||
)
|
||||
876
bambu_run/mqtt_client.py
Normal file
876
bambu_run/mqtt_client.py
Normal file
@@ -0,0 +1,876 @@
|
||||
"""
|
||||
BambuLab Cloud API Client
|
||||
Provides authentication, device management, and real-time MQTT monitoring
|
||||
for BambuLab 3D printers via the Cloud API.
|
||||
|
||||
Requires: pip install bambu-lab-cloud-api
|
||||
|
||||
Usage:
|
||||
from bambu_run.mqtt_client import BambuPrinter, PrinterState
|
||||
|
||||
printer = BambuPrinter(token="your_token", device_id="your_device_id")
|
||||
printer.connect()
|
||||
state = printer.get_state()
|
||||
snapshot = printer.get_snapshot()
|
||||
printer.disconnect()
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import select
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .conf import app_settings
|
||||
|
||||
# Re-export from bambu-lab-cloud-api package
|
||||
try:
|
||||
from bambulab import BambuAuthenticator, BambuClient, MQTTClient
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"bambu-lab-cloud-api package is required. Install with: pip install bambu-lab-cloud-api"
|
||||
) from e
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_stdout():
|
||||
"""Context manager to suppress stdout (for silencing library print statements)"""
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = io.StringIO()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
def timed_input(prompt: str, timeout_sec: int = 300) -> str:
|
||||
"""
|
||||
Get user input with a timeout.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to display
|
||||
timeout_sec: Timeout in seconds (default 300 = 5 minutes)
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
|
||||
Raises:
|
||||
TimeoutError: If no input received within timeout
|
||||
"""
|
||||
print(prompt, end='', flush=True)
|
||||
|
||||
if platform.system() == 'Windows':
|
||||
import threading
|
||||
result = {'value': None, 'done': False}
|
||||
|
||||
def get_input():
|
||||
try:
|
||||
result['value'] = input()
|
||||
except EOFError:
|
||||
result['value'] = None
|
||||
result['done'] = True
|
||||
|
||||
thread = threading.Thread(target=get_input, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=timeout_sec)
|
||||
|
||||
if not result['done']:
|
||||
print()
|
||||
raise TimeoutError(f"No input received within {timeout_sec} seconds")
|
||||
return result['value'] or ""
|
||||
else:
|
||||
ready, _, _ = select.select([sys.stdin], [], [], timeout_sec)
|
||||
if ready:
|
||||
return sys.stdin.readline().strip()
|
||||
else:
|
||||
print()
|
||||
raise TimeoutError(f"No input received within {timeout_sec} seconds")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilamentTray:
|
||||
"""Represents a single filament tray in an AMS unit"""
|
||||
tray_id: str = ""
|
||||
tray_id_name: str = ""
|
||||
tray_type: str = ""
|
||||
tray_sub_brands: str = ""
|
||||
tray_color: str = ""
|
||||
remain_percent: int = -1
|
||||
tray_weight: int = 0
|
||||
tray_diameter: float = 1.75
|
||||
tray_temp: int = 0
|
||||
nozzle_temp_min: int = 0
|
||||
nozzle_temp_max: int = 0
|
||||
state: int = 0
|
||||
tag_uid: str = ""
|
||||
tray_uuid: str = ""
|
||||
k: float = 0.0
|
||||
n: float = 0.0
|
||||
cali_idx: int = -1
|
||||
total_len: int = 0
|
||||
tray_info_idx: str = ""
|
||||
tray_time: int = 0
|
||||
tray_bed_temp: int = 0
|
||||
bed_temp_type: int = 0
|
||||
cols: List[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "FilamentTray":
|
||||
"""Create FilamentTray from MQTT tray data"""
|
||||
return cls(
|
||||
tray_id=str(data.get("id", "")),
|
||||
tray_id_name=data.get("tray_id_name", ""),
|
||||
tray_type=data.get("tray_type", ""),
|
||||
tray_sub_brands=data.get("tray_sub_brands", ""),
|
||||
tray_color=data.get("tray_color", ""),
|
||||
remain_percent=data.get("remain", -1),
|
||||
tray_weight=int(data.get("tray_weight", 0)),
|
||||
tray_diameter=float(data.get("tray_diameter", 1.75)),
|
||||
tray_temp=int(data.get("tray_temp", 0)),
|
||||
nozzle_temp_min=int(data.get("nozzle_temp_min", 0)),
|
||||
nozzle_temp_max=int(data.get("nozzle_temp_max", 0)),
|
||||
state=data.get("state", 0),
|
||||
tag_uid=data.get("tag_uid", ""),
|
||||
tray_uuid=data.get("tray_uuid", ""),
|
||||
k=float(data.get("k", 0.0)),
|
||||
n=float(data.get("n", 0.0)),
|
||||
cali_idx=int(data.get("cali_idx", -1)),
|
||||
total_len=int(data.get("total_len", 0)),
|
||||
tray_info_idx=data.get("tray_info_idx", ""),
|
||||
tray_time=int(data.get("tray_time", 0)),
|
||||
tray_bed_temp=int(data.get("bed_temp", 0)),
|
||||
bed_temp_type=int(data.get("bed_temp_type", 0)),
|
||||
cols=data.get("cols", []),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for database storage"""
|
||||
return {
|
||||
"tray_id": self.tray_id,
|
||||
"tray_id_name": self.tray_id_name,
|
||||
"tray_type": self.tray_type,
|
||||
"tray_sub_brands": self.tray_sub_brands,
|
||||
"tray_color": self.tray_color,
|
||||
"remain_percent": self.remain_percent,
|
||||
"tray_weight": self.tray_weight,
|
||||
"tray_diameter": self.tray_diameter,
|
||||
"tray_temp": self.tray_temp,
|
||||
"nozzle_temp_min": self.nozzle_temp_min,
|
||||
"nozzle_temp_max": self.nozzle_temp_max,
|
||||
"state": self.state,
|
||||
"tag_uid": self.tag_uid,
|
||||
"tray_uuid": self.tray_uuid,
|
||||
"k": self.k,
|
||||
"n": self.n,
|
||||
"cali_idx": self.cali_idx,
|
||||
"total_len": self.total_len,
|
||||
"tray_info_idx": self.tray_info_idx,
|
||||
"tray_time": self.tray_time,
|
||||
"tray_bed_temp": self.tray_bed_temp,
|
||||
"bed_temp_type": self.bed_temp_type,
|
||||
"cols": self.cols,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AMSUnit:
|
||||
"""Represents a single AMS (Automatic Material System) unit"""
|
||||
ams_id: str = ""
|
||||
unit_id: str = ""
|
||||
humidity: int = -1
|
||||
humidity_raw: int = -1
|
||||
temp: float = 0.0
|
||||
dry_time: int = 0
|
||||
chip_id: str = ""
|
||||
info: str = ""
|
||||
trays: List[FilamentTray] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AMSUnit":
|
||||
"""Create AMSUnit from MQTT ams data"""
|
||||
trays = [FilamentTray.from_dict(t) for t in data.get("tray", [])]
|
||||
return cls(
|
||||
ams_id=data.get("ams_id", ""),
|
||||
unit_id=str(data.get("id", "")),
|
||||
humidity=int(data.get("humidity", -1)),
|
||||
humidity_raw=int(data.get("humidity_raw", -1)),
|
||||
temp=float(data.get("temp", 0.0)),
|
||||
dry_time=data.get("dry_time", 0),
|
||||
chip_id=data.get("chip_id", ""),
|
||||
info=data.get("info", ""),
|
||||
trays=trays,
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for database storage"""
|
||||
return {
|
||||
"ams_id": self.ams_id,
|
||||
"unit_id": self.unit_id,
|
||||
"humidity": self.humidity,
|
||||
"humidity_raw": self.humidity_raw,
|
||||
"temp": self.temp,
|
||||
"dry_time": self.dry_time,
|
||||
"chip_id": self.chip_id,
|
||||
"info": self.info,
|
||||
"trays": [t.to_dict() for t in self.trays],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AMSState:
|
||||
"""Complete AMS system state including all units"""
|
||||
ams_exist_bits: str = ""
|
||||
tray_exist_bits: str = ""
|
||||
tray_now: str = ""
|
||||
tray_pre: str = ""
|
||||
tray_tar: str = ""
|
||||
ams_status: int = 0
|
||||
ams_rfid_status: int = 0
|
||||
tray_is_bbl_bits: str = ""
|
||||
tray_read_done_bits: str = ""
|
||||
version: int = 0
|
||||
insert_flag: bool = False
|
||||
power_on_flag: bool = False
|
||||
units: List[AMSUnit] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AMSState":
|
||||
"""Create AMSState from MQTT ams data"""
|
||||
units = [AMSUnit.from_dict(u) for u in data.get("ams", [])]
|
||||
return cls(
|
||||
ams_exist_bits=data.get("ams_exist_bits", ""),
|
||||
tray_exist_bits=data.get("tray_exist_bits", ""),
|
||||
tray_now=data.get("tray_now", ""),
|
||||
tray_pre=data.get("tray_pre", ""),
|
||||
tray_tar=data.get("tray_tar", ""),
|
||||
ams_status=data.get("ams_status", 0),
|
||||
ams_rfid_status=data.get("ams_rfid_status", 0),
|
||||
tray_is_bbl_bits=data.get("tray_is_bbl_bits", ""),
|
||||
tray_read_done_bits=data.get("tray_read_done_bits", ""),
|
||||
version=int(data.get("version", 0)),
|
||||
insert_flag=bool(data.get("insert_flag", False)),
|
||||
power_on_flag=bool(data.get("power_on_flag", False)),
|
||||
units=units,
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for database storage"""
|
||||
return {
|
||||
"ams_exist_bits": self.ams_exist_bits,
|
||||
"tray_exist_bits": self.tray_exist_bits,
|
||||
"tray_now": self.tray_now,
|
||||
"tray_pre": self.tray_pre,
|
||||
"tray_tar": self.tray_tar,
|
||||
"ams_status": self.ams_status,
|
||||
"ams_rfid_status": self.ams_rfid_status,
|
||||
"tray_is_bbl_bits": self.tray_is_bbl_bits,
|
||||
"tray_read_done_bits": self.tray_read_done_bits,
|
||||
"version": self.version,
|
||||
"insert_flag": self.insert_flag,
|
||||
"power_on_flag": self.power_on_flag,
|
||||
"units": [u.to_dict() for u in self.units],
|
||||
}
|
||||
|
||||
@property
|
||||
def total_trays(self) -> int:
|
||||
"""Total number of trays across all units"""
|
||||
return sum(len(u.trays) for u in self.units)
|
||||
|
||||
@property
|
||||
def loaded_trays(self) -> List[FilamentTray]:
|
||||
"""Get all trays that have filament loaded"""
|
||||
loaded = []
|
||||
for unit in self.units:
|
||||
for tray in unit.trays:
|
||||
if tray.tray_type:
|
||||
loaded.append(tray)
|
||||
return loaded
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrinterState:
|
||||
"""Complete printer state parsed from MQTT data"""
|
||||
timestamp: str = ""
|
||||
sequence_id: str = ""
|
||||
|
||||
# Temperature info
|
||||
nozzle_temp: float = 0.0
|
||||
nozzle_target_temp: float = 0.0
|
||||
bed_temp: float = 0.0
|
||||
bed_target_temp: float = 0.0
|
||||
chamber_temp: float = 0.0
|
||||
|
||||
# Print progress
|
||||
gcode_state: str = ""
|
||||
print_percent: int = 0
|
||||
remaining_time_min: int = 0
|
||||
layer_num: int = 0
|
||||
total_layer_num: int = 0
|
||||
print_line_number: int = 0
|
||||
|
||||
# Current job info
|
||||
gcode_file: str = ""
|
||||
subtask_name: str = ""
|
||||
subtask_id: str = ""
|
||||
task_id: str = ""
|
||||
project_id: str = ""
|
||||
profile_id: str = ""
|
||||
print_type: str = ""
|
||||
|
||||
# Fan speeds
|
||||
fan_gear: int = 0
|
||||
cooling_fan_speed: int = 0
|
||||
heatbreak_fan_speed: int = 0
|
||||
|
||||
# WiFi / Network
|
||||
wifi_signal: str = ""
|
||||
wifi_signal_dbm: int = 0
|
||||
|
||||
# Nozzle info
|
||||
nozzle_diameter: float = 0.4
|
||||
nozzle_type: str = ""
|
||||
|
||||
# System status
|
||||
home_flag: int = 0
|
||||
hw_switch_state: int = 0
|
||||
mc_print_stage: str = ""
|
||||
mc_print_sub_stage: int = 0
|
||||
print_error: int = 0
|
||||
stg_cur: int = 0
|
||||
|
||||
# AMS state
|
||||
ams: Optional[AMSState] = None
|
||||
|
||||
# Upgrade state
|
||||
upgrade_state: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Version info
|
||||
version: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Camera / Timelapse
|
||||
ipcam: Dict[str, Any] = field(default_factory=dict)
|
||||
timelapse: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Lights
|
||||
lights_report: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# HMS (Health Management System) messages
|
||||
hms: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Speed settings
|
||||
spd_lvl: int = 0
|
||||
spd_mag: int = 0
|
||||
|
||||
# Auxiliary fans
|
||||
big_fan1_speed: int = 0
|
||||
big_fan2_speed: int = 0
|
||||
|
||||
# System info
|
||||
sdcard: bool = False
|
||||
gcode_file_prepare_percent: str = ""
|
||||
lifecycle: str = ""
|
||||
|
||||
# External spool (virtual tray)
|
||||
vt_tray: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Raw data for any additional fields
|
||||
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
@staticmethod
|
||||
def _parse_wifi_signal(signal_str: str) -> int:
|
||||
"""Parse WiFi signal string (e.g., '-34dBm') to integer dBm"""
|
||||
if not signal_str:
|
||||
return 0
|
||||
try:
|
||||
return int(signal_str.replace("dBm", ""))
|
||||
except (ValueError, AttributeError):
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def from_mqtt_data(cls, data: Dict[str, Any], timestamp: Optional[str] = None) -> "PrinterState":
|
||||
"""Create PrinterState from MQTT push_status data."""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now(ZoneInfo(app_settings.TIMEZONE)).isoformat()
|
||||
|
||||
print_data = data.get("print", {})
|
||||
|
||||
# Parse AMS data if present
|
||||
ams = None
|
||||
if "ams" in print_data:
|
||||
ams = AMSState.from_dict(print_data["ams"])
|
||||
|
||||
wifi_signal = print_data.get("wifi_signal", "")
|
||||
|
||||
return cls(
|
||||
timestamp=timestamp,
|
||||
sequence_id=str(print_data.get("sequence_id", "")),
|
||||
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)),
|
||||
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)),
|
||||
bed_temp=float(print_data.get("bed_temper", 0.0)),
|
||||
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
|
||||
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
|
||||
gcode_state=print_data.get("gcode_state", ""),
|
||||
print_percent=int(print_data.get("mc_percent", 0)),
|
||||
remaining_time_min=int(print_data.get("mc_remaining_time", 0)),
|
||||
layer_num=int(print_data.get("layer_num", 0)),
|
||||
total_layer_num=int(print_data.get("total_layer_num", 0)),
|
||||
print_line_number=int(print_data.get("mc_print_line_number", 0)),
|
||||
gcode_file=print_data.get("gcode_file", ""),
|
||||
subtask_name=print_data.get("subtask_name", ""),
|
||||
subtask_id=print_data.get("subtask_id", ""),
|
||||
task_id=print_data.get("task_id", ""),
|
||||
project_id=print_data.get("project_id", ""),
|
||||
profile_id=print_data.get("profile_id", ""),
|
||||
print_type=print_data.get("print_type", ""),
|
||||
fan_gear=int(print_data.get("fan_gear", 0)),
|
||||
cooling_fan_speed=int(print_data.get("cooling_fan_speed", 0)),
|
||||
heatbreak_fan_speed=int(print_data.get("heatbreak_fan_speed", 0)),
|
||||
wifi_signal=wifi_signal,
|
||||
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
|
||||
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
||||
nozzle_type=print_data.get("nozzle_type", ""),
|
||||
home_flag=int(print_data.get("home_flag", 0)),
|
||||
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
||||
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
||||
mc_print_sub_stage=int(print_data.get("mc_print_sub_stage", 0)),
|
||||
print_error=int(print_data.get("print_error", 0)),
|
||||
stg_cur=int(print_data.get("stg_cur", 0)),
|
||||
ams=ams,
|
||||
upgrade_state=print_data.get("upgrade_state", {}),
|
||||
version=print_data.get("version", {}),
|
||||
ipcam=print_data.get("ipcam", {}),
|
||||
timelapse=print_data.get("timelapse", {}),
|
||||
lights_report=print_data.get("lights_report", []),
|
||||
hms=print_data.get("hms", []),
|
||||
spd_lvl=int(print_data.get("spd_lvl", 0)),
|
||||
spd_mag=int(print_data.get("spd_mag", 0)),
|
||||
big_fan1_speed=int(print_data.get("big_fan1_speed", 0)),
|
||||
big_fan2_speed=int(print_data.get("big_fan2_speed", 0)),
|
||||
sdcard=bool(print_data.get("sdcard", False)),
|
||||
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
||||
lifecycle=print_data.get("lifecycle", ""),
|
||||
vt_tray=print_data.get("vt_tray"),
|
||||
_raw_data=data,
|
||||
)
|
||||
|
||||
def get_snapshot(self) -> Dict[str, Any]:
|
||||
"""Get a simplified snapshot for database logging."""
|
||||
snapshot = {
|
||||
"timestamp": self.timestamp,
|
||||
"nozzle_temp": round(self.nozzle_temp, 2),
|
||||
"nozzle_target_temp": round(self.nozzle_target_temp, 2),
|
||||
"bed_temp": round(self.bed_temp, 2),
|
||||
"bed_target_temp": round(self.bed_target_temp, 2),
|
||||
"chamber_temp": round(self.chamber_temp, 2),
|
||||
"nozzle_diameter": self.nozzle_diameter,
|
||||
"nozzle_type": self.nozzle_type,
|
||||
"gcode_state": self.gcode_state,
|
||||
"print_type": self.print_type,
|
||||
"print_percent": self.print_percent,
|
||||
"remaining_time_min": self.remaining_time_min,
|
||||
"layer_num": self.layer_num,
|
||||
"total_layer_num": self.total_layer_num,
|
||||
"print_line_number": self.print_line_number,
|
||||
"subtask_name": self.subtask_name,
|
||||
"gcode_file": self.gcode_file,
|
||||
"cooling_fan_speed": self.cooling_fan_speed,
|
||||
"heatbreak_fan_speed": self.heatbreak_fan_speed,
|
||||
"big_fan1_speed": self.big_fan1_speed,
|
||||
"big_fan2_speed": self.big_fan2_speed,
|
||||
"spd_lvl": self.spd_lvl,
|
||||
"spd_mag": self.spd_mag,
|
||||
"wifi_signal_dbm": self.wifi_signal_dbm,
|
||||
"print_error": self.print_error,
|
||||
"has_errors": self.print_error != 0,
|
||||
"hms": self.hms,
|
||||
"stg_cur": self.stg_cur,
|
||||
"lights_report": self.lights_report,
|
||||
"chamber_light": self._get_chamber_light_status(),
|
||||
"ipcam_record": self.ipcam.get("ipcam_record", ""),
|
||||
"timelapse": self.ipcam.get("timelapse", ""),
|
||||
"sdcard": self.sdcard,
|
||||
"gcode_file_prepare_percent": self.gcode_file_prepare_percent,
|
||||
"lifecycle": self.lifecycle,
|
||||
}
|
||||
|
||||
if self.ams:
|
||||
snapshot["ams_unit_count"] = len(self.ams.units)
|
||||
snapshot["ams_status"] = self.ams.ams_status
|
||||
snapshot["ams_rfid_status"] = self.ams.ams_rfid_status
|
||||
snapshot["ams_exist_bits"] = self.ams.ams_exist_bits
|
||||
snapshot["tray_exist_bits"] = self.ams.tray_exist_bits
|
||||
snapshot["tray_is_bbl_bits"] = self.ams.tray_is_bbl_bits
|
||||
snapshot["tray_read_done_bits"] = self.ams.tray_read_done_bits
|
||||
snapshot["tray_now"] = self.ams.tray_now
|
||||
snapshot["ams_version"] = self.ams.version
|
||||
|
||||
filaments = []
|
||||
for unit in self.ams.units:
|
||||
for tray in unit.trays:
|
||||
if tray.tray_type:
|
||||
filaments.append({
|
||||
"tray_id": tray.tray_id,
|
||||
"slot": tray.tray_id_name,
|
||||
"type": tray.tray_type,
|
||||
"sub_type": tray.tray_sub_brands,
|
||||
"color": tray.tray_color,
|
||||
"remain_percent": tray.remain_percent,
|
||||
"tray_weight": tray.tray_weight,
|
||||
"tray_diameter": tray.tray_diameter,
|
||||
"nozzle_temp_min": tray.nozzle_temp_min,
|
||||
"nozzle_temp_max": tray.nozzle_temp_max,
|
||||
"tag_uid": tray.tag_uid,
|
||||
"state": tray.state,
|
||||
"tray_uuid": tray.tray_uuid,
|
||||
"k": tray.k,
|
||||
"n": tray.n,
|
||||
"cali_idx": tray.cali_idx,
|
||||
"total_len": tray.total_len,
|
||||
"tray_info_idx": tray.tray_info_idx,
|
||||
"tray_time": tray.tray_time,
|
||||
"tray_bed_temp": tray.tray_bed_temp,
|
||||
"bed_temp_type": tray.bed_temp_type,
|
||||
"cols": tray.cols,
|
||||
})
|
||||
snapshot["filaments"] = filaments
|
||||
|
||||
ams_units = []
|
||||
for unit in self.ams.units:
|
||||
ams_units.append({
|
||||
"unit_id": unit.unit_id,
|
||||
"ams_id": unit.ams_id,
|
||||
"chip_id": unit.chip_id,
|
||||
"info": unit.info,
|
||||
"humidity": unit.humidity,
|
||||
"humidity_raw": unit.humidity_raw,
|
||||
"temp": unit.temp,
|
||||
"dry_time": unit.dry_time,
|
||||
})
|
||||
snapshot["ams_units"] = ams_units
|
||||
|
||||
if self.ams.units:
|
||||
snapshot["ams_humidity"] = self.ams.units[0].humidity
|
||||
snapshot["ams_humidity_raw"] = self.ams.units[0].humidity_raw
|
||||
snapshot["ams_temp"] = self.ams.units[0].temp
|
||||
|
||||
if self.vt_tray:
|
||||
snapshot["external_spool"] = {
|
||||
"type": self.vt_tray.get("tray_type", ""),
|
||||
"color": self.vt_tray.get("tray_color", ""),
|
||||
"remain": self.vt_tray.get("remain", 0),
|
||||
}
|
||||
|
||||
return snapshot
|
||||
|
||||
def _get_chamber_light_status(self) -> str:
|
||||
"""Extract chamber light status from lights_report"""
|
||||
for light in self.lights_report:
|
||||
if light.get("node") == "chamber_light":
|
||||
return light.get("mode", "unknown")
|
||||
return "unknown"
|
||||
|
||||
@property
|
||||
def is_printing(self) -> bool:
|
||||
return self.gcode_state.upper() in ("RUNNING", "PRINTING")
|
||||
|
||||
@property
|
||||
def is_idle(self) -> bool:
|
||||
return self.gcode_state.upper() in ("IDLE", "FINISH", "")
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
return self.gcode_state.upper() == "PAUSE"
|
||||
|
||||
|
||||
class PrinterStateAccumulator:
|
||||
"""
|
||||
Accumulates MQTT updates into a complete printer state.
|
||||
|
||||
BambuLab MQTT sends incremental updates - each message may only contain
|
||||
a subset of fields that have changed. This class maintains the complete
|
||||
state by merging updates.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._state_data: Dict[str, Any] = {"print": {}}
|
||||
self._last_update: Optional[str] = None
|
||||
self._update_count: int = 0
|
||||
|
||||
def update(self, data: Dict[str, Any]) -> PrinterState:
|
||||
"""Merge new MQTT data into accumulated state and return complete PrinterState."""
|
||||
timestamp = datetime.now(ZoneInfo(app_settings.TIMEZONE)).isoformat()
|
||||
self._last_update = timestamp
|
||||
self._update_count += 1
|
||||
|
||||
if "print" in data:
|
||||
self._deep_merge(self._state_data["print"], data["print"])
|
||||
|
||||
return PrinterState.from_mqtt_data(self._state_data, timestamp)
|
||||
|
||||
def _deep_merge(self, base: Dict, update: Dict) -> None:
|
||||
"""Recursively merge update into base dict"""
|
||||
for key, value in update.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
def get_state(self) -> PrinterState:
|
||||
"""Get current accumulated state without updating"""
|
||||
timestamp = self._last_update or datetime.now(ZoneInfo(app_settings.TIMEZONE)).isoformat()
|
||||
return PrinterState.from_mqtt_data(self._state_data, timestamp)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset accumulated state"""
|
||||
self._state_data = {"print": {}}
|
||||
self._last_update = None
|
||||
self._update_count = 0
|
||||
|
||||
@property
|
||||
def update_count(self) -> int:
|
||||
return self._update_count
|
||||
|
||||
@property
|
||||
def last_update(self) -> Optional[str]:
|
||||
return self._last_update
|
||||
|
||||
|
||||
class BambuPrinter:
|
||||
"""
|
||||
High-level interface for BambuLab printer monitoring.
|
||||
Combines authentication, client, and MQTT into a single interface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
on_update: Optional[Callable[[PrinterState], None]] = None,
|
||||
silent: bool = True,
|
||||
verification_timeout: int = 300,
|
||||
):
|
||||
self.username = username or os.getenv("BAMBU_USERNAME")
|
||||
self.password = password or os.getenv("BAMBU_PASSWORD")
|
||||
self._token = token or os.getenv("BAMBU_TOKEN")
|
||||
self._device_id = device_id or os.getenv("BAMBU_DEVICE_ID")
|
||||
self._uid: Optional[str] = None
|
||||
self._on_update = on_update
|
||||
self._silent = silent
|
||||
self._verification_timeout = verification_timeout
|
||||
|
||||
self._client: Optional[BambuClient] = None
|
||||
self._mqtt: Optional[MQTTClient] = None
|
||||
self._accumulator = PrinterStateAccumulator()
|
||||
self._connected = False
|
||||
self._devices: List[Dict[str, Any]] = []
|
||||
|
||||
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
||||
"""Get a fresh token using credentials."""
|
||||
if not self.username or not self.password:
|
||||
raise ValueError(
|
||||
"Username and password required for token refresh. Provide as arguments "
|
||||
"or set BAMBU_USERNAME and BAMBU_PASSWORD environment variables."
|
||||
)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("BambuLab Authentication")
|
||||
print("=" * 60)
|
||||
print(f"Authenticating as: {self.username}")
|
||||
print("This may require email verification (2FA)...")
|
||||
print()
|
||||
|
||||
auth = BambuAuthenticator()
|
||||
|
||||
try:
|
||||
if self._silent:
|
||||
with suppress_stdout():
|
||||
token = auth.get_or_create_token(
|
||||
username=self.username,
|
||||
password=self.password
|
||||
)
|
||||
else:
|
||||
token = auth.get_or_create_token(
|
||||
username=self.username,
|
||||
password=self.password
|
||||
)
|
||||
|
||||
self._token = token
|
||||
print("Authentication successful!")
|
||||
print(f"Token: {token[:20]}...{token[-10:]}")
|
||||
print("=" * 60 + "\n")
|
||||
logger.info("BambuLab token obtained successfully")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
|
||||
if "verification" in error_msg or "code" in error_msg or "2fa" in error_msg:
|
||||
print("\n" + "-" * 60)
|
||||
print("EMAIL VERIFICATION REQUIRED")
|
||||
print("-" * 60)
|
||||
print("A verification code has been sent to your email.")
|
||||
print(f"You have {verification_code_timeout} seconds to enter it.")
|
||||
print()
|
||||
|
||||
try:
|
||||
code = timed_input(
|
||||
"Enter verification code: ",
|
||||
timeout_sec=verification_code_timeout
|
||||
)
|
||||
|
||||
if not code:
|
||||
raise ValueError("No verification code entered")
|
||||
|
||||
print("Verifying code...")
|
||||
token = auth.login(
|
||||
self.username,
|
||||
self.password,
|
||||
verification_code=code
|
||||
)
|
||||
|
||||
self._token = token
|
||||
print("\nAuthentication successful!")
|
||||
print(f"Token: {token[:20]}...{token[-10:]}")
|
||||
print("=" * 60 + "\n")
|
||||
print("TIP: Save this token to BAMBU_TOKEN env var to skip login next time")
|
||||
logger.info("BambuLab token obtained with 2FA verification")
|
||||
return token
|
||||
|
||||
except TimeoutError:
|
||||
print("\nVerification timed out!")
|
||||
raise TimeoutError(
|
||||
f"Verification code not entered within {verification_code_timeout} seconds"
|
||||
)
|
||||
else:
|
||||
print(f"\nAuthentication failed: {e}")
|
||||
raise
|
||||
|
||||
def _ensure_token(self) -> str:
|
||||
"""Ensure we have a valid token, refreshing if needed"""
|
||||
if self._token:
|
||||
logger.debug("Using existing token")
|
||||
return self._token
|
||||
|
||||
print("\n" + "!" * 60)
|
||||
print("NO TOKEN FOUND")
|
||||
print("!" * 60)
|
||||
print("Checked:")
|
||||
print(" - Constructor 'token' parameter: Not provided")
|
||||
print(" - Environment variable 'BAMBU_TOKEN': Not set")
|
||||
print()
|
||||
print("Will attempt to authenticate with username/password...")
|
||||
print("!" * 60 + "\n")
|
||||
|
||||
return self._get_fresh_token(verification_code_timeout=self._verification_timeout)
|
||||
|
||||
def _on_mqtt_message(self, device_id: str, data: Dict[str, Any]) -> None:
|
||||
"""Internal MQTT message handler"""
|
||||
if not data:
|
||||
return
|
||||
state = self._accumulator.update(data)
|
||||
if self._on_update:
|
||||
self._on_update(state)
|
||||
|
||||
def connect(self, blocking: bool = False, retry_on_auth_error: bool = True) -> None:
|
||||
"""Connect to printer via MQTT."""
|
||||
token = self._ensure_token()
|
||||
|
||||
try:
|
||||
self._client = BambuClient(token=token)
|
||||
user_info = self._client.get_user_info()
|
||||
self._uid = str(user_info.get("uid", ""))
|
||||
|
||||
if not self._device_id:
|
||||
self._devices = self._client.get_devices()
|
||||
if not self._devices:
|
||||
raise RuntimeError("No devices found on this account")
|
||||
self._device_id = self._devices[0].get("dev_id")
|
||||
|
||||
self._mqtt = MQTTClient(
|
||||
self._uid,
|
||||
token,
|
||||
self._device_id,
|
||||
on_message=self._on_mqtt_message
|
||||
)
|
||||
self._mqtt.connect(blocking=blocking)
|
||||
self._connected = True
|
||||
logger.info(f"Connected to BambuLab printer: {self._device_id}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
is_auth_error = any(x in error_msg for x in ["401", "unauthorized", "token", "auth", "expired"])
|
||||
|
||||
if is_auth_error and retry_on_auth_error and self.username and self.password:
|
||||
logger.warning("Auth error detected, refreshing token and retrying...")
|
||||
self._token = None
|
||||
self._get_fresh_token()
|
||||
self.connect(blocking=blocking, retry_on_auth_error=False)
|
||||
else:
|
||||
raise
|
||||
|
||||
def reconnect(self, blocking: bool = False) -> None:
|
||||
"""Disconnect and reconnect."""
|
||||
self.disconnect()
|
||||
self._accumulator.reset()
|
||||
self.connect(blocking=blocking)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from MQTT"""
|
||||
if self._mqtt:
|
||||
try:
|
||||
self._mqtt.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
logger.debug("Disconnected from BambuLab printer")
|
||||
|
||||
def get_state(self) -> PrinterState:
|
||||
"""Get current accumulated printer state"""
|
||||
return self._accumulator.get_state()
|
||||
|
||||
def get_snapshot(self) -> Dict[str, Any]:
|
||||
"""Get simplified snapshot for database logging"""
|
||||
return self._accumulator.get_state().get_snapshot()
|
||||
|
||||
@property
|
||||
def device_id(self) -> Optional[str]:
|
||||
return self._device_id
|
||||
|
||||
@property
|
||||
def devices(self) -> List[Dict[str, Any]]:
|
||||
return self._devices
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def __enter__(self):
|
||||
self.connect(blocking=False)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BambuAuthenticator",
|
||||
"BambuClient",
|
||||
"MQTTClient",
|
||||
"FilamentTray",
|
||||
"AMSUnit",
|
||||
"AMSState",
|
||||
"PrinterState",
|
||||
"PrinterStateAccumulator",
|
||||
"BambuPrinter",
|
||||
]
|
||||
61
bambu_run/static/bambu_run/css/dashboard.css
Normal file
61
bambu_run/static/bambu_run/css/dashboard.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Bambu Run Dashboard Styles */
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.infra-card-warning {
|
||||
background: linear-gradient(135deg, #ffc107 0%, #ffb300 100%);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.infra-card-info {
|
||||
background: linear-gradient(135deg, #0dcaf0 0%, #0bb5d6 100%);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.infra-card-danger {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.infra-card-success {
|
||||
background: linear-gradient(135deg, #198754 0%, #157347 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-coreui-theme="dark"] .infra-card-warning {
|
||||
background: linear-gradient(135deg, #ffb300 0%, #ff8f00 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-coreui-theme="dark"] .infra-card-info {
|
||||
background: linear-gradient(135deg, #0bb5d6 0%, #099cbd 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Stat display styling */
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.infra-card-warning .card-text,
|
||||
.infra-card-info .card-text {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
[data-coreui-theme="dark"] .infra-card-warning .card-text,
|
||||
[data-coreui-theme="dark"] .infra-card-info .card-text {
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
61
bambu_run/static/bambu_run/js/filament_type_form.js
Normal file
61
bambu_run/static/bambu_run/js/filament_type_form.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Dropdown-assisted text inputs for FilamentType add/edit form.
|
||||
* Reads existing DB values and preset suggestions from json_script tags,
|
||||
* then populates dropdown menus that fill the adjacent text input on click.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a dropdown menu with existing DB values and preset suggestions.
|
||||
* @param {string} dropdownId - ID of the <ul> dropdown menu element
|
||||
* @param {string} inputId - ID of the text input to fill on click
|
||||
* @param {Array<string>} existingValues - Values already in the database
|
||||
* @param {Array<string>} presetValues - Pre-coded suggestion values
|
||||
*/
|
||||
function buildDropdown(dropdownId, inputId, existingValues, presetValues) {
|
||||
const menu = document.getElementById(dropdownId);
|
||||
|
||||
// Add existing DB values
|
||||
existingValues.forEach(val => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<a class="dropdown-item" href="#">${val}</a>`;
|
||||
li.querySelector('a').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.getElementById(inputId).value = val;
|
||||
});
|
||||
menu.appendChild(li);
|
||||
});
|
||||
|
||||
// Add dotted separator if there were DB values
|
||||
if (existingValues.length > 0) {
|
||||
const sep = document.createElement('li');
|
||||
sep.innerHTML = '<hr class="dropdown-divider" style="border-style: dotted;">';
|
||||
menu.appendChild(sep);
|
||||
}
|
||||
|
||||
// Add preset values (skip duplicates already in DB)
|
||||
const existingSet = new Set(existingValues);
|
||||
presetValues.forEach(val => {
|
||||
if (existingSet.has(val)) return;
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<a class="dropdown-item text-muted" href="#">${val}</a>`;
|
||||
li.querySelector('a').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.getElementById(inputId).value = val;
|
||||
});
|
||||
menu.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
// Parse data from json_script tags and build all three dropdowns
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const existingTypes = JSON.parse(document.getElementById('existing-types').textContent);
|
||||
const existingSubTypes = JSON.parse(document.getElementById('existing-sub-types').textContent);
|
||||
const existingBrands = JSON.parse(document.getElementById('existing-brands').textContent);
|
||||
const presetTypes = JSON.parse(document.getElementById('preset-types').textContent);
|
||||
const presetSubTypes = JSON.parse(document.getElementById('preset-sub-types').textContent);
|
||||
const presetBrands = JSON.parse(document.getElementById('preset-brands').textContent);
|
||||
|
||||
buildDropdown('type-dropdown', 'id_type', existingTypes, presetTypes);
|
||||
buildDropdown('sub-type-dropdown', 'id_sub_type', existingSubTypes, presetSubTypes);
|
||||
buildDropdown('brand-dropdown', 'id_brand', existingBrands, presetBrands);
|
||||
});
|
||||
713
bambu_run/static/bambu_run/js/printer_charts.js
Normal file
713
bambu_run/static/bambu_run/js/printer_charts.js
Normal file
@@ -0,0 +1,713 @@
|
||||
// 3D Printer Charts Initialization and Management
|
||||
// Chart.js implementation for printer metrics visualization
|
||||
|
||||
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
||||
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
||||
|
||||
function initPrinterCharts(printerData, apiUrl) {
|
||||
// Apply filament card colors
|
||||
applyFilamentColors();
|
||||
|
||||
// Register the annotation plugin
|
||||
if (typeof Chart !== 'undefined' && typeof ChartAnnotation !== 'undefined') {
|
||||
Chart.register(ChartAnnotation);
|
||||
}
|
||||
|
||||
// Detect current theme
|
||||
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
|
||||
|
||||
// Set colors based on theme
|
||||
const tickColor = isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)';
|
||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
// Initialize Nozzle Temperature Chart
|
||||
const nozzleCtx = document.getElementById('nozzleTempChart').getContext('2d');
|
||||
nozzleTempChart = new Chart(nozzleCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Actual Temp',
|
||||
data: printerData.nozzle_temp,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
},
|
||||
{
|
||||
label: 'Target Temp',
|
||||
data: printerData.nozzle_target_temp,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.05)',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||
});
|
||||
|
||||
// Initialize Bed Temperature Chart
|
||||
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
||||
bedTempChart = new Chart(bedCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Actual Temp',
|
||||
data: printerData.bed_temp,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
},
|
||||
{
|
||||
label: 'Target Temp',
|
||||
data: printerData.bed_target_temp,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.05)',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||
});
|
||||
|
||||
// Initialize Print Progress Chart
|
||||
const progressCtx = document.getElementById('printProgressChart').getContext('2d');
|
||||
printProgressChart = new Chart(progressCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Print Progress',
|
||||
data: printerData.print_percent,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: getPercentageChartOptions(tickColor, gridColor, 'Print Progress')
|
||||
});
|
||||
|
||||
// Initialize Fan Speeds Chart
|
||||
const fanCtx = document.getElementById('fanSpeedsChart').getContext('2d');
|
||||
fanSpeedsChart = new Chart(fanCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Cooling Fan',
|
||||
data: printerData.cooling_fan_speed,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
},
|
||||
{
|
||||
label: 'Heatbreak Fan',
|
||||
data: printerData.heatbreak_fan_speed,
|
||||
borderColor: 'rgb(153, 102, 255)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: getPercentageChartOptions(tickColor, gridColor, 'Fan Speed')
|
||||
});
|
||||
|
||||
// Initialize WiFi Signal Chart
|
||||
const wifiCtx = document.getElementById('wifiSignalChart').getContext('2d');
|
||||
wifiSignalChart = new Chart(wifiCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'WiFi Signal',
|
||||
data: printerData.wifi_signal_dbm,
|
||||
borderColor: 'rgb(255, 205, 86)',
|
||||
backgroundColor: 'rgba(255, 205, 86, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: {}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: tickColor
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return 'Signal: ' + context.parsed.y + ' dBm';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: tickColor },
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
y: {
|
||||
reverse: false, // -30 dBm (better) should be higher than -40 dBm (worse)
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
callback: function(value) {
|
||||
return value + ' dBm';
|
||||
}
|
||||
},
|
||||
grid: { color: gridColor }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize AMS Conditions Chart
|
||||
const amsCtx = document.getElementById('amsConditionsChart').getContext('2d');
|
||||
amsConditionsChart = new Chart(amsCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Humidity (Raw)',
|
||||
data: printerData.ams_humidity_raw,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
yAxisID: 'y',
|
||||
spanGaps: true
|
||||
},
|
||||
{
|
||||
label: 'Temperature',
|
||||
data: printerData.ams_temp,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
yAxisID: 'y1',
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: {}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: tickColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: tickColor },
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Humidity',
|
||||
color: tickColor
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(54, 162, 235)',
|
||||
callback: function(value) {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Temperature (°C)',
|
||||
color: tickColor
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(255, 99, 132)',
|
||||
callback: function(value) {
|
||||
return value + '°C';
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Layer Progress Chart
|
||||
const layerCtx = document.getElementById('layerProgressChart').getContext('2d');
|
||||
layerProgressChart = new Chart(layerCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Current Layer',
|
||||
data: printerData.layer_num,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Total Layers',
|
||||
data: printerData.total_layer_num,
|
||||
borderColor: 'rgb(201, 203, 207)',
|
||||
backgroundColor: 'rgba(201, 203, 207, 0.05)',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: {}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: tickColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: tickColor },
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
stepSize: 1
|
||||
},
|
||||
grid: { color: gridColor }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Filament Timeline Chart
|
||||
const filamentCtx = document.getElementById('filamentTimelineChart').getContext('2d');
|
||||
const filamentDatasets = createFilamentDatasets(printerData.filament_timeline, printerData.timestamps);
|
||||
filamentTimelineChart = new Chart(filamentCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: filamentDatasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: {}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: tickColor,
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const datasetLabel = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
return datasetLabel + ': ' + value + '% remaining';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: tickColor },
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
y: {
|
||||
min: -10, // Allow for negative filament readings (e.g., -4%)
|
||||
max: 110, // 10% higher than 100% to make 100% line more visible
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
},
|
||||
grid: { color: gridColor }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up theme observer for dynamic theme switching
|
||||
setupThemeObserver();
|
||||
}
|
||||
|
||||
function getTemperatureChartOptions(tickColor, gridColor, unit) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: {}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: tickColor
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y.toFixed(1) + unit;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: tickColor
|
||||
},
|
||||
grid: {
|
||||
color: gridColor
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
callback: function(value) {
|
||||
return value + unit;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: gridColor
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getPercentageChartOptions(tickColor, gridColor, label) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: {}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: tickColor
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return label + ': ' + context.parsed.y + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: tickColor
|
||||
},
|
||||
grid: {
|
||||
color: gridColor
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: gridColor
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createFilamentDatasets(filamentTimeline, timestamps) {
|
||||
const datasets = [];
|
||||
const filamentKeys = Object.keys(filamentTimeline);
|
||||
|
||||
// Convert to array for sorting
|
||||
const filamentEntries = filamentKeys.map(key => ({
|
||||
key: key,
|
||||
data: filamentTimeline[key]
|
||||
}));
|
||||
|
||||
// Sort by tray_id (numeric first, External last), then by start_idx (chronological)
|
||||
filamentEntries.sort((a, b) => {
|
||||
const trayA = a.data.tray_id;
|
||||
const trayB = b.data.tray_id;
|
||||
|
||||
// Handle External vs numeric
|
||||
if (trayA === 'External' && trayB !== 'External') return 1;
|
||||
if (trayB === 'External' && trayA !== 'External') return -1;
|
||||
if (trayA === 'External' && trayB === 'External') {
|
||||
return a.data.start_idx - b.data.start_idx;
|
||||
}
|
||||
|
||||
// Both numeric - sort by tray_id first, then by start_idx
|
||||
const trayNumA = parseInt(trayA);
|
||||
const trayNumB = parseInt(trayB);
|
||||
if (trayNumA !== trayNumB) {
|
||||
return trayNumA - trayNumB;
|
||||
}
|
||||
return a.data.start_idx - b.data.start_idx;
|
||||
});
|
||||
|
||||
// Create datasets
|
||||
filamentEntries.forEach(entry => {
|
||||
const filament = entry.data;
|
||||
const color = '#' + filament.color.substring(0, 6);
|
||||
|
||||
// Build descriptive label
|
||||
let displayLabel;
|
||||
if (filament.tray_id === 'External') {
|
||||
displayLabel = `External (${filament.type})`;
|
||||
} else {
|
||||
displayLabel = `Tray ${filament.tray_id} (${filament.type})`;
|
||||
}
|
||||
|
||||
// Add brand if it's different from type (avoid redundancy)
|
||||
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
|
||||
displayLabel += ` - ${filament.brand}`;
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
label: displayLabel,
|
||||
data: filament.remain_data,
|
||||
borderColor: color,
|
||||
backgroundColor: hexToRgba(color, 0.1),
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
spanGaps: false // Don't connect across null values (filament changes)
|
||||
});
|
||||
});
|
||||
|
||||
return datasets;
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function applyFilamentColors() {
|
||||
// Apply colors to filament cards
|
||||
document.querySelectorAll('.filament-card').forEach(card => {
|
||||
const colorHex = card.getAttribute('data-filament-color');
|
||||
if (colorHex) {
|
||||
const color = '#' + colorHex;
|
||||
|
||||
// Set card background with gradient
|
||||
card.style.background = `linear-gradient(135deg, ${hexToRgba(color, 0.12)} 0%, ${hexToRgba(color, 0.03)} 100%)`;
|
||||
card.style.borderLeft = `4px solid ${color}`;
|
||||
|
||||
// Set badge color
|
||||
const badge = card.querySelector('.filament-badge');
|
||||
if (badge) {
|
||||
badge.style.backgroundColor = color;
|
||||
badge.style.color = getContrastColor(color);
|
||||
}
|
||||
|
||||
// Set progress bar color
|
||||
const progressBar = card.querySelector('.filament-progress');
|
||||
if (progressBar) {
|
||||
progressBar.style.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getContrastColor(hexColor) {
|
||||
// Convert hex to RGB
|
||||
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
// Return black or white based on luminance
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
function updateChartTheme() {
|
||||
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
|
||||
const tickColor = isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)';
|
||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
// Update all charts
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
charts.forEach(chart => {
|
||||
if (chart) {
|
||||
// Update legend colors
|
||||
chart.options.plugins.legend.labels.color = tickColor;
|
||||
|
||||
// Update x-axis colors
|
||||
chart.options.scales.x.ticks.color = tickColor;
|
||||
chart.options.scales.x.grid.color = gridColor;
|
||||
|
||||
// Update y-axis colors
|
||||
if (chart.options.scales.y) {
|
||||
chart.options.scales.y.ticks.color = tickColor;
|
||||
chart.options.scales.y.grid.color = gridColor;
|
||||
}
|
||||
|
||||
// Update y1-axis if exists (for dual-axis charts)
|
||||
if (chart.options.scales.y1) {
|
||||
if (chart.options.scales.y1.title) {
|
||||
chart.options.scales.y1.title.color = tickColor;
|
||||
}
|
||||
}
|
||||
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupThemeObserver() {
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-coreui-theme') {
|
||||
updateChartTheme();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-coreui-theme']
|
||||
});
|
||||
}
|
||||
418
bambu_run/static/bambu_run/js/printer_charts_control.js
Normal file
418
bambu_run/static/bambu_run/js/printer_charts_control.js
Normal file
@@ -0,0 +1,418 @@
|
||||
// 3D Printer Charts Control - Date/Time Filtering and Project Markers
|
||||
// Handles date range picker, time selection, and chart updates with annotations
|
||||
|
||||
// Global state
|
||||
const printerChartControls = {
|
||||
isFullDay: true,
|
||||
isCustomRange: false,
|
||||
apiUrl: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize on page load
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const apiUrlElement = document.getElementById('printerApiUrl');
|
||||
if (apiUrlElement) {
|
||||
printerChartControls.apiUrl = apiUrlElement.dataset.url;
|
||||
initializePrinterControls();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize printer chart date/time controls
|
||||
*/
|
||||
function initializePrinterControls() {
|
||||
const startDateInput = document.getElementById('printerStartDate');
|
||||
const endDateInput = document.getElementById('printerEndDate');
|
||||
const startTimeSelect = document.getElementById('printerStartTime');
|
||||
const endTimeSelect = document.getElementById('printerEndTime');
|
||||
const fullDayCheckbox = document.getElementById('printerFullDayCheckbox');
|
||||
const refreshBtn = document.getElementById('refreshPrinterCharts');
|
||||
const resetBtn = document.getElementById('resetPrinterCharts');
|
||||
|
||||
// Set max date to today
|
||||
const today = formatDate(new Date());
|
||||
startDateInput.max = today;
|
||||
endDateInput.max = today;
|
||||
|
||||
// Populate time dropdowns with 30-minute intervals
|
||||
populateTimeDropdowns(startTimeSelect, endTimeSelect);
|
||||
|
||||
// Set default values
|
||||
setDefaultPrinterDateTimeValues();
|
||||
|
||||
// Date input change handling
|
||||
startDateInput.addEventListener('change', handlePrinterDateChange);
|
||||
endDateInput.addEventListener('change', handlePrinterDateChange);
|
||||
|
||||
// Full Day checkbox toggle
|
||||
fullDayCheckbox.addEventListener('change', function() {
|
||||
printerChartControls.isFullDay = this.checked;
|
||||
togglePrinterTimeControls(!this.checked);
|
||||
updatePrinterDateRangeLabel();
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
refreshBtn.addEventListener('click', function() {
|
||||
refreshPrinterChartsData();
|
||||
});
|
||||
|
||||
// Reset button
|
||||
resetBtn.addEventListener('click', function() {
|
||||
resetPrinterControls();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate time dropdowns with 30-minute intervals
|
||||
*/
|
||||
function populateTimeDropdowns(startSelect, endSelect) {
|
||||
const times = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
times.push(timeStr);
|
||||
}
|
||||
}
|
||||
|
||||
times.forEach(time => {
|
||||
const option1 = new Option(time, time);
|
||||
const option2 = new Option(time, time);
|
||||
startSelect.add(option1);
|
||||
endSelect.add(option2);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle time picker controls
|
||||
*/
|
||||
function togglePrinterTimeControls(enabled) {
|
||||
document.getElementById('printerStartTime').disabled = !enabled;
|
||||
document.getElementById('printerEndTime').disabled = !enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default date/time values (last 24 hours)
|
||||
*/
|
||||
function setDefaultPrinterDateTimeValues() {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
document.getElementById('printerStartDate').value = formatDate(yesterday);
|
||||
document.getElementById('printerEndDate').value = formatDate(now);
|
||||
document.getElementById('printerStartTime').value = '00:00';
|
||||
document.getElementById('printerEndTime').value = '23:59';
|
||||
|
||||
const fullDayCheckbox = document.getElementById('printerFullDayCheckbox');
|
||||
fullDayCheckbox.checked = true;
|
||||
printerChartControls.isFullDay = true;
|
||||
togglePrinterTimeControls(false);
|
||||
|
||||
document.getElementById('printerDateRange').textContent = '(Last 24 Hours)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle date input changes
|
||||
*/
|
||||
function handlePrinterDateChange() {
|
||||
const startDate = document.getElementById('printerStartDate').value;
|
||||
const endDate = document.getElementById('printerEndDate').value;
|
||||
|
||||
// Ensure end date is not before start date
|
||||
if (startDate && endDate && startDate > endDate) {
|
||||
document.getElementById('printerEndDate').value = startDate;
|
||||
}
|
||||
|
||||
printerChartControls.isCustomRange = true;
|
||||
updatePrinterDateRangeLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the date range label
|
||||
*/
|
||||
function updatePrinterDateRangeLabel() {
|
||||
const startDate = document.getElementById('printerStartDate').value;
|
||||
const endDate = document.getElementById('printerEndDate').value;
|
||||
|
||||
let label = '';
|
||||
if (startDate === endDate) {
|
||||
label = '(' + startDate + ')';
|
||||
} else {
|
||||
label = '(' + startDate + ' to ' + endDate + ')';
|
||||
}
|
||||
document.getElementById('printerDateRange').textContent = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh printer charts data from API
|
||||
*/
|
||||
async function refreshPrinterChartsData() {
|
||||
const startDate = document.getElementById('printerStartDate').value;
|
||||
const endDate = document.getElementById('printerEndDate').value;
|
||||
const isFullDay = printerChartControls.isFullDay;
|
||||
|
||||
const startTime = isFullDay ? '00:00' : document.getElementById('printerStartTime').value;
|
||||
const endTime = isFullDay ? '23:59' : document.getElementById('printerEndTime').value;
|
||||
|
||||
// Show loading state (you can add a spinner here if needed)
|
||||
console.log('Refreshing printer charts...');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
start_time: startTime,
|
||||
end_time: endTime
|
||||
});
|
||||
|
||||
const response = await fetch(printerChartControls.apiUrl + '?' + params.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Update all charts with new data and project markers
|
||||
updateAllPrinterCharts(data);
|
||||
updatePrinterDateRangeLabel();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing printer charts:', error);
|
||||
alert('Error loading chart data: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all printer charts with new data
|
||||
*/
|
||||
function updateAllPrinterCharts(data) {
|
||||
// Update chart data
|
||||
updateChartData(nozzleTempChart, data.timestamps, [
|
||||
{ data: data.nozzle_temp, datasetIndex: 0 },
|
||||
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
||||
]);
|
||||
|
||||
updateChartData(bedTempChart, data.timestamps, [
|
||||
{ data: data.bed_temp, datasetIndex: 0 },
|
||||
{ data: data.bed_target_temp, datasetIndex: 1 }
|
||||
]);
|
||||
|
||||
updateChartData(printProgressChart, data.timestamps, [
|
||||
{ data: data.print_percent, datasetIndex: 0 }
|
||||
]);
|
||||
|
||||
updateChartData(fanSpeedsChart, data.timestamps, [
|
||||
{ data: data.cooling_fan_speed, datasetIndex: 0 },
|
||||
{ data: data.heatbreak_fan_speed, datasetIndex: 1 }
|
||||
]);
|
||||
|
||||
updateChartData(wifiSignalChart, data.timestamps, [
|
||||
{ data: data.wifi_signal_dbm, datasetIndex: 0 }
|
||||
]);
|
||||
|
||||
updateChartData(amsConditionsChart, data.timestamps, [
|
||||
{ data: data.ams_humidity_raw, datasetIndex: 0 },
|
||||
{ data: data.ams_temp, datasetIndex: 1 }
|
||||
]);
|
||||
|
||||
updateChartData(layerProgressChart, data.timestamps, [
|
||||
{ data: data.layer_num, datasetIndex: 0 },
|
||||
{ data: data.total_layer_num, datasetIndex: 1 }
|
||||
]);
|
||||
|
||||
// Update filament timeline chart
|
||||
if (data.filament_timeline) {
|
||||
const filamentDatasets = createFilamentDatasets(data.filament_timeline, data.timestamps);
|
||||
filamentTimelineChart.data.labels = data.timestamps;
|
||||
filamentTimelineChart.data.datasets = filamentDatasets;
|
||||
filamentTimelineChart.update();
|
||||
}
|
||||
|
||||
// Add project markers to all charts
|
||||
if (data.project_markers) {
|
||||
addProjectMarkersToCharts(data.project_markers, data.timestamps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update chart data
|
||||
*/
|
||||
function updateChartData(chart, labels, datasets) {
|
||||
if (!chart) return;
|
||||
|
||||
chart.data.labels = labels;
|
||||
datasets.forEach(({ data, datasetIndex }) => {
|
||||
if (chart.data.datasets[datasetIndex]) {
|
||||
chart.data.datasets[datasetIndex].data = data;
|
||||
}
|
||||
});
|
||||
chart.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add project markers (start/end lines) to all charts
|
||||
*/
|
||||
function addProjectMarkersToCharts(markers, timestamps) {
|
||||
console.log('Adding project markers:', markers);
|
||||
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
charts.forEach(chart => {
|
||||
if (!chart) return;
|
||||
|
||||
// Initialize annotations plugin if not already
|
||||
if (!chart.options.plugins.annotation) {
|
||||
chart.options.plugins.annotation = { annotations: {} };
|
||||
}
|
||||
|
||||
// Clear existing project markers
|
||||
chart.options.plugins.annotation.annotations = {};
|
||||
|
||||
// Track active tooltip
|
||||
let activeMarkerTooltip = null;
|
||||
|
||||
// Add markers
|
||||
markers.forEach((marker, idx) => {
|
||||
const isStart = marker.type === 'start';
|
||||
const xValue = marker.index; // Use the index directly, not the timestamp string
|
||||
|
||||
const projectName = marker.project_name || 'Unknown';
|
||||
const markerId = `marker_${idx}`;
|
||||
|
||||
chart.options.plugins.annotation.annotations[markerId] = {
|
||||
type: 'line',
|
||||
scaleID: 'x',
|
||||
value: xValue,
|
||||
borderColor: isStart ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.7)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
drawTime: 'beforeDatasetsDraw',
|
||||
// Tighter hit detection - only trigger when very close to the line
|
||||
borderDashOffset: 0,
|
||||
display: true,
|
||||
enter: (ctx, event) => {
|
||||
// Verify we're actually hovering over THIS specific annotation line
|
||||
// Check if mouse X position is close to the line's X position
|
||||
if (event && event.native) {
|
||||
const chartArea = chart.chartArea;
|
||||
const xScale = chart.scales.x;
|
||||
const lineXPixel = xScale.getPixelForValue(xValue);
|
||||
const mouseX = event.native.offsetX;
|
||||
|
||||
// Only show tooltip if mouse is within 10 pixels of the line
|
||||
const distance = Math.abs(mouseX - lineXPixel);
|
||||
if (distance > 10) {
|
||||
return; // Too far from this line, don't show tooltip
|
||||
}
|
||||
}
|
||||
|
||||
// Only show tooltip if not already showing from another marker
|
||||
if (activeMarkerTooltip && activeMarkerTooltip !== markerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeMarkerTooltip = markerId;
|
||||
|
||||
const tooltipText = isStart
|
||||
? `Print Start: ${projectName}`
|
||||
: `Print End: ${projectName}`;
|
||||
|
||||
// Change line appearance on hover
|
||||
ctx.element.options.borderWidth = 3;
|
||||
ctx.element.options.borderColor = isStart ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)';
|
||||
chart.update('none');
|
||||
|
||||
// Create or update tooltip element
|
||||
let tooltip = document.getElementById('annotation-tooltip');
|
||||
if (!tooltip) {
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.id = 'annotation-tooltip';
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||
tooltip.style.color = 'white';
|
||||
tooltip.style.padding = '6px 10px';
|
||||
tooltip.style.borderRadius = '4px';
|
||||
tooltip.style.fontSize = '13px';
|
||||
tooltip.style.pointerEvents = 'none';
|
||||
tooltip.style.zIndex = '9999';
|
||||
tooltip.style.display = 'none';
|
||||
tooltip.style.whiteSpace = 'nowrap';
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
tooltip.textContent = tooltipText;
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.dataset.markerId = markerId;
|
||||
|
||||
// Position at mouse location
|
||||
if (event && event.native) {
|
||||
tooltip.style.left = (event.native.clientX + 12) + 'px';
|
||||
tooltip.style.top = (event.native.clientY - 10) + 'px';
|
||||
}
|
||||
},
|
||||
leave: (ctx) => {
|
||||
// Only hide if this is the active marker
|
||||
if (activeMarkerTooltip === markerId) {
|
||||
activeMarkerTooltip = null;
|
||||
|
||||
// Restore line appearance
|
||||
ctx.element.options.borderWidth = 2;
|
||||
ctx.element.options.borderColor = isStart ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.7)';
|
||||
chart.update('none');
|
||||
|
||||
const tooltip = document.getElementById('annotation-tooltip');
|
||||
if (tooltip && tooltip.dataset.markerId === markerId) {
|
||||
tooltip.style.display = 'none';
|
||||
tooltip.dataset.markerId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
chart.update();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset printer controls to default
|
||||
*/
|
||||
function resetPrinterControls() {
|
||||
setDefaultPrinterDateTimeValues();
|
||||
|
||||
// Clear annotations and reload with original data
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
charts.forEach(chart => {
|
||||
if (chart && chart.options.plugins.annotation) {
|
||||
chart.options.plugins.annotation.annotations = {};
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
|
||||
// Reload page to get default data
|
||||
location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date as YYYY-MM-DD
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
88
bambu_run/templates/bambu_run/base.html
Normal file
88
bambu_run/templates/bambu_run/base.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-coreui-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Bambu Run{% endblock %}</title>
|
||||
<!-- CoreUI 5.3 CSS CDN -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/css/coreui.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/css/all.min.css" rel="stylesheet">
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
.sidebar-brand { padding: 1rem; font-size: 1.25rem; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar sidebar-dark sidebar-fixed" id="sidebar">
|
||||
<div class="sidebar-brand d-none d-md-flex">
|
||||
Bambu Run
|
||||
</div>
|
||||
<ul class="sidebar-nav" data-coreui="navigation">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'bambu_run:printer_dashboard' %}">
|
||||
<svg class="nav-icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-print"></use></svg>
|
||||
3D Printer
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'bambu_run:filament_list' %}">
|
||||
<svg class="nav-icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-layers"></use></svg>
|
||||
Filament Inventory
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wrapper d-flex flex-column min-vh-100">
|
||||
<header class="header header-sticky p-0 mb-4">
|
||||
<div class="container-fluid px-4">
|
||||
<button class="header-toggler" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
|
||||
<svg class="icon icon-lg"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-menu"></use></svg>
|
||||
</button>
|
||||
<ul class="header-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" id="themeToggle" type="button">
|
||||
<svg class="icon icon-lg"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-moon"></use></svg>
|
||||
</button>
|
||||
</li>
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body flex-grow-1">
|
||||
<div class="container-lg px-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer px-4">
|
||||
<div>Bambu Run</div>
|
||||
<div class="ms-auto">Powered by <a href="https://github.com/runnanli/Bambu-Run">Bambu Run</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- CoreUI 5.3 JS CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/js/coreui.bundle.min.js"></script>
|
||||
<script>
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const savedTheme = localStorage.getItem('bambu-run-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-coreui-theme', savedTheme);
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
const current = document.documentElement.getAttribute('data-coreui-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-coreui-theme', next);
|
||||
localStorage.setItem('bambu-run-theme', next);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>Delete Filament Color</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<h5><i class="bi bi-exclamation-triangle"></i> Warning</h5>
|
||||
<p>Are you sure you want to delete this filament color?</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5>Color Details:</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<div style="width: 100px; height: 100px; background-color: {{ object.get_hex_color }}; border-radius: 8px; border: 2px solid #ddd;"></div>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<p><strong>Color Name:</strong> {{ object.color_name }}</p>
|
||||
<p><strong>Hex Code:</strong> <span class="font-monospace">{{ object.get_hex_color }}</span></p>
|
||||
<p><strong>Type:</strong> {{ object.filament_type }}</p>
|
||||
<p><strong>Sub Type:</strong> {{ object.filament_sub_type|default:"-" }}</p>
|
||||
<p><strong>Brand:</strong> {{ object.brand }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Yes, Delete Color
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
80
bambu_run/templates/bambu_run/filament_color_form.html
Normal file
80
bambu_run/templates/bambu_run/filament_color_form.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>{% if form.instance.pk %}Edit{% else %}Add{% endif %} Filament Color</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<h5>Color Information</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Color Name *</label>
|
||||
{{ form.color_name }}
|
||||
<small class="form-text text-muted">e.g., Black, Orange, Signal White</small>
|
||||
{% if form.color_name.errors %}
|
||||
<div class="text-danger">{{ form.color_name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Color Hex Code *</label>
|
||||
{{ form.color_hex_input }}
|
||||
<small class="form-text text-muted">Format: #RRGGBB (without FF padding)</small>
|
||||
{% if form.color_hex_input.errors %}
|
||||
<div class="text-danger">{{ form.color_hex_input.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h5>Filament Type (for matching)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Filament Type</label>
|
||||
{{ form.filament_type_fk }}
|
||||
<small class="form-text text-muted">Select from the filament type registry</small>
|
||||
{% if form.filament_type_fk.errors %}
|
||||
<div class="text-danger">{{ form.filament_type_fk.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields for backward compatibility -->
|
||||
{{ form.color_code }}
|
||||
{{ form.filament_type }}
|
||||
{{ form.filament_sub_type }}
|
||||
{{ form.brand }}
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if form.instance.pk %}Update Color{% else %}Add Color{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger mt-3">
|
||||
<strong>Please correct the following errors:</strong>
|
||||
<ul>
|
||||
{% for field, errors in form.errors.items %}
|
||||
{% for error in errors %}
|
||||
<li>{{ field }}: {{ error }}</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
109
bambu_run/templates/bambu_run/filament_color_list.html
Normal file
109
bambu_run/templates/bambu_run/filament_color_list.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1>Filament Color Database</h1>
|
||||
<p class="text-muted">Manage filament colors for auto-matching</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add New Color
|
||||
</a>
|
||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Inventory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Summary</h5>
|
||||
<p class="card-text">
|
||||
<strong>Total Colors:</strong> {{ total_colors }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color List -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="align-middle">Color Preview</th>
|
||||
<th class="align-middle">Color Name</th>
|
||||
<th class="align-middle">Hex Code</th>
|
||||
<th class="align-middle">Type</th>
|
||||
<th class="align-middle">Sub Type</th>
|
||||
<th class="align-middle">Brand</th>
|
||||
<th class="align-middle">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for color in colors %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
|
||||
</td>
|
||||
<td class="align-middle"><strong>{{ color.color_name }}</strong></td>
|
||||
<td class="align-middle">
|
||||
<span class="font-monospace">{{ color.get_hex_color }}</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-secondary">{{ color.filament_type }}</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if color.filament_sub_type %}
|
||||
<span class="badge bg-info">{{ color.filament_sub_type }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">{{ color.brand }}</td>
|
||||
<td class="align-middle">
|
||||
<a href="{% url 'bambu_run:filament_color_update' color.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
||||
<a href="{% url 'bambu_run:filament_color_delete' color.pk %}" class="btn btn-sm btn-danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">
|
||||
No colors found. <a href="{% url 'bambu_run:filament_color_create' %}">Add your first color!</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page=1">First</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active"><span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
311
bambu_run/templates/bambu_run/filament_detail.html
Normal file
311
bambu_run/templates/bambu_run/filament_detail.html
Normal file
@@ -0,0 +1,311 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'bambu_run/css/dashboard.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>{{ filament }}</h1>
|
||||
<p class="text-body-secondary">Filament Spool Details</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning">Edit</a>
|
||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filament Info Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6>Color</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
|
||||
<div>
|
||||
<strong>{{ filament.color }}</strong><br>
|
||||
<small class="text-muted">{{ filament.color_hex }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6>Specifications</h6>
|
||||
<p class="mb-1"><strong>Type:</strong> {{ filament.type }}</p>
|
||||
{% if filament.sub_type %}
|
||||
<p class="mb-1"><strong>Sub Type:</strong> {{ filament.sub_type }}</p>
|
||||
{% endif %}
|
||||
<p class="mb-1"><strong>Brand:</strong> {{ filament.brand }}</p>
|
||||
<p class="mb-0"><strong>Diameter:</strong> {{ filament.diameter }}mm</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6>Remaining</h6>
|
||||
<div class="progress mb-2" style="height: 25px;">
|
||||
<div class="progress-bar {% if filament.remaining_percent < 20 %}bg-danger{% elif filament.remaining_percent < 50 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
style="width: {{ filament.remaining_percent }}%;">
|
||||
{{ filament.remaining_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small>{{ filament.remaining_weight_grams|default:"?" }}g of {{ filament.initial_weight_grams|default:"?" }}g</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6>Location</h6>
|
||||
{% if filament.is_loaded_in_ams %}
|
||||
<span class="badge bg-success fs-6">AMS Tray {{ filament.current_tray_id }}</span>
|
||||
<p class="mb-0 mt-2"><small>Loaded: {{ filament.last_loaded_date|date:"Y-m-d H:i" }}</small></p>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary fs-6">Storage</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Chart -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>Chart Filters</strong>
|
||||
<span class="text-muted" id="filamentDateRange">(Last 24 Hours)</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<!-- Date Range -->
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label class="form-label mb-0 small text-body-secondary">From:</label>
|
||||
<input type="date" class="form-control form-control-sm" id="filamentStartDate" style="width: auto;">
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label class="form-label mb-0 small text-body-secondary">To:</label>
|
||||
<input type="date" class="form-control form-control-sm" id="filamentEndDate" style="width: auto;">
|
||||
</div>
|
||||
<!-- Full Day Checkbox -->
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="filamentFullDayCheckbox" checked>
|
||||
<label class="form-check-label small" for="filamentFullDayCheckbox">Full Day</label>
|
||||
</div>
|
||||
<!-- Time Range -->
|
||||
<div class="d-flex align-items-center gap-1" id="filamentTimeRangeControls">
|
||||
<label class="form-label mb-0 small text-body-secondary">Time:</label>
|
||||
<select class="form-select form-select-sm" id="filamentStartTime" style="width: auto;" disabled></select>
|
||||
<span class="text-body-secondary">-</span>
|
||||
<select class="form-select form-select-sm" id="filamentEndTime" style="width: auto;" disabled></select>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<button type="button" class="btn btn-primary btn-sm" id="refreshFilamentChart">
|
||||
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="resetFilamentChart">
|
||||
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="height: 300px;">
|
||||
<canvas id="usageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print Jobs -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Print Jobs Using This Filament</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if print_usages %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Date</th>
|
||||
<th>Tray</th>
|
||||
<th>Consumed</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for usage in print_usages %}
|
||||
<tr>
|
||||
<td>{{ usage.print_job.project_name }}</td>
|
||||
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
||||
<td>Tray {{ usage.tray_id }}</td>
|
||||
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
||||
<td><span class="badge bg-{% if usage.print_job.final_status == 'FINISH' %}success{% else %}danger{% endif %}">{{ usage.print_job.final_status }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No print jobs recorded yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Info -->
|
||||
{% if filament.purchase_date or filament.purchase_price or filament.supplier %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Purchase Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% if filament.purchase_date %}
|
||||
<div class="col-md-4">
|
||||
<strong>Purchase Date:</strong> {{ filament.purchase_date|date:"Y-m-d" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if filament.purchase_price %}
|
||||
<div class="col-md-4">
|
||||
<strong>Price:</strong> ${{ filament.purchase_price }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if filament.supplier %}
|
||||
<div class="col-md-4">
|
||||
<strong>Supplier:</strong> {{ filament.supplier }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if filament.notes %}
|
||||
<hr>
|
||||
<strong>Notes:</strong>
|
||||
<p>{{ filament.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script>
|
||||
const filamentId = {{ filament.pk }};
|
||||
let usageChart = null;
|
||||
|
||||
// Populate time selects
|
||||
const startTimeSelect = document.getElementById('filamentStartTime');
|
||||
const endTimeSelect = document.getElementById('filamentEndTime');
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||
startTimeSelect.add(new Option(timeStr, timeStr));
|
||||
endTimeSelect.add(new Option(timeStr, timeStr));
|
||||
}
|
||||
}
|
||||
startTimeSelect.value = '00:00';
|
||||
endTimeSelect.value = '23:30';
|
||||
|
||||
// Initialize date inputs to last 24 hours
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
|
||||
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
|
||||
|
||||
// Full day checkbox handler
|
||||
document.getElementById('filamentFullDayCheckbox').addEventListener('change', function() {
|
||||
const isFullDay = this.checked;
|
||||
startTimeSelect.disabled = isFullDay;
|
||||
endTimeSelect.disabled = isFullDay;
|
||||
});
|
||||
|
||||
// Fetch and render chart
|
||||
async function fetchFilamentUsageData() {
|
||||
const startDate = document.getElementById('filamentStartDate').value;
|
||||
const endDate = document.getElementById('filamentEndDate').value;
|
||||
const isFullDay = document.getElementById('filamentFullDayCheckbox').checked;
|
||||
const startTime = isFullDay ? '00:00' : startTimeSelect.value;
|
||||
const endTime = isFullDay ? '23:59' : endTimeSelect.value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (startTime) params.append('start_time', startTime);
|
||||
if (endTime) params.append('end_time', endTime);
|
||||
|
||||
try {
|
||||
const response = await fetch(`{% url 'bambu_run:filament_usage_api' filament.pk %}?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Update date range display
|
||||
const dateRangeSpan = document.getElementById('filamentDateRange');
|
||||
if (startDate && endDate) {
|
||||
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
|
||||
} else {
|
||||
dateRangeSpan.textContent = '(Last 24 Hours)';
|
||||
}
|
||||
|
||||
// Update chart
|
||||
if (usageChart) {
|
||||
usageChart.data.labels = data.timestamps;
|
||||
usageChart.data.datasets[0].data = data.remaining;
|
||||
usageChart.update();
|
||||
} else {
|
||||
const ctx = document.getElementById('usageChart').getContext('2d');
|
||||
usageChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.timestamps,
|
||||
datasets: [{
|
||||
label: 'Remaining %',
|
||||
data: data.remaining,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching filament usage data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('refreshFilamentChart').addEventListener('click', fetchFilamentUsageData);
|
||||
document.getElementById('resetFilamentChart').addEventListener('click', function() {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
|
||||
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
|
||||
document.getElementById('filamentFullDayCheckbox').checked = true;
|
||||
startTimeSelect.disabled = true;
|
||||
endTimeSelect.disabled = true;
|
||||
fetchFilamentUsageData();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchFilamentUsageData();
|
||||
</script>
|
||||
{% endblock %}
|
||||
303
bambu_run/templates/bambu_run/filament_form.html
Normal file
303
bambu_run/templates/bambu_run/filament_form.html
Normal file
@@ -0,0 +1,303 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>{% if form.instance.pk %}Edit{% else %}Add{% endif %} Filament Spool</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<h5>Identification</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Spool Serial Number (SN)</label>
|
||||
{{ form.tray_uuid }}
|
||||
<small class="form-text text-muted">Auto-filled from MQTT tray_uuid</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">RFID Chip ID (tag_uid)</label>
|
||||
{{ form.tag_uid }}
|
||||
<small class="form-text text-muted">Auto-filled from MQTT RFID</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Custom Tag ID (Optional)</label>
|
||||
{{ form.tag_id }}
|
||||
<small class="form-text text-muted">User-defined barcode/label</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Created By</label>
|
||||
{{ form.created_by }}
|
||||
<small class="form-text text-muted">How this filament was added</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h5>Specifications</h5>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type *</label>
|
||||
{{ form.type }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Sub Type</label>
|
||||
{{ form.sub_type }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Brand *</label>
|
||||
{{ form.brand }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Color *</label>
|
||||
{{ form.color }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Color Picker</label>
|
||||
{{ form.color_hex }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ form.color_hex_text.label }}</label>
|
||||
{{ form.color_hex_text }}
|
||||
<small class="form-text text-muted">e.g. #0A2CA5</small>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Diameter (mm)</label>
|
||||
{{ form.diameter }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial Weight (g)</label>
|
||||
{{ form.initial_weight_grams }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h5>Current Status</h5>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Remaining %</label>
|
||||
{{ form.remaining_percent }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Remaining Weight (g)</label>
|
||||
{{ form.remaining_weight_grams }}
|
||||
<small class="form-text text-muted">Auto-calculated</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
{{ form.is_loaded_in_ams }}
|
||||
<label class="form-check-label">Loaded in AMS</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">AMS Tray ID (0-3)</label>
|
||||
{{ form.current_tray_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h5>Purchase Info (Optional)</h5>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Purchase Date</label>
|
||||
{{ form.purchase_date }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Price</label>
|
||||
{{ form.purchase_price }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Supplier</label>
|
||||
{{ form.supplier }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes</label>
|
||||
{{ form.notes }}
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Please correct the following errors:</strong>
|
||||
{{ form.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
{% if form.instance.pk %}
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal" id="deleteBtn">
|
||||
<i class="bi bi-trash-fill me-1"></i>Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.instance.pk %}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Delete Filament Spool
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" action="{% url 'bambu_run:filament_delete' form.instance.pk %}" id="deleteForm">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger mb-3" role="alert">
|
||||
<strong>Warning: This action cannot be undone!</strong>
|
||||
</div>
|
||||
<p>You are about to permanently delete:</p>
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body">
|
||||
<strong>{{ form.instance }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p>This will remove:</p>
|
||||
<ul>
|
||||
<li>This filament spool record</li>
|
||||
<li>All associated usage history</li>
|
||||
<li>All filament snapshots</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<div class="mb-3">
|
||||
<label for="deleteConfirmText" class="form-label">
|
||||
To confirm deletion, type <strong class="text-danger">DELETE</strong> in the box below:
|
||||
</label>
|
||||
<input type="text" id="deleteConfirmText" class="form-control form-control-lg" placeholder="Type DELETE to confirm" autocomplete="off">
|
||||
<div class="form-text">Must be in capital letters</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger" disabled>
|
||||
<i class="bi bi-trash-fill me-1"></i>Confirm Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Sync color picker and text input
|
||||
const colorPicker = document.getElementById('id_color_hex_picker');
|
||||
const colorText = document.getElementById('id_color_hex_text');
|
||||
|
||||
if (colorPicker && colorText) {
|
||||
colorPicker.addEventListener('input', function() {
|
||||
colorText.value = this.value.toUpperCase();
|
||||
});
|
||||
|
||||
colorText.addEventListener('input', function() {
|
||||
const value = this.value.trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
colorPicker.value = value;
|
||||
this.classList.remove('is-invalid');
|
||||
} else if (value.length === 7) {
|
||||
this.classList.add('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
|
||||
colorPicker.value = colorText.value;
|
||||
} else if (colorPicker.value && !colorText.value) {
|
||||
colorText.value = colorPicker.value.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation logic
|
||||
const deleteConfirmText = document.getElementById('deleteConfirmText');
|
||||
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
const deleteForm = document.getElementById('deleteForm');
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
|
||||
if (deleteConfirmText && confirmDeleteBtn) {
|
||||
deleteConfirmText.addEventListener('input', function() {
|
||||
const value = this.value.trim();
|
||||
if (value === 'DELETE') {
|
||||
confirmDeleteBtn.disabled = false;
|
||||
this.classList.remove('is-invalid');
|
||||
this.classList.add('is-valid');
|
||||
} else {
|
||||
confirmDeleteBtn.disabled = true;
|
||||
this.classList.remove('is-valid');
|
||||
if (value.length > 0) {
|
||||
this.classList.add('is-invalid');
|
||||
} else {
|
||||
this.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function(e) {
|
||||
if (confirmDeleteBtn.disabled) {
|
||||
e.preventDefault();
|
||||
alert('Please type DELETE to confirm deletion');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteModal) {
|
||||
deleteModal.addEventListener('hidden.bs.modal', function() {
|
||||
deleteConfirmText.value = '';
|
||||
confirmDeleteBtn.disabled = true;
|
||||
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
|
||||
});
|
||||
|
||||
deleteModal.addEventListener('shown.bs.modal', function() {
|
||||
deleteConfirmText.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Backup modal opener
|
||||
const deleteBtn = document.getElementById('deleteBtn');
|
||||
if (deleteBtn && deleteModal) {
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
if (!deleteModal.classList.contains('show')) {
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
const modalInstance = bootstrap.Modal.getOrCreateInstance(deleteModal);
|
||||
modalInstance.show();
|
||||
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
|
||||
const modalInstance = coreui.Modal.getOrCreateInstance(deleteModal);
|
||||
modalInstance.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
206
bambu_run/templates/bambu_run/filament_list.html
Normal file
206
bambu_run/templates/bambu_run/filament_list.html
Normal file
@@ -0,0 +1,206 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'bambu_run/css/dashboard.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>Filament Inventory</h1>
|
||||
<p class="text-body-secondary">Manage your 3D printer filament spools</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-outline-info me-2">
|
||||
<i class="bi bi-list-ul"></i> Manage Types
|
||||
</a>
|
||||
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-outline-info me-2">
|
||||
<i class="bi bi-palette"></i> Manage Colors
|
||||
</a>
|
||||
<a href="{% url 'bambu_run:filament_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Filament
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card infra-card-info">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Total Spools</div>
|
||||
<div class="stat-value">{{ total_spools }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card infra-card-success">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Loaded in AMS</div>
|
||||
<div class="stat-value">{{ loaded_spools }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card infra-card-warning">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Low Filament (<20%)</div>
|
||||
<div class="stat-value">{{ low_filaments }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="search" class="form-control" placeholder="Search..." value="{{ request.GET.search }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="type" class="form-select">
|
||||
<option value="">All Types</option>
|
||||
{% for type in filament_types %}
|
||||
<option value="{{ type }}" {% if request.GET.type == type %}selected{% endif %}>{{ type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="loaded" class="form-select">
|
||||
<option value="">All Spools</option>
|
||||
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
||||
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-secondary">Filter</button>
|
||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filament List -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="align-middle">SN</th>
|
||||
<th class="align-middle">Color</th>
|
||||
<th class="align-middle">Brand</th>
|
||||
<th class="align-middle">Type</th>
|
||||
<th class="align-middle">Sub Type</th>
|
||||
<th class="align-middle">Remaining</th>
|
||||
<th class="align-middle">Location</th>
|
||||
<th class="align-middle">Created By</th>
|
||||
<th class="align-middle">Last Used</th>
|
||||
<th class="align-middle">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for filament in filaments %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
{% if filament.tray_uuid %}
|
||||
<span class="font-monospace small"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="{{ filament.tray_uuid }}"
|
||||
style="cursor: help;">
|
||||
{{ filament.tray_uuid|slice:":8" }}...
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex align-items-center">
|
||||
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
|
||||
{{ filament.color }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">{{ filament.brand }}</td>
|
||||
<td class="align-middle"><span class="badge bg-secondary">{{ filament.type }}</span></td>
|
||||
<td class="align-middle">
|
||||
{% if filament.sub_type %}
|
||||
<span class="badge bg-info">{{ filament.sub_type }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div class="progress-bar {% if filament.remaining_percent < 20 %}bg-danger{% elif filament.remaining_percent < 50 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
style="width: {{ filament.remaining_percent }}%;">
|
||||
{{ filament.remaining_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if filament.is_loaded_in_ams %}
|
||||
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Storage</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if filament.created_by == 'Auto Detection' %}
|
||||
<span class="badge bg-primary">Auto</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Manual</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">{{ filament.last_used|date:"Y-m-d H:i"|default:"Never" }}</td>
|
||||
<td class="align-middle">
|
||||
<a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a>
|
||||
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="10" class="text-center text-muted">No filaments found. <a href="{% url 'bambu_run:filament_create' %}">Add your first spool!</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page=1">First</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active"><span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Enable Bootstrap tooltips for SN hover
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>Delete Filament Type</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<h5><i class="bi bi-exclamation-triangle"></i> Warning</h5>
|
||||
<p>Are you sure you want to delete this filament type?</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5>Type Details:</h5>
|
||||
<p><strong>Type:</strong> {{ object.type }}</p>
|
||||
<p><strong>Sub Type:</strong> {{ object.sub_type|default:"-" }}</p>
|
||||
<p><strong>Brand:</strong> {{ object.brand }}</p>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Yes, Delete Type
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
92
bambu_run/templates/bambu_run/filament_type_form.html
Normal file
92
bambu_run/templates/bambu_run/filament_type_form.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>{% if form.instance.pk %}Edit{% else %}Add{% endif %} Filament Type</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Type *</label>
|
||||
<div class="input-group">
|
||||
{{ form.type }}
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
||||
data-coreui-toggle="dropdown" aria-expanded="false"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" id="type-dropdown"></ul>
|
||||
</div>
|
||||
<small class="form-text text-muted">Base material: PLA, PETG, ABS, etc.</small>
|
||||
{% if form.type.errors %}
|
||||
<div class="text-danger">{{ form.type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Sub Type</label>
|
||||
<div class="input-group">
|
||||
{{ form.sub_type }}
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
||||
data-coreui-toggle="dropdown" aria-expanded="false"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" id="sub-type-dropdown"></ul>
|
||||
</div>
|
||||
<small class="form-text text-muted">Optional: PLA Basic, PLA Matte, etc.</small>
|
||||
{% if form.sub_type.errors %}
|
||||
<div class="text-danger">{{ form.sub_type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Brand *</label>
|
||||
<div class="input-group">
|
||||
{{ form.brand }}
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
||||
data-coreui-toggle="dropdown" aria-expanded="false"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" id="brand-dropdown"></ul>
|
||||
</div>
|
||||
{% if form.brand.errors %}
|
||||
<div class="text-danger">{{ form.brand.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if form.instance.pk %}Update Type{% else %}Add Type{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger mt-3">
|
||||
<strong>Please correct the following errors:</strong>
|
||||
<ul>
|
||||
{% for field, errors in form.errors.items %}
|
||||
{% for error in errors %}
|
||||
<li>{{ field }}: {{ error }}</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{{ existing_types|json_script:"existing-types" }}
|
||||
{{ existing_sub_types|json_script:"existing-sub-types" }}
|
||||
{{ existing_brands|json_script:"existing-brands" }}
|
||||
{{ preset_types|json_script:"preset-types" }}
|
||||
{{ preset_sub_types|json_script:"preset-sub-types" }}
|
||||
{{ preset_brands|json_script:"preset-brands" }}
|
||||
<script src="{% static 'bambu_run/js/filament_type_form.js' %}"></script>
|
||||
{% endblock %}
|
||||
99
bambu_run/templates/bambu_run/filament_type_list.html
Normal file
99
bambu_run/templates/bambu_run/filament_type_list.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1>Filament Type Registry</h1>
|
||||
<p class="text-muted">Manage filament types (material, sub-type, brand)</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add New Type
|
||||
</a>
|
||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Inventory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Summary</h5>
|
||||
<p class="card-text">
|
||||
<strong>Total Types:</strong> {{ total_types }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type List -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="align-middle">Type</th>
|
||||
<th class="align-middle">Sub Type</th>
|
||||
<th class="align-middle">Brand</th>
|
||||
<th class="align-middle">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ft in types %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-secondary">{{ ft.type }}</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if ft.sub_type %}
|
||||
<span class="badge bg-info">{{ ft.sub_type }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">{{ ft.brand }}</td>
|
||||
<td class="align-middle">
|
||||
<a href="{% url 'bambu_run:filament_type_update' ft.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
||||
<a href="{% url 'bambu_run:filament_type_delete' ft.pk %}" class="btn btn-sm btn-danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">
|
||||
No filament types found. <a href="{% url 'bambu_run:filament_type_create' %}">Add your first type!</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page=1">First</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active"><span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
390
bambu_run/templates/bambu_run/printer_dashboard.html
Normal file
390
bambu_run/templates/bambu_run/printer_dashboard.html
Normal file
@@ -0,0 +1,390 @@
|
||||
{% extends bambu_run_base_template %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'bambu_run/css/dashboard.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>3D Printer Dashboard</h1>
|
||||
<p class="text-body-secondary">
|
||||
Real-time monitoring for {{ device_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Summary Cards Row -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Nozzle Temperature Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-warning">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Nozzle Temp</div>
|
||||
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}°C</div>
|
||||
</div>
|
||||
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bed Temperature Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-danger">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Bed Temp</div>
|
||||
<div class="stat-value">{{ stats.bed_temp|floatformat:1 }}°C</div>
|
||||
</div>
|
||||
<i class="bi bi-thermometer-half" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print Progress Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-info">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Print Progress</div>
|
||||
<div class="stat-value">{{ stats.print_percent }}%</div>
|
||||
</div>
|
||||
<i class="bi bi-pie-chart-fill" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chamber Light Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Chamber Light</div>
|
||||
<div class="stat-value">{{ stats.chamber_light|upper }}</div>
|
||||
</div>
|
||||
<i class="bi bi-lightbulb-fill" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Print Job Info -->
|
||||
{% if stats.subtask_name and stats.subtask_name != 'No active print' %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Current Print Job</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Job Name:</strong> {{ stats.subtask_name }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>State:</strong> {{ stats.gcode_state }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Progress:</strong> {{ stats.print_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- AMS Status Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>AMS Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<strong>Temperature:</strong>
|
||||
{% if stats.ams_temp %}
|
||||
{{ stats.ams_temp|floatformat:1 }}°C
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Humidity:</strong>
|
||||
{% if stats.ams_humidity %}
|
||||
{{ stats.ams_humidity }}%
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filaments Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Filaments</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.filaments %}
|
||||
<div class="row g-3">
|
||||
{% for filament in stats.filaments %}
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
|
||||
{% if filament.filament_pk %}
|
||||
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
|
||||
<svg class="icon icon-sm text-body-secondary"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-external-link"></use></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
|
||||
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="small">Remaining</span>
|
||||
<span class="badge filament-badge">{{ filament.remain_percent }}%</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: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if stats.external_spool.type %}
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-2">External Spool</h6>
|
||||
<p class="mb-1 small"><strong>{{ stats.external_spool.type }}</strong> - External</p>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="small">Remaining</span>
|
||||
<span class="badge filament-badge">{{ stats.external_spool.remain }}%</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: {{ stats.external_spool.remain }}%;" aria-valuenow="{{ stats.external_spool.remain }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-body-secondary">No filament data available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date/Time Filter Controls -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>Chart Filters</strong>
|
||||
<span class="text-muted" id="printerDateRange">(Last 24 Hours)</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<!-- Date Range -->
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label class="form-label mb-0 small text-body-secondary">From:</label>
|
||||
<input type="date" class="form-control form-control-sm" id="printerStartDate" style="width: auto;">
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label class="form-label mb-0 small text-body-secondary">To:</label>
|
||||
<input type="date" class="form-control form-control-sm" id="printerEndDate" style="width: auto;">
|
||||
</div>
|
||||
<!-- Full Day Checkbox -->
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="printerFullDayCheckbox" checked>
|
||||
<label class="form-check-label small" for="printerFullDayCheckbox">Full Day</label>
|
||||
</div>
|
||||
<!-- Time Range -->
|
||||
<div class="d-flex align-items-center gap-1" id="printerTimeRangeControls">
|
||||
<label class="form-label mb-0 small text-body-secondary">Time:</label>
|
||||
<select class="form-select form-select-sm" id="printerStartTime" style="width: auto;" disabled></select>
|
||||
<span class="text-body-secondary">-</span>
|
||||
<select class="form-select form-select-sm" id="printerEndTime" style="width: auto;" disabled></select>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<button type="button" class="btn btn-primary btn-sm" id="refreshPrinterCharts">
|
||||
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="resetPrinterCharts">
|
||||
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filament Timeline Chart - Full Width -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">Filament Remaining Timeline</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="filamentTimelineChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Nozzle Temperature Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Nozzle Temperature</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="nozzleTempChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bed Temperature Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Bed Temperature</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="bedTempChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Print Progress Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Print Progress</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="printProgressChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fan Speeds Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Fan Speeds</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="fanSpeedsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- WiFi Signal Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">WiFi Signal Strength</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="wifiSignalChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AMS Conditions Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">AMS Conditions</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="amsConditionsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Layer Progress Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Layer Progress</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="layerProgressChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<p class="text-body-secondary text-end">
|
||||
Last updated: {{ stats.timestamp }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
|
||||
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
|
||||
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
|
||||
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const printerData = {{ printer_data_json|safe }};
|
||||
const apiUrl = '{% url "bambu_run:printer_api" %}';
|
||||
initPrinterCharts(printerData, apiUrl);
|
||||
|
||||
// Add project markers if they exist
|
||||
if (printerData.project_markers && printerData.project_markers.length > 0) {
|
||||
setTimeout(function() {
|
||||
addProjectMarkersToCharts(printerData.project_markers, printerData.timestamps);
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
29
bambu_run/urls.py
Normal file
29
bambu_run/urls.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "bambu_run"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
||||
path("api/printer/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
||||
|
||||
# Filament Inventory routes
|
||||
path("filaments/", views.FilamentListView.as_view(), name="filament_list"),
|
||||
path("api/filaments/<int:pk>/usage/", views.FilamentUsageDataAPIView.as_view(), name="filament_usage_api"),
|
||||
path("filaments/add/", views.FilamentCreateView.as_view(), name="filament_create"),
|
||||
path("filaments/<int:pk>/", views.FilamentDetailView.as_view(), name="filament_detail"),
|
||||
path("filaments/<int:pk>/edit/", views.FilamentUpdateView.as_view(), name="filament_update"),
|
||||
path("filaments/<int:pk>/delete/", views.FilamentDeleteView.as_view(), name="filament_delete"),
|
||||
|
||||
# FilamentColor management routes
|
||||
path("filament-colors/", views.FilamentColorListView.as_view(), name="filament_color_list"),
|
||||
path("filament-colors/add/", views.FilamentColorCreateView.as_view(), name="filament_color_create"),
|
||||
path("filament-colors/<int:pk>/edit/", views.FilamentColorUpdateView.as_view(), name="filament_color_update"),
|
||||
path("filament-colors/<int:pk>/delete/", views.FilamentColorDeleteView.as_view(), name="filament_color_delete"),
|
||||
|
||||
# FilamentType management routes
|
||||
path("filament-types/", views.FilamentTypeListView.as_view(), name="filament_type_list"),
|
||||
path("filament-types/add/", views.FilamentTypeCreateView.as_view(), name="filament_type_create"),
|
||||
path("filament-types/<int:pk>/edit/", views.FilamentTypeUpdateView.as_view(), name="filament_type_update"),
|
||||
path("filament-types/<int:pk>/delete/", views.FilamentTypeDeleteView.as_view(), name="filament_type_delete"),
|
||||
]
|
||||
76
bambu_run/utils.py
Normal file
76
bambu_run/utils.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Utility functions for filament color matching
|
||||
"""
|
||||
|
||||
|
||||
def strip_color_padding(mqtt_color):
|
||||
"""
|
||||
Strip FF padding from MQTT color
|
||||
MQTT: '000000FF' -> '000000'
|
||||
MQTT: 'FF6A13FF' -> 'FF6A13'
|
||||
"""
|
||||
if not mqtt_color:
|
||||
return None
|
||||
if len(mqtt_color) == 8:
|
||||
return mqtt_color[:6].upper()
|
||||
return mqtt_color[:6].upper() if len(mqtt_color) >= 6 else mqtt_color.upper()
|
||||
|
||||
|
||||
def match_filament_color(filament_type, filament_sub_type, color_code, brand='Bambu Lab'):
|
||||
"""
|
||||
Match a FilamentColor from database based on type, sub_type, color_code, and brand
|
||||
|
||||
Returns:
|
||||
FilamentColor instance or None
|
||||
"""
|
||||
from .models import FilamentColor
|
||||
|
||||
if not all([filament_type, color_code]):
|
||||
return None
|
||||
|
||||
# Try exact match first (with sub_type)
|
||||
if filament_sub_type:
|
||||
color_match = FilamentColor.objects.filter(
|
||||
filament_type=filament_type,
|
||||
filament_sub_type=filament_sub_type,
|
||||
color_code=color_code,
|
||||
brand=brand
|
||||
).first()
|
||||
|
||||
if color_match:
|
||||
return color_match
|
||||
|
||||
# Try match without sub_type (more flexible)
|
||||
color_match = FilamentColor.objects.filter(
|
||||
filament_type=filament_type,
|
||||
color_code=color_code,
|
||||
brand=brand
|
||||
).first()
|
||||
|
||||
return color_match
|
||||
|
||||
|
||||
def match_and_update_filament_color(filament_color):
|
||||
"""
|
||||
Retroactively update all Filament spools that match this FilamentColor
|
||||
|
||||
Returns:
|
||||
Number of Filament records updated
|
||||
"""
|
||||
from .models import Filament
|
||||
|
||||
query_filters = {
|
||||
'type': filament_color.filament_type,
|
||||
'brand': filament_color.brand,
|
||||
}
|
||||
|
||||
color_hex = f"#{filament_color.color_code}"
|
||||
query_filters['color_hex'] = color_hex
|
||||
|
||||
if filament_color.filament_sub_type:
|
||||
query_filters['sub_type'] = filament_color.filament_sub_type
|
||||
|
||||
matching_filaments = Filament.objects.filter(**query_filters)
|
||||
updated_count = matching_filaments.update(color=filament_color.color_name)
|
||||
|
||||
return updated_count
|
||||
709
bambu_run/views.py
Normal file
709
bambu_run/views.py
Normal file
@@ -0,0 +1,709 @@
|
||||
from datetime import timedelta
|
||||
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.utils import timezone
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q, Sum
|
||||
import json
|
||||
import zoneinfo
|
||||
|
||||
from .conf import app_settings
|
||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
||||
|
||||
|
||||
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "bambu_run/printer_dashboard.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
|
||||
try:
|
||||
printer_device = Printer.objects.filter(is_active=True).first()
|
||||
if not printer_device:
|
||||
context["error"] = (
|
||||
"No 3D printer device found. Please run bambu_collector first."
|
||||
)
|
||||
return context
|
||||
except Exception as e:
|
||||
context["error"] = f"Error loading printer device: {str(e)}"
|
||||
return context
|
||||
|
||||
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
||||
|
||||
# Last 24 hours of live data
|
||||
time_24h_ago = timezone.now() - timedelta(hours=24)
|
||||
metrics = PrinterMetrics.objects.filter(
|
||||
device=printer_device, timestamp__gte=time_24h_ago
|
||||
).prefetch_related('filament_snapshots').order_by("timestamp")
|
||||
|
||||
latest_metric = metrics.last()
|
||||
|
||||
printer_data_json = {
|
||||
"timestamps": [
|
||||
m.timestamp.astimezone(tz).strftime("%H:%M") for m in metrics
|
||||
],
|
||||
"nozzle_temp": [
|
||||
float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics
|
||||
],
|
||||
"nozzle_target_temp": [
|
||||
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
||||
for m in metrics
|
||||
],
|
||||
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
||||
"bed_target_temp": [
|
||||
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
||||
],
|
||||
"print_percent": [
|
||||
m.print_percent if m.print_percent else 0 for m in metrics
|
||||
],
|
||||
"print_type": [m.print_type for m in metrics],
|
||||
"gcode_state": [m.gcode_state for m in metrics],
|
||||
"cooling_fan_speed": [
|
||||
m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics
|
||||
],
|
||||
"heatbreak_fan_speed": [
|
||||
m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics
|
||||
],
|
||||
"wifi_signal_dbm": [
|
||||
m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics
|
||||
],
|
||||
"ams_humidity_raw": [
|
||||
m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics
|
||||
],
|
||||
"ams_temp": [
|
||||
float(m.ams_temp) if m.ams_temp else None for m in metrics
|
||||
],
|
||||
"layer_num": [
|
||||
m.layer_num if m.layer_num else 0 for m in metrics
|
||||
],
|
||||
"total_layer_num": [
|
||||
m.total_layer_num if m.total_layer_num else 0 for m in metrics
|
||||
],
|
||||
"filament_timeline": self._prepare_filament_timeline(metrics),
|
||||
}
|
||||
|
||||
stats = {}
|
||||
if latest_metric:
|
||||
filaments_list = []
|
||||
try:
|
||||
filament_snapshots = latest_metric.filament_snapshots.select_related('filament').all()
|
||||
for snapshot in filament_snapshots:
|
||||
filament_dict = {
|
||||
'tray_id': snapshot.tray_id,
|
||||
'type': snapshot.type or 'Unknown',
|
||||
'brand': snapshot.sub_type or 'Unknown',
|
||||
'color': snapshot.color or 'FFFFFFFF',
|
||||
'remain_percent': snapshot.remain_percent or 0,
|
||||
}
|
||||
if snapshot.filament:
|
||||
filament_dict['color_name'] = snapshot.filament.color
|
||||
filament_dict['filament_pk'] = snapshot.filament.pk
|
||||
filaments_list.append(filament_dict)
|
||||
except Exception:
|
||||
filaments_list = []
|
||||
|
||||
stats = {
|
||||
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
||||
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
||||
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
||||
"print_percent": latest_metric.print_percent or 0,
|
||||
"gcode_state": latest_metric.gcode_state or "Unknown",
|
||||
"print_type": latest_metric.print_type or "idle",
|
||||
"subtask_name": latest_metric.subtask_name or "No active print",
|
||||
"chamber_light": latest_metric.chamber_light or "unknown",
|
||||
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||
"ams_humidity": latest_metric.ams_humidity,
|
||||
"filaments": filaments_list,
|
||||
"external_spool": latest_metric.external_spool or {},
|
||||
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
project_markers = self._calculate_project_markers(list(metrics), tz)
|
||||
printer_data_json["project_markers"] = project_markers
|
||||
|
||||
context["printer_device"] = printer_device
|
||||
context["device_name"] = printer_device.name
|
||||
context["stats"] = stats
|
||||
context["metrics_count"] = metrics.count()
|
||||
context["printer_data_json"] = json.dumps(printer_data_json)
|
||||
|
||||
return context
|
||||
|
||||
def _calculate_project_markers(self, metrics, timezone_info):
|
||||
"""Calculate where print jobs start and end"""
|
||||
markers = []
|
||||
current_job = None
|
||||
last_state = None
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
subtask = metric.subtask_name
|
||||
gcode_state = metric.gcode_state
|
||||
|
||||
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
||||
|
||||
if subtask and subtask != current_job and is_printing:
|
||||
markers.append({
|
||||
'type': 'start',
|
||||
'index': idx,
|
||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||
'project_name': subtask,
|
||||
})
|
||||
current_job = subtask
|
||||
last_state = gcode_state
|
||||
|
||||
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
||||
markers.append({
|
||||
'type': 'end',
|
||||
'index': idx,
|
||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||
'project_name': current_job,
|
||||
})
|
||||
current_job = None
|
||||
|
||||
last_state = gcode_state
|
||||
|
||||
return markers
|
||||
|
||||
def _prepare_filament_timeline(self, metrics):
|
||||
"""Prepare filament data organized by unique filament configurations."""
|
||||
filament_data = {}
|
||||
total_points = len(metrics)
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
try:
|
||||
snapshots = metric.filament_snapshots.all()
|
||||
except Exception:
|
||||
snapshots = []
|
||||
|
||||
for snapshot in snapshots:
|
||||
tray_id = snapshot.tray_id
|
||||
fil_type = snapshot.type or 'Unknown'
|
||||
fil_sub_type = snapshot.sub_type or 'Unknown'
|
||||
fil_color = snapshot.color or 'FFFFFFFF'
|
||||
|
||||
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||
|
||||
if unique_key not in filament_data:
|
||||
filament_data[unique_key] = {
|
||||
'tray_id': tray_id,
|
||||
'type': fil_type,
|
||||
'brand': fil_sub_type,
|
||||
'color': fil_color,
|
||||
'remain_data': [None] * total_points,
|
||||
'start_idx': idx,
|
||||
}
|
||||
|
||||
remain_percent = snapshot.remain_percent or 0
|
||||
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
external = metric.external_spool or {}
|
||||
if external.get('type'):
|
||||
fil_type = external.get('type', 'Unknown')
|
||||
fil_color = external.get('color', '161616FF')
|
||||
unique_key = f"External_{fil_type}_{fil_color}"
|
||||
|
||||
if unique_key not in filament_data:
|
||||
filament_data[unique_key] = {
|
||||
'tray_id': 'External',
|
||||
'type': fil_type,
|
||||
'brand': 'External',
|
||||
'color': fil_color,
|
||||
'remain_data': [None] * total_points,
|
||||
'start_idx': idx,
|
||||
}
|
||||
|
||||
remain_percent = external.get('remain', 0)
|
||||
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
||||
|
||||
return filament_data
|
||||
|
||||
|
||||
class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||
"""API endpoint for dynamic printer chart updates"""
|
||||
|
||||
def get(self, request):
|
||||
start_date = request.GET.get("start_date")
|
||||
end_date = request.GET.get("end_date")
|
||||
start_time = request.GET.get("start_time", "00:00")
|
||||
end_time = request.GET.get("end_time", "23:59")
|
||||
|
||||
try:
|
||||
printer_device = Printer.objects.filter(is_active=True).first()
|
||||
if not printer_device:
|
||||
return JsonResponse({"error": "No printer device found"}, status=404)
|
||||
|
||||
query = PrinterMetrics.objects.filter(device=printer_device).prefetch_related('filament_snapshots')
|
||||
|
||||
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
||||
|
||||
if start_date and start_time:
|
||||
from datetime import datetime
|
||||
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
|
||||
start_dt = start_dt_naive.replace(tzinfo=tz)
|
||||
query = query.filter(timestamp__gte=start_dt)
|
||||
|
||||
if end_date and end_time:
|
||||
from datetime import datetime
|
||||
end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M")
|
||||
end_dt = end_dt_naive.replace(tzinfo=tz)
|
||||
query = query.filter(timestamp__lte=end_dt)
|
||||
|
||||
metrics = query.order_by("timestamp")
|
||||
|
||||
data = {
|
||||
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics],
|
||||
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics],
|
||||
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics],
|
||||
"nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics],
|
||||
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
||||
"bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics],
|
||||
"print_percent": [m.print_percent if m.print_percent else 0 for m in metrics],
|
||||
"cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics],
|
||||
"heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics],
|
||||
"wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics],
|
||||
"ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics],
|
||||
"ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics],
|
||||
"layer_num": [m.layer_num if m.layer_num else 0 for m in metrics],
|
||||
"total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics],
|
||||
"gcode_state": [m.gcode_state for m in metrics],
|
||||
"print_type": [m.print_type for m in metrics],
|
||||
"subtask_name": [m.subtask_name for m in metrics],
|
||||
}
|
||||
|
||||
project_markers = self._calculate_project_markers(metrics, tz)
|
||||
data["project_markers"] = project_markers
|
||||
|
||||
filament_timeline = self._prepare_filament_timeline_for_api(metrics)
|
||||
data["filament_timeline"] = filament_timeline
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
|
||||
def _calculate_project_markers(self, metrics, timezone_info):
|
||||
markers = []
|
||||
current_job = None
|
||||
last_state = None
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
subtask = metric.subtask_name
|
||||
gcode_state = metric.gcode_state
|
||||
|
||||
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
||||
|
||||
if subtask and subtask != current_job and is_printing:
|
||||
markers.append({
|
||||
'type': 'start',
|
||||
'index': idx,
|
||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||
'project_name': subtask,
|
||||
})
|
||||
current_job = subtask
|
||||
last_state = gcode_state
|
||||
|
||||
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
||||
markers.append({
|
||||
'type': 'end',
|
||||
'index': idx,
|
||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||
'project_name': current_job,
|
||||
})
|
||||
current_job = None
|
||||
|
||||
last_state = gcode_state
|
||||
|
||||
return markers
|
||||
|
||||
def _prepare_filament_timeline_for_api(self, metrics):
|
||||
filament_data = {}
|
||||
total_points = len(metrics)
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
try:
|
||||
snapshots = metric.filament_snapshots.all()
|
||||
except Exception:
|
||||
snapshots = []
|
||||
|
||||
for snapshot in snapshots:
|
||||
tray_id = snapshot.tray_id
|
||||
fil_type = snapshot.type or 'Unknown'
|
||||
fil_sub_type = snapshot.sub_type or 'Unknown'
|
||||
fil_color = snapshot.color or 'FFFFFFFF'
|
||||
|
||||
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||
|
||||
if unique_key not in filament_data:
|
||||
filament_data[unique_key] = {
|
||||
'tray_id': tray_id,
|
||||
'type': fil_type,
|
||||
'brand': fil_sub_type,
|
||||
'color': fil_color,
|
||||
'remain_data': [None] * total_points,
|
||||
'start_idx': idx,
|
||||
}
|
||||
|
||||
remain_percent = snapshot.remain_percent or 0
|
||||
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
external = metric.external_spool or {}
|
||||
if external.get('type'):
|
||||
fil_type = external.get('type', 'Unknown')
|
||||
fil_color = external.get('color', '161616FF')
|
||||
unique_key = f"External_{fil_type}_{fil_color}"
|
||||
|
||||
if unique_key not in filament_data:
|
||||
filament_data[unique_key] = {
|
||||
'tray_id': 'External',
|
||||
'type': fil_type,
|
||||
'brand': 'External',
|
||||
'color': fil_color,
|
||||
'remain_data': [None] * total_points,
|
||||
'start_idx': idx,
|
||||
}
|
||||
|
||||
remain_percent = external.get('remain', 0)
|
||||
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
||||
|
||||
return filament_data
|
||||
|
||||
|
||||
class FilamentUsageDataAPIView(LoginRequiredMixin, View):
|
||||
"""API endpoint for filament usage history with date/time filtering"""
|
||||
|
||||
def get(self, request, pk):
|
||||
start_date = request.GET.get("start_date")
|
||||
end_date = request.GET.get("end_date")
|
||||
start_time = request.GET.get("start_time", "00:00")
|
||||
end_time = request.GET.get("end_time", "23:59")
|
||||
|
||||
try:
|
||||
filament = Filament.objects.get(pk=pk)
|
||||
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
||||
query = filament.usage_snapshots.select_related('printer_metric')
|
||||
|
||||
if start_date and start_time:
|
||||
from datetime import datetime
|
||||
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
|
||||
start_dt = start_dt_naive.replace(tzinfo=tz)
|
||||
query = query.filter(printer_metric__timestamp__gte=start_dt)
|
||||
|
||||
if end_date and end_time:
|
||||
from datetime import datetime
|
||||
end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M")
|
||||
end_dt = end_dt_naive.replace(tzinfo=tz)
|
||||
query = query.filter(printer_metric__timestamp__lte=end_dt)
|
||||
|
||||
if not start_date and not end_date:
|
||||
time_24h_ago = timezone.now() - timedelta(hours=24)
|
||||
query = query.filter(printer_metric__timestamp__gte=time_24h_ago)
|
||||
|
||||
snapshots = query.order_by('printer_metric__timestamp')
|
||||
|
||||
data = {
|
||||
"timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots],
|
||||
"remaining": [s.remain_percent for s in snapshots]
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
except Filament.DoesNotExist:
|
||||
return JsonResponse({"error": "Filament not found"}, status=404)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ==================== Filament CRUD Views ====================
|
||||
|
||||
class FilamentListView(LoginRequiredMixin, ListView):
|
||||
model = Filament
|
||||
template_name = 'bambu_run/filament_list.html'
|
||||
context_object_name = 'filaments'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Filament.objects.all()
|
||||
|
||||
filament_type = self.request.GET.get('type')
|
||||
if filament_type:
|
||||
queryset = queryset.filter(type=filament_type)
|
||||
|
||||
loaded = self.request.GET.get('loaded')
|
||||
if loaded == 'yes':
|
||||
queryset = queryset.filter(is_loaded_in_ams=True)
|
||||
elif loaded == 'no':
|
||||
queryset = queryset.filter(is_loaded_in_ams=False)
|
||||
|
||||
search = self.request.GET.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(brand__icontains=search) |
|
||||
Q(color__icontains=search) |
|
||||
Q(type__icontains=search)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['total_spools'] = Filament.objects.count()
|
||||
context['loaded_spools'] = Filament.objects.filter(is_loaded_in_ams=True).count()
|
||||
context['low_filaments'] = Filament.objects.filter(remaining_percent__lt=20).count()
|
||||
context['filament_types'] = sorted(
|
||||
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class FilamentCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Filament
|
||||
form_class = FilamentForm
|
||||
template_name = 'bambu_run/filament_form.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Filament spool "{form.instance}" added successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class FilamentUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Filament
|
||||
form_class = FilamentForm
|
||||
template_name = 'bambu_run/filament_form.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Filament spool "{form.instance}" updated successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class FilamentDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = Filament
|
||||
template_name = 'bambu_run/filament_confirm_delete.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
filament = self.get_object()
|
||||
messages.success(self.request, f'Filament spool "{filament}" has been deleted.')
|
||||
return super().delete(request, *args, **kwargs)
|
||||
|
||||
|
||||
class FilamentDetailView(LoginRequiredMixin, DetailView):
|
||||
model = Filament
|
||||
template_name = 'bambu_run/filament_detail.html'
|
||||
context_object_name = 'filament'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
filament = self.object
|
||||
|
||||
context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20]
|
||||
|
||||
total_consumed = filament.print_usages.aggregate(
|
||||
total=Sum('consumed_percent')
|
||||
)['total'] or 0
|
||||
context['total_consumed_percent'] = total_consumed
|
||||
|
||||
return context
|
||||
|
||||
|
||||
# ==================== FilamentColor Views ====================
|
||||
|
||||
class FilamentColorListView(LoginRequiredMixin, ListView):
|
||||
model = FilamentColor
|
||||
template_name = 'bambu_run/filament_color_list.html'
|
||||
context_object_name = 'colors'
|
||||
paginate_by = 50
|
||||
|
||||
def get_queryset(self):
|
||||
return FilamentColor.objects.all().order_by('filament_type', 'filament_sub_type', 'color_name')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['total_colors'] = FilamentColor.objects.count()
|
||||
return context
|
||||
|
||||
|
||||
class FilamentColorCreateView(LoginRequiredMixin, CreateView):
|
||||
model = FilamentColor
|
||||
form_class = FilamentColorForm
|
||||
template_name = 'bambu_run/filament_color_form.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_color_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
self._update_matching_filaments(self.object)
|
||||
return response
|
||||
|
||||
def _update_matching_filaments(self, filament_color):
|
||||
from .utils import match_and_update_filament_color
|
||||
updated_count = match_and_update_filament_color(filament_color)
|
||||
if updated_count > 0:
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Color '{filament_color.color_name}' created! "
|
||||
f"Updated {updated_count} matching filament spool(s)."
|
||||
)
|
||||
|
||||
|
||||
class FilamentColorUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = FilamentColor
|
||||
form_class = FilamentColorForm
|
||||
template_name = 'bambu_run/filament_color_form.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_color_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
self._update_matching_filaments(self.object)
|
||||
return response
|
||||
|
||||
def _update_matching_filaments(self, filament_color):
|
||||
from .utils import match_and_update_filament_color
|
||||
updated_count = match_and_update_filament_color(filament_color)
|
||||
if updated_count > 0:
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Color '{filament_color.color_name}' updated! "
|
||||
f"Updated {updated_count} matching filament spool(s)."
|
||||
)
|
||||
|
||||
|
||||
class FilamentColorDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = FilamentColor
|
||||
template_name = 'bambu_run/filament_color_confirm_delete.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_color_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
messages.success(request, f"Color '{self.get_object().color_name}' deleted successfully!")
|
||||
return super().delete(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ==================== FilamentType Views ====================
|
||||
|
||||
class FilamentTypeListView(LoginRequiredMixin, ListView):
|
||||
model = FilamentType
|
||||
template_name = 'bambu_run/filament_type_list.html'
|
||||
context_object_name = 'types'
|
||||
paginate_by = 50
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['total_types'] = FilamentType.objects.count()
|
||||
return context
|
||||
|
||||
|
||||
class FilamentTypeCreateView(LoginRequiredMixin, CreateView):
|
||||
model = FilamentType
|
||||
form_class = FilamentTypeForm
|
||||
template_name = 'bambu_run/filament_type_form.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_type_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['existing_types'] = list(
|
||||
FilamentType.objects.values_list('type', flat=True).distinct().order_by('type')
|
||||
)
|
||||
context['existing_sub_types'] = list(
|
||||
FilamentType.objects.exclude(sub_type__isnull=True).exclude(sub_type='')
|
||||
.values_list('sub_type', flat=True).distinct().order_by('sub_type')
|
||||
)
|
||||
context['existing_brands'] = list(
|
||||
FilamentType.objects.values_list('brand', flat=True).distinct().order_by('brand')
|
||||
)
|
||||
context['preset_types'] = FilamentTypeForm.PRESET_TYPES
|
||||
context['preset_sub_types'] = FilamentTypeForm.PRESET_SUB_TYPES
|
||||
context['preset_brands'] = FilamentTypeForm.PRESET_BRANDS
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Filament type "{form.instance}" added successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class FilamentTypeUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = FilamentType
|
||||
form_class = FilamentTypeForm
|
||||
template_name = 'bambu_run/filament_type_form.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_type_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['existing_types'] = list(
|
||||
FilamentType.objects.values_list('type', flat=True).distinct().order_by('type')
|
||||
)
|
||||
context['existing_sub_types'] = list(
|
||||
FilamentType.objects.exclude(sub_type__isnull=True).exclude(sub_type='')
|
||||
.values_list('sub_type', flat=True).distinct().order_by('sub_type')
|
||||
)
|
||||
context['existing_brands'] = list(
|
||||
FilamentType.objects.values_list('brand', flat=True).distinct().order_by('brand')
|
||||
)
|
||||
context['preset_types'] = FilamentTypeForm.PRESET_TYPES
|
||||
context['preset_sub_types'] = FilamentTypeForm.PRESET_SUB_TYPES
|
||||
context['preset_brands'] = FilamentTypeForm.PRESET_BRANDS
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, f'Filament type "{form.instance}" updated successfully!')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class FilamentTypeDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = FilamentType
|
||||
template_name = 'bambu_run/filament_type_confirm_delete.html'
|
||||
success_url = reverse_lazy('bambu_run:filament_type_list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
return context
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
messages.success(request, f"Filament type '{self.get_object()}' deleted successfully!")
|
||||
return super().delete(request, *args, **kwargs)
|
||||
Reference in New Issue
Block a user