From 58ebdf518e83088220974f07be63ff3966eee7e1 Mon Sep 17 00:00:00 2001
From: RNL
Date: Sat, 20 Jun 2026 14:48:45 +1000
Subject: [PATCH] Always show device dropdown and add bambu_diagnose for
multi-printer troubleshooting.
---
bambu_run/diagnostics.py | 78 +++++++++++
.../management/commands/bambu_diagnose.py | 128 ++++++++++++++++++
.../bambu_run/printer_dashboard.html | 5 +-
bambu_run/views.py | 3 +-
tests/test_diagnostics.py | 68 ++++++++++
tests/test_printer_routing.py | 9 ++
6 files changed, 288 insertions(+), 3 deletions(-)
create mode 100644 bambu_run/diagnostics.py
create mode 100644 bambu_run/management/commands/bambu_diagnose.py
create mode 100644 tests/test_diagnostics.py
diff --git a/bambu_run/diagnostics.py b/bambu_run/diagnostics.py
new file mode 100644
index 0000000..266c2c7
--- /dev/null
+++ b/bambu_run/diagnostics.py
@@ -0,0 +1,78 @@
+"""
+Pure helpers for the `bambu_diagnose` management command.
+
+Kept separate from the command itself (and free of Django/network imports)
+so the report-building and redaction logic can be unit-tested without
+talking to the real Bambu Lab cloud or MQTT broker.
+"""
+
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+
+# Keys whose values are always replaced outright, regardless of nesting depth.
+_SECRET_KEY_SUBSTRINGS = ("password", "token", "secret", "access_code", "authorization")
+
+# Keys that identify a specific physical device/spool/account — not secret,
+# but identifying, so they're partially masked by default before anything
+# gets pasted into a public GitHub issue.
+_IDENTIFIER_KEYS = {"dev_id", "device_id", "serial_number", "tray_uuid", "tag_uid", "uid"}
+
+
+def _mask_identifier(value: Any) -> Any:
+ if not isinstance(value, str) or len(value) <= 8:
+ return "***"
+ return f"{value[:4]}...{value[-4:]}"
+
+
+def redact_diagnostics(data: Any, redact: bool = True) -> Any:
+ """Recursively redact secrets and mask identifiers in a diagnostics payload.
+
+ `redact=False` returns the data unchanged — only for the reporter's own
+ local debugging, never for anything posted publicly.
+ """
+ if not redact:
+ return data
+ return _redact(data)
+
+
+def _redact(obj: Any) -> Any:
+ if isinstance(obj, dict):
+ result = {}
+ for key, value in obj.items():
+ lowered = key.lower()
+ if any(secret in lowered for secret in _SECRET_KEY_SUBSTRINGS):
+ result[key] = "***REDACTED***"
+ elif lowered in _IDENTIFIER_KEYS:
+ result[key] = _mask_identifier(value)
+ else:
+ result[key] = _redact(value)
+ return result
+ if isinstance(obj, list):
+ return [_redact(item) for item in obj]
+ return obj
+
+
+def build_diagnostics_report(
+ devices: List[Dict[str, Any]],
+ raw_payloads: Dict[str, Optional[Dict[str, Any]]],
+) -> Dict[str, Any]:
+ """Assemble the (pre-redaction) diagnostics report from discovered devices
+ and whatever raw MQTT payload was captured for each during the listen window.
+ """
+ device_entries = []
+ for device in devices:
+ dev_id = device.get("dev_id")
+ payload = raw_payloads.get(dev_id)
+ entry = {
+ "device_info": device,
+ "raw_mqtt_payload": payload,
+ }
+ if payload is None:
+ entry["note"] = "No MQTT data received within the listen window."
+ device_entries.append(entry)
+
+ return {
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "device_count": len(devices),
+ "devices": device_entries,
+ }
diff --git a/bambu_run/management/commands/bambu_diagnose.py b/bambu_run/management/commands/bambu_diagnose.py
new file mode 100644
index 0000000..fc18c46
--- /dev/null
+++ b/bambu_run/management/commands/bambu_diagnose.py
@@ -0,0 +1,128 @@
+"""
+Diagnose multi-printer cloud data for a Bambu Lab account.
+
+Run this if `bambu_collector` doesn't pick up all your printers, or the data
+collected for a second/third printer looks wrong. It authenticates with your
+Bambu Lab account, lists every device the cloud API reports, listens briefly
+for raw MQTT data from each one, and writes a redacted JSON report you can
+attach to a GitHub issue.
+
+Usage:
+ python manage.py bambu_diagnose
+ python manage.py bambu_diagnose --listen-seconds 15
+ python manage.py bambu_diagnose --output my_report.json
+ python manage.py bambu_diagnose --no-redact # local debugging only — do NOT post this output publicly
+"""
+
+import json
+import logging
+import time
+
+from django.core.management.base import BaseCommand, CommandError
+
+from bambu_run.diagnostics import build_diagnostics_report, redact_diagnostics
+
+logger = logging.getLogger("bambu_run.diagnose")
+
+
+class Command(BaseCommand):
+ help = "Authenticate, list every printer on the account, and write a redacted diagnostics report."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--listen-seconds", type=float, default=8.0,
+ help="How long to listen for MQTT data per device (default: 8)",
+ )
+ parser.add_argument(
+ "--output", type=str, default=None,
+ help="Output file path (default: bambu_diagnostics_.json)",
+ )
+ parser.add_argument(
+ "--no-redact", action="store_true",
+ help="Keep full serials/identifiers unmasked. For your own debugging only — "
+ "do not paste this output into a public GitHub issue.",
+ )
+
+ def handle(self, *args, **options):
+ import os
+ from bambu_run.mqtt_client import BambuPrinter, BambuClient
+
+ listen_seconds = options["listen_seconds"]
+ redact = not options["no_redact"]
+
+ bambu_username = os.environ.get("BAMBU_USERNAME")
+ bambu_password = os.environ.get("BAMBU_PASSWORD")
+ bambu_token = os.environ.get("BAMBU_TOKEN")
+
+ 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"
+ )
+
+ self.stdout.write("Authenticating with Bambu Lab cloud...")
+ auth = BambuPrinter(username=bambu_username, password=bambu_password, token=bambu_token)
+ token = auth._ensure_token()
+
+ cloud = BambuClient(token=token)
+ devices = cloud.get_devices()
+
+ self.stdout.write(self.style.SUCCESS(f"Found {len(devices)} device(s) on this account:"))
+ for device in devices:
+ self.stdout.write(
+ f" - {device.get('name', 'unknown')} "
+ f"({device.get('dev_product_name', 'unknown model')}) "
+ f"online={device.get('online')}"
+ )
+ if len(devices) < 2:
+ self.stdout.write(self.style.WARNING(
+ "Only one device returned by the cloud API — if you own multiple printers, "
+ "this is likely the root cause. Note this in the GitHub issue."
+ ))
+
+ raw_payloads = {}
+ for device in devices:
+ dev_id = device.get("dev_id")
+ if not dev_id:
+ continue
+ self.stdout.write(f"Listening to {device.get('name', dev_id)} for {listen_seconds:.0f}s...")
+ client = BambuPrinter(token=token, device_id=dev_id)
+ try:
+ client.connect(blocking=False)
+ self._request_full_status_when_ready(client)
+ time.sleep(listen_seconds)
+ state = client.get_state()
+ raw_payloads[dev_id] = state._raw_data.get("print") if state._raw_data else None
+ except Exception as e:
+ self.stdout.write(self.style.WARNING(f" Could not collect data for {dev_id}: {e}"))
+ raw_payloads[dev_id] = None
+ finally:
+ client.disconnect()
+
+ report = build_diagnostics_report(devices, raw_payloads)
+ report = redact_diagnostics(report, redact=redact)
+
+ output_path = options["output"] or f"bambu_diagnostics_{int(time.time())}.json"
+ with open(output_path, "w") as f:
+ json.dump(report, f, indent=2, default=str)
+
+ self.stdout.write(self.style.SUCCESS(f"\nDiagnostics written to: {output_path}"))
+ if not redact:
+ self.stdout.write(self.style.WARNING(
+ "--no-redact was used: this file contains unmasked serials/identifiers. "
+ "Do not attach it to a public GitHub issue as-is."
+ ))
+ else:
+ self.stdout.write(
+ "Serials/identifiers are masked. Please skim the file once before posting — "
+ "then attach it to https://github.com/RunLit/Bambu-Run/issues/10"
+ )
+
+ def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ mqtt_client = getattr(client, "_mqtt", None)
+ if mqtt_client is not None and getattr(mqtt_client, "connected", False):
+ client._mqtt.request_full_status()
+ return
+ time.sleep(0.5)
diff --git a/bambu_run/templates/bambu_run/printer_dashboard.html b/bambu_run/templates/bambu_run/printer_dashboard.html
index 0c88400..4837c19 100644
--- a/bambu_run/templates/bambu_run/printer_dashboard.html
+++ b/bambu_run/templates/bambu_run/printer_dashboard.html
@@ -15,8 +15,9 @@