Skip to content

feat: Bambu Lab printer integration#17

Open
peterus wants to merge 8 commits intomainfrom
feat/bambulab-integration
Open

feat: Bambu Lab printer integration#17
peterus wants to merge 8 commits intomainfrom
feat/bambulab-integration

Conversation

@peterus
Copy link
Copy Markdown
Owner

@peterus peterus commented Mar 27, 2026

Summary

Add support for Bambu Lab 3D printers alongside existing Klipper/Moonraker support.

Changes

Printer Backend Abstraction (Phase 1)

  • PrinterBackend Protocol in core/services/printer_backend.py with NormalizedJobStatus dataclass
  • Factory function get_printer_backend() for backend-agnostic printer access
  • MoonrakerError now extends PrinterError for unified error handling

Model Changes (Phase 2)

  • New BambuCloudAccount model for Cloud API credentials (encrypted JWT tokens)
  • PrinterProfile extended with printer_type choice field + Bambu Lab fields
  • Renamed klipper_job_idremote_job_id (data-preserving RenameField migration)

BambuLabClient Service (Phase 3)

  • Full Bambu Lab Cloud API, MQTT, and FTP integration via bambu-lab-cloud-api library
  • Token encryption at rest using Fernet (derived from Django SECRET_KEY)
  • LAN upload preferred, Cloud as fallback

Views Refactoring (Phase 4)

  • All 6 direct MoonrakerClient instantiations replaced with get_printer_backend()
  • QueueCheckPrinterStatusView uses NormalizedJobStatus instead of Moonraker-specific parsing

Auth Wizard (Phase 5)

  • 3-step web wizard: Login → 2FA verification → Device selection
  • Session-based state management, password never persisted
  • Account list/delete/refresh management views

Dynamic Printer Form (Phase 6)

  • printer_type radio buttons with JS-driven field toggling
  • Conditional validation (Klipper requires URL, Bambu requires account + device ID)

Tests (Phase 7)

  • 41 new tests covering token encryption, factory, client, auth wizard, forms, account management
  • Updated 7 existing tests for printer_type requirement

License

  • Upgraded GPLv3 → AGPLv3 (required by bambu-lab-cloud-api dependency)

Dependencies

  • bambu-lab-cloud-api>=1.0
  • cryptography>=42.0

All 357 tests pass ✅

peterus and others added 5 commits March 27, 2026 22:41
The AGPLv3 better protects this web application against closed-source
SaaS forks by requiring source code disclosure for network use (closes
the SaaS loophole of GPLv3).

This change is also required for compatibility with the bambu-lab-cloud-api
library (AGPLv3) which will be used for Bambu Lab printer integration.

As sole copyright holder, no third-party approval is needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add PrinterBackend Protocol with NormalizedJobStatus for backend-agnostic
  printer communication
- Add get_printer_backend() factory function for lazy backend instantiation
- Add BambuCloudAccount model for Bambu Lab Cloud auth (email, token, region)
- Extend PrinterProfile with printer_type choice field (klipper/bambulab),
  bambu_account FK, bambu_device_id, and bambu_ip_address fields
- Rename PrintJobPlate.klipper_job_id to remote_job_id (RenameField migration)
- Adapt MoonrakerClient: MoonrakerError now extends PrinterError,
  get_job_status() returns NormalizedJobStatus
- Refactor all views (printers.py, queue.py) to use get_printer_backend()
  and catch PrinterError instead of MoonrakerClient/MoonrakerError directly
- QueueCheckPrinterStatusView now uses NormalizedJobStatus fields instead
  of parsing Moonraker-specific response structure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Implement BambuLabClient implementing the PrinterBackend protocol
- Cloud API: device listing, online status, cloud upload
- MQTT: real-time status polling, print start/cancel commands
- Local FTP: LAN upload (preferred) with Cloud fallback
- Fernet-based token encryption using Django SECRET_KEY
- State mapping: Bambu gcode_states → NormalizedJobStatus
- Add bambu-lab-cloud-api and cryptography to requirements.txt
- Wire factory function to create BambuLabClient for bambulab type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Auth Wizard (3-step flow):
- Step 1: Enter Bambu Lab Cloud email + password → triggers 2FA email
- Step 2: Enter 6-digit verification code → obtains JWT token
- Step 3: Select printer from device list → creates PrinterProfile

Account Management:
- BambuAccountListView: list connected accounts with linked printers
- BambuAccountDeleteView: disconnect with linked-printers warning
- BambuAccountRefreshView: re-authenticate expired tokens

Forms:
- BambuAuthStep1Form, Step2Form, Step3Form for wizard steps
- PrinterProfileForm updated with printer_type, Bambu Lab fields,
  and conditional validation based on selected type

