599 lines
21 KiB
Python
Raw Normal View History

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:
2024-12-07 00:17:57 -05:00
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)
2024-12-07 00:17:57 -05:00
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
2024-12-07 00:17:57 -05:00
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")
2024-12-07 00:17:57 -05:00
# raise EmailCodeRequiredError()
return loginType
elif loginType == 'tfa':
# Store the tfaKey for later use
LOGGER.debug(f"Received tfa response")
self._tfaKey = auth_json.get("tfaKey")
2024-12-07 00:17:57 -05:00
# raise TfaCodeRequiredError()
return loginType
else:
LOGGER.debug(f"Did not understand json. loginType = '{loginType}'")
LOGGER.error(f"Response not understood: '{response.text}'")
return ValueError(1) # FIXME
2024-12-07 00:17:57 -05:00
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:
2024-12-07 00:17:57 -05:00
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']
2024-12-07 00:17:57 -05:00
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
2024-12-07 00:17:57 -05:00
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
2024-12-07 00:17:57 -05:00
# 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
# }
# ]
# }
2024-12-07 00:17:57 -05:00
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()
2024-12-07 00:17:57 -05:00
if result is None:
LOGGER.error("Unable to authenticate.")
return None
elif len(result) < 20:
return result
else:
self._auth_token = result
self._username = self._get_username_from_authentication_token()
return 'success'
# 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()
2024-12-07 00:17:57 -05:00
if self._auth_token != "" and self._username != "" and self._auth_token != None and self._username != None:
return "success"
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()
2024-12-07 00:17:57 -05:00
if self._auth_token != "" and self._username != "" and self._auth_token != None and self._username != None:
return "success"
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()
2024-12-07 00:17:57 -05:00
# 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
2024-12-07 00:17:57 -05:00
@property
def auth_token(self):
return self._auth_token
2024-12-07 00:17:57 -05:00
@property
def bambu_connected(self) -> bool:
return self._auth_token != "" and self._auth_token != None
2024-12-07 00:17:57 -05:00
@property
def cloud_mqtt_host(self):
return "cn.mqtt.bambulab.com" if self._region == "China" else "us.mqtt.bambulab.com"