Conversation
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>
There was a problem hiding this comment.
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
PrinterBackendabstraction (get_printer_backend,NormalizedJobStatus, unifiedPrinterError) 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 |
| # 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
_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.
| # 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 | ||
|
|
There was a problem hiding this comment.
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).
| <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> |
There was a problem hiding this comment.
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).
core/forms/bambuauth.py
Outdated
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
| 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) | ||
|
|
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
| 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 {} | ||
|
|
There was a problem hiding this comment.
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.
core/services/bambulab.py
Outdated
| 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) | ||
|
|
There was a problem hiding this comment.
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.
core/views/bambuauth.py
Outdated
| 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") | ||
|
|
There was a problem hiding this comment.
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.
- 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>
Summary
Add support for Bambu Lab 3D printers alongside existing Klipper/Moonraker support.
Changes
Printer Backend Abstraction (Phase 1)
PrinterBackendProtocol incore/services/printer_backend.pywithNormalizedJobStatusdataclassget_printer_backend()for backend-agnostic printer accessMoonrakerErrornow extendsPrinterErrorfor unified error handlingModel Changes (Phase 2)
BambuCloudAccountmodel for Cloud API credentials (encrypted JWT tokens)PrinterProfileextended withprinter_typechoice field + Bambu Lab fieldsklipper_job_id→remote_job_id(data-preservingRenameFieldmigration)BambuLabClient Service (Phase 3)
bambu-lab-cloud-apilibrarySECRET_KEY)Views Refactoring (Phase 4)
MoonrakerClientinstantiations replaced withget_printer_backend()QueueCheckPrinterStatusViewusesNormalizedJobStatusinstead of Moonraker-specific parsingAuth Wizard (Phase 5)
Dynamic Printer Form (Phase 6)
printer_typeradio buttons with JS-driven field togglingTests (Phase 7)
printer_typerequirementLicense
bambu-lab-cloud-apidependency)Dependencies
bambu-lab-cloud-api>=1.0cryptography>=42.0All 357 tests pass ✅