Initial spin-off of bambu-run from my private project separation

This commit is contained in:
RNL
2026-02-15 23:51:36 +11:00
parent 37c84fcd9f
commit 441db069c5
43 changed files with 7295 additions and 1 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Bambu Run Configuration
# Copy this file to .env and fill in your values
# Required: Your Bambu Lab printer's local IP address
PRINTER_IP=192.168.1.xxx
# Required: Your printer's access token (found in printer settings)
ACCESS_TOKEN=your_access_token_here
# Required: Your printer's serial number (found in Settings > Device Info)
PRINTER_SERIAL=your_serial_number
# Optional: Timezone (default: UTC)
# TIMEZONE=Australia/Melbourne
# Optional: Django settings
# DEBUG=True
# DJANGO_SECRET_KEY=change-me-to-a-random-string
# ALLOWED_HOSTS=localhost,127.0.0.1

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[standalone]"
# Copy application code
COPY . .
# Create data directory for SQLite
RUN mkdir -p /app/data
# Collect static files
ENV DJANGO_SETTINGS_MODULE=standalone.settings
RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
# Supervisor config to run both web and collector
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 8000
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

182
README.md
View File

@@ -1,2 +1,182 @@
# Bambu-Run
Unlock deeper control, richer data access, and powerful customization capabilities for your Bambu Lab 3D printer
Unlock deeper control, richer data access, and powerful customization capabilities for your Bambu Lab 3D printer.
Bambu-Run is a self-hosted web dashboard that connects to your Bambu Lab printer over your local network via MQTT. It gives you real-time monitoring (temperatures, fan speeds, print progress) and a full filament inventory system — all running on hardware you own.
## Getting Started (Beginner Friendly)
This guide walks you through setting up Bambu-Run on a **Raspberry Pi** from scratch. No prior server experience needed.
### What You'll Need
- A Raspberry Pi (3B+, 4, or 5) with Raspberry Pi OS installed and connected to your network
- Your Bambu Lab printer on the **same local network** as the Pi
- Your printer's **IP address**, **access token**, and **serial number** (we'll show you how to find these below)
- A computer on the same network to SSH into the Pi
### Step 1: Find Your Printer's Connection Details
You'll need three pieces of information from your printer. Here's how to find them:
**IP Address:**
1. On your printer's touchscreen, go to **Settings** (gear icon)
2. Tap **Network** — your IP address is shown (e.g. `192.168.1.42`)
**Access Token:**
1. On the touchscreen, go to **Settings**
2. Tap **General** > **Access Code** — note down the 8-character code
**Serial Number:**
1. On the touchscreen, go to **Settings**
2. Tap **Device Info** — the serial number is listed at the top
Write all three down. You'll need them in Step 4.
### Step 2: Connect to Your Raspberry Pi
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows) and SSH into the Pi:
```bash
ssh pi@raspberrypi.local
```
> If `raspberrypi.local` doesn't work, use your Pi's IP address instead (check your router's admin page to find it).
The default password is `raspberry` (you should change it after first login with `passwd`).
### Step 3: Install Docker
Docker lets you run Bambu-Run in a container — no need to install Python, databases, or anything else manually.
Run these commands one at a time:
```bash
# Download and run Docker's install script
curl -fsSL https://get.docker.com | sudo sh
# Let your user run Docker without sudo
sudo usermod -aG docker $USER
```
**Important:** Log out and log back in for the group change to take effect:
```bash
exit
```
Then SSH back in:
```bash
ssh pi@raspberrypi.local
```
Verify Docker is working:
```bash
docker --version
```
You should see something like `Docker version 27.x.x` — the exact number doesn't matter.
### Step 4: Download and Configure Bambu-Run
```bash
# Clone the project
git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run
# Create your configuration file
cp .env.example .env
```
Now edit the `.env` file with your printer details:
```bash
nano .env
```
Fill in the three values you noted in Step 1:
```
PRINTER_IP=192.168.1.42
ACCESS_TOKEN=your8char
PRINTER_SERIAL=01P00A000000000
```
Optionally set your timezone (defaults to UTC):
```
TIMEZONE=Australia/Melbourne
```
> You can find your timezone name at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
To save and exit nano: press `Ctrl + X`, then `Y`, then `Enter`.
### Step 5: Start Bambu-Run
```bash
docker compose up -d
```
This will:
- Download all required software automatically (takes a few minutes the first time)
- Set up the database
- Start the web dashboard and printer data collector in the background
Check that it's running:
```bash
docker compose ps
```
You should see the `bambu-run` service with status `Up`.
### Step 6: Create Your Login Account
```bash
docker compose exec bambu-run python standalone/manage.py createsuperuser
```
You'll be prompted to choose a username, email (optional), and password. This is your login for the dashboard.
### Step 7: Open the Dashboard
On any device connected to your network (phone, tablet, computer), open a browser and go to:
```
http://raspberrypi.local:8000
```
> If that doesn't work, use your Pi's IP address: `http://<pi-ip-address>:8000`
Log in with the account you just created. You should see your printer dashboard with live data flowing in.
### Troubleshooting
**"Cannot connect to printer" or no data showing:**
- Make sure your printer is turned on and connected to the same network
- Double-check the IP address, access token, and serial number in your `.env` file
- Check the logs: `docker compose logs -f`
**"Cannot connect to Docker daemon":**
- Did you log out and back in after Step 3? Docker group changes require a new session
**Dashboard not loading in browser:**
- Verify the container is running: `docker compose ps`
- Try using the Pi's IP address instead of `raspberrypi.local`
**Updating to a newer version:**
```bash
cd ~/Bambu-Run
git pull
docker compose up -d --build
```
**Stopping Bambu-Run:**
```bash
docker compose down
```
Your data is preserved in a Docker volume and will be there when you start it again.

1
bambu_run/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = "bambu_run.apps.BambuRunConfig"

107
bambu_run/admin.py Normal file
View 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
View 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
View 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
View 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

View File

View 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"
)
)

