Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions completions/_bambu
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ _bambu() {
'auto-off:fire a hook on FINISH/FAILED'
'queue:persistent print queue (add/list/clear/start)'
'cal:per-filament calibration tracking'
'doctor:firmware + library diagnostic report'
'snap:capture one frame from the chamber camera'
'stream:capture frames periodically into a folder'
'timelapse:stitch captured frames into MP4'
Expand Down
2 changes: 1 addition & 1 deletion completions/bambu.bash
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ _bambu() {
}

if [ "${cword}" -eq 1 ]; then
COMPREPLY=( $(compgen -W "status pause resume cancel home light print queue cal schedule quiet auto-off snap stream timelapse vision" -- "${cur}") )
COMPREPLY=( $(compgen -W "status pause resume cancel home light print queue cal doctor schedule quiet auto-off snap stream timelapse vision" -- "${cur}") )
return
fi

Expand Down
58 changes: 58 additions & 0 deletions src/bambu_ai/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,59 @@ def _cmd_light(p, args):
# ----------------------------------------------------------------- new subcommands


@_with_printer
def _cmd_doctor(p, _args) -> None:
"""Diagnostic report (issue #16)."""
import sys as _sys
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

import bambu_ai

from .doctor import build_report

# Wait briefly for the printer state to populate.
for _ in range(50):
if p.get_state() not in ("UNKNOWN", None, ""):
break
time.sleep(0.1)

cfg = load_config()
# Best-effort firmware discovery — bambulabs_api may or may not expose it.
firmware = None
for attr in ("get_firmware_version", "firmware_version"):
fn = getattr(p, attr, None)
if callable(fn):
try:
firmware = str(fn())
except Exception: # noqa: BLE001
pass
elif fn is not None:
firmware = str(fn)
if firmware:
break

def _v(pkg: str) -> str:
try:
return _pkg_version(pkg)
except PackageNotFoundError:
return "?"

# Sneaky: tell build_report whether MQTT is ready (without forging the printer object).
p._mqtt_ready_for_doctor = p.mqtt_client_ready()
report = build_report(
printer=p,
firmware=firmware,
model="Bambu Lab A1 mini", # this repo targets A1 mini explicitly
serial=cfg["SERIAL"],
bambu_ai_version=bambu_ai.__version__,
bambulabs_api_version=_v("bambulabs_api"),
paho_mqtt_version=_v("paho-mqtt"),
python_version=f"{_sys.version_info.major}.{_sys.version_info.minor}.{_sys.version_info.micro}",
)
print(report.render())


def _cmd_cal_log(args) -> None:
"""Record calibration values for a spool (issue #7)."""
from .calibration import CalibrationLog, CalibrationRecord
Expand Down Expand Up @@ -555,6 +608,11 @@ def main() -> None:
)
autooff.set_defaults(func=_cmd_autooff)

# doctor (issue #16) — diagnostic report
sub.add_parser("doctor", help="firmware + library + known-bug diagnostic report").set_defaults(
func=_cmd_doctor
)

