mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
Always show device dropdown and add bambu_diagnose for multi-printer troubleshooting.
This commit is contained in:
78
bambu_run/diagnostics.py
Normal file
78
bambu_run/diagnostics.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
128
bambu_run/management/commands/bambu_diagnose.py
Normal file
128
bambu_run/management/commands/bambu_diagnose.py
Normal file
@@ -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_<timestamp>.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)
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if show_printer_switcher %}
|
{% if show_printer_switcher %}
|
||||||
<div class="col-auto">
|
<div class="col-auto d-flex align-items-center gap-2">
|
||||||
<select class="form-select" aria-label="Select printer"
|
<label for="printerSwitcher" class="form-label mb-0 text-nowrap">Device:</label>
|
||||||
|
<select id="printerSwitcher" class="form-select" aria-label="Select printer"
|
||||||
onchange="if (this.value) { window.location.href = this.value; }">
|
onchange="if (this.value) { window.location.href = this.value; }">
|
||||||
{% for p in all_printers %}
|
{% for p in all_printers %}
|
||||||
<option value="{% url 'bambu_run:printer_dashboard' pk=p.pk %}"
|
<option value="{% url 'bambu_run:printer_dashboard' pk=p.pk %}"
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
all_printers = Printer.objects.filter(is_active=True)
|
all_printers = Printer.objects.filter(is_active=True)
|
||||||
context["all_printers"] = all_printers
|
context["all_printers"] = all_printers
|
||||||
context["show_printer_switcher"] = all_printers.count() > 1
|
# Shown even with a single printer — hints that multi-printer support exists.
|
||||||
|
context["show_printer_switcher"] = all_printers.exists()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
|
printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
|
||||||
|
|||||||
68
tests/test_diagnostics.py
Normal file
68
tests/test_diagnostics.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from bambu_run.diagnostics import redact_diagnostics, build_diagnostics_report
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_password_and_token_like_keys():
|
||||||
|
data = {"BAMBU_PASSWORD": "hunter2", "access_token": "abc123", "ok": "fine"}
|
||||||
|
|
||||||
|
redacted = redact_diagnostics(data)
|
||||||
|
|
||||||
|
assert redacted["BAMBU_PASSWORD"] == "***REDACTED***"
|
||||||
|
assert redacted["access_token"] == "***REDACTED***"
|
||||||
|
assert redacted["ok"] == "fine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_masks_known_identifier_keys_partially():
|
||||||
|
data = {"dev_id": "31B8BP592601478", "tray_uuid": "EE37828FA8844DE1AB12"}
|
||||||
|
|
||||||
|
redacted = redact_diagnostics(data)
|
||||||
|
|
||||||
|
assert redacted["dev_id"] == "31B8...1478"
|
||||||
|
assert redacted["tray_uuid"] == "EE37...AB12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_identifier_values_fully_masked():
|
||||||
|
data = {"dev_id": "short"}
|
||||||
|
|
||||||
|
redacted = redact_diagnostics(data)
|
||||||
|
|
||||||
|
assert redacted["dev_id"] == "***"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recurses_into_nested_structures():
|
||||||
|
data = {"devices": [{"dev_id": "31B8BP592601478", "name": "RNL-H2C"}]}
|
||||||
|
|
||||||
|
redacted = redact_diagnostics(data)
|
||||||
|
|
||||||
|
assert redacted["devices"][0]["dev_id"] == "31B8...1478"
|
||||||
|
assert redacted["devices"][0]["name"] == "RNL-H2C"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_redact_passthrough_keeps_original_values():
|
||||||
|
data = {"dev_id": "31B8BP592601478", "BAMBU_PASSWORD": "hunter2"}
|
||||||
|
|
||||||
|
result = redact_diagnostics(data, redact=False)
|
||||||
|
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_diagnostics_report_structure():
|
||||||
|
devices = [{"dev_id": "SERIAL-A", "name": "Printer A", "dev_product_name": "H2C"}]
|
||||||
|
raw_payloads = {"SERIAL-A": {"device": {"extruder": {"info": []}}}}
|
||||||
|
|
||||||
|
report = build_diagnostics_report(devices, raw_payloads)
|
||||||
|
|
||||||
|
assert report["device_count"] == 1
|
||||||
|
assert "generated_at" in report
|
||||||
|
assert report["devices"][0]["device_info"]["dev_id"] == "SERIAL-A"
|
||||||
|
assert report["devices"][0]["raw_mqtt_payload"] == {"device": {"extruder": {"info": []}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_diagnostics_report_handles_missing_payload():
|
||||||
|
devices = [{"dev_id": "SERIAL-A", "name": "Printer A"}]
|
||||||
|
|
||||||
|
report = build_diagnostics_report(devices, raw_payloads={})
|
||||||
|
|
||||||
|
assert report["devices"][0]["raw_mqtt_payload"] is None
|
||||||
|
assert report["devices"][0]["note"] == "No MQTT data received within the listen window."
|
||||||
@@ -26,6 +26,15 @@ def test_dashboard_defaults_to_first_active_printer(logged_in_client):
|
|||||||
|
|
||||||
assert resp.context["printer_device"].pk == printer.pk
|
assert resp.context["printer_device"].pk == printer.pk
|
||||||
assert list(resp.context["all_printers"]) == [printer]
|
assert list(resp.context["all_printers"]) == [printer]
|
||||||
|
# Switcher shows even with a single printer, as a hint that multi-printer exists.
|
||||||
|
assert resp.context["show_printer_switcher"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_hides_switcher_with_zero_printers(logged_in_client):
|
||||||
|
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
|
||||||
|
|
||||||
|
assert resp.context["show_printer_switcher"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
Reference in New Issue
Block a user