View 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}%")

View 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"),
),
]

View File

595
bambu_run/models.py Normal file
View 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
View 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",
]

View 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);
}

View 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);
});

View 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']
});
}

View 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}`;
}

View 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>

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 (&lt;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 %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 }}&deg;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 }}&deg;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 }}&deg;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
View 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
View 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
View 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)

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
bambu-run:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- bambu_data:/app/data
restart: unless-stopped
volumes:
bambu_data:

38
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,38 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
[program:web]
command=gunicorn standalone.wsgi:application --bind 0.0.0.0:8000 --workers 2
directory=/app
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
autorestart=true
[program:collector]
command=python standalone/manage.py bambu_collector
directory=/app
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
autorestart=true
startretries=10
startsecs=5
[program:migrate]
command=python standalone/manage.py migrate --noinput
directory=/app
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
autorestart=false
startsecs=0
priority=1

60
pyproject.toml Normal file
View File

@@ -0,0 +1,60 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "bambu-run"
version = "0.1.0"
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Runnan Li"},
]
keywords = ["django", "bambu-lab", "3d-printer", "filament", "mqtt", "monitoring"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Home Automation",
]
dependencies = [
"django>=4.2",
"bambu-lab-cloud-api",
]
[project.optional-dependencies]
standalone = [
"gunicorn",
"python-dotenv",
]
dev = [
"ruff",
"pytest",
"pytest-django",
]
[project.urls]
Homepage = "https://github.com/RunLit/Bambu-Run"
Repository = "https://github.com/RunLit/Bambu-Run"
Issues = "https://github.com/RunLit/Bambu-Run/issues"
[tool.setuptools.packages.find]
include = ["bambu_run*"]
[tool.setuptools.package-data]
bambu_run = [
"templates/bambu_run/*.html",
"static/bambu_run/**/*",
]

0
standalone/__init__.py Normal file
View File

21
standalone/manage.py Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for Bambu Run standalone deployment."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

138
standalone/settings.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Minimal Django settings for Bambu Run standalone deployment.
Reads configuration from environment variables or .env file.
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"bambu-run-insecure-default-change-me-in-production",
)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get("DEBUG", "True").lower() in ("true", "1", "yes")
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"bambu_run",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "standalone.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "standalone" / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "standalone.wsgi.application"
# Database — SQLite for zero-setup deployment
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
}
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Internationalization
LANGUAGE_CODE = "en-us"
TIME_ZONE = os.environ.get("TIMEZONE", "UTC")
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Login / Logout
LOGIN_URL = "/accounts/login/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/accounts/login/"
# Bambu Run settings
BAMBU_RUN_TIMEZONE = os.environ.get("TIMEZONE", "UTC")
BAMBU_RUN_BASE_TEMPLATE = "bambu_run/base.html"
# Printer connection — read from environment
PRINTER_IP = os.environ.get("PRINTER_IP", "")
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{asctime} {levelname} {name} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
"loggers": {
"bambu_run": {
"handlers": ["console"],
"level": "DEBUG" if DEBUG else "INFO",
"propagate": False,
},
},
}

View File

@@ -0,0 +1,47 @@
<!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>Login - Bambu Run</title>
<link href="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/css/coreui.min.css" rel="stylesheet">
</head>
<body class="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-4">
<div class="card-group d-block d-md-flex row">
<div class="card p-4">
<div class="card-body">
<h1>Bambu Run</h1>
<p class="text-body-secondary">Sign in to your account</p>
{% if form.errors %}
<div class="alert alert-danger">
Invalid username or password.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="input-group mb-3">
<span class="input-group-text">@</span>
<input type="text" name="username" class="form-control" placeholder="Username" autofocus>
</div>
<div class="input-group mb-4">
<span class="input-group-text">*</span>
<input type="password" name="password" class="form-control" placeholder="Password">
</div>
<div class="row">
<div class="col-6">
<button class="btn btn-primary px-4" type="submit">Login</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/js/coreui.bundle.min.js"></script>
</body>
</html>

13
standalone/urls.py Normal file
View File

@@ -0,0 +1,13 @@
"""URL configuration for Bambu Run standalone deployment."""
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from django.views.generic import RedirectView
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/login/", auth_views.LoginView.as_view(template_name="registration/login.html"), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
path("", include("bambu_run.urls")),
]

9
standalone/wsgi.py Normal file
View File

@@ -0,0 +1,9 @@
"""WSGI config for Bambu Run standalone deployment."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")
application = get_wsgi_application()