# cal (issue #7) — per-filament calibration tracking
cal = sub.add_parser("cal", help="track per-filament calibration values")
csub = cal.add_subparsers(dest="cal_cmd", required=True)
Expand Down
105 changes: 105 additions & 0 deletions src/bambu_ai/doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""bambu doctor — diagnostic report (issue #16).

Prints firmware version, library versions, model, and any known
compatibility issues from a hand-maintained matrix. The goal is to save
users (and agents) from filing 'feature X broke after the firmware update'
issues without first checking whether the breakage is known.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

# Hand-maintained: known broken / quirky features by Bambu A1 / A1 mini firmware.
# Format: each entry is (firmware-prefix, feature, severity, description).
# severity ∈ {"info", "warn", "broken"}.
KNOWN_FIRMWARE_ISSUES: list[tuple[str, str, str, str]] = [
(
"01.07",
"ha-bambulab integration",
"warn",
"A1 + AMS2 sometimes detected as X1 — see ha-bambulab#1621",
),
(
"01.08",
"Access Code",
"info",
"Some firmware bumps regenerate the Access Code; if MQTT auth fails after update, re-grab it from the WLAN screen.",
),
]


@dataclass
class DoctorReport:
"""Structured report so callers (and tests) can inspect the result."""

firmware: str | None
model: str | None
serial_suffix: str # last 4 chars only, redacted
bambulabs_api_version: str
bambu_ai_version: str
paho_mqtt_version: str
python_version: str
known_issues: list[tuple[str, str, str]] # (feature, severity, description)
mqtt_ready: bool
camera_alive: bool | None

def render(self) -> str:
lines: list[str] = ["bambu doctor — diagnostic report", ""]
lines.append("environment:")
lines.append(f" python: {self.python_version}")
lines.append(f" bambu-ai: {self.bambu_ai_version}")
lines.append(f" bambulabs_api: {self.bambulabs_api_version}")
lines.append(f" paho-mqtt: {self.paho_mqtt_version}")
lines.append("")
lines.append("printer:")
lines.append(f" firmware: {self.firmware or '?'}")
lines.append(f" model: {self.model or '?'}")
lines.append(f" serial suffix: …{self.serial_suffix}")
lines.append(f" MQTT ready: {self.mqtt_ready}")
lines.append(f" camera alive: {self.camera_alive}")
lines.append("")
if self.known_issues:
lines.append(f"known issues affecting firmware {self.firmware}:")
for feature, severity, desc in self.known_issues:
lines.append(f" [{severity.upper()}] {feature}: {desc}")
else:
lines.append("no known issues recorded for this firmware.")
return "\n".join(lines)


def match_known_issues(firmware: str | None) -> list[tuple[str, str, str]]:
if not firmware:
return []
return [
(feature, severity, desc)
for prefix, feature, severity, desc in KNOWN_FIRMWARE_ISSUES
if firmware.startswith(prefix)
]


def build_report(
*,
printer: Any,
firmware: str | None,
model: str | None,
serial: str,
bambu_ai_version: str,
bambulabs_api_version: str,
paho_mqtt_version: str,
python_version: str,
) -> DoctorReport:
"""Pure constructor — tests pass in stub values; the CLI fills them from the live printer."""
return DoctorReport(
firmware=firmware,
model=model,
serial_suffix=serial[-4:] if serial else "????",
bambulabs_api_version=bambulabs_api_version,
bambu_ai_version=bambu_ai_version,
paho_mqtt_version=paho_mqtt_version,
python_version=python_version,
known_issues=match_known_issues(firmware),
mqtt_ready=bool(getattr(printer, "_mqtt_ready_for_doctor", None)) if printer is not None else False,
camera_alive=None,
)
105 changes: 105 additions & 0 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Tests for the bambu doctor diagnostic builder (issue #16)."""

from __future__ import annotations

from unittest.mock import MagicMock

from bambu_ai.doctor import build_report, match_known_issues


class TestMatchKnownIssues:
def test_returns_empty_when_no_firmware(self) -> None:
assert match_known_issues(None) == []
assert match_known_issues("") == []

def test_matches_by_prefix(self) -> None:
issues = match_known_issues("01.07.04.00")
assert any("ha-bambulab" in feature.lower() for feature, _, _ in issues)

def test_no_match_returns_empty(self) -> None:
# Hypothetical future firmware not in the table.
assert match_known_issues("99.99.99.99") == []


class TestBuildReport:
def test_redacts_serial_to_last_4(self) -> None:
printer = MagicMock()
printer._mqtt_ready_for_doctor = True
r = build_report(
printer=printer,
firmware="01.07.01.00",
model="A1 mini",
serial="9999XXXXXXXXX20",
bambu_ai_version="0.1.0",
bambulabs_api_version="2.6.6",
paho_mqtt_version="2.1.0",
python_version="3.14.0",
)
assert r.serial_suffix == "XX20"
assert "9999XXXXXXXXX20" not in r.render()

def test_renders_known_issues_section(self) -> None:
printer = MagicMock()
printer._mqtt_ready_for_doctor = True
r = build_report(
printer=printer,
firmware="01.07.01.00",
model="A1 mini",
serial="0000000000000XX",
bambu_ai_version="0.1.0",
bambulabs_api_version="2.6.6",
paho_mqtt_version="2.1.0",
python_version="3.14.0",
)
out = r.render()
assert "known issues" in out.lower()
assert "ha-bambulab" in out

def test_renders_no_issues_section_when_clean(self) -> None:
printer = MagicMock()
printer._mqtt_ready_for_doctor = True
r = build_report(
printer=printer,
firmware="99.99.99.99",
model="A1 mini",
serial="abcdefghijklmnop",
bambu_ai_version="0.1.0",
bambulabs_api_version="2.6.6",
paho_mqtt_version="2.1.0",
python_version="3.14.0",
)
assert "no known issues" in r.render().lower()

def test_render_includes_versions(self) -> None:
printer = MagicMock()
printer._mqtt_ready_for_doctor = True
out = build_report(
printer=printer,
firmware="01.07.01.00",
model="A1 mini",
serial="xxxxxxxxxxxxxxx0",
bambu_ai_version="0.1.0",
bambulabs_api_version="2.6.6",
paho_mqtt_version="2.1.0",
python_version="3.14.0",
).render()
assert "0.1.0" in out
assert "2.6.6" in out
assert "2.1.0" in out
assert "3.14.0" in out

def test_handles_missing_firmware_gracefully(self) -> None:
printer = MagicMock()
printer._mqtt_ready_for_doctor = False
r = build_report(
printer=printer,
firmware=None,
model=None,
serial="x",
bambu_ai_version="0.1.0",
bambulabs_api_version="2.6.6",
paho_mqtt_version="2.1.0",
python_version="3.14.0",
)
out = r.render()
assert "?" in out # both firmware and model rendered as "?"
Loading