diff --git a/Dockerfile b/Dockerfile index 4f32a0a..07bf362 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,11 +10,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ supervisor \ && rm -rf /var/lib/apt/lists/* -# Install bambu-lab-cloud-api without deps (opencv-python is declared but unused at runtime) +# Install bambu-lab-cloud-api without deps (opencv-python is declared but unused at runtime). +# Then stub out opencv-python so pip's resolver considers it satisfied and won't try to +# build it from source (no C compiler, no armv7l wheel available). RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \ - pip install --no-cache-dir paho-mqtt requests flask flask-cors flask-limiter + pip install --no-cache-dir paho-mqtt requests flask flask-cors flask-limiter && \ + python3 -c "import site, pathlib; \ + d = pathlib.Path(site.getsitepackages()[0]) / 'opencv_python-4.99.0.dist-info'; \ + d.mkdir(); \ + (d / 'METADATA').write_text('Metadata-Version: 2.1\nName: opencv-python\nVersion: 4.99.0\n'); \ + (d / 'INSTALLER').write_text('pip\n'); \ + (d / 'RECORD').write_text('')" -# Install project and remaining dependencies +# Install project and remaining dependencies (pip sees opencv-python already satisfied) COPY pyproject.toml . RUN pip install --no-cache-dir ".[standalone]" diff --git a/README.md b/README.md index be7c323..58ea1eb 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,15 @@ This downloads all required software (takes a few minutes the first time). ### Step 5a: First-Time Authentication -The first time you connect, Bambu Lab requires email verification. You need to run the collector **interactively** (not in the background) so you can enter the 6-digit code: +The first time you connect, Bambu Lab requires email verification. You need to run the collector **interactively** (not in the background) so you can enter the 6-digit code. + +First, set up the database: + +```bash +docker compose run --rm bambu-run python standalone/manage.py migrate --noinput +``` + +Then run the collector (this is what triggers Bambu Lab to send the verification email): ```bash docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once diff --git a/bambu_run/migrations/0001_initial.py b/bambu_run/migrations/0001_initial.py index 57fa6c4..773ae1a 100644 --- a/bambu_run/migrations/0001_initial.py +++ b/bambu_run/migrations/0001_initial.py @@ -1,15 +1,8 @@ -""" -Initial migration for bambu_run. +# Generated by Django 5.2.8 on 2026-02-19 00:42 -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 +import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): @@ -22,60 +15,93 @@ class Migration(migrations.Migration): 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)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(help_text="Friendly device name", max_length=200), + ), + ( + "model", + models.CharField( + help_text="Device model (e.g., X1C, P1S)", max_length=100 + ), + ), + ( + "manufacturer", + models.CharField( + default="Bambu Lab", help_text="e.g., Bambu Lab", max_length=100 + ), + ), + ("description", models.TextField(blank=True, null=True)), + ( + "serial_number", + models.CharField( + blank=True, max_length=100, null=True, unique=True + ), + ), + ("ip_address", models.GenericIPAddressField(blank=True, null=True)), + ("is_active", models.BooleanField(default=True)), + ( + "location", + models.CharField( + blank=True, help_text="Physical location", max_length=200 + ), + ), + ("first_seen", models.DateTimeField(auto_now_add=True)), + ("last_updated", 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"], + "ordering": ["name"], }, ), 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)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + help_text="Base material: PLA, PETG, ABS, etc.", max_length=50 + ), + ), + ( + "sub_type", + models.CharField( + blank=True, + help_text="Sub-type: PLA Basic, PLA Matte, etc.", + max_length=100, + null=True, + ), + ), + ( + "brand", + models.CharField( + default="Bambu Lab", + help_text="Manufacturer name", + max_length=100, + ), + ), ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], options={ "verbose_name": "Filament Type", @@ -86,93 +112,665 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="FilamentColor", + name="Filament", 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")), + ( + "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 MQTT", + max_length=100, + null=True, + unique=True, + ), + ), + ( + "tag_uid", + models.CharField( + blank=True, + db_index=True, + help_text="RFID chip unique identifier", + max_length=100, + null=True, + ), + ), + ( + "tag_id", + models.CharField( + blank=True, + help_text="User-defined unique identifier (barcode, label, etc.)", + max_length=100, + null=True, + ), + ), + ( + "created_by", + models.CharField( + choices=[ + ("Auto Detection", "Auto Detection"), + ("Manual", "Manual"), + ], + default="Manual", + help_text="How this filament was added to inventory", + max_length=20, + ), + ), + ( + "type", + models.CharField( + help_text="PLA, PETG, ABS, TPU, etc.", max_length=50 + ), + ), + ( + "sub_type", + models.CharField( + blank=True, + help_text="Material sub-type from MQTT: 'PLA Matte', 'PLA Basic', etc.", + max_length=100, + null=True, + ), + ), + ( + "brand", + models.CharField(help_text="Manufacturer name", max_length=100), + ), + ("color", models.CharField(help_text="Color name", max_length=50)), + ( + "color_hex", + models.CharField( + blank=True, + help_text="Color hex code for display (#RRGGBB)", + max_length=7, + null=True, + ), + ), + ( + "diameter", + models.DecimalField( + decimal_places=2, + default=1.75, + help_text="Filament diameter in mm (1.75 or 2.85)", + max_digits=4, + ), + ), + ( + "initial_weight_grams", + models.IntegerField( + blank=True, + help_text="Spool weight when new (typically 1000g)", + null=True, + ), + ), + ( + "remaining_percent", + models.IntegerField( + default=100, help_text="Estimated remaining filament (0-100%)" + ), + ), + ( + "remaining_weight_grams", + models.IntegerField( + blank=True, help_text="Calculated remaining weight", null=True + ), + ), + ( + "is_loaded_in_ams", + models.BooleanField( + default=False, + help_text="Is this spool currently loaded in AMS?", + ), + ), + ( + "current_tray_id", + models.IntegerField( + blank=True, + help_text="Which AMS slot (0-3) if loaded", + null=True, + ), + ), + ( + "last_loaded_date", + models.DateTimeField( + blank=True, + help_text="When was this spool loaded into AMS", + null=True, + ), + ), + ("purchase_date", models.DateField(blank=True, null=True)), + ( + "purchase_price", + models.DecimalField( + blank=True, decimal_places=2, max_digits=8, null=True + ), + ), + ("supplier", models.CharField(blank=True, max_length=100, null=True)), + ( + "notes", + models.TextField( + blank=True, help_text="Custom notes about this spool" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "last_used", + models.DateTimeField( + blank=True, + help_text="Last time this spool was used in a print", + null=True, + ), + ), + ( + "filament_type", + models.ForeignKey( + blank=True, + help_text="Link to FilamentType registry", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="filaments", + to="bambu_run.filamenttype", + ), + ), ], options={ - "verbose_name": "Filament Color", - "verbose_name_plural": "Filament Colors", - "db_table": "infrastructure_filament_color", - "ordering": ["filament_type", "color_name"], + "verbose_name": "Filament Spool", + "verbose_name_plural": "Filament Spools", + "db_table": "infrastructure_filament", + "ordering": ["type", "brand", "color", "-remaining_percent"], }, ), migrations.CreateModel( - name="Filament", + name="PrinterMetrics", 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")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "timestamp", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + help_text="When this reading was taken", + ), + ), + ( + "nozzle_temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ( + "nozzle_target_temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ( + "bed_temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ( + "bed_target_temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ( + "chamber_temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ( + "nozzle_diameter", + models.DecimalField( + blank=True, decimal_places=2, max_digits=3, null=True + ), + ), + ("nozzle_type", models.CharField(blank=True, max_length=50, null=True)), + ( + "gcode_state", + models.CharField( + blank=True, + help_text="FINISH, RUNNING, IDLE, etc.", + max_length=50, + null=True, + ), + ), + ( + "print_type", + models.CharField( + blank=True, + help_text="idle, printing, etc.", + max_length=50, + null=True, + ), + ), + ( + "print_percent", + models.IntegerField( + blank=True, help_text="Print progress percentage", null=True + ), + ), + ( + "remaining_time_min", + models.IntegerField( + blank=True, + help_text="Estimated remaining time in minutes", + 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 layers in print", null=True + ), + ), + ("print_line_number", models.IntegerField(blank=True, null=True)), + ( + "subtask_name", + models.CharField(blank=True, max_length=200, null=True), + ), + ("gcode_file", models.CharField(blank=True, max_length=200, null=True)), + ("cooling_fan_speed", models.IntegerField(blank=True, null=True)), + ("heatbreak_fan_speed", models.IntegerField(blank=True, null=True)), + ( + "big_fan1_speed", + models.IntegerField( + blank=True, help_text="Auxiliary/chamber fan 1 speed", null=True + ), + ), + ( + "big_fan2_speed", + models.IntegerField( + blank=True, help_text="Auxiliary/chamber fan 2 speed", null=True + ), + ), + ( + "spd_lvl", + models.IntegerField( + blank=True, + help_text="Speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)", + null=True, + ), + ), + ( + "spd_mag", + models.IntegerField( + blank=True, help_text="Speed magnitude percentage", null=True + ), + ), + ("wifi_signal_dbm", models.IntegerField(blank=True, null=True)), + ("print_error", models.IntegerField(default=0)), + ("has_errors", models.BooleanField(default=False)), + ( + "chamber_light", + models.CharField( + blank=True, help_text="on/off", max_length=20, null=True + ), + ), + ( + "ipcam_record", + models.CharField( + blank=True, help_text="enable/disable", max_length=20, null=True + ), + ), + ( + "timelapse", + models.CharField( + blank=True, help_text="enable/disable", max_length=20, null=True + ), + ), + ( + "stg_cur", + models.IntegerField( + blank=True, help_text="Current print stage", null=True + ), + ), + ( + "sdcard", + models.BooleanField( + blank=True, help_text="SD card present", null=True + ), + ), + ( + "gcode_file_prepare_percent", + models.CharField( + blank=True, + help_text="File preparation progress", + max_length=10, + null=True, + ), + ), + ( + "lifecycle", + models.CharField( + blank=True, + help_text="Product lifecycle state", + max_length=50, + null=True, + ), + ), + ( + "hms", + models.JSONField( + default=list, + help_text="Health management system messages (errors/warnings)", + ), + ), + ("ams_unit_count", models.IntegerField(blank=True, null=True)), + ("ams_status", models.IntegerField(blank=True, null=True)), + ("ams_rfid_status", models.IntegerField(blank=True, null=True)), + ( + "ams_humidity", + models.IntegerField( + blank=True, + help_text="AMS humidity level (processed)", + null=True, + ), + ), + ( + "ams_humidity_raw", + models.IntegerField( + blank=True, help_text="AMS raw humidity reading", null=True + ), + ), + ( + "ams_temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ( + "ams_version", + models.IntegerField( + blank=True, help_text="AMS firmware version", null=True + ), + ), + ( + "tray_is_bbl_bits", + models.CharField( + blank=True, + help_text="Which trays have Bambu Lab (OEM) filament", + max_length=20, + null=True, + ), + ), + ( + "tray_read_done_bits", + models.CharField( + blank=True, + help_text="RFID read completion status bits", + max_length=20, + null=True, + ), + ), + ( + "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}]" + ), + ), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="printer_metrics", + to="bambu_run.printer", + ), + ), ], options={ - "verbose_name": "Filament", - "verbose_name_plural": "Filaments", - "db_table": "infrastructure_filament", - "ordering": ["-updated_at"], + "verbose_name": "Printer Metric", + "verbose_name_plural": "Printer Metrics", + "db_table": "infrastructure_printer_metrics", + "ordering": ["-timestamp"], }, ), 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")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("tray_id", models.IntegerField(help_text="AMS slot number (0-3)")), + ( + "slot_name", + models.CharField( + blank=True, + help_text="Slot identifier like A00-W1", + max_length=20, + null=True, + ), + ), + ("type", models.CharField(blank=True, max_length=50, null=True)), + ( + "sub_type", + models.CharField( + blank=True, + help_text="Material sub-type from MQTT (PLA Basic, PLA Matte, etc.)", + max_length=100, + null=True, + ), + ), + ( + "brand", + models.CharField( + blank=True, + help_text="Deprecated: MQTT doesn't provide brand. Use Filament.brand instead.", + max_length=100, + null=True, + ), + ), + ("color", models.CharField(blank=True, max_length=50, null=True)), + ("remain_percent", models.IntegerField(blank=True, null=True)), + ( + "k_value", + models.DecimalField( + blank=True, decimal_places=4, max_digits=6, null=True + ), + ), + ( + "tag_uid", + models.CharField( + blank=True, + db_index=True, + help_text="RFID chip unique identifier", + max_length=100, + null=True, + ), + ), + ( + "tray_uuid", + models.CharField( + blank=True, + help_text="Tray UUID from MQTT", + max_length=100, + null=True, + ), + ), + ( + "state", + models.IntegerField( + blank=True, help_text="Tray state from MQTT", null=True + ), + ), + ( + "temp", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ("humidity", models.IntegerField(blank=True, null=True)), + ( + "auto_matched", + models.BooleanField( + default=True, + help_text="Was this auto-matched to inventory or manually set?", + ), + ), + ( + "match_method", + models.CharField( + default="none", + help_text="tag_id, lowest_remaining, manual, or none", + max_length=20, + ), + ), + ( + "filament", + models.ForeignKey( + blank=True, + help_text="Matched filament from inventory (null if no match)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="usage_snapshots", + to="bambu_run.filament", + ), + ), + ( + "printer_metric", + models.ForeignKey( + 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"], + "ordering": ["printer_metric", "tray_id"], }, ), 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")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "project_name", + models.CharField( + help_text="From subtask_name field", max_length=200 + ), + ), + ("gcode_file", models.CharField(blank=True, max_length=200, null=True)), + ("start_time", models.DateTimeField(help_text="When print started")), + ( + "end_time", + models.DateTimeField( + blank=True, help_text="When print finished/failed", null=True + ), + ), + ( + "duration_minutes", + models.IntegerField( + blank=True, help_text="Total print duration", null=True + ), + ), + ("total_layers", models.IntegerField(blank=True, null=True)), + ( + "final_status", + models.CharField( + blank=True, + help_text="FINISH, FAILED, CANCELLED", + max_length=50, + null=True, + ), + ), + ( + "completion_percent", + models.IntegerField( + default=0, help_text="Final completion percentage" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="print_jobs", + to="bambu_run.printer", + ), + ), + ( + "end_metric", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ended_jobs", + to="bambu_run.printermetrics", + ), + ), + ( + "start_metric", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="started_jobs", + to="bambu_run.printermetrics", + ), + ), ], options={ "verbose_name": "Print Job", @@ -184,25 +782,250 @@ class Migration(migrations.Migration): 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")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("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( + blank=True, + help_text="Filament remaining % at job end", + null=True, + ), + ), + ( + "consumed_percent", + models.IntegerField( + blank=True, help_text="Amount consumed during print", null=True + ), + ), + ( + "consumed_grams", + models.IntegerField( + blank=True, help_text="Estimated grams consumed", null=True + ), + ), + ( + "is_primary", + models.BooleanField( + default=True, help_text="Primary filament vs multi-color" + ), + ), + ( + "filament", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="print_usages", + to="bambu_run.filament", + ), + ), + ( + "print_job", + models.ForeignKey( + 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"], + "ordering": ["print_job", "tray_id"], }, ), - # Add indexes + migrations.CreateModel( + name="FilamentColor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "color_code", + models.CharField( + help_text="Hex color code without padding (e.g., '000000' not '000000FF')", + max_length=6, + ), + ), + ( + "color_name", + models.CharField( + help_text="Human-readable color name (e.g., 'Black', 'Orange')", + max_length=100, + ), + ), + ( + "filament_type", + models.CharField( + help_text="Base material type: PLA, PETG, ABS, TPU, etc.", + max_length=50, + ), + ), + ( + "filament_sub_type", + models.CharField( + blank=True, + help_text="Material sub-type: 'PLA Basic', 'PLA Matte', 'ABS GF', etc.", + max_length=100, + null=True, + ), + ), + ( + "brand", + models.CharField( + default="Bambu Lab", + help_text="Manufacturer name", + max_length=100, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "filament_type_fk", + models.ForeignKey( + blank=True, + help_text="Link to FilamentType 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", "filament_sub_type", "color_name"], + "indexes": [ + models.Index( + fields=[ + "color_code", + "filament_type", + "filament_sub_type", + "brand", + ], + name="infrastruct_color_c_de04ed_idx", + ), + models.Index( + fields=["filament_type"], name="infrastruct_filamen_0465c7_idx" + ), + ], + "unique_together": { + ("color_code", "filament_type", "filament_sub_type", "brand") + }, + }, + ), + migrations.AddIndex( + model_name="filament", + index=models.Index( + fields=["type", "brand", "color"], name="infrastruct_type_42e074_idx" + ), + ), + migrations.AddIndex( + model_name="filament", + index=models.Index( + fields=["tray_uuid"], name="infrastruct_tray_uu_578cd9_idx" + ), + ), + migrations.AddIndex( + model_name="filament", + index=models.Index( + fields=["tag_uid"], name="infrastruct_tag_uid_4c70bd_idx" + ), + ), + migrations.AddIndex( + model_name="filament", + index=models.Index(fields=["tag_id"], name="infrastruct_tag_id_d422f5_idx"), + ), + migrations.AddIndex( + model_name="filament", + index=models.Index( + fields=["is_loaded_in_ams", "current_tray_id"], + name="infrastruct_is_load_2a6d6a_idx", + ), + ), + migrations.AddIndex( + model_name="filament", + index=models.Index( + fields=["remaining_percent"], name="infrastruct_remaini_5a2b06_idx" + ), + ), + migrations.AddIndex( + model_name="filament", + index=models.Index( + fields=["created_by"], name="infrastruct_created_be984e_idx" + ), + ), migrations.AddIndex( model_name="printermetrics", - index=models.Index(fields=["device", "-timestamp"], name="infra_pm_device_ts_idx"), + index=models.Index( + fields=["device", "-timestamp"], name="printer_dev_time_idx" + ), + ), + migrations.AddIndex( + model_name="printermetrics", + index=models.Index(fields=["-timestamp"], name="printer_time_idx"), + ), + migrations.AddIndex( + model_name="filamentsnapshot", + index=models.Index( + fields=["printer_metric", "tray_id"], + name="infrastruct_printer_e9159d_idx", + ), + ), + migrations.AddIndex( + model_name="filamentsnapshot", + index=models.Index( + fields=["filament"], name="infrastruct_filamen_7fb827_idx" + ), + ), + migrations.AddIndex( + model_name="printjob", + index=models.Index( + fields=["device", "-start_time"], name="infrastruct_device__d4514f_idx" + ), + ), + migrations.AddIndex( + model_name="printjob", + index=models.Index( + fields=["project_name"], name="infrastruct_project_7aa127_idx" + ), + ), + migrations.AddIndex( + model_name="printjob", + index=models.Index( + fields=["-start_time"], name="infrastruct_start_t_a87ee3_idx" + ), + ), + migrations.AddIndex( + model_name="filamentusage", + index=models.Index( + fields=["print_job"], name="infrastruct_print_j_aaee76_idx" + ), + ), + migrations.AddIndex( + model_name="filamentusage", + index=models.Index( + fields=["filament"], name="infrastruct_filamen_2377d2_idx" + ), ), ] diff --git a/bambu_run/mqtt_client.py b/bambu_run/mqtt_client.py index 3f231a4..25db77e 100644 --- a/bambu_run/mqtt_client.py +++ b/bambu_run/mqtt_client.py @@ -687,27 +687,28 @@ class BambuPrinter: print("BambuLab Authentication") print("=" * 60) print(f"Authenticating as: {self.username}") - print("This may require email verification (2FA)...") + print() + print(">>> ACTION MAY BE REQUIRED <<<") + print("Bambu Lab will send a 6-digit verification code to your") + print("registered email. Watch this terminal — if a prompt") + print(f"appears below, enter the code and press Enter.") + print(f"(You have {verification_code_timeout} seconds to respond.)") + print("=" * 60) 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 - ) + # Always show stdout during auth — suppress_stdout would hide + # interactive prompts from the library (e.g. verification code input). + 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(f"Token: {token}") print("=" * 60 + "\n") logger.info("BambuLab token obtained successfully") return token diff --git a/bambu_run/static/bambu_run/css/dashboard.css b/bambu_run/static/bambu_run/css/dashboard.css index dd87df5..bda76e6 100644 --- a/bambu_run/static/bambu_run/css/dashboard.css +++ b/bambu_run/static/bambu_run/css/dashboard.css @@ -5,6 +5,11 @@ height: 300px; } +.no-data-message { + font-size: 0.9rem; + font-style: italic; +} + /* Card styling */ .infra-card-warning { background: linear-gradient(135deg, #ffc107 0%, #ffb300 100%); diff --git a/bambu_run/static/bambu_run/js/printer_charts.js b/bambu_run/static/bambu_run/js/printer_charts.js index d8d3510..9b3f7f4 100644 --- a/bambu_run/static/bambu_run/js/printer_charts.js +++ b/bambu_run/static/bambu_run/js/printer_charts.js @@ -4,10 +4,30 @@ let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart; let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart; +function showNoDataMessage(canvasId) { + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const container = canvas.closest('.chart-container'); + if (!container) return; + canvas.style.display = 'none'; + const msg = document.createElement('div'); + msg.className = 'no-data-message d-flex align-items-center justify-content-center h-100 text-body-secondary'; + msg.textContent = 'No data available for this period'; + container.appendChild(msg); +} + function initPrinterCharts(printerData, apiUrl) { // Apply filament card colors applyFilamentColors(); + // If no data, show placeholder messages and exit early + if (!printerData.timestamps || printerData.timestamps.length === 0) { + ['nozzleTempChart', 'bedTempChart', 'printProgressChart', 'fanSpeedsChart', + 'wifiSignalChart', 'amsConditionsChart', 'layerProgressChart', 'filamentTimelineChart' + ].forEach(showNoDataMessage); + return; + } + // Register the annotation plugin if (typeof Chart !== 'undefined' && typeof ChartAnnotation !== 'undefined') { Chart.register(ChartAnnotation); diff --git a/bambu_run/static/bambu_run/vendors/coreui-icons-free.svg b/bambu_run/static/bambu_run/vendors/coreui-icons-free.svg new file mode 100644 index 0000000..ed86831 --- /dev/null +++ b/bambu_run/static/bambu_run/vendors/coreui-icons-free.svg @@ -0,0 +1,1672 @@ + \ No newline at end of file diff --git a/bambu_run/templates/bambu_run/base.html b/bambu_run/templates/bambu_run/base.html index 96e26dd..cd79dbc 100644 --- a/bambu_run/templates/bambu_run/base.html +++ b/bambu_run/templates/bambu_run/base.html @@ -1,55 +1,101 @@ +{% load static %} + + {% block title %}Bambu Run{% endblock %} + + {% block extra_css %}{% endblock %} + {% block extra_head %}{% endblock %}
-
    + {% block theme_toggle %} - {% if user.is_authenticated %} - - {% endif %} + {% endblock %} + {% block logout_nav %}{% endblock %}
@@ -62,18 +108,31 @@
+