Templates:
- 3 wizard step templates with progress indicator
- Account list and delete confirmation templates
- Dynamic printer form with JS field toggling (Klipper ↔ Bambu Lab)
- Printer list updated with 'Connect Bambu Lab' button
- Navigation menu updated with Bambu Lab Accounts link

URLs:
- bambu/connect/, bambu/verify/, bambu/devices/ (wizard)
- bambu/accounts/, bambu/accounts/<pk>/delete|refresh/ (management)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 41 new tests in test_bambulab.py covering:
  - Token encryption/decryption (Fernet roundtrip)
  - PrinterBackend factory function (Klipper/Bambu/unknown)
  - NormalizedJobStatus (terminal states, repr, defaults)
  - BambuLabClient init validation and mocked operations
  - Auth wizard 3-step flow with mocked bambulab library
  - Account management (list, delete, refresh, isolation)
  - PrinterProfileForm conditional validation per type
- Update existing tests to include printer_type field:
  - test_forms.py: PrinterProfileFormTests
  - test_views_printers.py: create/update/other-user tests
  - test_permissions.py: operator/admin printer creation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 27, 2026 21:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a backend abstraction to support Bambu Lab printers alongside the existing Moonraker/Klipper integration, including models, services, UI, and tests.

Changes:

  • Introduces PrinterBackend abstraction (get_printer_backend, NormalizedJobStatus, unified PrinterError) and updates queue/printer views to use it.
  • Adds Bambu Lab Cloud integration (account model, auth wizard views/forms/templates, Bambu backend client, printer profile fields + migration).
  • Updates dependencies/tests and switches project licensing to AGPLv3.

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
requirements.txt Adds Bambu Cloud API + cryptography dependencies
core/views/queue.py Refactors queue actions/status polling to backend factory
core/views/printers.py Uses backend factory for connectivity/status/upload
core/views/bambuauth.py Adds 3-step Bambu Cloud auth wizard + account management views
core/views/init.py Re-exports new Bambu auth views
core/urls/bambuauth.py Adds URL routes for Bambu wizard + account management
core/urls/init.py Includes Bambu auth URL patterns
core/tests/test_views_printers.py Updates printer view tests for printer_type requirement
core/tests/test_permissions.py Updates permission tests for printer creation fields
core/tests/test_forms.py Updates printer form tests for conditional validation
core/tests/test_bambulab.py Adds comprehensive Bambu backend/wizard/encryption tests
core/templates/core/printerprofile_list.html Adds “Connect Bambu Lab” entry point in printers list UI
core/templates/core/printerprofile_form.html Adds printer-type UI and toggled connection fields
core/templates/core/bambuaccount_wizard_step1.html Wizard step 1 template (login)
core/templates/core/bambuaccount_wizard_step2.html Wizard step 2 template (2FA verify)
core/templates/core/bambuaccount_wizard_step3.html Wizard step 3 template (device selection)
core/templates/core/bambuaccount_list.html Account list UI
core/templates/core/bambuaccount_confirm_delete.html Account disconnect confirmation UI
core/templates/base.html Adds nav item for Bambu accounts
core/services/printer_backend.py Adds backend protocol + factory + normalized job status
core/services/moonraker.py Unifies errors + returns normalized job status
core/services/bambulab.py Implements Bambu backend (Cloud/MQTT/FTP + token encryption)
core/models/printing.py Renames plate job tracking field to remote_job_id
core/models/printers.py Adds BambuCloudAccount model + printer type + Bambu fields
core/models/init.py Re-exports BambuCloudAccount
core/migrations/0014_bambulab_integration.py Applies schema changes for new model/fields and rename
core/forms/printers.py Adds printer_type and conditional validation for backends
core/forms/bambuauth.py Adds wizard step forms
core/forms/init.py Re-exports Bambu auth forms
README.md Updates license references to AGPLv3
LICENSE Replaces GPLv3 text with AGPLv3 text

Comment on lines +89 to +92
# Store in session for step 2
self.request.session[_SESSION_BAMBU_EMAIL] = email
self.request.session[_SESSION_BAMBU_PASSWORD] = password
self.request.session[_SESSION_BAMBU_REGION] = region
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 1 stores the user's Bambu Lab password in the Django session (bambu_auth_password). This contradicts the PR description (“password never persisted”) and increases risk if session data is leaked/backed up. Prefer not storing the password at all (re-prompt on step 2), or at minimum set a very short session expiry for the wizard and aggressively clear the password on any failure/redirect path.

Copilot uses AI. Check for mistakes.
Comment on lines +236 to +247
def get_form_kwargs(self) -> dict[str, Any]:
"""Pass device list to the form."""
kwargs = super().get_form_kwargs()
kwargs["devices"] = self._get_devices()
return kwargs

def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
"""Add device details to the template context."""
context = super().get_context_data(**kwargs)
context["bambu_email"] = self.request.session.get(_SESSION_BAMBU_EMAIL, "")
context["devices"] = self._get_devices()
return context
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_devices() performs a Cloud API call and also emits messages on failure, but it is called multiple times per request (get_form_kwargs(), get_context_data(), and again in form_valid()). This can duplicate network traffic and duplicate error messages. Cache the fetched device list on self for the lifetime of the request and reuse it across these methods.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +60
# Limit bambu_account to current user's accounts if we have a request
if "bambu_account" in self.fields:
self.fields["bambu_account"].required = False

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bambu_account is not restricted to the current user anywhere, so the form will expose all BambuCloudAccount records and allow linking a printer to another user’s account (data leak + authorization bypass). Filter bambu_account queryset to BambuCloudAccount.objects.filter(user=request.user) in the create/update views (or accept a user in the form __init__ and apply the filter there).

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +37
<div>
<i class="bi bi-clock me-1"></i>Token expires:
<span class="{% if account.token_expires_at < now %}text-danger{% endif %}">
{{ account.token_expires_at|timesince }} ago
</span>
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now is referenced in the template (account.token_expires_at < now) but is never provided by the view context, which can cause a template rendering error during comparison. Also timesince ... ago is misleading for future expiration times. Fix by adding now = timezone.now() to the context (or using the {% now %} tag) and render expiration using timeuntil when token_expires_at is in the future (and timesince only when expired).

Copilot uses AI. Check for mistakes.
Comment on lines +118 to +124
class BambuAccountDeleteForm(forms.Form):
"""Confirmation form for disconnecting a Bambu Lab account."""

confirm = forms.BooleanField(
label="I understand this will disconnect all linked printers",
required=True,
)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BambuAccountDeleteForm is defined/exported but not used by the delete view/template (the DeleteView uses a plain POST with CSRF and there is no confirmation checkbox). Either wire this form into the delete flow or remove it to avoid dead/unused code paths.

Copilot uses AI. Check for mistakes.
Comment on lines +330 to +355
class BambuAccountListView(LoginRequiredMixin, ListView):
"""List the current user's Bambu Lab Cloud accounts."""

model = BambuCloudAccount
template_name = "core/bambuaccount_list.html"
context_object_name = "accounts"

def get_queryset(self):
"""Filter to only the current user's accounts."""
return BambuCloudAccount.objects.filter(user=self.request.user)


class BambuAccountDeleteView(LoginRequiredMixin, DeleteView):
"""Disconnect (delete) a Bambu Lab Cloud account.

Also removes any printer profiles linked to this account.
"""

model = BambuCloudAccount
template_name = "core/bambuaccount_confirm_delete.html"
success_url = reverse_lazy("core:bambuaccount_list")

def get_queryset(self):
"""Restrict to accounts owned by the current user."""
return BambuCloudAccount.objects.filter(user=self.request.user)

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Account management endpoints (list/delete/refresh) currently allow any logged-in user. These operate on printer backend credentials and should be restricted with PrinterManageMixin (or equivalent) to match the rest of the printers configuration RBAC.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to 55
# Check backend connectivity for each printer
statuses = {}
for printer in context["printer_profiles"]:
if printer.moonraker_url:
try:
client = MoonrakerClient(printer.moonraker_url, printer.moonraker_api_key)
client.get_printer_status()
statuses[printer.pk] = "online"
except MoonrakerError:
statuses[printer.pk] = "offline"
else:
statuses[printer.pk] = "unconfigured"
try:
backend = get_printer_backend(printer)
backend.get_printer_status()
statuses[printer.pk] = "online"
except PrinterError:
statuses[printer.pk] = "offline"
context["printer_statuses"] = statuses
return context
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list view collapses all PrinterError cases into offline. This loses the previous “not configured” state (e.g., Klipper without URL, Bambu without account/device ID) and the template still contains Moonraker-specific tooltip text for the badges. Consider distinguishing configuration errors from connectivity errors (e.g., set unconfigured when get_printer_backend() raises due to missing config) so the UI can guide users correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +55
class NormalizedJobStatus:
"""Normalized print job status returned by all backends.

Attributes:
state: One of ``"printing"``, ``"complete"``, ``"error"``,
``"cancelled"``, ``"standby"``, ``"idle"``.
progress: Print progress as a float from 0.0 to 1.0.
filename: Name of the file currently being printed.
temperatures: Optional dict with ``"bed"`` and ``"nozzle"`` keys
mapping to float temperatures in °C.
"""

STATE_PRINTING = "printing"
STATE_COMPLETE = "complete"
STATE_ERROR = "error"
STATE_CANCELLED = "cancelled"
STATE_STANDBY = "standby"
STATE_IDLE = "idle"
TERMINAL_STATES = {STATE_COMPLETE, STATE_ERROR, STATE_CANCELLED, STATE_STANDBY}

def __init__(
self,
state: str,
progress: float = 0.0,
filename: str = "",
temperatures: dict[str, float] | None = None,
) -> None:
self.state = state
self.progress = progress
self.filename = filename
self.temperatures = temperatures or {}

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description calls out NormalizedJobStatus as a dataclass, but it’s currently implemented as a regular class. If consumers rely on dataclass behavior (e.g., auto __eq__, easy asdict, frozen semantics), consider converting it to @dataclass (potentially slots=True) or update the PR description to match the implementation.

Copilot uses AI. Check for mistakes.
Comment on lines +327 to +366
def upload_gcode(
self, file_path: str | Path, filename: str | None = None
) -> dict[str, Any]:
"""Upload G-code to the printer, preferring LAN FTP.

Tries LocalFTPClient (LAN) first. Falls back to Cloud upload
if no LAN IP is configured.

Args:
file_path: Local path to the G-code file.
filename: Optional remote filename.

Returns:
Dictionary with upload result details.

Raises:
FileNotFoundError: If the file does not exist.
BambuLabError: If upload fails via both LAN and Cloud.
"""
file_path = Path(file_path)
if not file_path.exists():
raise FileNotFoundError(f"G-code file not found: {file_path}")

if filename is None:
filename = file_path.name

# Try LAN FTP upload first
if self._ip_address:
try:
return self._upload_via_ftp(file_path, filename)
except BambuLabError as ftp_exc:
logger.warning(
"LAN FTP upload failed for '%s', falling back to Cloud: %s",
self.printer.name,
ftp_exc,
)

# Fallback: Cloud upload
return self._upload_via_cloud(file_path, filename)

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upload_gcode(..., filename=...) accepts an explicit remote filename, but the Bambu implementation ignores it: both the LAN FTP path (ftp.upload_file(str(file_path), ...)) and the Cloud path (cloud.upload_file(str(file_path))) upload using the local file name. This can desync queue logic (e.g., plate.remote_job_id set to LN_... while the printer only has plate.gcode_file.name) and make start_print(remote_filename) fail because the uploaded file name doesn't match. Either honor the filename argument (rename/copy to a temp file before upload or use a library API that sets the remote name) or explicitly document/adjust the protocol and callers to use the backend’s actual uploaded identifier.

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +206
class BambuAccountStep3View(LoginRequiredMixin, FormView):
"""Step 3: Select a printer from the authenticated account.

Lists all devices bound to the Bambu Lab account and creates
a :class:`BambuCloudAccount` and :class:`PrinterProfile` for the
selected device.
"""

template_name = "core/bambuaccount_wizard_step3.html"
form_class = BambuAuthStep3Form
success_url = reverse_lazy("core:printerprofile_list")

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 3 creates/updates BambuCloudAccount and creates PrinterProfile records, but it only uses LoginRequiredMixin. This should require PrinterManageMixin to avoid unauthorized users adding printers/accounts.

Copilot uses AI. Check for mistakes.
peterus and others added 3 commits March 27, 2026 23:21
- Fix ruff lint errors: remove unused imports (User, reverse,
  BambuLabError), fix import ordering, replace try-except-pass with
  contextlib.suppress, remove f-string without placeholders, fix
  line length
- RBAC: replace LoginRequiredMixin with PrinterManageMixin on all
  wizard and account management views (steps 1-3, delete, refresh);
  list view stays LoginRequiredMixin (read-only)
- Remove Moonraker-specific URL guard in RunNextQueueView —
  get_printer_backend() validates configuration per type
- Cache _get_devices() on self in Step 3 to avoid 3x API calls
- Fix template: add 'now' to BambuAccountListView context, use
  timeuntil for future token expiry dates
- Remove unused BambuAccountDeleteForm dead code
- Distinguish 'unconfigured' vs 'offline' printer status with
  backend-agnostic badge tooltips
- Convert NormalizedJobStatus to @DataClass
- Honor filename param in FTP upload (pass remote_filename)
- Add RBAC tests: Designer blocked from wizard and account delete
- Note: bambu_account is intentionally not user-filtered — all
  users with can_manage_printers can access all printers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create docs/integrations/bambulab.md with full guide (wizard, LAN,
  printing workflow, troubleshooting, security)
- Update README.md: overview, features, tech stack, project structure
  (package layout), data models, test count
- Update docs/index.md: add Bambu Lab to feature list and integrations
- Update docs/integrations/moonraker.md: printer type selection, link
  to Bambu Lab page
- Update docs/user-guide/first-steps.md: tabbed printer setup for
  Klipper and Bambu Lab
- Update docs/user-guide/printing.md: G-code upload for both backends
- Update docs/development/architecture.md: new services, models,
  PrinterBackend Protocol
- Update docs/advanced/docker.md: note Bambu Lab is cloud-based
- Update mkdocs.yml nav: add Bambu Lab entry

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants