Compare commits

..

19 Commits

Author SHA1 Message Date
jneilliii
52ba3ff214 0.1.8rc12
update pybambu module from upstream HA project
2024-12-01 16:30:12 -05:00
jneilliii
98ab94b371 0.1.8rc11
update pybambu/bambu_cloud.py from upstream HA project
2024-11-14 23:35:03 -05:00
jneilliii
b54e372342 0.1.8rc10
reduce settings save by refactoring ams data updates
2024-11-14 21:26:41 -05:00
jneilliii
76f706df19 0.1.8rc9
* check for filename in cache subfolder for files started through cloud connected printers
* send 0% progress when in prepare state and progress is 100
* minor UI tweaks
2024-11-09 21:57:17 -05:00
jneilliii
5c8a9787d4 0.1.8rc8
* updates to pybambu module
* update cloud login workflow, #59
2024-11-09 20:01:41 -05:00
jneilliii
e3fda73dd3 0.1.8rc7
* update pybambu module from upstream HA project, groundwork for fixing new cloud authorization process, #59
* potential fix for stuck progress/canceled printing status, #52
2024-11-09 16:15:13 -05:00
jneilliii
5633d6f6ea groundwork for plate processing contained in 3mf file, will store contents of plate_1.json in OctoPrint metadata for that file 2024-11-04 22:32:52 -05:00
jneilliii
884101c0ba
bump version 2024-11-04 14:19:27 -05:00
jneilliii
7c87ba9482
fix base path for start print command for non X1 devices 2024-11-04 14:18:46 -05:00
jneilliii
1d9f874560 update user-agent 2024-11-01 20:59:31 -04:00
jneilliii
21e30034d0 switch to any comparison instead of all for allowed suffixes to prevent issues with file names that contain additional periods, ie part_v2.1.gcode.3mf 2024-11-01 20:12:34 -04:00
jneilliii
3c8b904a26 revert access code requirement 2024-10-30 00:38:25 -04:00
jneilliii
55ad4c1718 Merge branch 'rc' of https://github.com/jneilliii/OctoPrint-BambuPrinter into rc 2024-10-30 00:34:44 -04:00
jneilliii
4ef8e40702 remove call to creating connection in printer_worker thread since it's called on initialization. 2024-10-30 00:34:15 -04:00
jneilliii
2537bc8f57 remove call to creating connection in printer_worker thread since it's called on initialization. 2024-10-30 00:28:37 -04:00
jneilliii
28be048300 improve initial connection sequence, wait for bambu client connection before returning firmware information. 2024-10-28 17:56:41 -04:00
jneilliii
eaa0ed94c0 0.1.8rc2
fix references to commands after migrating to internal pybambu module
2024-10-28 13:17:53 -04:00
jneilliii
14af93b1d0 add filaments.json to MANIFEST.in and setup.py to distribute with sdist install 2024-10-27 22:59:54 -04:00
jneilliii
d94df9dd85
0.1.8rc1 (#56)
* bring latest pybambu module in from home assistant integration
* add onBeforePrintStart callback to prevent starting "local" files and display print options dialog.* add onBeforePrintStart callback to prevent starting "local" files and display print options dialog (with AMS mapping support)
* add AMS display in sidebar
2024-10-26 23:41:17 -04:00
30 changed files with 4215 additions and 1147 deletions

21
.github/workflows/issue-validator.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: issue validator
on:
workflow_dispatch:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Okabe-Junya/issue-validator@v0.4.1
with:
body: '/\[(octoprint\.log)\]|\[(plugin_bambu_printer_serial\.log)\]/g'
body-regex-flags: 'true'
is-auto-close: 'true'
issue-type: 'both'
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,3 +2,4 @@ include README.md
recursive-include octoprint_bambu_printer/templates *
recursive-include octoprint_bambu_printer/translations *
recursive-include octoprint_bambu_printer/static *
include octoprint_bambu_printer/printer/pybambu/filaments.json

View File

@ -1,28 +0,0 @@
def get_settings_defaults(self):
return {
# ...existing code...
# Add option to disable camera functionality
"disable_camera": False,
# ...existing code...
}
# ...existing code...
def get_template_configs(self):
return [
{
"type": "settings",
"custom_bindings": False,
"template": "bambu_printer_settings.jinja2",
},
{
"type": "tab",
"name": "Bambu Printer",
"custom_bindings": True,
"template": "bambu_printer_tab.jinja2",
},
]
# ...existing code...

View File

@ -1,4 +1,6 @@
from __future__ import absolute_import, annotations
import json
from pathlib import Path
import threading
from time import perf_counter
@ -6,6 +8,8 @@ from contextlib import contextmanager
import flask
import logging.handlers
from urllib.parse import quote as urlquote
import os
import zipfile
import octoprint.printer
import octoprint.server
@ -22,7 +26,7 @@ from octoprint.access.permissions import Permissions
from octoprint.logging.handlers import CleaningTimedRotatingFileHandler
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
from pybambu import BambuCloud
from octoprint_bambu_printer.printer.pybambu import BambuCloud
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
RemoteSDCardFileList,
@ -57,6 +61,7 @@ class BambuPrintPlugin(
_plugin_manager: octoprint.plugin.PluginManager
_bambu_file_system: RemoteSDCardFileList
_timelapse_files_view: CachedFileView
_bambu_cloud: None
def on_settings_initialized(self):
self._bambu_file_system = RemoteSDCardFileList(self._settings)
@ -67,7 +72,9 @@ class BambuPrintPlugin(
self._timelapse_files_view.with_filter("timelapse/", ".avi")
def get_assets(self):
return {"js": ["js/bambu_printer.js"]}
return {"js": ["js/jquery-ui.min.js", "js/knockout-sortable.1.2.0.js", "js/bambu_printer.js"],
"css": ["css/bambu_printer.css"]
}
def get_template_configs(self):
return [
@ -77,7 +84,7 @@ class BambuPrintPlugin(
"custom_bindings": True,
"template": "bambu_timelapse.jinja2",
},
] # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}]
{"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}]
def get_settings_defaults(self):
return {
@ -85,7 +92,7 @@ class BambuPrintPlugin(
"serial": "",
"host": "",
"access_code": "",
"username": "octobambu",
"username": "bblp",
"timelapse": False,
"bed_leveling": True,
"flow_cali": False,
@ -97,13 +104,22 @@ class BambuPrintPlugin(
"email": "",
"auth_token": "",
"always_use_default_options": False,
"ams_data": [],
"ams_mapping": [],
"ams_current_tray": 255,
}
def on_settings_save(self, data):
if data.get("local_mqtt", False) is True:
data["auth_token"] = ""
octoprint.plugin.SettingsPlugin.on_settings_save(self, data)
def is_api_adminonly(self):
return True
def get_api_commands(self):
return {"register": ["email", "password", "region", "auth_token"]}
return {"register": ["email", "password", "region", "auth_token"],
"verify": ["auth_type", "password"]}
def on_api_command(self, command, data):
if command == "register":
@ -114,20 +130,57 @@ class BambuPrintPlugin(
and "auth_token" in data
):
self._logger.info(f"Registering user {data['email']}")
bambu_cloud = BambuCloud(
self._bambu_cloud = BambuCloud(
data["region"], data["email"], data["password"], data["auth_token"]
)
bambu_cloud.login(data["region"], data["email"], data["password"])
auth_response = self._bambu_cloud.login(data["region"], data["email"], data["password"])
return flask.jsonify(
{
"auth_token": bambu_cloud.auth_token,
"username": bambu_cloud.username,
"auth_response": auth_response,
}
)
elif command == "verify":
auth_response = None
if (
"auth_type" in data
and "password" in data
and self._bambu_cloud is not None
):
self._logger.info(f"Verifying user {self._bambu_cloud._email}")
if data["auth_type"] == "verifyCode":
auth_response = self._bambu_cloud.login_with_verification_code(data["password"])
elif data["auth_type"] == "tfa":
auth_response = self._bambu_cloud.login_with_2fa_code(data["password"])
else:
self._logger.warning(f"Unknown verification type: {data['auth_type']}")
if auth_response == "success":
return flask.jsonify(
{
"auth_token": self._bambu_cloud.auth_token,
"username": self._bambu_cloud.username
}
)
else:
self._logger.info(f"Error verifying: {auth_response}")
return flask.jsonify(
{
"error": "Unable to verify"
}
)
def on_event(self, event, payload):
if event == Events.TRANSFER_DONE:
self._printer.commands("M20 L T", force=True)
elif event == Events.FILE_ADDED:
if payload["operation"] == "add" and "3mf" in payload["type"]:
file_container = os.path.join(self._settings.getBaseFolder("uploads"), payload["path"])
with zipfile.ZipFile(file_container) as z:
with z.open("Metadata/plate_1.json", "r") as json_data:
plate_data = json.load(json_data)
if plate_data:
self._file_manager.set_additional_metadata("sdcard", payload["path"], "plate_data", plate_data, overwrite=True)
def support_3mf_files(self):
return {"machinecode": {"3mf": ["3mf"]}}
@ -286,10 +339,10 @@ class BambuPrintPlugin(
def get_update_information(self):
return {
"bambu_printer": {
"displayName": "Manus Bambu Printer",
"displayName": "Bambu Printer",
"displayVersion": self._plugin_version,
"type": "github_release",
"user": "ManuelW",
"user": "jneilliii",
"repo": "OctoPrint-BambuPrinter",
"current": self._plugin_version,
"stable_branch": {
@ -304,6 +357,6 @@ class BambuPrintPlugin(
"comittish": ["rc", "master"],
}
],
"pip": "https://gitlab.fire-devils.org/3D-Druck/OctoPrint-BambuPrinter/archive/{target_version}.zip",
"pip": "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip",
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, List, Optional
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
@ -12,59 +12,87 @@ from pathlib import Path
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
@dataclass
class CachedFileView:
def __init__(
self, file_system, on_update: Optional[Callable] = None, base_path: str = ""
):
self._filters = []
self._file_system = file_system
self._base_path = base_path
self._update_complete_callback = on_update
self._file_info_cache = []
file_system: RemoteSDCardFileList
folder_view: dict[tuple[str, str | list[str] | None], None] = field(
default_factory=dict
) # dict preserves order, but set does not. We use only dict keys as storage
on_update: Callable[[], None] | None = None
def with_filter(self, path: str, extension: str):
self._filters.append({"path": path, "extension": extension})
def __post_init__(self):
self._file_alias_cache: dict[str, str] = {}
self._file_data_cache: dict[str, FileInfo] = {}
def with_filter(
self, folder: str, extensions: str | list[str] | None = None
) -> "CachedFileView":
self.folder_view[(folder, extensions)] = None
return self
def update(self) -> None:
try:
def list_all_views(self):
existing_files: list[str] = []
result: list[FileInfo] = []
with self.file_system.get_ftps_client() as ftp:
for key in self.folder_view.keys():
result.extend(self.file_system.list_files(*key, ftp, existing_files))
return result
def update(self):
file_info_list = self.list_all_views()
self._file_info_cache = file_info_list
self._update_file_list_cache(file_info_list)
if self.on_update:
self.on_update()
# Rufe Callback auf, wenn vorhanden
if self._update_complete_callback is not None:
self._update_complete_callback()
except Exception as e:
import logging
logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter").error(
f"Error updating file list: {e}", exc_info=True
)
def _update_file_list_cache(self, files: list[FileInfo]):
self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files}
self._file_data_cache = {info.path.as_posix(): info for info in files}
def list_all_views(self) -> List[FileInfo]:
# Verwende die Mock-Implementation von get_file_list statt FTPS
try:
return self._file_system.get_file_list(self._base_path)
except Exception as e:
import logging
logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter").error(
f"Error listing files: {e}", exc_info=True
)
return []
def get_all_info(self):
self.update()
return self.get_all_cached_info()
def get_all_cached_info(self) -> List[FileInfo]:
return self._file_info_cache
def get_all_cached_info(self):
return list(self._file_data_cache.values())
def get_file_by_stem(self, file_stem: str, extensions: list[str]) -> FileInfo | None:
"""Get file info by file name without extension"""
for file_info in self._file_info_cache:
for extension in extensions:
if file_info.file_name.lower().startswith(f"{file_stem.lower()}{extension}"):
return file_info
def get_keys_as_list(self):
return list(self._file_data_cache.keys()) + list(self._file_alias_cache.keys())
def get_file_data(self, file_path: str | Path) -> FileInfo | None:
file_data = self.get_file_data_cached(file_path)
if file_data is None:
self.update()
file_data = self.get_file_data_cached(file_path)
return file_data
def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None:
if isinstance(file_path, str):
file_path = Path(file_path).as_posix().strip("/")
else:
file_path = file_path.as_posix().strip("/")
if file_path not in self._file_data_cache:
file_path = self._file_alias_cache.get(file_path, file_path)
return self._file_data_cache.get(file_path, None)
def get_file_by_name(self, file_name: str):
if file_name == "":
return None
def get_file_data(self, file_path: str) -> FileInfo | None:
for file_info in self._file_info_cache:
if file_info.path.lower() == file_path.lower() or file_info.file_name.lower() == file_path.lower():
return file_info
return None
file_list = self.get_keys_as_list()
if not file_name in file_list:
if f"{file_name}.3mf" in file_list:
file_name = f"{file_name}.3mf"
elif f"{file_name}.gcode.3mf" in file_list:
file_name = f"{file_name}.gcode.3mf"
elif f"cache/{file_name}.3mf" in file_list:
file_name = f"cache/{file_name}.3mf"
elif f"cache/{file_name}.gcode.3mf" in file_list:
file_name = f"cache/{file_name}.gcode.3mf"
file_data = self.get_file_data_cached(file_name)
if file_data is None:
self.update()
return self.get_file_by_name(file_name)
return file_data

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import datetime
from pathlib import Path
from typing import Iterable, Iterator, List
from typing import Iterable, Iterator
import logging.handlers
from octoprint.util import get_dos_filename
@ -17,7 +17,6 @@ class RemoteSDCardFileList:
self._settings = settings
self._selected_project_file: FileInfo | None = None
self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter")
self._mock_files = [] # Lokales Cache für Mock-Dateien
def delete_file(self, file_path: Path) -> None:
try:
@ -81,56 +80,8 @@ class RemoteSDCardFileList:
self._logger.exception(e, exc_info=False)
def get_ftps_client(self):
"""
Implementieren wir eine Mock-Version des FTPS-Clients, die keinen echten FTP-Zugriff erfordert.
"""
class MockFTPSClient:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def get_file_list(self, path=""):
"""Gibt die Mock-Dateiliste zurück"""
return self._mock_files
mock_client = MockFTPSClient()
mock_client._mock_files = self._mock_files
return mock_client
@property
def is_available(self) -> bool:
"""
Da wir kein FTP verwenden, ist dieser Service immer verfügbar
"""
return True
def get_file_list(self, path: str) -> List[FileInfo]:
"""
Gibt eine Liste von Dateien im angegebenen Pfad zurück.
Da wir kein FTP verwenden, geben wir eine leere Liste oder gespeicherte Mock-Dateien zurück.
"""
self._logger.debug(f"Listing files in path: {path}")
return self._mock_files
def add_mock_file(self, file_info: FileInfo):
"""
Fügt eine Mock-Datei zur Liste hinzu (für Tests oder wenn keine FTP-Verbindung möglich ist)
"""
self._mock_files.append(file_info)
self._logger.debug(f"Added mock file: {file_info.file_name}")
def clear_mock_files(self):
"""Löscht alle gespeicherten Mock-Dateien"""
self._mock_files = []
self._logger.debug("Mock file list cleared")
def delete_file(self, path: str) -> bool:
"""
Simuliert das Löschen einer Datei, entfernt sie aus der Mock-Liste
"""
self._logger.debug(f"Deleting file: {path}")
before_count = len(self._mock_files)
self._mock_files = [f for f in self._mock_files if f.path != path]
return before_count > len(self._mock_files)
host = self._settings.get(["host"])
access_code = self._settings.get(["access_code"])
return IoTFTPSClient(
f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True
)

View File

@ -10,6 +10,9 @@ from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import
class PrintJob:
file_info: FileInfo
progress: int
remaining_time: int
current_layer: int
total_layers: int
@property
def file_position(self):

View File

@ -0,0 +1,4 @@
"""Initialise the Bambu Client"""
# TODO: Once complete, move pybambu to PyPi
from .bambu_client import BambuClient
from .bambu_cloud import BambuCloud

View File

@ -0,0 +1,594 @@
from __future__ import annotations
import asyncio
import queue
import json
import math
import re
import socket
import ssl
import struct
import threading
import time
from dataclasses import dataclass
from typing import Any
import paho.mqtt.client as mqtt
from .bambu_cloud import BambuCloud
from .const import (
LOGGER,
Features,
)
from .models import Device, SlicerSettings
from .commands import (
GET_VERSION,
PUSH_ALL,
START_PUSH,
)
class WatchdogThread(threading.Thread):
def __init__(self, client):
self._client = client
self._watchdog_fired = False
self._stop_event = threading.Event()
self._last_received_data = time.time()
super().__init__()
self.daemon = True
self.setName(f"{self._client._device.info.device_type}-Watchdog-{threading.get_native_id()}")
def stop(self):
self._stop_event.set()
def received_data(self):
self._last_received_data = time.time()
def run(self):
LOGGER.info("Watchdog thread started.")
WATCHDOG_TIMER = 30
while True:
# Wait out the remainder of the watchdog delay or 1s, whichever is higher.
interval = time.time() - self._last_received_data
wait_time = max(1, WATCHDOG_TIMER - interval)
if self._stop_event.wait(wait_time):
# Stop event has been set. Exit thread.
break
interval = time.time() - self._last_received_data
if not self._watchdog_fired and (interval > WATCHDOG_TIMER):
LOGGER.debug(f"Watchdog fired. No data received for {math.floor(interval)} seconds for {self._client._serial}.")
self._watchdog_fired = True
self._client._on_watchdog_fired()
elif interval < WATCHDOG_TIMER:
self._watchdog_fired = False
LOGGER.info("Watchdog thread exited.")
class ChamberImageThread(threading.Thread):
def __init__(self, client):
self._client = client
self._stop_event = threading.Event()
super().__init__()
self.daemon = True
self.setName(f"{self._client._device.info.device_type}-Chamber-{threading.get_native_id()}")
def stop(self):
self._stop_event.set()
def run(self):
LOGGER.debug("Chamber image thread started.")
auth_data = bytearray()
username = 'bblp'
access_code = self._client._access_code
hostname = self._client.host
port = 6000
MAX_CONNECT_ATTEMPTS = 12
connect_attempts = 0
auth_data += struct.pack("<I", 0x40) # '@'\0\0\0
auth_data += struct.pack("<I", 0x3000) # \0'0'\0\0
auth_data += struct.pack("<I", 0) # \0\0\0\0
auth_data += struct.pack("<I", 0) # \0\0\0\0
for i in range(0, len(username)):
auth_data += struct.pack("<c", username[i].encode('ascii'))
for i in range(0, 32 - len(username)):
auth_data += struct.pack("<x")
for i in range(0, len(access_code)):
auth_data += struct.pack("<c", access_code[i].encode('ascii'))
for i in range(0, 32 - len(access_code)):
auth_data += struct.pack("<x")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
jpeg_start = bytearray([0xff, 0xd8, 0xff, 0xe0])
jpeg_end = bytearray([0xff, 0xd9])
read_chunk_size = 4096 # 4096 is the max we'll get even if we increase this.
# Payload format for each image is:
# 16 byte header:
# Bytes 0:3 = little endian payload size for the jpeg image (does not include this header).
# Bytes 4:7 = 0x00000000
# Bytes 8:11 = 0x00000001
# Bytes 12:15 = 0x00000000
# These first 16 bytes are always delivered by themselves.
#
# Bytes 16:19 = jpeg_start magic bytes
# Bytes 20:payload_size-2 = jpeg image bytes
# Bytes payload_size-2:payload_size = jpeg_end magic bytes
#
# Further attempts to receive data will get SSLWantReadError until a new image is ready (1-2 seconds later)
while connect_attempts < MAX_CONNECT_ATTEMPTS and not self._stop_event.is_set():
connect_attempts += 1
try:
with socket.create_connection((hostname, port)) as sock:
try:
sslSock = ctx.wrap_socket(sock, server_hostname=hostname)
sslSock.write(auth_data)
img = None
payload_size = 0
status = sslSock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
LOGGER.debug(f"SOCKET STATUS: {status}")
if status != 0:
LOGGER.error(f"Socket error: {status}")
except socket.error as e:
LOGGER.error(f"Socket error: {e}")
# Sleep to allow printer to stabilize during boot when it may fail these connection attempts repeatedly.
time.sleep(1)
continue
sslSock.setblocking(False)
while not self._stop_event.is_set():
try:
dr = sslSock.recv(read_chunk_size)
#LOGGER.debug(f"Received {len(dr)} bytes.")
except ssl.SSLWantReadError:
#LOGGER.debug("SSLWantReadError")
time.sleep(1)
continue
except Exception as e:
LOGGER.error("A Chamber Image thread inner exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
time.sleep(1)
continue
if img is not None and len(dr) > 0:
img += dr
if len(img) > payload_size:
# We got more data than we expected.
LOGGER.error(f"Unexpected image payload received: {len(img)} > {payload_size}")
# Reset buffer
img = None
elif len(img) == payload_size:
# We should have the full image now.
if img[:4] != jpeg_start:
LOGGER.error("JPEG start magic bytes missing.")
elif img[-2:] != jpeg_end:
LOGGER.error("JPEG end magic bytes missing.")
else:
# Content is as expected. Send it.
self._client.on_jpeg_received(img)
# Reset buffer
img = None
# else:
# Otherwise we need to continue looping without reseting the buffer to receive the remaining data
# and without delaying.
elif len(dr) == 16:
# We got the header bytes. Get the expected payload size from it and create the image buffer bytearray.
# Reset connect_attempts now we know the connect was successful.
connect_attempts = 0
img = bytearray()
payload_size = int.from_bytes(dr[0:3], byteorder='little')
elif len(dr) == 0:
# This occurs if the wrong access code was provided.
LOGGER.error("Chamber image connection rejected by the printer. Check provided access code and IP address.")
# Sleep for a short while and then re-attempt the connection.
time.sleep(5)
break
else:
LOGGER.error(f"UNEXPECTED DATA RECEIVED: {len(dr)}")
time.sleep(1)
except OSError as e:
if e.errno == 113:
LOGGER.debug("Host is unreachable")
else:
LOGGER.error("A Chamber Image thread outer exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
if not self._stop_event.is_set():
time.sleep(1) # Avoid a tight loop if this is a persistent error.
except Exception as e:
LOGGER.error(f"A Chamber Image thread outer exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
if not self._stop_event.is_set():
time.sleep(1) # Avoid a tight loop if this is a persistent error.
LOGGER.debug("Chamber image thread exited.")
class MqttThread(threading.Thread):
def __init__(self, client):
self._client = client
self._stop_event = threading.Event()
super().__init__()
self.daemon = True
self.setName(f"{self._client._device.info.device_type}-Mqtt-{threading.get_native_id()}")
def stop(self):
self._stop_event.set()
def run(self):
LOGGER.info("MQTT listener thread started.")
exceptionSeen = ""
while True:
try:
host = self._client.host if self._client._local_mqtt else self._client.bambu_cloud.cloud_mqtt_host
LOGGER.debug(f"Connect: Attempting Connection to {host}")
self._client.client.connect(host, self._client._port, keepalive=5)
LOGGER.debug("Starting listen loop")
self._client.client.loop_forever()
LOGGER.debug("Ended listen loop.")
break
except TimeoutError as e:
if exceptionSeen != "TimeoutError":
LOGGER.debug(f"TimeoutError: {e}.")
exceptionSeen = "TimeoutError"
time.sleep(5)
except ConnectionError as e:
if exceptionSeen != "ConnectionError":
LOGGER.debug(f"ConnectionError: {e}.")
exceptionSeen = "ConnectionError"
time.sleep(5)
except OSError as e:
if e.errno == 113:
if exceptionSeen != "OSError113":
LOGGER.debug(f"OSError: {e}.")
exceptionSeen = "OSError113"
time.sleep(5)
else:
LOGGER.error("A listener loop thread exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
time.sleep(1) # Avoid a tight loop if this is a persistent error.
except Exception as e:
LOGGER.error("A listener loop thread exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
time.sleep(1) # Avoid a tight loop if this is a persistent error.
if self._client.client is None:
break
self._client.client.disconnect()
LOGGER.info("MQTT listener thread exited.")
@dataclass
class BambuClient:
"""Initialize Bambu Client to connect to MQTT Broker"""
_watchdog = None
_camera = None
_usage_hours: float
def __init__(self, config):
self.host = config['host']
self._callback = None
self._access_code = config.get('access_code', '')
self._auth_token = config.get('auth_token', '')
self._device_type = config.get('device_type', 'unknown')
self._local_mqtt = config.get('local_mqtt', False)
self._manual_refresh_mode = config.get('manual_refresh_mode', False)
self._serial = config.get('serial', '')
self._usage_hours = config.get('usage_hours', 0)
self._username = config.get('username', '')
self._enable_camera = config.get('enable_camera', True)
self._connected = False
self._port = 1883
self._refreshed = False
self._device = Device(self)
self.bambu_cloud = BambuCloud(
config.get('region', ''),
config.get('email', ''),
config.get('username', ''),
config.get('auth_token', '')
)
self.slicer_settings = SlicerSettings(self)
@property
def connected(self):
"""Return if connected to server"""
return self._connected
@property
def manual_refresh_mode(self):
"""Return if the integration is running in poll mode"""
return self._manual_refresh_mode
async def set_manual_refresh_mode(self, on):
self._manual_refresh_mode = on
if self._manual_refresh_mode:
# Disconnect from the server. User must manually hit the refresh button to connect to refresh and then it will immediately disconnect.
self.disconnect()
else:
# Reconnect normally
self.connect(self._callback)
@property
def camera_enabled(self):
return self._enable_camera
def callback(self, event: str):
if self._callback is not None:
self._callback(event)
def set_camera_enabled(self, enable):
self._enable_camera = enable
if self._enable_camera:
self._start_camera()
else:
self._stop_camera()
def setup_tls(self):
self.client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE)
self.client.tls_insecure_set(True)
def connect(self, callback):
"""Connect to the MQTT Broker"""
self.client = mqtt.Client()
self._callback = callback
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = self.on_message
# Set aggressive reconnect polling.
self.client.reconnect_delay_set(min_delay=1, max_delay=1)
# Run the blocking tls_set method in a separate thread
self.setup_tls()
self._port = 8883
if self._local_mqtt:
self.client.username_pw_set("bblp", password=self._access_code)
else:
self.client.username_pw_set(self._username, password=self._auth_token)
LOGGER.debug("Starting MQTT listener thread")
self._mqtt = MqttThread(self)
self._mqtt.start()
def subscribe_and_request_info(self):
LOGGER.debug("Loading slicer settings...")
self.slicer_settings.update()
LOGGER.debug("Now subscribing...")
self.subscribe()
LOGGER.debug("On Connect: Getting version info")
self.publish(GET_VERSION)
LOGGER.debug("On Connect: Request push all")
self.publish(PUSH_ALL)
def on_connect(self,
client_: mqtt.Client,
userdata: None,
flags: dict[str, Any],
result_code: int,
properties: mqtt.Properties | None = None, ):
"""Handle connection"""
LOGGER.info("On Connect: Connected to printer")
self._on_connect()
def _start_camera(self):
if not self._device.supports_feature(Features.CAMERA_RTSP):
if self._device.supports_feature(Features.CAMERA_IMAGE):
if self._enable_camera:
LOGGER.debug("Starting Chamber Image thread")
self._camera = ChamberImageThread(self)
self._camera.start()
elif (self.host == "") or (self._access_code == ""):
LOGGER.debug("Skipping camera setup as local access details not provided.")
def _stop_camera(self):
if self._camera is not None:
LOGGER.debug("Stopping camera thread")
self._camera.stop()
self._camera.join()
def _on_connect(self):
self._connected = True
self.subscribe_and_request_info()
LOGGER.debug("Starting watchdog thread")
self._watchdog = WatchdogThread(self)
self._watchdog.start()
self._start_camera()
def try_on_connect(self,
client_: mqtt.Client,
userdata: None,
flags: dict[str, Any],
result_code: int,
properties: mqtt.Properties | None = None, ):
"""Handle connection"""
LOGGER.info("On Connect: Connected to printer")
self._connected = True
LOGGER.debug("Now test subscribing...")
self.subscribe()
# For the initial configuration connection attempt, we just need version info.
LOGGER.debug("On Connect: Getting version info")
self.publish(GET_VERSION)
def on_disconnect(self,
client_: mqtt.Client,
userdata: None,
result_code: int):
"""Called when MQTT Disconnects"""
LOGGER.warn(f"On Disconnect: Printer disconnected with error code: {result_code}")
self._on_disconnect()
def _on_disconnect(self):
LOGGER.debug("_on_disconnect: Lost connection to the printer")
self._connected = False
self._device.info.set_online(False)
if self._watchdog is not None:
LOGGER.debug("Stopping watchdog thread")
self._watchdog.stop()
self._watchdog.join()
self._stop_camera()
def _on_watchdog_fired(self):
LOGGER.info("Watch dog fired")
self._device.info.set_online(False)
self.publish(START_PUSH)
def on_jpeg_received(self, bytes):
self._device.chamber_image.set_jpeg(bytes)
def on_message(self, client, userdata, message):
"""Return the payload when received"""
try:
# X1 mqtt payload is inconsistent. Adjust it for consistent logging.
clean_msg = re.sub(r"\\n *", "", str(message.payload))
if self._refreshed:
LOGGER.debug(f"Received data: {clean_msg}")
json_data = json.loads(message.payload)
if json_data.get("event"):
# These are events from the bambu cloud mqtt feed and allow us to detect when a local
# device has connected/disconnected (e.g. turned on/off)
if json_data.get("event").get("event") == "client.connected":
LOGGER.debug("Client connected event received.")
self._on_disconnect() # We aren't guaranteed to recieve a client.disconnected event.
self._on_connect()
elif json_data.get("event").get("event") == "client.disconnected":
LOGGER.debug("Client disconnected event received.")
self._on_disconnect()
else:
self._device.info.set_online(True)
self._watchdog.received_data()
if json_data.get("print"):
self._device.print_update(data=json_data.get("print"))
# Once we receive data, if in manual refresh mode, we disconnect again.
if self._manual_refresh_mode:
self.disconnect()
if json_data.get("print").get("msg", 0) == 0:
self._refreshed= False
elif json_data.get("info") and json_data.get("info").get("command") == "get_version":
LOGGER.debug("Got Version Data")
self._device.info_update(data=json_data.get("info"))
except Exception as e:
LOGGER.error("An exception occurred processing a message:", exc_info=e)
def subscribe(self):
"""Subscribe to report topic"""
LOGGER.debug(f"Subscribing: device/{self._serial}/report")
self.client.subscribe(f"device/{self._serial}/report")
def publish(self, msg):
"""Publish a custom message"""
result = self.client.publish(f"device/{self._serial}/request", json.dumps(msg))
status = result[0]
if status == 0:
LOGGER.debug(f"Sent {msg} to topic device/{self._serial}/request")
return True
LOGGER.error(f"Failed to send message to topic device/{self._serial}/request")
return False
async def refresh(self):
"""Force refresh data"""
if self._manual_refresh_mode:
self.connect(self._callback)
else:
LOGGER.debug("Force Refresh: Getting Version Info")
self._refreshed = True
self.publish(GET_VERSION)
LOGGER.debug("Force Refresh: Request Push All")
self._refreshed = True
self.publish(PUSH_ALL)
self.slicer_settings.update()
def get_device(self):
"""Return device"""
return self._device
def disconnect(self):
"""Disconnect the Bambu Client from server"""
LOGGER.debug(" Disconnect: Client Disconnecting")
if self.client is not None:
self.client.disconnect()
self.client = None
async def try_connection(self):
"""Test if we can connect to an MQTT broker."""
LOGGER.debug("Try Connection")
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_message(client, userdata, message):
json_data = json.loads(message.payload)
LOGGER.debug(f"Try Connection: Got '{json_data}'")
if json_data.get("info") and json_data.get("info").get("command") == "get_version":
LOGGER.debug("Got Version Command Data")
self._device.info_update(data=json_data.get("info"))
result.put(True)
self.client = mqtt.Client()
self.client.on_connect = self.try_on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = on_message
# Run the blocking tls_set method in a separate thread
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.setup_tls)
if self._local_mqtt:
self.client.username_pw_set("bblp", password=self._access_code)
else:
self.client.username_pw_set(self._username, password=self._auth_token)
self._port = 8883
LOGGER.debug("Test connection: Connecting to %s", self.host)
try:
self.client.connect(self.host, self._port)
self.client.loop_start()
if result.get(timeout=10):
return True
except OSError as e:
return False
except queue.Empty:
return False
finally:
self.disconnect()
async def __aenter__(self):
"""Async enter.
Returns:
The BambuLab object.
"""
return self
async def __aexit__(self, *_exc_info):
"""Async exit.
Args:
_exc_info: Exec type.
"""
self.disconnect()

View File

@ -0,0 +1,583 @@
from __future__ import annotations
from enum import (
Enum,
)
import base64
import cloudscraper
import json
import requests
class ConnectionMechanismEnum(Enum):
CLOUDSCRAPER = 1,
CURL_CFFI = 2,
REQUESTS = 3
CONNECTION_MECHANISM = ConnectionMechanismEnum.CLOUDSCRAPER
curl_available = False
if CONNECTION_MECHANISM == ConnectionMechanismEnum.CURL_CFFI:
try:
from curl_cffi import requests as curl_requests
curl_available = True
except ImportError:
curl_available = False
from dataclasses import dataclass
from .const import (
LOGGER,
BambuUrl
)
from .utils import get_Url
IMPERSONATE_BROWSER='chrome'
class CloudflareError(Exception):
def __init__(self):
super().__init__("Blocked by Cloudflare")
self.error_code = 403
class EmailCodeRequiredError(Exception):
def __init__(self):
super().__init__("Email code required")
self.error_code = 400
class EmailCodeExpiredError(Exception):
def __init__(self):
super().__init__("Email code expired")
self.error_code = 400
class EmailCodeIncorrectError(Exception):
def __init__(self):
super().__init__("Email code incorrect")
self.error_code = 400
class TfaCodeRequiredError(Exception):
def __init__(self):
super().__init__("Two factor authentication code required")
self.error_code = 400
class CurlUnavailableError(Exception):
def __init__(self):
super().__init__("curl library unavailable")
self.error_code = 400
@dataclass
class BambuCloud:
def __init__(self, region: str, email: str, username: str, auth_token: str):
self._region = region
self._email = email
self._username = username
self._auth_token = auth_token
self._tfaKey = None
def _get_headers(self):
return {
'User-Agent': 'bambu_network_agent/01.09.05.01',
'X-BBL-Client-Name': 'OrcaSlicer',
'X-BBL-Client-Type': 'slicer',
'X-BBL-Client-Version': '01.09.05.51',
'X-BBL-Language': 'en-US',
'X-BBL-OS-Type': 'linux',
'X-BBL-OS-Version': '6.2.0',
'X-BBL-Agent-Version': '01.09.05.01',
'X-BBL-Executable-info': '{}',
'X-BBL-Agent-OS-Type': 'linux',
'accept': 'application/json',
'Content-Type': 'application/json'
}
# Orca/Bambu Studio also add this - need to work out what an appropriate ID is to put here:
# 'X-BBL-Device-ID': BBL_AUTH_UUID,
# Example: X-BBL-Device-ID: 370f9f43-c6fe-47d7-aec9-5fe5ef7e7673
def _get_headers_with_auth_token(self) -> dict:
if CONNECTION_MECHANISM == ConnectionMechanismEnum.CURL_CFFI:
headers = {}
else:
headers = self._get_headers()
headers['Authorization'] = f"Bearer {self._auth_token}"
return headers
def _test_response(self, response, return400=False):
# Check specifically for cloudflare block
if response.status_code == 403 and 'cloudflare' in response.text:
LOGGER.debug("BLOCKED BY CLOUDFLARE")
raise CloudflareError()
if response.status_code == 400 and not return400:
LOGGER.error(f"Connection failed with error code: {response.status_code}")
LOGGER.debug(f"Response: '{response.text}'")
raise PermissionError(response.status_code, response.text)
if response.status_code > 400:
LOGGER.error(f"Connection failed with error code: {response.status_code}")
LOGGER.debug(f"Response: '{response.text}'")
raise PermissionError(response.status_code, response.text)
LOGGER.debug(f"Response: {response.status_code}")
def _get(self, urlenum: BambuUrl):
url = get_Url(urlenum, self._region)
headers=self._get_headers_with_auth_token()
if CONNECTION_MECHANISM == ConnectionMechanismEnum.CURL_CFFI:
if not curl_available:
LOGGER.debug(f"Curl library is unavailable.")
raise CurlUnavailableError()
response = curl_requests.get(url, headers=headers, timeout=10, impersonate=IMPERSONATE_BROWSER)
elif CONNECTION_MECHANISM == ConnectionMechanismEnum.CLOUDSCRAPER:
if len(headers) == 0:
headers = self._get_headers()
scraper = cloudscraper.create_scraper()
response = scraper.get(url, headers=headers, timeout=10)
elif CONNECTION_MECHANISM == ConnectionMechanismEnum.REQUESTS:
if len(headers) == 0:
headers = self._get_headers()
response = requests.get(url, headers=headers, timeout=10)
else:
raise NotImplementedError()
self._test_response(response)
return response
def _post(self, urlenum: BambuUrl, json: str, headers={}, return400=False):
url = get_Url(urlenum, self._region)
if CONNECTION_MECHANISM == ConnectionMechanismEnum.CURL_CFFI:
if not curl_available:
LOGGER.debug(f"Curl library is unavailable.")
raise CurlUnavailableError()
response = curl_requests.post(url, headers=headers, json=json, impersonate=IMPERSONATE_BROWSER)
elif CONNECTION_MECHANISM == ConnectionMechanismEnum.CLOUDSCRAPER:
if len(headers) == 0:
headers = self._get_headers()
scraper = cloudscraper.create_scraper()
response = scraper.post(url, headers=headers, json=json)
elif CONNECTION_MECHANISM == ConnectionMechanismEnum.REQUESTS:
if len(headers) == 0:
headers = self._get_headers()
response = requests.post(url, headers=headers, json=json)
else:
raise NotImplementedError()
self._test_response(response, return400)
return response
def _get_authentication_token(self) -> str:
LOGGER.debug("Getting accessToken from Bambu Cloud")
# First we need to find out how Bambu wants us to login.
data = {
"account": self._email,
"password": self._password,
"apiError": ""
}
response = self._post(BambuUrl.LOGIN, json=data)
auth_json = response.json()
accessToken = auth_json.get('accessToken', '')
if accessToken != '':
# We were provided the accessToken directly.
return accessToken
loginType = auth_json.get("loginType", None)
if loginType is None:
LOGGER.error(f"loginType not present")
LOGGER.error(f"Response not understood: '{response.text}'")
return ValueError(0) # FIXME
elif loginType == 'verifyCode':
LOGGER.debug(f"Received verifyCode response")
raise EmailCodeRequiredError()
elif loginType == 'tfa':
# Store the tfaKey for later use
LOGGER.debug(f"Received tfa response")
self._tfaKey = auth_json.get("tfaKey")
raise TfaCodeRequiredError()
else:
LOGGER.debug(f"Did not understand json. loginType = '{loginType}'")
LOGGER.error(f"Response not understood: '{response.text}'")
return ValueError(1) # FIXME
def _get_email_verification_code(self):
# Send the verification code request
data = {
"email": self._email,
"type": "codeLogin"
}
LOGGER.debug("Requesting verification code")
self._post(BambuUrl.EMAIL_CODE, json=data)
LOGGER.debug("Verification code requested successfully.")
def _get_authentication_token_with_verification_code(self, code) -> dict:
LOGGER.debug("Attempting to connect with provided verification code.")
data = {
"account": self._email,
"code": code
}
response = self._post(BambuUrl.LOGIN, json=data, return400=True)
status_code = response.status_code
if status_code == 200:
LOGGER.debug("Authentication successful.")
LOGGER.debug(f"Response = '{response.json()}'")
elif status_code == 400:
LOGGER.debug(f"Received response: {response.json()}")
if response.json()['code'] == 1:
# Code has expired. Request a new one.
self._get_email_verification_code()
raise EmailCodeExpiredError()
elif response.json()['code'] == 2:
# Code was incorrect. Let the user try again.
raise EmailCodeIncorrectError()
else:
LOGGER.error(f"Response not understood: '{response.json()}'")
raise ValueError(response.json()['code'])
return response.json()['accessToken']
def _get_authentication_token_with_2fa_code(self, code: str) -> dict:
LOGGER.debug("Attempting to connect with provided 2FA code.")
data = {
"tfaKey": self._tfaKey,
"tfaCode": code
}
response = self._post(BambuUrl.TFA_LOGIN, json=data)
LOGGER.debug(f"Response: {response.status_code}")
if response.status_code == 200:
LOGGER.debug("Authentication successful.")
cookies = response.cookies.get_dict()
token_from_tfa = cookies.get("token")
#LOGGER.debug(f"token_from_tfa: {token_from_tfa}")
return token_from_tfa
def _get_username_from_authentication_token(self) -> str:
LOGGER.debug("Trying to get username from authentication token.")
# User name is in 2nd portion of the auth token (delimited with periods)
username = None
tokens = self._auth_token.split(".")
if len(tokens) != 3:
LOGGER.debug("Received authToken is not a JWT.")
LOGGER.debug("Trying to use project API to retrieve username instead")
response = self.get_projects();
if response is not None:
projectsnode = response.get('projects', None)
if projectsnode is None:
LOGGER.debug("Failed to find projects node")
else:
if len(projectsnode) == 0:
LOGGER.debug("No projects node in response")
else:
project=projectsnode[0]
if project.get('user_id', None) is None:
LOGGER.debug("No user_id entry")
else:
username = f"u_{project['user_id']}"
LOGGER.debug(f"Found user_id of {username}")
else:
LOGGER.debug("Authentication token looks to be a JWT")
try:
b64_string = self._auth_token.split(".")[1]
# String must be multiples of 4 chars in length. For decode pad with = character
b64_string += "=" * ((4 - len(b64_string) % 4) % 4)
jsonAuthToken = json.loads(base64.b64decode(b64_string))
# Gives json payload with "username":"u_<digits>" within it
username = jsonAuthToken.get('username', None)
except:
LOGGER.debug("Unable to decode authToken to json to retrieve username.")
if username is None:
LOGGER.debug(f"Unable to decode authToken to retrieve username. AuthToken = {self._auth_token}")
return username
# Retrieves json description of devices in the form:
# {
# 'message': 'success',
# 'code': None,
# 'error': None,
# 'devices': [
# {
# 'dev_id': 'REDACTED',
# 'name': 'Bambu P1S',
# 'online': True,
# 'print_status': 'SUCCESS',
# 'dev_model_name': 'C12',
# 'dev_product_name': 'P1S',
# 'dev_access_code': 'REDACTED',
# 'nozzle_diameter': 0.4
# },
# {
# 'dev_id': 'REDACTED',
# 'name': 'Bambu P1P',
# 'online': True,
# 'print_status': 'RUNNING',
# 'dev_model_name': 'C11',
# 'dev_product_name': 'P1P',
# 'dev_access_code': 'REDACTED',
# 'nozzle_diameter': 0.4
# },
# {
# 'dev_id': 'REDACTED',
# 'name': 'Bambu X1C',
# 'online': True,
# 'print_status': 'RUNNING',
# 'dev_model_name': 'BL-P001',
# 'dev_product_name': 'X1 Carbon',
# 'dev_access_code': 'REDACTED',
# 'nozzle_diameter': 0.4
# }
# ]
# }
def test_authentication(self, region: str, email: str, username: str, auth_token: str) -> bool:
self._region = region
self._email = email
self._username = username
self._auth_token = auth_token
try:
self.get_device_list()
except:
return False
return True
def login(self, region: str, email: str, password: str) -> str:
self._region = region
self._email = email
self._password = password
result = self._get_authentication_token()
self._auth_token = result
self._username = self._get_username_from_authentication_token()
def login_with_verification_code(self, code: str):
result = self._get_authentication_token_with_verification_code(code)
self._auth_token = result
self._username = self._get_username_from_authentication_token()
def login_with_2fa_code(self, code: str):
result = self._get_authentication_token_with_2fa_code(code)
self._auth_token = result
self._username = self._get_username_from_authentication_token()
def get_device_list(self) -> dict:
LOGGER.debug("Getting device list from Bambu Cloud")
try:
response = self._get(BambuUrl.BIND)
except:
return None
return response.json()['devices']
# The slicer settings are of the following form:
#
# {
# "message": "success",
# "code": null,
# "error": null,
# "print": {
# "public": [
# {
# "setting_id": "GP004",
# "version": "01.09.00.15",
# "name": "0.20mm Standard @BBL X1C",
# "update_time": "2024-07-04 11:27:08",
# "nickname": null
# },
# ...
# }
# "private": []
# },
# "printer": {
# "public": [
# {
# "setting_id": "GM001",
# "version": "01.09.00.15",
# "name": "Bambu Lab X1 Carbon 0.4 nozzle",
# "update_time": "2024-07-04 11:25:07",
# "nickname": null
# },
# ...
# ],
# "private": []
# },
# "filament": {
# "public": [
# {
# "setting_id": "GFSA01",
# "version": "01.09.00.15",
# "name": "Bambu PLA Matte @BBL X1C",
# "update_time": "2024-07-04 11:29:21",
# "nickname": null,
# "filament_id": "GFA01"
# },
# ...
# ],
# "private": [
# {
# "setting_id": "PFUS46ea5c221cabe5",
# "version": "1.9.0.14",
# "name": "Fillamentum PLA Extrafill @Bambu Lab X1 Carbon 0.4 nozzle",
# "update_time": "2024-07-10 06:48:17",
# "base_id": null,
# "filament_id": "Pc628b24",
# "filament_type": "PLA",
# "filament_is_support": "0",
# "nozzle_temperature": [
# 190,
# 240
# ],
# "nozzle_hrc": "3",
# "filament_vendor": "Fillamentum"
# },
# ...
# ]
# },
# "settings": {}
# }
def get_slicer_settings(self) -> dict:
LOGGER.debug("Getting slicer settings from Bambu Cloud")
try:
response = self._get(BambuUrl.SLICER_SETTINGS)
except:
return None
LOGGER.debug("Succeeded")
return response.json()
# The task list is of the following form with a 'hits' array with typical 20 entries.
#
# "total": 531,
# "hits": [
# {
# "id": 35237965,
# "designId": 0,
# "designTitle": "",
# "instanceId": 0,
# "modelId": "REDACTED",
# "title": "REDACTED",
# "cover": "REDACTED",
# "status": 4,
# "feedbackStatus": 0,
# "startTime": "2023-12-21T19:02:16Z",
# "endTime": "2023-12-21T19:02:35Z",
# "weight": 34.62,
# "length": 1161,
# "costTime": 10346,
# "profileId": 35276233,
# "plateIndex": 1,
# "plateName": "",
# "deviceId": "REDACTED",
# "amsDetailMapping": [
# {
# "ams": 4,
# "sourceColor": "F4D976FF",
# "targetColor": "F4D976FF",
# "filamentId": "GFL99",
# "filamentType": "PLA",
# "targetFilamentType": "",
# "weight": 34.62
# }
# ],
# "mode": "cloud_file",
# "isPublicProfile": false,
# "isPrintable": true,
# "deviceModel": "P1P",
# "deviceName": "Bambu P1P",
# "bedType": "textured_plate"
# },
def get_tasklist(self) -> dict:
LOGGER.debug("Getting full task list from Bambu Cloud")
try:
response = self._get(BambuUrl.TASKS)
except:
return None
return response.json()
# Returns a list of projects for the account.
#
# {
# "message": "success",
# "code": null,
# "error": null,
# "projects": [
# {
# "project_id": "164995388",
# "user_id": "1688388450",
# "model_id": "US48e2103d939bf8",
# "status": "ACTIVE",
# "name": "Alcohol_Marker_Storage_for_Copic,_Ohuhu_and_the_like",
# "content": "{'printed_plates': [{'plate': 1}]}",
# "create_time": "2024-11-17 06:12:33",
# "update_time": "2024-11-17 06:12:40"
# },
# ...
#
def get_projects(self) -> dict:
LOGGER.debug("Getting projects list from Bambu Cloud")
try:
response = self._get(BambuUrl.PROJECTS)
except:
return None
return response.json()
def get_latest_task_for_printer(self, deviceId: str) -> dict:
LOGGER.debug(f"Getting latest task for printer from Bambu Cloud")
try:
data = self.get_tasklist_for_printer(deviceId)
if len(data) != 0:
return data[0]
LOGGER.debug("No tasks found for printer")
return None
except:
return None
def get_tasklist_for_printer(self, deviceId: str) -> dict:
LOGGER.debug(f"Getting full task list for printer from Bambu Cloud")
tasks = []
data = self.get_tasklist()
for task in data['hits']:
if task['deviceId'] == deviceId:
tasks.append(task)
return tasks
def get_device_type_from_device_product_name(self, device_product_name: str):
if device_product_name == "X1 Carbon":
return "X1C"
return device_product_name.replace(" ", "")
def download(self, url: str) -> bytearray:
LOGGER.debug(f"Downloading cover image: {url}")
try:
# This is just a standard download from an unauthenticated end point.
response = requests.get(url)
except:
return None
return response.content
@property
def username(self):
return self._username
@property
def auth_token(self):
return self._auth_token
@property
def bambu_connected(self) -> bool:
return self._auth_token != "" and self._auth_token != None
@property
def cloud_mqtt_host(self):
return "cn.mqtt.bambulab.com" if self._region == "China" else "us.mqtt.bambulab.com"

View File

@ -0,0 +1,24 @@
"""MQTT Commands"""
CHAMBER_LIGHT_ON = {
"system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "on",
"led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}}
CHAMBER_LIGHT_OFF = {
"system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "off",
"led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}}
SPEED_PROFILE_TEMPLATE = {"print": {"sequence_id": "0", "command": "print_speed", "param": ""}}
GET_VERSION = {"info": {"sequence_id": "0", "command": "get_version"}}
PAUSE = {"print": {"sequence_id": "0", "command": "pause"}}
RESUME = {"print": {"sequence_id": "0", "command": "resume"}}
STOP = {"print": {"sequence_id": "0", "command": "stop"}}
PUSH_ALL = {"pushing": {"sequence_id": "0", "command": "pushall"}}
START_PUSH = { "pushing": {"sequence_id": "0", "command": "start"}}
SEND_GCODE_TEMPLATE = {"print": {"sequence_id": "0", "command": "gcode_line", "param": ""}} # param = GCODE_EACH_LINE_SEPARATED_BY_\n
# X1 only currently
GET_ACCESSORIES = {"system": {"sequence_id": "0", "command": "get_accessories", "accessory_type": "none"}}

View File

@ -0,0 +1,199 @@
import json
import logging
from pathlib import Path
from enum import (
Enum,
IntEnum,
)
from .const_hms_errors import HMS_ERRORS
# These errors cover those that are AMS and/or slot specific.
# 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error.
from .const_ams_errors import HMS_AMS_ERRORS
from .const_print_errors import PRINT_ERROR_ERRORS
LOGGER = logging.getLogger(__package__)
class Features(Enum):
AUX_FAN = 1,
CHAMBER_LIGHT = 2,
CHAMBER_FAN = 3,
CHAMBER_TEMPERATURE = 4,
CURRENT_STAGE = 5,
PRINT_LAYERS = 6,
AMS = 7,
EXTERNAL_SPOOL = 8,
K_VALUE = 9,
START_TIME = 10,
AMS_TEMPERATURE = 11,
CAMERA_RTSP = 13,
START_TIME_GENERATED = 14,
CAMERA_IMAGE = 15,
DOOR_SENSOR = 16,
MANUAL_MODE = 17,
AMS_FILAMENT_REMAINING = 18,
SET_TEMPERATURE = 19,
class FansEnum(Enum):
PART_COOLING = 1,
AUXILIARY = 2,
CHAMBER = 3,
HEATBREAK = 4,
class TempEnum(Enum):
HEATBED = 1,
NOZZLE = 2
CURRENT_STAGE_IDS = {
"default": "unknown",
0: "printing",
1: "auto_bed_leveling",
2: "heatbed_preheating",
3: "sweeping_xy_mech_mode",
4: "changing_filament",
5: "m400_pause",
6: "paused_filament_runout",
7: "heating_hotend",
8: "calibrating_extrusion",
9: "scanning_bed_surface",
10: "inspecting_first_layer",
11: "identifying_build_plate_type",
12: "calibrating_micro_lidar", # DUPLICATED?
13: "homing_toolhead",
14: "cleaning_nozzle_tip",
15: "checking_extruder_temperature",
16: "paused_user",
17: "paused_front_cover_falling",
18: "calibrating_micro_lidar", # DUPLICATED?
19: "calibrating_extrusion_flow",
20: "paused_nozzle_temperature_malfunction",
21: "paused_heat_bed_temperature_malfunction",
22: "filament_unloading",
23: "paused_skipped_step",
24: "filament_loading",
25: "calibrating_motor_noise",
26: "paused_ams_lost",
27: "paused_low_fan_speed_heat_break",
28: "paused_chamber_temperature_control_error",
29: "cooling_chamber",
30: "paused_user_gcode",
31: "motor_noise_showoff",
32: "paused_nozzle_filament_covered_detected",
33: "paused_cutter_error",
34: "paused_first_layer_error",
35: "paused_nozzle_clog",
# X1 returns -1 for idle
-1: "idle", # DUPLICATED
# P1 returns 255 for idle
255: "idle", # DUPLICATED
}
CURRENT_STAGE_OPTIONS = list(set(CURRENT_STAGE_IDS.values())) # Conversion to set first removes the duplicates
GCODE_STATE_OPTIONS = [
"failed",
"finish",
"idle",
"init",
"offline",
"pause",
"prepare",
"running",
"slicing",
"unknown"
]
SPEED_PROFILE = {
1: "silent",
2: "standard",
3: "sport",
4: "ludicrous"
}
PRINT_TYPE_OPTIONS = {
"cloud",
"local",
"idle",
"system",
"unknown"
}
def load_dict(filename: str) -> dict:
with open(filename) as f:
return json.load(f);
FILAMENT_NAMES = load_dict(Path(__file__).with_name('filaments.json'))
HMS_SEVERITY_LEVELS = {
"default": "unknown",
1: "fatal",
2: "serious",
3: "common",
4: "info"
}
HMS_MODULES = {
"default": "unknown",
0x05: "mainboard",
0x0C: "xcam",
0x07: "ams",
0x08: "toolhead",
0x03: "mc"
}
class SdcardState(Enum):
NO_SDCARD = 0x00000000,
HAS_SDCARD_NORMAL = 0x00000100,
HAS_SDCARD_ABNORMAL = 0x00000200,
SDCARD_STATE_NUM = 0x00000300,
class Home_Flag_Values(IntEnum):
X_AXIS = 0x00000001,
Y_AXIS = 0x00000002,
Z_AXIS = 0x00000004,
VOLTAGE220 = 0x00000008,
XCAM_AUTO_RECOVERY_STEP_LOSS = 0x00000010,
CAMERA_RECORDING = 0x00000020,
# Gap
AMS_CALIBRATE_REMAINING = 0x00000080,
SD_CARD_PRESENT = 0x00000100,
SD_CARD_ABNORMAL = 0x00000200,
AMS_AUTO_SWITCH = 0x00000400,
# Gap
XCAM_ALLOW_PROMPT_SOUND = 0x00020000,
WIRED_NETWORK = 0x00040000,
FILAMENT_TANGLE_DETECT_SUPPORTED = 0x00080000,
FILAMENT_TANGLE_DETECTED = 0x00100000,
SUPPORTS_MOTOR_CALIBRATION = 0x00200000,
# Gap
DOOR_OPEN = 0x00800000,
# Gap
INSTALLED_PLUS = 0x04000000,
SUPPORTED_PLUS = 0x08000000,
# Gap
class BambuUrl(Enum):
LOGIN = 1,
TFA_LOGIN = 2,
EMAIL_CODE = 3,
BIND = 4,
SLICER_SETTINGS = 5,
TASKS = 6,
PROJECTS = 7,
BAMBU_URL = {
BambuUrl.LOGIN: 'https://api.bambulab.com/v1/user-service/user/login',
BambuUrl.TFA_LOGIN: 'https://bambulab.com/api/sign-in/tfa',
BambuUrl.EMAIL_CODE: 'https://api.bambulab.com/v1/user-service/user/sendemail/code',
BambuUrl.BIND: 'https://api.bambulab.com/v1/iot-service/api/user/bind',
BambuUrl.SLICER_SETTINGS: 'https://api.bambulab.com/v1/iot-service/api/slicer/setting?version=1.10.0.89',
BambuUrl.TASKS: 'https://api.bambulab.com/v1/user-service/my/tasks',
BambuUrl.PROJECTS: 'https://api.bambulab.com/v1/iot-service/api/user/project',
}

View File

@ -0,0 +1,85 @@
{
"GFA00": "Bambu PLA Basic",
"GFA01": "Bambu PLA Matte",
"GFA02": "Bambu PLA Metal",
"GFA05": "Bambu PLA Silk",
"GFA07": "Bambu PLA Marble",
"GFA08": "Bambu PLA Sparkle",
"GFA09": "Bambu PLA Tough",
"GFA11": "Bambu PLA Aero",
"GFA12": "Bambu PLA Glow",
"GFA13": "Bambu PLA Dynamic",
"GFA15": "Bambu PLA Galaxy",
"GFA50": "Bambu PLA-CF",
"GFB00": "Bambu ABS",
"GFB01": "Bambu ASA",
"GFB02": "Bambu ASA-Aero",
"GFB50": "Bambu ABS-GF",
"GFB51": "Bambu ASA-CF",
"GFB60": "PolyLite ABS",
"GFB61": "PolyLite ASA",
"GFB98": "Generic ASA",
"GFB99": "Generic ABS",
"GFC00": "Bambu PC",
"GFC99": "Generic PC",
"GFG00": "Bambu PETG Basic",
"GFG01": "Bambu PETG Translucent",
"GFG02": "Bambu PETG HF",
"GFG50": "Bambu PETG-CF",
"GFG60": "PolyLite PETG",
"GFG96": "Generic PETG HF",
"GFG97": "Generic PCTG",
"GFG98": "Generic PETG-CF",
"GFG99": "Generic PETG",
"GFL00": "PolyLite PLA",
"GFL01": "PolyTerra PLA",
"GFL03": "eSUN PLA+",
"GFL04": "Overture PLA",
"GFL05": "Overture Matte PLA",
"GFL06": "Fiberon PETG-ESD",
"GFL50": "Fiberon PA6-CF",
"GFL51": "Fiberon PA6-GF",
"GFL52": "Fiberon PA12-CF",
"GFL53": "Fiberon PA612-CF",
"GFL54": "Fiberon PET-CF",
"GFL55": "Fiberon PETG-rCF",
"GFL95": "Generic PLA High Speed",
"GFL96": "Generic PLA Silk",
"GFL98": "Generic PLA-CF",
"GFL99": "Generic PLA",
"GFN03": "Bambu PA-CF",
"GFN04": "Bambu PAHT-CF",
"GFN05": "Bambu PA6-CF",
"GFN06": "Bambu PPA-CF",
"GFN08": "Bambu PA6-GF",
"GFN96": "Generic PPA-GF",
"GFN97": "Generic PPA-CF",
"GFN98": "Generic PA-CF",
"GFN99": "Generic PA",
"GFP95": "Generic PP-GF",
"GFP96": "Generic PP-CF",
"GFP97": "Generic PP",
"GFP98": "Generic PE-CF",
"GFP99": "Generic PE",
"GFR98": "Generic PHA",
"GFR99": "Generic EVA",
"GFS00": "Bambu Support W",
"GFS01": "Bambu Support G",
"GFS02": "Bambu Support For PLA",
"GFS03": "Bambu Support For PA/PET",
"GFS04": "Bambu PVA",
"GFS05": "Bambu Support For PLA/PETG",
"GFS06": "Bambu Support for ABS",
"GFS97": "Generic BVOH",
"GFS98": "Generic HIPS",
"GFS99": "Generic PVA",
"GFT01": "Bambu PET-CF",
"GFT02": "Bambu PPS-CF",
"GFT97": "Generic PPS",
"GFT98": "Generic PPS-CF",
"GFU00": "Bambu TPU 95A HF",
"GFU01": "Bambu TPU 95A",
"GFU02": "Bambu TPU for AMS",
"GFU98": "Generic TPU for AMS",
"GFU99": "Generic TPU"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,248 @@
import math
from datetime import datetime, timedelta
from .const import (
CURRENT_STAGE_IDS,
SPEED_PROFILE,
FILAMENT_NAMES,
HMS_ERRORS,
HMS_AMS_ERRORS,
PRINT_ERROR_ERRORS,
HMS_SEVERITY_LEVELS,
HMS_MODULES,
LOGGER,
BAMBU_URL,
FansEnum,
TempEnum
)
from .commands import SEND_GCODE_TEMPLATE
def search(lst, predicate, default={}):
"""Search an array for a string"""
for item in lst:
if predicate(item):
return item
return default
def fan_percentage(speed):
"""Converts a fan speed to percentage"""
if not speed:
return 0
percentage = (int(speed) / 15) * 100
return round(percentage / 10) * 10
def fan_percentage_to_gcode(fan: FansEnum, percentage: int):
"""Converts a fan speed percentage to the gcode command to set that"""
if fan == FansEnum.PART_COOLING:
fanString = "P1"
elif fan == FansEnum.AUXILIARY:
fanString = "P2"
elif fan == FansEnum.CHAMBER:
fanString = "P3"
percentage = round(percentage / 10) * 10
speed = math.ceil(255 * percentage / 100)
command = SEND_GCODE_TEMPLATE
command['print']['param'] = f"M106 {fanString} S{speed}\n"
return command
def set_temperature_to_gcode(temp: TempEnum, temperature: int):
"""Converts a temperature to the gcode command to set that"""
if temp == TempEnum.NOZZLE:
tempCommand = "M104"
elif temp == TempEnum.HEATBED:
tempCommand = "M140"
command = SEND_GCODE_TEMPLATE
command['print']['param'] = f"{tempCommand} S{temperature}\n"
return command
def to_whole(number):
if not number:
return 0
return round(number)
def get_filament_name(idx, custom_filaments: dict):
"""Converts a filament idx to a human-readable name"""
result = FILAMENT_NAMES.get(idx, "unknown")
if result == "unknown" and idx != "":
result = custom_filaments.get(idx, "unknown")
# if result == "unknown" and idx != "":
# LOGGER.debug(f"UNKNOWN FILAMENT IDX: '{idx}'")
return result
def get_speed_name(id):
"""Return the human-readable name for a speed id"""
return SPEED_PROFILE.get(int(id), "standard")
def get_current_stage(id) -> str:
"""Return the human-readable description for a stage action"""
return CURRENT_STAGE_IDS.get(int(id), "unknown")
def get_HMS_error_text(hms_code: str):
"""Return the human-readable description for an HMS error"""
ams_code = get_generic_AMS_HMS_error_code(hms_code)
ams_error = HMS_AMS_ERRORS.get(ams_code, "")
if ams_error != "":
# 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error
ams_index = int(hms_code[3:4], 16) + 1
ams_slot = int(hms_code[6:7], 16) + 1
ams_error = ams_error.replace('AMS1', f"AMS{ams_index}")
ams_error = ams_error.replace('slot 1', f"slot {ams_slot}")
return ams_error
return HMS_ERRORS.get(hms_code, "unknown")
def get_print_error_text(print_error_code: str):
"""Return the human-readable description for a print error"""
hex_conversion = f'0{int(print_error_code):x}'
print_error_code = hex_conversion[slice(0,4,1)] + "_" + hex_conversion[slice(4,8,1)]
print_error = PRINT_ERROR_ERRORS.get(print_error_code.upper(), "")
if print_error != "":
return print_error
return PRINT_ERROR_ERRORS.get(print_error_code, "unknown")
def get_HMS_severity(code: int) -> str:
uint_code = code >> 16
if code > 0 and uint_code in HMS_SEVERITY_LEVELS:
return HMS_SEVERITY_LEVELS[uint_code]
return HMS_SEVERITY_LEVELS["default"]
def get_HMS_module(attr: int) -> str:
uint_attr = (attr >> 24) & 0xFF
if attr > 0 and uint_attr in HMS_MODULES:
return HMS_MODULES[uint_attr]
return HMS_MODULES["default"]
def get_generic_AMS_HMS_error_code(hms_code: str):
code1 = int(hms_code[0:4], 16)
code2 = int(hms_code[5:9], 16)
code3 = int(hms_code[10:14], 16)
code4 = int(hms_code[15:19], 16)
# 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error
ams_code = f"{code1 & 0xFFF8:0>4X}_{code2 & 0xF8FF:0>4X}_{code3:0>4X}_{code4:0>4X}"
ams_error = HMS_AMS_ERRORS.get(ams_code, "")
if ams_error != "":
return ams_code
return f"{code1:0>4X}_{code2:0>4X}_{code3:0>4X}_{code4:0>4X}"
def get_printer_type(modules, default):
# Known possible values:
#
# A1/P1 printers are of the form:
# {
# "name": "esp32",
# "project_name": "C11",
# "sw_ver": "01.07.23.47",
# "hw_ver": "AP04",
# "sn": "**REDACTED**",
# "flag": 0
# },
# P1P = AP04 / C11
# P1S = AP04 / C12
# A1Mini = AP05 / N1 or AP04 / N1 or AP07 / N1
# A1 = AP05 / N2S
#
# X1C printers are of the form:
# {
# "hw_ver": "AP05",
# "name": "rv1126",
# "sn": "**REDACTED**",
# "sw_ver": "00.00.28.55"
# },
# X1C = AP05
#
# X1E printers are of the form:
# {
# "flag": 0,
# "hw_ver": "AP02",
# "name": "ap",
# "sn": "**REDACTED**",
# "sw_ver": "00.00.32.14"
# }
# X1E = AP02
apNode = search(modules, lambda x: x.get('hw_ver', "").find("AP0") == 0)
if len(apNode.keys()) > 1:
hw_ver = apNode['hw_ver']
project_name = apNode.get('project_name', '')
if hw_ver == 'AP02':
return 'X1E'
elif project_name == 'N1':
return 'A1MINI'
elif hw_ver == 'AP04':
if project_name == 'C11':
return 'P1P'
if project_name == 'C12':
return 'P1S'
elif hw_ver == 'AP05':
if project_name == 'N2S':
return 'A1'
if project_name == '':
return 'X1C'
LOGGER.debug(f"UNKNOWN DEVICE: hw_ver='{hw_ver}' / project_name='{project_name}'")
return default
def get_hw_version(modules, default):
"""Retrieve hardware version of printer"""
apNode = search(modules, lambda x: x.get('hw_ver', "").find("AP0") == 0)
if len(apNode.keys()) > 1:
return apNode.get("hw_ver")
return default
def get_sw_version(modules, default):
"""Retrieve software version of printer"""
ota = search(modules, lambda x: x.get('name', "") == "ota")
if len(ota.keys()) > 1:
return ota.get("sw_ver")
return default
def get_start_time(timestamp):
"""Return start time of a print"""
if timestamp == 0:
return None
return datetime.fromtimestamp(timestamp)
def get_end_time(remaining_time):
"""Calculate the end time of a print"""
end_time = round_minute(datetime.now() + timedelta(minutes=remaining_time))
return end_time
def round_minute(date: datetime = None, round_to: int = 1):
""" Round datetime object to minutes"""
if not date:
date = datetime.now()
date = date.replace(second=0, microsecond=0)
delta = date.minute % round_to
return date.replace(minute=date.minute - delta)
def get_Url(url: str, region: str):
urlstr = BAMBU_URL[url]
if region == "China":
urlstr = urlstr.replace('.com', '.cn')
return urlstr

View File

@ -25,7 +25,7 @@ class IdleState(APrinterState):
filesystem_root = (
"file:///mnt/sdcard/"
if self._printer._settings.get(["device_type"]) in ["X1", "X1C"]
else "file:///"
else "file:///sdcard/"
)
print_command = {
@ -49,7 +49,7 @@ class IdleState(APrinterState):
),
"layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]),
"use_ams": self._printer._settings.get_boolean(["use_ams"]),
"ams_mapping": "",
"ams_mapping": self._printer._settings.get(["ams_mapping"]),
}
}

View File

@ -8,7 +8,7 @@ if TYPE_CHECKING:
import threading
import pybambu.commands
import octoprint_bambu_printer.printer.pybambu.commands
from octoprint.util import RepeatedTimer
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
@ -37,14 +37,14 @@ class PausedState(APrinterState):
def start_new_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.RESUME):
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.RESUME):
self._log.info("print resumed")
else:
self._log.info("print resume failed")
def cancel_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.STOP):
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.STOP):
self._log.info("print cancelled")
self._printer.finalize_print_job()
else:

View File

@ -10,9 +10,9 @@ if TYPE_CHECKING:
import threading
import pybambu
import pybambu.models
import pybambu.commands
import octoprint_bambu_printer.printer.pybambu
import octoprint_bambu_printer.printer.pybambu.models
import octoprint_bambu_printer.printer.pybambu.commands
from octoprint_bambu_printer.printer.print_job import PrintJob
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
@ -22,7 +22,7 @@ class PrintingState(APrinterState):
def __init__(self, printer: BambuVirtualPrinter) -> None:
super().__init__(printer)
self._current_print_job = None
self._printer.current_print_job = None
self._is_printing = False
self._sd_printing_thread = None
@ -40,12 +40,15 @@ class PrintingState(APrinterState):
self._printer.current_print_job = None
def _start_worker_thread(self):
if self._sd_printing_thread is None:
self._is_printing = True
if self._sd_printing_thread is None:
self._sd_printing_thread = threading.Thread(target=self._printing_worker)
self._sd_printing_thread.start()
else:
self._sd_printing_thread.join()
def _printing_worker(self):
self._log.debug(f"_printing_worker before while loop: {self._printer.current_print_job}")
while (
self._is_printing
and self._printer.current_print_job is not None
@ -55,6 +58,7 @@ class PrintingState(APrinterState):
self._printer.report_print_job_status()
time.sleep(3)
self._log.debug(f"_printing_worker after while loop: {self._printer.current_print_job}")
self.update_print_job_info()
if (
self._printer.current_print_job is not None
@ -64,30 +68,34 @@ class PrintingState(APrinterState):
def update_print_job_info(self):
print_job_info = self._printer.bambu_client.get_device().print_job
task_name: str = print_job_info.subtask_name
project_file_info = self._printer.project_files.get_file_by_stem(
task_name, [".gcode", ".3mf"]
)
subtask_name: str = print_job_info.subtask_name
gcode_file: str = print_job_info.gcode_file
self._log.debug(f"update_print_job_info: {print_job_info}")
project_file_info = self._printer.project_files.get_file_by_name(subtask_name) or self._printer.project_files.get_file_by_name(gcode_file)
if project_file_info is None:
self._log.debug(f"No 3mf file found for {print_job_info}")
self._current_print_job = None
self._printer.current_print_job = None
self._printer.change_state(self._printer._state_idle)
return
progress = print_job_info.print_percentage
self._printer.current_print_job = PrintJob(project_file_info, progress)
if print_job_info.gcode_state == "PREPARE" and progress == 100:
progress = 0
self._printer.current_print_job = PrintJob(project_file_info, progress, print_job_info.remaining_time, print_job_info.current_layer, print_job_info.total_layers)
self._printer.select_project_file(project_file_info.path.as_posix())
def pause_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.PAUSE):
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.PAUSE):
self._log.info("print paused")
else:
self._log.info("print pause failed")
def cancel_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.STOP):
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.STOP):
self._log.info("print cancelled")
self._printer.finalize_print_job()
else:

View File

@ -0,0 +1,24 @@
#sidebar_plugin_bambu_printer div.well {
min-height: 70px;
}
#sidebar_plugin_bambu_printer div.well div.span3.text-center div.row-fluid {
padding-top: 10px;
}
#sidebar_plugin_bambu_printer div.well div.span3.text-center div.row-fluid.active {
border: 2px solid;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
#bambu_printer_print_options div.well {
min-height: 60px;
}
#bambu_printer_print_options div.modal-body {
overflow: inherit !important;
}

View File

@ -15,18 +15,69 @@ $(function () {
self.accessViewModel = parameters[3];
self.timelapseViewModel = parameters[4];
self.use_ams = true;
self.ams_mapping = ko.observableArray([]);
self.job_info = ko.observable();
self.auth_type = ko.observable("");
self.show_password = ko.pureComputed(function(){
return self.settingsViewModel.settings.plugins.bambu_printer.auth_token() === '';
});
self.show_verification = ko.pureComputed(function(){
return self.auth_type() !== '';
});
self.ams_mapping_computed = function(){
var output_list = [];
var index = 0;
ko.utils.arrayForEach(self.settingsViewModel.settings.plugins.bambu_printer.ams_data(), function(item){
if(item){
output_list = output_list.concat(item.tray());
}
});
ko.utils.arrayForEach(output_list, function(item){
item["index"] = ko.observable(index);
index++;
});
return output_list;
};
self.getAuthToken = function (data) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token("");
self.auth_type("");
OctoPrint.simpleApiCommand("bambu_printer", "register", {
"email": self.settingsViewModel.settings.plugins.bambu_printer.email(),
"password": $("#bambu_cloud_password").val(),
"region": self.settingsViewModel.settings.plugins.bambu_printer.region(),
"auth_token": self.settingsViewModel.settings.plugins.bambu_printer.auth_token()
})
.done(function (response) {
self.auth_type(response.auth_response);
});
};
self.verifyCode = function (data) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token("");
OctoPrint.simpleApiCommand("bambu_printer", "verify", {
"password": $("#bambu_cloud_verify_code").val(),
"auth_type": self.auth_type(),
})
.done(function (response) {
console.log(response);
if (response.auth_token) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token(response.auth_token);
self.settingsViewModel.settings.plugins.bambu_printer.username(response.username);
self.auth_type("");
} else if (response.error) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token("");
$("#bambu_cloud_verify_code").val("");
}
});
};
@ -68,81 +119,72 @@ $(function () {
}
if (data.files !== undefined) {
console.log(data.files);
self.listHelper.updateItems(data.files);
self.listHelper.resetPage();
}
if (data.job_info !== undefined) {
self.job_info(data.job_info);
}
};
self.onBeforeBinding = function () {
$('#bambu_timelapse').appendTo("#timelapse");
};
self.onAfterBinding = function () {
console.log(self.ams_mapping_computed());
};
self.showTimelapseThumbnail = function(data) {
$("#bambu_printer_timelapse_thumbnail").attr("src", data.thumbnail);
$("#bambu_printer_timelapse_preview").modal('show');
};
/*$('#files div.upload-buttons > span.fileinput-button:first, #files div.folder-button').remove();
$('#files div.upload-buttons > span.fileinput-button:first').removeClass('span6').addClass('input-block-level');
self.onBeforePrintStart = function(start_print_command) {
let confirmation_html = '' +
' <div class="row-fluid form-vertical">\n' +
' <div class="control-group">\n' +
' <label class="control-label">' + gettext("Plate Number") + '</label>\n' +
' <div class="controls">\n' +
' <input type="number" min="1" value="1" id="bambu_printer_plate_number" class="input-mini">\n' +
' </div>\n' +
' </div>\n' +
' </div>';
if(!self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options()){
confirmation_html += '\n' +
' <div class="row-fluid">\n' +
' <div class="span6">\n' +
' <label class="checkbox"><input id="bambu_printer_timelapse" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.timelapse()) ? ' checked' : '') + '> ' + gettext("Enable timelapse") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_bed_leveling" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling()) ? ' checked' : '') + '> ' + gettext("Enable bed leveling") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_flow_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.flow_cali()) ? ' checked' : '') + '> ' + gettext("Enable flow calibration") + '</label>\n' +
' </div>\n' +
' <div class="span6">\n' +
' <label class="checkbox"><input id="bambu_printer_vibration_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali()) ? ' checked' : '') + '> ' + gettext("Enable vibration calibration") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_layer_inspect" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect()) ? ' checked' : '') + '> ' + gettext("Enable first layer inspection") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_use_ams" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.use_ams()) ? ' checked' : '') + '> ' + gettext("Use AMS") + '</label>\n' +
' </div>\n' +
' </div>\n';
}
showConfirmationDialog({
title: "Bambu Print Options",
html: confirmation_html,
cancel: gettext("Cancel"),
proceed: [gettext("Print"), gettext("Always")],
onproceed: function (idx) {
if(idx === 1){
self.settingsViewModel.settings.plugins.bambu_printer.timelapse($('#bambu_printer_timelapse').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling($('#bambu_printer_bed_leveling').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.flow_cali($('#bambu_printer_flow_cali').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali($('#bambu_printer_vibration_cali').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect($('#bambu_printer_layer_inspect').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.use_ams($('#bambu_printer_use_ams').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options(true);
self.settingsViewModel.saveData();
}
// replace this with our own print command API call?
start_print_command();
},
nofade: true
});
self.onBeforePrintStart = function(start_print_command, data) {
self.ams_mapping(self.ams_mapping_computed());
self.start_print_command = start_print_command;
self.use_ams = self.settingsViewModel.settings.plugins.bambu_printer.use_ams();
// prevent starting locally stored files, once data is added to core OctoPrint this
// could be adjusted to include additional processing like get sliced file's
// spool assignments and colors from plate_#.json inside 3mf file.
if(data && data.origin !== "sdcard") {
return false;
};*/
}
$("#bambu_printer_print_options").modal('show');
return false;
};
self.toggle_spool_active = function(data) {
if(data.index() >= 0){
data.original_index = ko.observable(data.index());
data.index(-1);
} else {
data.index(data.original_index());
}
};
self.cancel_print_options = function() {
self.settingsViewModel.settings.plugins.bambu_printer.use_ams(self.use_ams);
$("#bambu_printer_print_options").modal('hide');
};
self.accept_print_options = function() {
console.log("starting print!!!!");
console.log(self.ams_mapping());
$("#bambu_printer_print_options").modal('hide');
var flattened_ams_mapping = ko.utils.arrayMap(self.ams_mapping(), function(item) {
return item.index();
});
self.settingsViewModel.settings.plugins.bambu_printer.ams_mapping(flattened_ams_mapping);
self.settingsViewModel.saveData(undefined, self.start_print_command);
// self.settingsViewModel.saveData();
};
}
OCTOPRINT_VIEWMODELS.push({
construct: Bambu_printerViewModel,
// ViewModels your plugin depends on, e.g. loginStateViewModel, settingsViewModel, ...
dependencies: ["settingsViewModel", "filesViewModel", "loginStateViewModel", "accessViewModel", "timelapseViewModel"],
// Elements to bind to, e.g. #settings_plugin_bambu_printer, #tab_plugin_bambu_printer, ...
elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse"]
elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse", "#sidebar_plugin_bambu_printer"]
});
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,490 @@
// knockout-sortable 1.2.0 | (c) 2019 Ryan Niemeyer | http://www.opensource.org/licenses/mit-license
;(function(factory) {
if (typeof define === "function" && define.amd) {
// AMD anonymous module
define(["knockout", "jquery", "jquery-ui/ui/widgets/sortable", "jquery-ui/ui/widgets/draggable", "jquery-ui/ui/widgets/droppable"], factory);
} else if (typeof require === "function" && typeof exports === "object" && typeof module === "object") {
// CommonJS module
var ko = require("knockout"),
jQuery = require("jquery");
require("jquery-ui/ui/widgets/sortable");
require("jquery-ui/ui/widgets/draggable");
require("jquery-ui/ui/widgets/droppable");
factory(ko, jQuery);
} else {
// No module loader (plain <script> tag) - put directly in global namespace
factory(window.ko, window.jQuery);
}
})(function(ko, $) {
var ITEMKEY = "ko_sortItem",
INDEXKEY = "ko_sourceIndex",
LISTKEY = "ko_sortList",
PARENTKEY = "ko_parentList",
DRAGKEY = "ko_dragItem",
unwrap = ko.utils.unwrapObservable,
dataGet = ko.utils.domData.get,
dataSet = ko.utils.domData.set,
version = $.ui && $.ui.version,
//1.8.24 included a fix for how events were triggered in nested sortables. indexOf checks will fail if version starts with that value (0 vs. -1)
hasNestedSortableFix = version && version.indexOf("1.6.") && version.indexOf("1.7.") && (version.indexOf("1.8.") || version === "1.8.24");
//internal afterRender that adds meta-data to children
var addMetaDataAfterRender = function(elements, data) {
ko.utils.arrayForEach(elements, function(element) {
if (element.nodeType === 1) {
dataSet(element, ITEMKEY, data);
dataSet(element, PARENTKEY, dataGet(element.parentNode, LISTKEY));
}
});
};
//prepare the proper options for the template binding
var prepareTemplateOptions = function(valueAccessor, dataName) {
var result = {},
options = {},
actualAfterRender;
//build our options to pass to the template engine
if (ko.utils.peekObservable(valueAccessor()).data) {
options = unwrap(valueAccessor() || {});
result[dataName] = options.data;
if (options.hasOwnProperty("template")) {
result.name = options.template;
}
} else {
result[dataName] = valueAccessor();
}
ko.utils.arrayForEach(["afterAdd", "afterRender", "as", "beforeRemove", "includeDestroyed", "templateEngine", "templateOptions", "nodes"], function (option) {
if (options.hasOwnProperty(option)) {
result[option] = options[option];
} else if (ko.bindingHandlers.sortable.hasOwnProperty(option)) {
result[option] = ko.bindingHandlers.sortable[option];
}
});
//use an afterRender function to add meta-data
if (dataName === "foreach") {
if (result.afterRender) {
//wrap the existing function, if it was passed
actualAfterRender = result.afterRender;
result.afterRender = function(element, data) {
addMetaDataAfterRender.call(data, element, data);
actualAfterRender.call(data, element, data);
};
} else {
result.afterRender = addMetaDataAfterRender;
}
}
//return options to pass to the template binding
return result;
};
var updateIndexFromDestroyedItems = function(index, items) {
var unwrapped = unwrap(items);
if (unwrapped) {
for (var i = 0; i <= index; i++) {
//add one for every destroyed item we find before the targetIndex in the target array
if (unwrapped[i] && unwrap(unwrapped[i]._destroy)) {
index++;
}
}
}
return index;
};
//remove problematic leading/trailing whitespace from templates
var stripTemplateWhitespace = function(element, name) {
var templateSource,
templateElement;
//process named templates
if (name) {
templateElement = document.getElementById(name);
if (templateElement) {
templateSource = new ko.templateSources.domElement(templateElement);
templateSource.text($.trim(templateSource.text()));
}
}
else {
//remove leading/trailing non-elements from anonymous templates
$(element).contents().each(function() {
if (this && this.nodeType !== 1) {
element.removeChild(this);
}
});
}
};
//connect items with observableArrays
ko.bindingHandlers.sortable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var $element = $(element),
value = unwrap(valueAccessor()) || {},
templateOptions = prepareTemplateOptions(valueAccessor, "foreach"),
sortable = {},
startActual, updateActual;
stripTemplateWhitespace(element, templateOptions.name);
//build a new object that has the global options with overrides from the binding
$.extend(true, sortable, ko.bindingHandlers.sortable);
if (value.options && sortable.options) {
ko.utils.extend(sortable.options, value.options);
delete value.options;
}
ko.utils.extend(sortable, value);
//if allowDrop is an observable or a function, then execute it in a computed observable
if (sortable.connectClass && (ko.isObservable(sortable.allowDrop) || typeof sortable.allowDrop == "function")) {
ko.computed({
read: function() {
var value = unwrap(sortable.allowDrop),
shouldAdd = typeof value == "function" ? value.call(this, templateOptions.foreach) : value;
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, shouldAdd);
},
disposeWhenNodeIsRemoved: element
}, this);
} else {
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, sortable.allowDrop);
}
//wrap the template binding
ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
//keep a reference to start/update functions that might have been passed in
startActual = sortable.options.start;
updateActual = sortable.options.update;
//ensure draggable table row cells maintain their width while dragging (unless a helper is provided)
if ( !sortable.options.helper ) {
sortable.options.helper = function(e, ui) {
if (ui.is("tr")) {
ui.children().each(function() {
$(this).width($(this).width());
});
}
return ui;
};
}
//initialize sortable binding after template binding has rendered in update function
var createTimeout = setTimeout(function() {
var dragItem;
var originalReceive = sortable.options.receive;
$element.sortable(ko.utils.extend(sortable.options, {
start: function(event, ui) {
//track original index
var el = ui.item[0];
dataSet(el, INDEXKEY, ko.utils.arrayIndexOf(ui.item.parent().children(), el));
//make sure that fields have a chance to update model
ui.item.find("input:focus").change();
if (startActual) {
startActual.apply(this, arguments);
}
},
receive: function(event, ui) {
//optionally apply an existing receive handler
if (typeof originalReceive === "function") {
originalReceive.call(this, event, ui);
}
dragItem = dataGet(ui.item[0], DRAGKEY);
if (dragItem) {
//copy the model item, if a clone option is provided
if (dragItem.clone) {
dragItem = dragItem.clone();
}
//configure a handler to potentially manipulate item before drop
if (sortable.dragged) {
dragItem = sortable.dragged.call(this, dragItem, event, ui) || dragItem;
}
}
},
update: function(event, ui) {
var sourceParent, targetParent, sourceIndex, targetIndex, arg,
el = ui.item[0],
parentEl = ui.item.parent()[0],
item = dataGet(el, ITEMKEY) || dragItem;
if (!item) {
$(el).remove();
}
dragItem = null;
//make sure that moves only run once, as update fires on multiple containers
if (item && (this === parentEl) || (!hasNestedSortableFix && $.contains(this, parentEl))) {
//identify parents
sourceParent = dataGet(el, PARENTKEY);
sourceIndex = dataGet(el, INDEXKEY);
targetParent = dataGet(el.parentNode, LISTKEY);
targetIndex = ko.utils.arrayIndexOf(ui.item.parent().children(), el);
//take destroyed items into consideration
if (!templateOptions.includeDestroyed) {
sourceIndex = updateIndexFromDestroyedItems(sourceIndex, sourceParent);
targetIndex = updateIndexFromDestroyedItems(targetIndex, targetParent);
}
//build up args for the callbacks
if (sortable.beforeMove || sortable.afterMove) {
arg = {
item: item,
sourceParent: sourceParent,
sourceParentNode: sourceParent && ui.sender || el.parentNode,
sourceIndex: sourceIndex,
targetParent: targetParent,
targetIndex: targetIndex,
cancelDrop: false
};
//execute the configured callback prior to actually moving items
if (sortable.beforeMove) {
sortable.beforeMove.call(this, arg, event, ui);
}
}
//call cancel on the correct list, so KO can take care of DOM manipulation
if (sourceParent) {
$(sourceParent === targetParent ? this : ui.sender || this).sortable("cancel");
}
//for a draggable item just remove the element
else {
$(el).remove();
}
//if beforeMove told us to cancel, then we are done
if (arg && arg.cancelDrop) {
return;
}
//if the strategy option is unset or false, employ the order strategy involving removal and insertion of items
if (!sortable.hasOwnProperty("strategyMove") || sortable.strategyMove === false) {
//do the actual move
if (targetIndex >= 0) {
if (sourceParent) {
sourceParent.splice(sourceIndex, 1);
//if using deferred updates plugin, force updates
if (ko.processAllDeferredBindingUpdates) {
ko.processAllDeferredBindingUpdates();
}
//if using deferred updates on knockout 3.4, force updates
if (ko.options && ko.options.deferUpdates) {
ko.tasks.runEarly();
}
}
targetParent.splice(targetIndex, 0, item);
}
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
}
else { //employ the strategy of moving items
if (targetIndex >= 0) {
if (sourceParent) {
if (sourceParent !== targetParent) {
// moving from one list to another
sourceParent.splice(sourceIndex, 1);
targetParent.splice(targetIndex, 0, item);
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
ui.item.remove();
}
else {
// moving within same list
var underlyingList = unwrap(sourceParent);
// notify 'beforeChange' subscribers
if (sourceParent.valueWillMutate) {
sourceParent.valueWillMutate();
}
// move from source index ...
underlyingList.splice(sourceIndex, 1);
// ... to target index
underlyingList.splice(targetIndex, 0, item);
// notify subscribers
if (sourceParent.valueHasMutated) {
sourceParent.valueHasMutated();
}
}
}
else {
// drop new element from outside
targetParent.splice(targetIndex, 0, item);
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
ui.item.remove();
}
}
}
//if using deferred updates plugin, force updates
if (ko.processAllDeferredBindingUpdates) {
ko.processAllDeferredBindingUpdates();
}
//allow binding to accept a function to execute after moving the item
if (sortable.afterMove) {
sortable.afterMove.call(this, arg, event, ui);
}
}
if (updateActual) {
updateActual.apply(this, arguments);
}
},
connectWith: sortable.connectClass ? "." + sortable.connectClass : false
}));
//handle enabling/disabling sorting
if (sortable.isEnabled !== undefined) {
ko.computed({
read: function() {
$element.sortable(unwrap(sortable.isEnabled) ? "enable" : "disable");
},
disposeWhenNodeIsRemoved: element
});
}
}, 0);
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
//only call destroy if sortable has been created
if ($element.data("ui-sortable") || $element.data("sortable")) {
$element.sortable("destroy");
}
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, false);
//do not create the sortable if the element has been removed from DOM
clearTimeout(createTimeout);
});
return { 'controlsDescendantBindings': true };
},
update: function(element, valueAccessor, allBindingsAccessor, data, context) {
var templateOptions = prepareTemplateOptions(valueAccessor, "foreach");
//attach meta-data
dataSet(element, LISTKEY, templateOptions.foreach);
//call template binding's update with correct options
ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
connectClass: 'ko_container',
allowDrop: true,
afterMove: null,
beforeMove: null,
options: {}
};
//create a draggable that is appropriate for dropping into a sortable
ko.bindingHandlers.draggable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var value = unwrap(valueAccessor()) || {},
options = value.options || {},
draggableOptions = ko.utils.extend({}, ko.bindingHandlers.draggable.options),
templateOptions = prepareTemplateOptions(valueAccessor, "data"),
connectClass = value.connectClass || ko.bindingHandlers.draggable.connectClass,
isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.draggable.isEnabled;
value = "data" in value ? value.data : value;
//set meta-data
dataSet(element, DRAGKEY, value);
//override global options with override options passed in
ko.utils.extend(draggableOptions, options);
//setup connection to a sortable
draggableOptions.connectToSortable = connectClass ? "." + connectClass : false;
//initialize draggable
$(element).draggable(draggableOptions);
//handle enabling/disabling sorting
if (isEnabled !== undefined) {
ko.computed({
read: function() {
$(element).draggable(unwrap(isEnabled) ? "enable" : "disable");
},
disposeWhenNodeIsRemoved: element
});
}
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
$(element).draggable("destroy");
});
return ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
update: function(element, valueAccessor, allBindingsAccessor, data, context) {
var templateOptions = prepareTemplateOptions(valueAccessor, "data");
return ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
connectClass: ko.bindingHandlers.sortable.connectClass,
options: {
helper: "clone"
}
};
// Simple Droppable Implementation
// binding that updates (function or observable)
ko.bindingHandlers.droppable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var value = unwrap(valueAccessor()) || {},
options = value.options || {},
droppableOptions = ko.utils.extend({}, ko.bindingHandlers.droppable.options),
isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.droppable.isEnabled;
//override global options with override options passed in
ko.utils.extend(droppableOptions, options);
//get reference to drop method
value = "data" in value ? value.data : valueAccessor();
//set drop method
droppableOptions.drop = function(event, ui) {
var droppedItem = dataGet(ui.draggable[0], DRAGKEY) || dataGet(ui.draggable[0], ITEMKEY);
value(droppedItem);
};
//initialize droppable
$(element).droppable(droppableOptions);
//handle enabling/disabling droppable
if (isEnabled !== undefined) {
ko.computed({
read: function() {
$(element).droppable(unwrap(isEnabled) ? "enable": "disable");
},
disposeWhenNodeIsRemoved: element
});
}
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
$(element).droppable("destroy");
});
},
options: {
accept: "*"
}
};
});

View File

@ -0,0 +1,31 @@
<div id="bambu_printer_print_options" class="modal hide fade">
<div class="modal-header">
<h3>{{ _('Bambu Print Options') }}</h3>
</div>
<div class="modal-body">
<div class="row-fluid">
<div class="span6">
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.timelapse"> {{ _('Enable timelapse') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.bed_leveling"> {{ _('Enable bed leveling') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.flow_cali"> {{ _('Enable flow calibration') }}</label>
</div>
<div class="span6">
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.vibration_cali"> {{ _('Enable vibration calibration') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.layer_inspect"> {{ _('Enable first layer inspection') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.use_ams"> {{ _('Use AMS') }}</label>
</div>
</div>
<div class="row-fluid" data-bind="visible: settingsViewModel.settings.plugins.bambu_printer.use_ams">
{{ _('Filament Assighnment') }}: {{ _('Click') }} <a href="#">{{ _('here') }}</a> {{ _('for usage details.') }}
</div>
<div class="row-fluid" data-bind="visible: settingsViewModel.settings.plugins.bambu_printer.use_ams, sortable: {data: ams_mapping, options: {cancel: '.unsortable'}}">
<div class="btn" data-bind="attr: {title: name}, event: {dblclick: $root.toggle_spool_active}, css: {disabled: (index()<0)}">
<i class="fa fa-2x fa-dot-circle" data-bind="css: {'fas': !empty(), 'far': empty()}, style: {'color': ('#'+color())}"></i>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" data-bind="click: cancel_print_options">{{ _('Cancel') }}</button>
<button class="btn btn-primary" data-bind="click: accept_print_options">{{ _('Print') }}</button>
</div>
</div>

View File

@ -40,15 +40,24 @@
<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()">
<label class="control-label">{{ _('Email') }}</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.email" title="{{ _('Registered email address') }}"></input>
<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.email" title="{{ _('Registered email address') }}" autocomplete="off"></input>
</div>
</div>
<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()">
<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt() && show_password()">
<label class="control-label">{{ _('Password') }}</label>
<div class="controls">
<div class="input-block-level input-append" data-bind="css: {'input-append': !show_verification()}">
<input id="bambu_cloud_password" type="password" class="input-text input-block-level" title="{{ _('Password to generate verification code') }}" autocomplete="new-password"></input>
<span class="btn btn-primary add-on" data-bind="visible: !show_verification(), click: getAuthToken">{{ _('Login') }}</span>
</div>
</div>
</div>
<div class="control-group" data-bind="visible: show_verification()">
<label class="control-label">{{ _('Verify') }}</label>
<div class="controls">
<div class="input-block-level input-append">
<input id="bambu_cloud_password" type="password" class="input-text input-block-level" title="{{ _('Password to generate Auth Token') }}"></input>
<span class="btn btn-primary add-on" data-bind="click: getAuthToken">{{ _('Login') }}</span>
<input id="bambu_cloud_verify_code" type="password" class="input-text input-block-level" title="{{ _('Verification code to generate auth token') }}"></input>
<span class="btn btn-primary add-on" data-bind="click: verifyCode">{{ _('Verify') }}</span>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
<div class="row-fluid" data-bind="foreach: {data: settingsViewModel.settings.plugins.bambu_printer.ams_data, as: 'ams'}">
<!-- ko if: $data -->
<div class="well" data-bind="foreach: tray">
<div class="span3 text-center" data-bind="attr: {title: name}">
<div class="row-fluid" data-bind="css: {'active': ($root.settingsViewModel.settings.plugins.bambu_printer.ams_current_tray() == (($parentContext.$index() * 4) + $index()))}">
<i class="fa fa-3x fa-dot-circle" data-bind="css: {'fas': !empty(), 'far': empty()}, style: {'color': ('#'+color())}"></i><br>
<div class="text-center" data-bind="text: type"></div>
</div>
</div>
</div>
<!-- /ko -->
</div>
<div class="row-fluid" data-bind="visible: job_info">
<div class="span6">{{ _('Layer') }}:</div><div class="span6" data-bind="text: function(){return (job_info.current_layer() + ' of ' + job_info.total_layers);}"></div>
</div>

View File

@ -14,4 +14,3 @@ OctoPrint~=1.10.2
setuptools~=70.0.0
pyserial~=3.5
Flask~=2.2.5
paho-mqtt~=2.1.0

View File

@ -14,26 +14,26 @@ plugin_package = "octoprint_bambu_printer"
plugin_name = "OctoPrint-BambuPrinter"
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
plugin_version = "1.0.0"
plugin_version = "0.1.8rc12"
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
# module
plugin_description = """Connects OctoPrint to BambuLabs printers."""
# The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module
plugin_author = "ManuelW"
plugin_author = "jneilliii"
# The plugin's author's mail address.
plugin_author_email = "manuelw@example.com"
plugin_author_email = "jneilliii+github@gmail.com"
# The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module
plugin_url = "https://gitlab.fire-devils.org/3D-Druck/OctoPrint-BambuPrinter"
plugin_url = "https://github.com/jneilliii/OctoPrint-BambuPrinter"
# The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module
plugin_license = "AGPLv3"
# Any additional requirements besides OctoPrint should be listed here
plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"]
plugin_requires = ["paho-mqtt<2", "python-dateutil", "cloudscraper"]
### --------------------------------------------------------------------------------------------------------------------
### More advanced options that you usually shouldn't have to touch follow after this point
@ -43,7 +43,7 @@ plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"]
# already be installed automatically if they exist. Note that if you add something here you'll also need to update
# MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your
# files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598
plugin_additional_data = []
plugin_additional_data = ["octoprint_bambu_printer/printer/pybambu/filaments.json"]
# Any additional python packages you need to install with your plugin that are not contained in <plugin_package>.*
plugin_additional_packages = []

View File

@ -1,17 +0,0 @@
<div class="control-group">
<label class="control-label">{{ _('Connection Options') }}</label>
<div class="controls">
<label class="checkbox">
<input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.use_mqtt_bridge"> {{ _('Use MQTT Bridge') }}
<span class="help-block">
{{ _('Connect via a MQTT broker that bridges communications from the printer. Useful for connecting to a printer on a different network.') }}
</span>
</label>
<label class="checkbox">
<input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.disable_camera"> {{ _('Disable Camera Functionality') }}
<span class="help-block">
{{ _('Disable camera streaming and image capture to avoid connection errors. Enable this if you see frequent connection refused errors in the logs.') }}
</span>
</label>
</div>
</div>

View File

@ -7,8 +7,8 @@ from typing import Any
from unittest.mock import MagicMock, patch
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
import pybambu
import pybambu.commands
import octoprint_bambu_printer.printer.pybambu
import octoprint_bambu_printer.printer.pybambu.commands
from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient