From 441db069c5d1aa75114206a0dbe2328549c30018 Mon Sep 17 00:00:00 2001 From: RNL Date: Sun, 15 Feb 2026 23:51:36 +1100 Subject: [PATCH] Initial spin-off of bambu-run from my private project separation --- .env.example | 19 + Dockerfile | 32 + README.md | 182 +++- bambu_run/__init__.py | 1 + bambu_run/admin.py | 107 +++ bambu_run/apps.py | 7 + bambu_run/conf.py | 55 ++ bambu_run/forms.py | 232 +++++ bambu_run/management/__init__.py | 0 bambu_run/management/commands/__init__.py | 0 .../management/commands/bambu_cleanup.py | 142 +++ .../management/commands/bambu_collector.py | 674 ++++++++++++++ bambu_run/migrations/0001_initial.py | 208 +++++ bambu_run/migrations/__init__.py | 0 bambu_run/models.py | 595 ++++++++++++ bambu_run/mqtt_client.py | 876 ++++++++++++++++++ bambu_run/static/bambu_run/css/dashboard.css | 61 ++ .../static/bambu_run/js/filament_type_form.js | 61 ++ .../static/bambu_run/js/printer_charts.js | 713 ++++++++++++++ .../bambu_run/js/printer_charts_control.js | 418 +++++++++ bambu_run/templates/bambu_run/base.html | 88 ++ .../filament_color_confirm_delete.html | 46 + .../bambu_run/filament_color_form.html | 80 ++ .../bambu_run/filament_color_list.html | 109 +++ .../templates/bambu_run/filament_detail.html | 311 +++++++ .../templates/bambu_run/filament_form.html | 303 ++++++ .../templates/bambu_run/filament_list.html | 206 ++++ .../filament_type_confirm_delete.html | 37 + .../bambu_run/filament_type_form.html | 92 ++ .../bambu_run/filament_type_list.html | 99 ++ .../bambu_run/printer_dashboard.html | 390 ++++++++ bambu_run/urls.py | 29 + bambu_run/utils.py | 76 ++ bambu_run/views.py | 709 ++++++++++++++ docker-compose.yml | 12 + docker/supervisord.conf | 38 + pyproject.toml | 60 ++ standalone/__init__.py | 0 standalone/manage.py | 21 + standalone/settings.py | 138 +++ standalone/templates/registration/login.html | 47 + standalone/urls.py | 13 + standalone/wsgi.py | 9 + 43 files changed, 7295 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 bambu_run/__init__.py create mode 100644 bambu_run/admin.py create mode 100644 bambu_run/apps.py create mode 100644 bambu_run/conf.py create mode 100644 bambu_run/forms.py create mode 100644 bambu_run/management/__init__.py create mode 100644 bambu_run/management/commands/__init__.py create mode 100644 bambu_run/management/commands/bambu_cleanup.py create mode 100644 bambu_run/management/commands/bambu_collector.py create mode 100644 bambu_run/migrations/0001_initial.py create mode 100644 bambu_run/migrations/__init__.py create mode 100644 bambu_run/models.py create mode 100644 bambu_run/mqtt_client.py create mode 100644 bambu_run/static/bambu_run/css/dashboard.css create mode 100644 bambu_run/static/bambu_run/js/filament_type_form.js create mode 100644 bambu_run/static/bambu_run/js/printer_charts.js create mode 100644 bambu_run/static/bambu_run/js/printer_charts_control.js create mode 100644 bambu_run/templates/bambu_run/base.html create mode 100644 bambu_run/templates/bambu_run/filament_color_confirm_delete.html create mode 100644 bambu_run/templates/bambu_run/filament_color_form.html create mode 100644 bambu_run/templates/bambu_run/filament_color_list.html create mode 100644 bambu_run/templates/bambu_run/filament_detail.html create mode 100644 bambu_run/templates/bambu_run/filament_form.html create mode 100644 bambu_run/templates/bambu_run/filament_list.html create mode 100644 bambu_run/templates/bambu_run/filament_type_confirm_delete.html create mode 100644 bambu_run/templates/bambu_run/filament_type_form.html create mode 100644 bambu_run/templates/bambu_run/filament_type_list.html create mode 100644 bambu_run/templates/bambu_run/printer_dashboard.html create mode 100644 bambu_run/urls.py create mode 100644 bambu_run/utils.py create mode 100644 bambu_run/views.py create mode 100644 docker-compose.yml create mode 100644 docker/supervisord.conf create mode 100644 pyproject.toml create mode 100644 standalone/__init__.py create mode 100644 standalone/manage.py create mode 100644 standalone/settings.py create mode 100644 standalone/templates/registration/login.html create mode 100644 standalone/urls.py create mode 100644 standalone/wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b25fd37 --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5156ea0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 05c77fe..53bf70c 100644 --- a/README.md +++ b/README.md @@ -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://: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. diff --git a/bambu_run/__init__.py b/bambu_run/__init__.py new file mode 100644 index 0000000..1d8477d --- /dev/null +++ b/bambu_run/__init__.py @@ -0,0 +1 @@ +default_app_config = "bambu_run.apps.BambuRunConfig" diff --git a/bambu_run/admin.py b/bambu_run/admin.py new file mode 100644 index 0000000..fdb879b --- /dev/null +++ b/bambu_run/admin.py @@ -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') diff --git a/bambu_run/apps.py b/bambu_run/apps.py new file mode 100644 index 0000000..e9dc4e8 --- /dev/null +++ b/bambu_run/apps.py @@ -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" diff --git a/bambu_run/conf.py b/bambu_run/conf.py new file mode 100644 index 0000000..78e849c --- /dev/null +++ b/bambu_run/conf.py @@ -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() diff --git a/bambu_run/forms.py b/bambu_run/forms.py new file mode 100644 index 0000000..09ba672 --- /dev/null +++ b/bambu_run/forms.py @@ -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 diff --git a/bambu_run/management/__init__.py b/bambu_run/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bambu_run/management/commands/__init__.py b/bambu_run/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bambu_run/management/commands/bambu_cleanup.py b/bambu_run/management/commands/bambu_cleanup.py new file mode 100644 index 0000000..781135b --- /dev/null +++ b/bambu_run/management/commands/bambu_cleanup.py @@ -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" + ) + ) diff --git a/bambu_run/management/commands/bambu_collector.py b/bambu_run/management/commands/bambu_collector.py new file mode 100644 index 0000000..4b18dc0 --- /dev/null +++ b/bambu_run/management/commands/bambu_collector.py @@ -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}%") diff --git a/bambu_run/migrations/0001_initial.py b/bambu_run/migrations/0001_initial.py new file mode 100644 index 0000000..57fa6c4 --- /dev/null +++ b/bambu_run/migrations/0001_initial.py @@ -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"), + ), + ] diff --git a/bambu_run/migrations/__init__.py b/bambu_run/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bambu_run/models.py b/bambu_run/models.py new file mode 100644 index 0000000..f471fef --- /dev/null +++ b/bambu_run/models.py @@ -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) + ) diff --git a/bambu_run/mqtt_client.py b/bambu_run/mqtt_client.py new file mode 100644 index 0000000..3f231a4 --- /dev/null +++ b/bambu_run/mqtt_client.py @@ -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", +] diff --git a/bambu_run/static/bambu_run/css/dashboard.css b/bambu_run/static/bambu_run/css/dashboard.css new file mode 100644 index 0000000..dd87df5 --- /dev/null +++ b/bambu_run/static/bambu_run/css/dashboard.css @@ -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); +} diff --git a/bambu_run/static/bambu_run/js/filament_type_form.js b/bambu_run/static/bambu_run/js/filament_type_form.js new file mode 100644 index 0000000..cd8d845 --- /dev/null +++ b/bambu_run/static/bambu_run/js/filament_type_form.js @@ -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