mirror of
https://git.mirrors.martin98.com/https://github.com/Ultimaker/Cura
synced 2025-06-29 17:45:08 +08:00
Merge remote-tracking branch 'origin/4.0' into CL-1165_missing_cloud_info
This commit is contained in:
commit
495115c9f7
1
.gitignore
vendored
1
.gitignore
vendored
@ -42,7 +42,6 @@ plugins/cura-siemensnx-plugin
|
||||
plugins/CuraBlenderPlugin
|
||||
plugins/CuraCloudPlugin
|
||||
plugins/CuraDrivePlugin
|
||||
plugins/CuraDrive
|
||||
plugins/CuraLiveScriptingPlugin
|
||||
plugins/CuraOpenSCADPlugin
|
||||
plugins/CuraPrintProfileCreator
|
||||
|
@ -6,6 +6,7 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Message import Message
|
||||
from cura import UltimakerCloudAuthentication
|
||||
|
||||
from cura.OAuth2.AuthorizationService import AuthorizationService
|
||||
from cura.OAuth2.Models import OAuth2Settings
|
||||
@ -37,15 +38,16 @@ class Account(QObject):
|
||||
self._logged_in = False
|
||||
|
||||
self._callback_port = 32118
|
||||
self._oauth_root = "https://account.ultimaker.com"
|
||||
self._cloud_api_root = "https://api.ultimaker.com"
|
||||
self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
|
||||
|
||||
self._oauth_settings = OAuth2Settings(
|
||||
OAUTH_SERVER_URL= self._oauth_root,
|
||||
CALLBACK_PORT=self._callback_port,
|
||||
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
||||
CLIENT_ID="um----------------------------ultimaker_cura",
|
||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download packages.rating.read packages.rating.write",
|
||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
|
||||
"cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write",
|
||||
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
||||
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
||||
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
||||
@ -60,6 +62,11 @@ class Account(QObject):
|
||||
self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged)
|
||||
self._authorization_service.loadAuthDataFromPreferences()
|
||||
|
||||
## Returns a boolean indicating whether the given authentication is applied against staging or not.
|
||||
@property
|
||||
def is_staging(self) -> bool:
|
||||
return "staging" in self._oauth_root
|
||||
|
||||
@pyqtProperty(bool, notify=loginStateChanged)
|
||||
def isLoggedIn(self) -> bool:
|
||||
return self._logged_in
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Tuple, Optional, TYPE_CHECKING
|
||||
from typing import Tuple, Optional, TYPE_CHECKING, Dict, Any
|
||||
|
||||
from cura.Backups.BackupsManager import BackupsManager
|
||||
|
||||
@ -24,12 +24,12 @@ class Backups:
|
||||
## Create a new back-up using the BackupsManager.
|
||||
# \return Tuple containing a ZIP file with the back-up data and a dict
|
||||
# with metadata about the back-up.
|
||||
def createBackup(self) -> Tuple[Optional[bytes], Optional[dict]]:
|
||||
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]:
|
||||
return self.manager.createBackup()
|
||||
|
||||
## Restore a back-up using the BackupsManager.
|
||||
# \param zip_file A ZIP file containing the actual back-up data.
|
||||
# \param meta_data Some metadata needed for restoring a back-up, like the
|
||||
# Cura version number.
|
||||
def restoreBackup(self, zip_file: bytes, meta_data: dict) -> None:
|
||||
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any]) -> None:
|
||||
return self.manager.restoreBackup(zip_file, meta_data)
|
||||
|
36
cura/ApplicationMetadata.py
Normal file
36
cura/ApplicationMetadata.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
# ---------
|
||||
# Genearl constants used in Cura
|
||||
# ---------
|
||||
DEFAULT_CURA_DISPLAY_NAME = "Ultimaker Cura"
|
||||
DEFAULT_CURA_VERSION = "master"
|
||||
DEFAULT_CURA_BUILD_TYPE = ""
|
||||
DEFAULT_CURA_DEBUG_MODE = False
|
||||
DEFAULT_CURA_SDK_VERSION = "6.0.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppDisplayName # type: ignore
|
||||
except ImportError:
|
||||
CuraAppDisplayName = DEFAULT_CURA_DISPLAY_NAME
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraVersion # type: ignore
|
||||
except ImportError:
|
||||
CuraVersion = DEFAULT_CURA_VERSION # [CodeStyle: Reflecting imported value]
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraBuildType # type: ignore
|
||||
except ImportError:
|
||||
CuraBuildType = DEFAULT_CURA_BUILD_TYPE
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraDebugMode # type: ignore
|
||||
except ImportError:
|
||||
CuraDebugMode = DEFAULT_CURA_DEBUG_MODE
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraSDKVersion # type: ignore
|
||||
except ImportError:
|
||||
CuraSDKVersion = DEFAULT_CURA_SDK_VERSION
|
@ -117,6 +117,8 @@ from cura.ObjectsModel import ObjectsModel
|
||||
from cura.PrinterOutputDevice import PrinterOutputDevice
|
||||
from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage
|
||||
|
||||
from cura import ApplicationMetadata, UltimakerCloudAuthentication
|
||||
|
||||
from UM.FlameProfiler import pyqtSlot
|
||||
from UM.Decorators import override
|
||||
|
||||
@ -129,21 +131,12 @@ if TYPE_CHECKING:
|
||||
|
||||
numpy.seterr(all = "ignore")
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppDisplayName, CuraVersion, CuraBuildType, CuraDebugMode, CuraSDKVersion # type: ignore
|
||||
except ImportError:
|
||||
CuraAppDisplayName = "Ultimaker Cura"
|
||||
CuraVersion = "master" # [CodeStyle: Reflecting imported value]
|
||||
CuraBuildType = ""
|
||||
CuraDebugMode = False
|
||||
CuraSDKVersion = "6.0.0"
|
||||
|
||||
|
||||
class CuraApplication(QtApplication):
|
||||
# SettingVersion represents the set of settings available in the machine/extruder definitions.
|
||||
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
|
||||
# changes of the settings.
|
||||
SettingVersion = 5
|
||||
SettingVersion = 6
|
||||
|
||||
Created = False
|
||||
|
||||
@ -164,11 +157,11 @@ class CuraApplication(QtApplication):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(name = "cura",
|
||||
app_display_name = CuraAppDisplayName,
|
||||
version = CuraVersion,
|
||||
api_version = CuraSDKVersion,
|
||||
buildtype = CuraBuildType,
|
||||
is_debug_mode = CuraDebugMode,
|
||||
app_display_name = ApplicationMetadata.CuraAppDisplayName,
|
||||
version = ApplicationMetadata.CuraVersion,
|
||||
api_version = ApplicationMetadata.CuraSDKVersion,
|
||||
buildtype = ApplicationMetadata.CuraBuildType,
|
||||
is_debug_mode = ApplicationMetadata.CuraDebugMode,
|
||||
tray_icon_name = "cura-icon-32.png",
|
||||
**kwargs)
|
||||
|
||||
@ -263,6 +256,14 @@ class CuraApplication(QtApplication):
|
||||
from cura.CuraPackageManager import CuraPackageManager
|
||||
self._package_manager_class = CuraPackageManager
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def ultimakerCloudApiRootUrl(self) -> str:
|
||||
return UltimakerCloudAuthentication.CuraCloudAPIRoot
|
||||
|
||||
@pyqtProperty(str, constant = True)
|
||||
def ultimakerCloudAccountRootUrl(self) -> str:
|
||||
return UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
|
||||
|
||||
# Adds command line options to the command line parser. This should be called after the application is created and
|
||||
# before the pre-start.
|
||||
def addCommandLineOptions(self):
|
||||
@ -954,7 +955,7 @@ class CuraApplication(QtApplication):
|
||||
engine.rootContext().setContextProperty("CuraApplication", self)
|
||||
engine.rootContext().setContextProperty("PrintInformation", self._print_information)
|
||||
engine.rootContext().setContextProperty("CuraActions", self._cura_actions)
|
||||
engine.rootContext().setContextProperty("CuraSDKVersion", CuraSDKVersion)
|
||||
engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion)
|
||||
|
||||
qmlRegisterUncreatableType(CuraApplication, "Cura", 1, 0, "ResourceTypes", "Just an Enum type")
|
||||
|
||||
|
@ -8,3 +8,4 @@ CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False
|
||||
CuraSDKVersion = "@CURA_SDK_VERSION@"
|
||||
CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@"
|
||||
CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@"
|
||||
CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@"
|
||||
|
@ -60,4 +60,4 @@ class GlobalStacksModel(ListModel):
|
||||
"connectionType": connection_type,
|
||||
"metadata": container_stack.getMetaData().copy()})
|
||||
items.sort(key=lambda i: not i["hasRemoteConnection"])
|
||||
self.setItems(items)
|
||||
self.setItems(items)
|
||||
|
@ -25,7 +25,7 @@ class MultiplyObjectsJob(Job):
|
||||
|
||||
def run(self):
|
||||
status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime=0,
|
||||
dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Object"))
|
||||
dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Objects"))
|
||||
status_message.show()
|
||||
scene = Application.getInstance().getController().getScene()
|
||||
|
||||
|
@ -52,8 +52,11 @@ class AuthorizationService:
|
||||
if not self._user_profile:
|
||||
# If no user profile was stored locally, we try to get it from JWT.
|
||||
self._user_profile = self._parseJWT()
|
||||
if not self._user_profile:
|
||||
|
||||
if not self._user_profile and self._auth_data:
|
||||
# If there is still no user profile from the JWT, we have to log in again.
|
||||
Logger.log("w", "The user profile could not be loaded. The user must log in again!")
|
||||
self.deleteAuthData()
|
||||
return None
|
||||
|
||||
return self._user_profile
|
||||
|
@ -54,7 +54,7 @@ class ConfigurationModel(QObject):
|
||||
for configuration in self._extruder_configurations:
|
||||
if configuration is None:
|
||||
return False
|
||||
return self._printer_type is not None
|
||||
return self._printer_type != ""
|
||||
|
||||
def __str__(self):
|
||||
message_chunks = []
|
||||
|
@ -4,6 +4,7 @@
|
||||
from UM.FileHandler.FileHandler import FileHandler #For typing.
|
||||
from UM.Logger import Logger
|
||||
from UM.Scene.SceneNode import SceneNode #For typing.
|
||||
from cura.API import Account
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState, ConnectionType
|
||||
@ -11,12 +12,13 @@ from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState, Conne
|
||||
from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication
|
||||
from time import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Callable, Dict, List, Optional, Union
|
||||
from enum import IntEnum
|
||||
|
||||
import os # To get the username
|
||||
import gzip
|
||||
|
||||
|
||||
class AuthState(IntEnum):
|
||||
NotAuthenticated = 1
|
||||
AuthenticationRequested = 2
|
||||
@ -41,7 +43,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
self._api_prefix = ""
|
||||
self._address = address
|
||||
self._properties = properties
|
||||
self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(), CuraApplication.getInstance().getVersion())
|
||||
self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(),
|
||||
CuraApplication.getInstance().getVersion())
|
||||
|
||||
self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]]
|
||||
self._authentication_state = AuthState.NotAuthenticated
|
||||
@ -55,7 +58,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
self._gcode = [] # type: List[str]
|
||||
self._connection_state_before_timeout = None # type: Optional[ConnectionState]
|
||||
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
|
||||
raise NotImplementedError("requestWrite needs to be implemented")
|
||||
|
||||
def setAuthenticationState(self, authentication_state: AuthState) -> None:
|
||||
@ -143,10 +147,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
url = QUrl("http://" + self._address + self._api_prefix + target)
|
||||
request = QNetworkRequest(url)
|
||||
if content_type is not None:
|
||||
request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
|
||||
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
|
||||
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
|
||||
return request
|
||||
|
||||
## This method was only available privately before, but it was actually called from SendMaterialJob.py.
|
||||
# We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
|
||||
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
|
||||
return self._createFormPart(content_header, data, content_type)
|
||||
|
||||
@ -163,9 +169,15 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
part.setBody(data)
|
||||
return part
|
||||
|
||||
## Convenience function to get the username from the OS.
|
||||
# The code was copied from the getpass module, as we try to use as little dependencies as possible.
|
||||
## Convenience function to get the username, either from the cloud or from the OS.
|
||||
def _getUserName(self) -> str:
|
||||
# check first if we are logged in with the Ultimaker Account
|
||||
account = CuraApplication.getInstance().getCuraAPI().account # type: Account
|
||||
if account and account.isLoggedIn:
|
||||
return account.userName
|
||||
|
||||
# Otherwise get the username from the US
|
||||
# The code below was copied from the getpass module, as we try to use as little dependencies as possible.
|
||||
for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
|
||||
user = os.environ.get(name)
|
||||
if user:
|
||||
@ -181,49 +193,89 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
self._createNetworkManager()
|
||||
assert (self._manager is not None)
|
||||
|
||||
def put(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
## Sends a put request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param data: The data to be sent in the body
|
||||
# \param content_type: The content type of the body data.
|
||||
# \param on_finished: The function to call when the response is received.
|
||||
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
|
||||
def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None,
|
||||
on_finished: Optional[Callable[[QNetworkReply], None]] = None,
|
||||
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
|
||||
self._validateManager()
|
||||
request = self._createEmptyRequest(target)
|
||||
self._last_request_time = time()
|
||||
if self._manager is not None:
|
||||
reply = self._manager.put(request, data.encode())
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
else:
|
||||
Logger.log("e", "Could not find manager.")
|
||||
|
||||
def delete(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
request = self._createEmptyRequest(url, content_type = content_type)
|
||||
self._last_request_time = time()
|
||||
|
||||
if not self._manager:
|
||||
Logger.log("e", "No network manager was created to execute the PUT call with.")
|
||||
return
|
||||
|
||||
body = data if isinstance(data, bytes) else data.encode() # type: bytes
|
||||
reply = self._manager.put(request, body)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
|
||||
if on_progress is not None:
|
||||
reply.uploadProgress.connect(on_progress)
|
||||
|
||||
## Sends a delete request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param on_finished: The function to be call when the response is received.
|
||||
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
self._validateManager()
|
||||
request = self._createEmptyRequest(target)
|
||||
self._last_request_time = time()
|
||||
if self._manager is not None:
|
||||
reply = self._manager.deleteResource(request)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
else:
|
||||
Logger.log("e", "Could not find manager.")
|
||||
|
||||
def get(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
request = self._createEmptyRequest(url)
|
||||
self._last_request_time = time()
|
||||
|
||||
if not self._manager:
|
||||
Logger.log("e", "No network manager was created to execute the DELETE call with.")
|
||||
return
|
||||
|
||||
reply = self._manager.deleteResource(request)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
|
||||
## Sends a get request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param on_finished: The function to be call when the response is received.
|
||||
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
self._validateManager()
|
||||
request = self._createEmptyRequest(target)
|
||||
self._last_request_time = time()
|
||||
if self._manager is not None:
|
||||
reply = self._manager.get(request)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
else:
|
||||
Logger.log("e", "Could not find manager.")
|
||||
|
||||
def post(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None:
|
||||
request = self._createEmptyRequest(url)
|
||||
self._last_request_time = time()
|
||||
|
||||
if not self._manager:
|
||||
Logger.log("e", "No network manager was created to execute the GET call with.")
|
||||
return
|
||||
|
||||
reply = self._manager.get(request)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
|
||||
## Sends a post request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param data: The data to be sent in the body
|
||||
# \param on_finished: The function to call when the response is received.
|
||||
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
|
||||
def post(self, url: str, data: Union[str, bytes],
|
||||
on_finished: Optional[Callable[[QNetworkReply], None]],
|
||||
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
|
||||
self._validateManager()
|
||||
request = self._createEmptyRequest(target)
|
||||
self._last_request_time = time()
|
||||
if self._manager is not None:
|
||||
reply = self._manager.post(request, data.encode())
|
||||
if on_progress is not None:
|
||||
reply.uploadProgress.connect(on_progress)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
else:
|
||||
Logger.log("e", "Could not find manager.")
|
||||
|
||||
def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply:
|
||||
request = self._createEmptyRequest(url)
|
||||
self._last_request_time = time()
|
||||
|
||||
if not self._manager:
|
||||
Logger.log("e", "Could not find manager.")
|
||||
return
|
||||
|
||||
body = data if isinstance(data, bytes) else data.encode() # type: bytes
|
||||
reply = self._manager.post(request, body)
|
||||
if on_progress is not None:
|
||||
reply.uploadProgress.connect(on_progress)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
|
||||
def postFormWithParts(self, target: str, parts: List[QHttpPart],
|
||||
on_finished: Optional[Callable[[QNetworkReply], None]],
|
||||
on_progress: Optional[Callable[[int, int], None]] = None) -> QNetworkReply:
|
||||
self._validateManager()
|
||||
request = self._createEmptyRequest(target, content_type=None)
|
||||
multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
|
||||
|
@ -132,9 +132,9 @@ class PrintJobOutputModel(QObject):
|
||||
|
||||
@pyqtProperty(float, notify = timeElapsedChanged)
|
||||
def progress(self) -> float:
|
||||
result = self.timeElapsed / self.timeTotal
|
||||
# Never get a progress past 1.0
|
||||
return min(result, 1.0)
|
||||
time_elapsed = max(float(self.timeElapsed), 1.0) # Prevent a division by zero exception
|
||||
result = time_elapsed / self.timeTotal
|
||||
return min(result, 1.0) # Never get a progress past 1.0
|
||||
|
||||
@pyqtProperty(str, notify=stateChanged)
|
||||
def state(self) -> str:
|
||||
|
@ -1,10 +1,12 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from enum import IntEnum
|
||||
from typing import Callable, List, Optional, Union
|
||||
|
||||
from UM.Decorators import deprecated
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.OutputDevice.OutputDevice import OutputDevice
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl, Q_ENUMS
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
from UM.Logger import Logger
|
||||
@ -12,9 +14,6 @@ from UM.Signal import signalemitter
|
||||
from UM.Qt.QtApplication import QtApplication
|
||||
from UM.FlameProfiler import pyqtSlot
|
||||
|
||||
from enum import IntEnum # For the connection state tracking.
|
||||
from typing import Callable, List, Optional, Union
|
||||
|
||||
MYPY = False
|
||||
if MYPY:
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
@ -54,10 +53,6 @@ class ConnectionType(IntEnum):
|
||||
@signalemitter
|
||||
class PrinterOutputDevice(QObject, OutputDevice):
|
||||
|
||||
# Put ConnectionType here with Q_ENUMS() so it can be registered as a QML type and accessible via QML, and there is
|
||||
# no need to remember what those Enum integer values mean.
|
||||
Q_ENUMS(ConnectionType)
|
||||
|
||||
printersChanged = pyqtSignal()
|
||||
connectionStateChanged = pyqtSignal(str)
|
||||
acceptsCommandsChanged = pyqtSignal()
|
||||
@ -80,28 +75,28 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
||||
self._printers = [] # type: List[PrinterOutputModel]
|
||||
self._unique_configurations = [] # type: List[ConfigurationModel]
|
||||
|
||||
self._monitor_view_qml_path = "" #type: str
|
||||
self._monitor_component = None #type: Optional[QObject]
|
||||
self._monitor_item = None #type: Optional[QObject]
|
||||
self._monitor_view_qml_path = "" # type: str
|
||||
self._monitor_component = None # type: Optional[QObject]
|
||||
self._monitor_item = None # type: Optional[QObject]
|
||||
|
||||
self._control_view_qml_path = "" #type: str
|
||||
self._control_component = None #type: Optional[QObject]
|
||||
self._control_item = None #type: Optional[QObject]
|
||||
self._control_view_qml_path = "" # type: str
|
||||
self._control_component = None # type: Optional[QObject]
|
||||
self._control_item = None # type: Optional[QObject]
|
||||
|
||||
self._accepts_commands = False #type: bool
|
||||
self._accepts_commands = False # type: bool
|
||||
|
||||
self._update_timer = QTimer() #type: QTimer
|
||||
self._update_timer = QTimer() # type: QTimer
|
||||
self._update_timer.setInterval(2000) # TODO; Add preference for update interval
|
||||
self._update_timer.setSingleShot(False)
|
||||
self._update_timer.timeout.connect(self._update)
|
||||
|
||||
self._connection_state = ConnectionState.Closed #type: ConnectionState
|
||||
self._connection_type = connection_type
|
||||
self._connection_state = ConnectionState.Closed # type: ConnectionState
|
||||
self._connection_type = connection_type # type: ConnectionType
|
||||
|
||||
self._firmware_updater = None #type: Optional[FirmwareUpdater]
|
||||
self._firmware_name = None #type: Optional[str]
|
||||
self._address = "" #type: str
|
||||
self._connection_text = "" #type: str
|
||||
self._firmware_updater = None # type: Optional[FirmwareUpdater]
|
||||
self._firmware_name = None # type: Optional[str]
|
||||
self._address = "" # type: str
|
||||
self._connection_text = "" # type: str
|
||||
self.printersChanged.connect(self._onPrintersChanged)
|
||||
QtApplication.getInstance().getOutputDeviceManager().outputDevicesChanged.connect(self._updateUniqueConfigurations)
|
||||
|
||||
@ -130,10 +125,11 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
||||
self._connection_state = connection_state
|
||||
self.connectionStateChanged.emit(self._id)
|
||||
|
||||
def getConnectionType(self) -> "ConnectionType":
|
||||
@pyqtProperty(int, constant = True)
|
||||
def connectionType(self) -> "ConnectionType":
|
||||
return self._connection_type
|
||||
|
||||
@pyqtProperty(str, notify = connectionStateChanged)
|
||||
@pyqtProperty(int, notify = connectionStateChanged)
|
||||
def connectionState(self) -> "ConnectionState":
|
||||
return self._connection_state
|
||||
|
||||
@ -147,7 +143,8 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
||||
|
||||
return None
|
||||
|
||||
def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional["FileHandler"] = None, **kwargs: str) -> None:
|
||||
def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional["FileHandler"] = None, **kwargs: str) -> None:
|
||||
raise NotImplementedError("requestWrite needs to be implemented")
|
||||
|
||||
@pyqtProperty(QObject, notify = printersChanged)
|
||||
@ -223,8 +220,10 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
||||
return self._unique_configurations
|
||||
|
||||
def _updateUniqueConfigurations(self) -> None:
|
||||
self._unique_configurations = list(set([printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None]))
|
||||
self._unique_configurations.sort(key = lambda k: k.printerType)
|
||||
self._unique_configurations = sorted(
|
||||
{printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None},
|
||||
key=lambda config: config.printerType,
|
||||
)
|
||||
self.uniqueConfigurationsChanged.emit()
|
||||
|
||||
# Returns the unique configurations of the printers within this output device
|
||||
|
@ -178,6 +178,7 @@ class MachineManager(QObject):
|
||||
self._printer_output_devices.append(printer_output_device)
|
||||
|
||||
self.outputDevicesChanged.emit()
|
||||
self.printerConnectedStatusChanged.emit()
|
||||
|
||||
@pyqtProperty(QObject, notify = currentConfigurationChanged)
|
||||
def currentConfiguration(self) -> ConfigurationModel:
|
||||
@ -521,7 +522,7 @@ class MachineManager(QObject):
|
||||
return ""
|
||||
|
||||
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
|
||||
def printerConnected(self):
|
||||
def printerConnected(self) -> bool:
|
||||
return bool(self._printer_output_devices)
|
||||
|
||||
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
|
||||
@ -531,6 +532,20 @@ class MachineManager(QObject):
|
||||
return connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value]
|
||||
return False
|
||||
|
||||
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
|
||||
def activeMachineIsGroup(self) -> bool:
|
||||
return bool(self._printer_output_devices) and len(self._printer_output_devices[0].printers) > 1
|
||||
|
||||
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
|
||||
def activeMachineHasActiveNetworkConnection(self) -> bool:
|
||||
# A network connection is only available if any output device is actually a network connected device.
|
||||
return any(d.connectionType == ConnectionType.NetworkConnection for d in self._printer_output_devices)
|
||||
|
||||
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
|
||||
def activeMachineHasActiveCloudConnection(self) -> bool:
|
||||
# A cloud connection is only available if any output device actually is a cloud connected device.
|
||||
return any(d.connectionType == ConnectionType.CloudConnection for d in self._printer_output_devices)
|
||||
|
||||
def activeMachineNetworkKey(self) -> str:
|
||||
if self._global_container_stack:
|
||||
return self._global_container_stack.getMetaDataEntry("um_network_key", "")
|
||||
|
28
cura/UltimakerCloudAuthentication.py
Normal file
28
cura/UltimakerCloudAuthentication.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
# ---------
|
||||
# Constants used for the Cloud API
|
||||
# ---------
|
||||
DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str
|
||||
DEFAULT_CLOUD_API_VERSION = "1" # type: str
|
||||
DEFAULT_CLOUD_ACCOUNT_API_ROOT = "https://account.ultimaker.com" # type: str
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraCloudAPIRoot # type: ignore
|
||||
if CuraCloudAPIRoot == "":
|
||||
CuraCloudAPIRoot = DEFAULT_CLOUD_API_ROOT
|
||||
except ImportError:
|
||||
CuraCloudAPIRoot = DEFAULT_CLOUD_API_ROOT
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraCloudAPIVersion # type: ignore
|
||||
except ImportError:
|
||||
CuraCloudAPIVersion = DEFAULT_CLOUD_API_VERSION
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraCloudAccountAPIRoot # type: ignore
|
||||
if CuraCloudAccountAPIRoot == "":
|
||||
CuraCloudAccountAPIRoot = DEFAULT_CLOUD_ACCOUNT_API_ROOT
|
||||
except ImportError:
|
||||
CuraCloudAccountAPIRoot = DEFAULT_CLOUD_ACCOUNT_API_ROOT
|
12
plugins/CuraDrive/__init__.py
Normal file
12
plugins/CuraDrive/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from .src.DrivePluginExtension import DrivePluginExtension
|
||||
|
||||
|
||||
def getMetaData():
|
||||
return {}
|
||||
|
||||
|
||||
def register(app):
|
||||
return {"extension": DrivePluginExtension()}
|
8
plugins/CuraDrive/plugin.json
Normal file
8
plugins/CuraDrive/plugin.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Cura Backups",
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Backup and restore your configuration.",
|
||||
"version": "1.2.0",
|
||||
"api": 6,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
168
plugins/CuraDrive/src/DriveApiService.py
Normal file
168
plugins/CuraDrive/src/DriveApiService.py
Normal file
@ -0,0 +1,168 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Optional, List, Dict
|
||||
|
||||
import requests
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Signal import Signal, signalemitter
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
from .UploadBackupJob import UploadBackupJob
|
||||
from .Settings import Settings
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.
|
||||
@signalemitter
|
||||
class DriveApiService:
|
||||
BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
|
||||
|
||||
# Emit signal when restoring backup started or finished.
|
||||
restoringStateChanged = Signal()
|
||||
|
||||
# Emit signal when creating backup started or finished.
|
||||
creatingStateChanged = Signal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cura_api = CuraApplication.getInstance().getCuraAPI()
|
||||
|
||||
def getBackups(self) -> List[Dict[str, Any]]:
|
||||
access_token = self._cura_api.account.accessToken
|
||||
if not access_token:
|
||||
Logger.log("w", "Could not get access token.")
|
||||
return []
|
||||
|
||||
backup_list_request = requests.get(self.BACKUP_URL, headers = {
|
||||
"Authorization": "Bearer {}".format(access_token)
|
||||
})
|
||||
|
||||
# HTTP status 300s mean redirection. 400s and 500s are errors.
|
||||
# Technically 300s are not errors, but the use case here relies on "requests" to handle redirects automatically.
|
||||
if backup_list_request.status_code >= 300:
|
||||
Logger.log("w", "Could not get backups list from remote: %s", backup_list_request.text)
|
||||
Message(catalog.i18nc("@info:backup_status", "There was an error listing your backups."), title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
return []
|
||||
return backup_list_request.json()["data"]
|
||||
|
||||
def createBackup(self) -> None:
|
||||
self.creatingStateChanged.emit(is_creating = True)
|
||||
|
||||
# Create the backup.
|
||||
backup_zip_file, backup_meta_data = self._cura_api.backups.createBackup()
|
||||
if not backup_zip_file or not backup_meta_data:
|
||||
self.creatingStateChanged.emit(is_creating = False, error_message ="Could not create backup.")
|
||||
return
|
||||
|
||||
# Create an upload entry for the backup.
|
||||
timestamp = datetime.now().isoformat()
|
||||
backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"])
|
||||
backup_upload_url = self._requestBackupUpload(backup_meta_data, len(backup_zip_file))
|
||||
if not backup_upload_url:
|
||||
self.creatingStateChanged.emit(is_creating = False, error_message ="Could not upload backup.")
|
||||
return
|
||||
|
||||
# Upload the backup to storage.
|
||||
upload_backup_job = UploadBackupJob(backup_upload_url, backup_zip_file)
|
||||
upload_backup_job.finished.connect(self._onUploadFinished)
|
||||
upload_backup_job.start()
|
||||
|
||||
def _onUploadFinished(self, job: "UploadBackupJob") -> None:
|
||||
if job.backup_upload_error_message != "":
|
||||
# If the job contains an error message we pass it along so the UI can display it.
|
||||
self.creatingStateChanged.emit(is_creating = False, error_message = job.backup_upload_error_message)
|
||||
else:
|
||||
self.creatingStateChanged.emit(is_creating = False)
|
||||
|
||||
def restoreBackup(self, backup: Dict[str, Any]) -> None:
|
||||
self.restoringStateChanged.emit(is_restoring = True)
|
||||
download_url = backup.get("download_url")
|
||||
if not download_url:
|
||||
# If there is no download URL, we can't restore the backup.
|
||||
return self._emitRestoreError()
|
||||
|
||||
download_package = requests.get(download_url, stream = True)
|
||||
if download_package.status_code >= 300:
|
||||
# Something went wrong when attempting to download the backup.
|
||||
Logger.log("w", "Could not download backup from url %s: %s", download_url, download_package.text)
|
||||
return self._emitRestoreError()
|
||||
|
||||
# We store the file in a temporary path fist to ensure integrity.
|
||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||
for chunk in download_package:
|
||||
write_backup.write(chunk)
|
||||
|
||||
if not self._verifyMd5Hash(temporary_backup_file.name, backup.get("md5_hash", "")):
|
||||
# Don't restore the backup if the MD5 hashes do not match.
|
||||
# This can happen if the download was interrupted.
|
||||
Logger.log("w", "Remote and local MD5 hashes do not match, not restoring backup.")
|
||||
return self._emitRestoreError()
|
||||
|
||||
# Tell Cura to place the backup back in the user data folder.
|
||||
with open(temporary_backup_file.name, "rb") as read_backup:
|
||||
self._cura_api.backups.restoreBackup(read_backup.read(), backup.get("metadata", {}))
|
||||
self.restoringStateChanged.emit(is_restoring = False)
|
||||
|
||||
def _emitRestoreError(self) -> None:
|
||||
self.restoringStateChanged.emit(is_restoring = False,
|
||||
error_message = catalog.i18nc("@info:backup_status",
|
||||
"There was an error trying to restore your backup."))
|
||||
|
||||
# Verify the MD5 hash of a file.
|
||||
# \param file_path Full path to the file.
|
||||
# \param known_hash The known MD5 hash of the file.
|
||||
# \return: Success or not.
|
||||
@staticmethod
|
||||
def _verifyMd5Hash(file_path: str, known_hash: str) -> bool:
|
||||
with open(file_path, "rb") as read_backup:
|
||||
local_md5_hash = base64.b64encode(hashlib.md5(read_backup.read()).digest(), altchars = b"_-").decode("utf-8")
|
||||
return known_hash == local_md5_hash
|
||||
|
||||
def deleteBackup(self, backup_id: str) -> bool:
|
||||
access_token = self._cura_api.account.accessToken
|
||||
if not access_token:
|
||||
Logger.log("w", "Could not get access token.")
|
||||
return False
|
||||
|
||||
delete_backup = requests.delete("{}/{}".format(self.BACKUP_URL, backup_id), headers = {
|
||||
"Authorization": "Bearer {}".format(access_token)
|
||||
})
|
||||
if delete_backup.status_code >= 300:
|
||||
Logger.log("w", "Could not delete backup: %s", delete_backup.text)
|
||||
return False
|
||||
return True
|
||||
|
||||
# Request a backup upload slot from the API.
|
||||
# \param backup_metadata: A dict containing some meta data about the backup.
|
||||
# \param backup_size The size of the backup file in bytes.
|
||||
# \return: The upload URL for the actual backup file if successful, otherwise None.
|
||||
def _requestBackupUpload(self, backup_metadata: Dict[str, Any], backup_size: int) -> Optional[str]:
|
||||
access_token = self._cura_api.account.accessToken
|
||||
if not access_token:
|
||||
Logger.log("w", "Could not get access token.")
|
||||
return None
|
||||
|
||||
backup_upload_request = requests.put(self.BACKUP_URL, json = {
|
||||
"data": {
|
||||
"backup_size": backup_size,
|
||||
"metadata": backup_metadata
|
||||
}
|
||||
}, headers = {
|
||||
"Authorization": "Bearer {}".format(access_token)
|
||||
})
|
||||
|
||||
# Any status code of 300 or above indicates an error.
|
||||
if backup_upload_request.status_code >= 300:
|
||||
Logger.log("w", "Could not request backup upload: %s", backup_upload_request.text)
|
||||
return None
|
||||
|
||||
return backup_upload_request.json()["data"]["upload_url"]
|
162
plugins/CuraDrive/src/DrivePluginExtension.py
Normal file
162
plugins/CuraDrive/src/DrivePluginExtension.py
Normal file
@ -0,0 +1,162 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal
|
||||
|
||||
from UM.Extension import Extension
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
from .Settings import Settings
|
||||
from .DriveApiService import DriveApiService
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
# The DivePluginExtension provides functionality to backup and restore your Cura configuration to Ultimaker's cloud.
|
||||
class DrivePluginExtension(QObject, Extension):
|
||||
|
||||
# Signal emitted when the list of backups changed.
|
||||
backupsChanged = pyqtSignal()
|
||||
|
||||
# Signal emitted when restoring has started. Needed to prevent parallel restoring.
|
||||
restoringStateChanged = pyqtSignal()
|
||||
|
||||
# Signal emitted when creating has started. Needed to prevent parallel creation of backups.
|
||||
creatingStateChanged = pyqtSignal()
|
||||
|
||||
# Signal emitted when preferences changed (like auto-backup).
|
||||
preferencesChanged = pyqtSignal()
|
||||
|
||||
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
|
||||
|
||||
def __init__(self) -> None:
|
||||
QObject.__init__(self, None)
|
||||
Extension.__init__(self)
|
||||
|
||||
# Local data caching for the UI.
|
||||
self._drive_window = None # type: Optional[QObject]
|
||||
self._backups = [] # type: List[Dict[str, Any]]
|
||||
self._is_restoring_backup = False
|
||||
self._is_creating_backup = False
|
||||
|
||||
# Initialize services.
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
self._drive_api_service = DriveApiService()
|
||||
|
||||
# Attach signals.
|
||||
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
|
||||
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
|
||||
|
||||
# Register preferences.
|
||||
preferences.addPreference(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, False)
|
||||
preferences.addPreference(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY,
|
||||
datetime.now().strftime(self.DATE_FORMAT))
|
||||
|
||||
# Register the menu item
|
||||
self.addMenuItem(catalog.i18nc("@item:inmenu", "Manage backups"), self.showDriveWindow)
|
||||
|
||||
# Make auto-backup on boot if required.
|
||||
CuraApplication.getInstance().engineCreatedSignal.connect(self._autoBackup)
|
||||
|
||||
def showDriveWindow(self) -> None:
|
||||
if not self._drive_window:
|
||||
plugin_dir_path = CuraApplication.getInstance().getPluginRegistry().getPluginPath("CuraDrive")
|
||||
path = os.path.join(plugin_dir_path, "src", "qml", "main.qml")
|
||||
self._drive_window = CuraApplication.getInstance().createQmlComponent(path, {"CuraDrive": self})
|
||||
self.refreshBackups()
|
||||
if self._drive_window:
|
||||
self._drive_window.show()
|
||||
|
||||
def _autoBackup(self) -> None:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
|
||||
self.createBackup()
|
||||
|
||||
def _isLastBackupTooLongAgo(self) -> bool:
|
||||
current_date = datetime.now()
|
||||
last_backup_date = self._getLastBackupDate()
|
||||
date_diff = current_date - last_backup_date
|
||||
return date_diff.days > 1
|
||||
|
||||
def _getLastBackupDate(self) -> "datetime":
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
last_backup_date = preferences.getValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY)
|
||||
return datetime.strptime(last_backup_date, self.DATE_FORMAT)
|
||||
|
||||
def _storeBackupDate(self) -> None:
|
||||
backup_date = datetime.now().strftime(self.DATE_FORMAT)
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
preferences.setValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY, backup_date)
|
||||
|
||||
def _onLoginStateChanged(self, logged_in: bool = False) -> None:
|
||||
if logged_in:
|
||||
self.refreshBackups()
|
||||
|
||||
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
|
||||
self._is_restoring_backup = is_restoring
|
||||
self.restoringStateChanged.emit()
|
||||
if error_message:
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
|
||||
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
|
||||
self._is_creating_backup = is_creating
|
||||
self.creatingStateChanged.emit()
|
||||
if error_message:
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
else:
|
||||
self._storeBackupDate()
|
||||
if not is_creating and not error_message:
|
||||
# We've finished creating a new backup, to the list has to be updated.
|
||||
self.refreshBackups()
|
||||
|
||||
@pyqtSlot(bool, name = "toggleAutoBackup")
|
||||
def toggleAutoBackup(self, enabled: bool) -> None:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
preferences.setValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, enabled)
|
||||
|
||||
@pyqtProperty(bool, notify = preferencesChanged)
|
||||
def autoBackupEnabled(self) -> bool:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
return bool(preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY))
|
||||
|
||||
@pyqtProperty("QVariantList", notify = backupsChanged)
|
||||
def backups(self) -> List[Dict[str, Any]]:
|
||||
return self._backups
|
||||
|
||||
@pyqtSlot(name = "refreshBackups")
|
||||
def refreshBackups(self) -> None:
|
||||
self._backups = self._drive_api_service.getBackups()
|
||||
self.backupsChanged.emit()
|
||||
|
||||
@pyqtProperty(bool, notify = restoringStateChanged)
|
||||
def isRestoringBackup(self) -> bool:
|
||||
return self._is_restoring_backup
|
||||
|
||||
@pyqtProperty(bool, notify = creatingStateChanged)
|
||||
def isCreatingBackup(self) -> bool:
|
||||
return self._is_creating_backup
|
||||
|
||||
@pyqtSlot(str, name = "restoreBackup")
|
||||
def restoreBackup(self, backup_id: str) -> None:
|
||||
for backup in self._backups:
|
||||
if backup.get("backup_id") == backup_id:
|
||||
self._drive_api_service.restoreBackup(backup)
|
||||
return
|
||||
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
|
||||
|
||||
@pyqtSlot(name = "createBackup")
|
||||
def createBackup(self) -> None:
|
||||
self._drive_api_service.createBackup()
|
||||
|
||||
@pyqtSlot(str, name = "deleteBackup")
|
||||
def deleteBackup(self, backup_id: str) -> None:
|
||||
self._drive_api_service.deleteBackup(backup_id)
|
||||
self.refreshBackups()
|
13
plugins/CuraDrive/src/Settings.py
Normal file
13
plugins/CuraDrive/src/Settings.py
Normal file
@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from cura import UltimakerCloudAuthentication
|
||||
|
||||
|
||||
class Settings:
|
||||
# Keeps the plugin settings.
|
||||
DRIVE_API_VERSION = 1
|
||||
DRIVE_API_URL = "{}/cura-drive/v{}".format(UltimakerCloudAuthentication.CuraCloudAPIRoot, str(DRIVE_API_VERSION))
|
||||
|
||||
AUTO_BACKUP_ENABLED_PREFERENCE_KEY = "cura_drive/auto_backup_enabled"
|
||||
AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY = "cura_drive/auto_backup_date"
|
41
plugins/CuraDrive/src/UploadBackupJob.py
Normal file
41
plugins/CuraDrive/src/UploadBackupJob.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import requests
|
||||
|
||||
from UM.Job import Job
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
class UploadBackupJob(Job):
|
||||
MESSAGE_TITLE = catalog.i18nc("@info:title", "Backups")
|
||||
|
||||
# This job is responsible for uploading the backup file to cloud storage.
|
||||
# As it can take longer than some other tasks, we schedule this using a Cura Job.
|
||||
def __init__(self, signed_upload_url: str, backup_zip: bytes) -> None:
|
||||
super().__init__()
|
||||
self._signed_upload_url = signed_upload_url
|
||||
self._backup_zip = backup_zip
|
||||
self._upload_success = False
|
||||
self.backup_upload_error_message = ""
|
||||
|
||||
def run(self) -> None:
|
||||
upload_message = Message(catalog.i18nc("@info:backup_status", "Uploading your backup..."), title = self.MESSAGE_TITLE, progress = -1)
|
||||
upload_message.show()
|
||||
|
||||
backup_upload = requests.put(self._signed_upload_url, data = self._backup_zip)
|
||||
upload_message.hide()
|
||||
|
||||
if backup_upload.status_code >= 300:
|
||||
self.backup_upload_error_message = backup_upload.text
|
||||
Logger.log("w", "Could not upload backup file: %s", backup_upload.text)
|
||||
Message(catalog.i18nc("@info:backup_status", "There was an error while uploading your backup."), title = self.MESSAGE_TITLE).show()
|
||||
else:
|
||||
self._upload_success = True
|
||||
Message(catalog.i18nc("@info:backup_status", "Your backup has finished uploading."), title = self.MESSAGE_TITLE).show()
|
||||
|
||||
self.finished.emit(self)
|
0
plugins/CuraDrive/src/__init__.py
Normal file
0
plugins/CuraDrive/src/__init__.py
Normal file
39
plugins/CuraDrive/src/qml/components/BackupList.qml
Normal file
39
plugins/CuraDrive/src/qml/components/BackupList.qml
Normal file
@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
import UM 1.1 as UM
|
||||
|
||||
ScrollView
|
||||
{
|
||||
property alias model: backupList.model
|
||||
width: parent.width
|
||||
clip: true
|
||||
ListView
|
||||
{
|
||||
id: backupList
|
||||
width: parent.width
|
||||
delegate: Item
|
||||
{
|
||||
// Add a margin, otherwise the scrollbar is on top of the right most component
|
||||
width: parent.width - UM.Theme.getSize("default_margin").width
|
||||
height: childrenRect.height
|
||||
|
||||
BackupListItem
|
||||
{
|
||||
id: backupListItem
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Rectangle
|
||||
{
|
||||
id: divider
|
||||
color: UM.Theme.getColor("lining")
|
||||
height: UM.Theme.getSize("default_lining").height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
46
plugins/CuraDrive/src/qml/components/BackupListFooter.qml
Normal file
46
plugins/CuraDrive/src/qml/components/BackupListFooter.qml
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
import UM 1.3 as UM
|
||||
import Cura 1.0 as Cura
|
||||
|
||||
import "../components"
|
||||
|
||||
RowLayout
|
||||
{
|
||||
id: backupListFooter
|
||||
width: parent.width
|
||||
property bool showInfoButton: false
|
||||
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: infoButton
|
||||
text: catalog.i18nc("@button", "Want more?")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
|
||||
visible: backupListFooter.showInfoButton
|
||||
}
|
||||
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: createBackupButton
|
||||
text: catalog.i18nc("@button", "Backup Now")
|
||||
iconSource: UM.Theme.getIcon("plus")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: CuraDrive.createBackup()
|
||||
busy: CuraDrive.isCreatingBackup
|
||||
}
|
||||
|
||||
Cura.CheckBoxWithTooltip
|
||||
{
|
||||
id: autoBackupEnabled
|
||||
checked: CuraDrive.autoBackupEnabled
|
||||
onClicked: CuraDrive.toggleAutoBackup(autoBackupEnabled.checked)
|
||||
text: catalog.i18nc("@checkbox:description", "Auto Backup")
|
||||
tooltip: catalog.i18nc("@checkbox:description", "Automatically create a backup each day that Cura is started.")
|
||||
}
|
||||
}
|
113
plugins/CuraDrive/src/qml/components/BackupListItem.qml
Normal file
113
plugins/CuraDrive/src/qml/components/BackupListItem.qml
Normal file
@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Layouts 1.3
|
||||
import QtQuick.Dialogs 1.1
|
||||
|
||||
import UM 1.1 as UM
|
||||
import Cura 1.0 as Cura
|
||||
|
||||
Item
|
||||
{
|
||||
id: backupListItem
|
||||
width: parent.width
|
||||
height: showDetails ? dataRow.height + backupDetails.height : dataRow.height
|
||||
property bool showDetails: false
|
||||
|
||||
// Backup details toggle animation.
|
||||
Behavior on height
|
||||
{
|
||||
PropertyAnimation
|
||||
{
|
||||
duration: 70
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout
|
||||
{
|
||||
id: dataRow
|
||||
spacing: UM.Theme.getSize("wide_margin").width
|
||||
width: parent.width
|
||||
height: 50 * screenScaleFactor
|
||||
|
||||
UM.SimpleButton
|
||||
{
|
||||
width: UM.Theme.getSize("section_icon").width
|
||||
height: UM.Theme.getSize("section_icon").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
onClicked: backupListItem.showDetails = !backupListItem.showDetails
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
text: new Date(modelData.generated_time).toLocaleString(UM.Preferences.getValue("general/language"))
|
||||
color: UM.Theme.getColor("text")
|
||||
elide: Text.ElideRight
|
||||
Layout.minimumWidth: 100 * screenScaleFactor
|
||||
Layout.maximumWidth: 500 * screenScaleFactor
|
||||
Layout.fillWidth: true
|
||||
font: UM.Theme.getFont("default")
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
text: modelData.metadata.description
|
||||
color: UM.Theme.getColor("text")
|
||||
elide: Text.ElideRight
|
||||
Layout.minimumWidth: 100 * screenScaleFactor
|
||||
Layout.maximumWidth: 500 * screenScaleFactor
|
||||
Layout.fillWidth: true
|
||||
font: UM.Theme.getFont("default")
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
text: catalog.i18nc("@button", "Restore")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: confirmRestoreDialog.visible = true
|
||||
}
|
||||
|
||||
UM.SimpleButton
|
||||
{
|
||||
width: UM.Theme.getSize("message_close").width
|
||||
height: UM.Theme.getSize("message_close").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("cross1")
|
||||
onClicked: confirmDeleteDialog.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
BackupListItemDetails
|
||||
{
|
||||
id: backupDetails
|
||||
backupDetailsData: modelData
|
||||
width: parent.width
|
||||
visible: parent.showDetails
|
||||
anchors.top: dataRow.bottom
|
||||
}
|
||||
|
||||
MessageDialog
|
||||
{
|
||||
id: confirmDeleteDialog
|
||||
title: catalog.i18nc("@dialog:title", "Delete Backup")
|
||||
text: catalog.i18nc("@dialog:info", "Are you sure you want to delete this backup? This cannot be undone.")
|
||||
standardButtons: StandardButton.Yes | StandardButton.No
|
||||
onYes: CuraDrive.deleteBackup(modelData.backup_id)
|
||||
}
|
||||
|
||||
MessageDialog
|
||||
{
|
||||
id: confirmRestoreDialog
|
||||
title: catalog.i18nc("@dialog:title", "Restore Backup")
|
||||
text: catalog.i18nc("@dialog:info", "You will need to restart Cura before your backup is restored. Do you want to close Cura now?")
|
||||
standardButtons: StandardButton.Yes | StandardButton.No
|
||||
onYes: CuraDrive.restoreBackup(modelData.backup_id)
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
import UM 1.1 as UM
|
||||
|
||||
ColumnLayout
|
||||
{
|
||||
id: backupDetails
|
||||
width: parent.width
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
property var backupDetailsData
|
||||
|
||||
// Cura version
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("application")
|
||||
label: catalog.i18nc("@backuplist:label", "Cura Version")
|
||||
value: backupDetailsData.metadata.cura_release
|
||||
}
|
||||
|
||||
// Machine count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("printer_single")
|
||||
label: catalog.i18nc("@backuplist:label", "Machines")
|
||||
value: backupDetailsData.metadata.machine_count
|
||||
}
|
||||
|
||||
// Material count
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("category_material")
|
||||
label: catalog.i18nc("@backuplist:label", "Materials")
|
||||
value: backupDetailsData.metadata.material_count
|
||||
}
|
||||
|
||||
// Profile count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("settings")
|
||||
label: catalog.i18nc("@backuplist:label", "Profiles")
|
||||
value: backupDetailsData.metadata.profile_count
|
||||
}
|
||||
|
||||
// Plugin count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("plugin")
|
||||
label: catalog.i18nc("@backuplist:label", "Plugins")
|
||||
value: backupDetailsData.metadata.plugin_count
|
||||
}
|
||||
|
||||
// Spacer.
|
||||
Item
|
||||
{
|
||||
width: parent.width
|
||||
height: UM.Theme.getSize("default_margin").height
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
import UM 1.3 as UM
|
||||
|
||||
RowLayout
|
||||
{
|
||||
id: detailsRow
|
||||
width: parent.width
|
||||
height: 40 * screenScaleFactor
|
||||
|
||||
property alias iconSource: icon.source
|
||||
property alias label: detailName.text
|
||||
property alias value: detailValue.text
|
||||
|
||||
UM.RecolorImage
|
||||
{
|
||||
id: icon
|
||||
width: 18 * screenScaleFactor
|
||||
height: width
|
||||
source: ""
|
||||
color: UM.Theme.getColor("text")
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: detailName
|
||||
color: UM.Theme.getColor("text")
|
||||
elide: Text.ElideRight
|
||||
Layout.minimumWidth: 50 * screenScaleFactor
|
||||
Layout.maximumWidth: 100 * screenScaleFactor
|
||||
Layout.fillWidth: true
|
||||
font: UM.Theme.getFont("default")
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: detailValue
|
||||
color: UM.Theme.getColor("text")
|
||||
elide: Text.ElideRight
|
||||
Layout.minimumWidth: 50 * screenScaleFactor
|
||||
Layout.maximumWidth: 100 * screenScaleFactor
|
||||
Layout.fillWidth: true
|
||||
font: UM.Theme.getFont("default")
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
BIN
plugins/CuraDrive/src/qml/images/icon.png
Normal file
BIN
plugins/CuraDrive/src/qml/images/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
BIN
plugins/CuraDrive/src/qml/images/loading.gif
Normal file
BIN
plugins/CuraDrive/src/qml/images/loading.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.6 KiB |
44
plugins/CuraDrive/src/qml/main.qml
Normal file
44
plugins/CuraDrive/src/qml/main.qml
Normal file
@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Window 2.2
|
||||
|
||||
import UM 1.3 as UM
|
||||
import Cura 1.1 as Cura
|
||||
|
||||
import "components"
|
||||
import "pages"
|
||||
|
||||
Window
|
||||
{
|
||||
id: curaDriveDialog
|
||||
minimumWidth: Math.round(UM.Theme.getSize("modal_window_minimum").width)
|
||||
minimumHeight: Math.round(UM.Theme.getSize("modal_window_minimum").height)
|
||||
maximumWidth: Math.round(minimumWidth * 1.2)
|
||||
maximumHeight: Math.round(minimumHeight * 1.2)
|
||||
width: minimumWidth
|
||||
height: minimumHeight
|
||||
color: UM.Theme.getColor("main_background")
|
||||
title: catalog.i18nc("@title:window", "Cura Backups")
|
||||
|
||||
// Globally available.
|
||||
UM.I18nCatalog
|
||||
{
|
||||
id: catalog
|
||||
name: "cura"
|
||||
}
|
||||
|
||||
WelcomePage
|
||||
{
|
||||
id: welcomePage
|
||||
visible: !Cura.API.account.isLoggedIn
|
||||
}
|
||||
|
||||
BackupsPage
|
||||
{
|
||||
id: backupsPage
|
||||
visible: Cura.API.account.isLoggedIn
|
||||
}
|
||||
}
|
75
plugins/CuraDrive/src/qml/pages/BackupsPage.qml
Normal file
75
plugins/CuraDrive/src/qml/pages/BackupsPage.qml
Normal file
@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
import UM 1.3 as UM
|
||||
import Cura 1.1 as Cura
|
||||
|
||||
import "../components"
|
||||
|
||||
Item
|
||||
{
|
||||
id: backupsPage
|
||||
anchors.fill: parent
|
||||
anchors.margins: UM.Theme.getSize("wide_margin").width
|
||||
|
||||
ColumnLayout
|
||||
{
|
||||
spacing: UM.Theme.getSize("wide_margin").height
|
||||
width: parent.width
|
||||
anchors.fill: parent
|
||||
|
||||
Label
|
||||
{
|
||||
id: backupTitle
|
||||
text: catalog.i18nc("@title", "My Backups")
|
||||
font: UM.Theme.getFont("large")
|
||||
color: UM.Theme.getColor("text")
|
||||
Layout.fillWidth: true
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
text: catalog.i18nc("@empty_state",
|
||||
"You don't have any backups currently. Use the 'Backup Now' button to create one.")
|
||||
width: parent.width
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("text")
|
||||
wrapMode: Label.WordWrap
|
||||
visible: backupList.model.length == 0
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
BackupList
|
||||
{
|
||||
id: backupList
|
||||
model: CuraDrive.backups
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
text: catalog.i18nc("@backup_limit_info",
|
||||
"During the preview phase, you'll be limited to 5 visible backups. Remove a backup to see older ones.")
|
||||
width: parent.width
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("text")
|
||||
wrapMode: Label.WordWrap
|
||||
visible: backupList.model.length > 4
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
BackupListFooter
|
||||
{
|
||||
id: backupListFooter
|
||||
showInfoButton: backupList.model.length > 4
|
||||
}
|
||||
}
|
||||
}
|
56
plugins/CuraDrive/src/qml/pages/WelcomePage.qml
Normal file
56
plugins/CuraDrive/src/qml/pages/WelcomePage.qml
Normal file
@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtQuick.Window 2.2
|
||||
|
||||
import UM 1.3 as UM
|
||||
import Cura 1.1 as Cura
|
||||
|
||||
import "../components"
|
||||
|
||||
|
||||
Column
|
||||
{
|
||||
id: welcomePage
|
||||
spacing: UM.Theme.getSize("wide_margin").height
|
||||
width: parent.width
|
||||
height: childrenRect.height
|
||||
anchors.centerIn: parent
|
||||
|
||||
Image
|
||||
{
|
||||
id: profileImage
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: "../images/icon.png"
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: Math.round(parent.width / 4)
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: welcomeTextLabel
|
||||
text: catalog.i18nc("@description", "Backup and synchronize your Cura settings.")
|
||||
width: Math.round(parent.width / 2)
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("text")
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
wrapMode: Label.WordWrap
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: loginButton
|
||||
width: UM.Theme.getSize("account_button").width
|
||||
height: UM.Theme.getSize("account_button").height
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: catalog.i18nc("@button", "Sign in")
|
||||
onClicked: Cura.API.account.login()
|
||||
fixedWidthMode: true
|
||||
}
|
||||
}
|
||||
|
@ -488,7 +488,7 @@ UM.Dialog
|
||||
{
|
||||
objectName: "postProcessingSaveAreaButton"
|
||||
visible: activeScriptsList.count > 0
|
||||
height: UM.Theme.getSize("save_button_save_to_button").height
|
||||
height: UM.Theme.getSize("action_button").height
|
||||
width: height
|
||||
tooltip: catalog.i18nc("@info:tooltip", "Change active post-processing scripts")
|
||||
onClicked: dialog.show()
|
||||
|
@ -71,6 +71,11 @@ Item
|
||||
target: UM.Preferences
|
||||
onPreferenceChanged:
|
||||
{
|
||||
if (preference !== "view/only_show_top_layers" && preference !== "view/top_layer_count" && ! preference.match("layerview/"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
playButton.pauseSimulation()
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,11 @@ Cura.ExpandableComponent
|
||||
target: UM.Preferences
|
||||
onPreferenceChanged:
|
||||
{
|
||||
if (preference !== "view/only_show_top_layers" && preference !== "view/top_layer_count" && ! preference.match("layerview/"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
layerTypeCombobox.currentIndex = UM.SimulationView.compatibilityMode ? 1 : UM.Preferences.getValue("layerview/layer_view_type")
|
||||
layerTypeCombobox.updateLegends(layerTypeCombobox.currentIndex)
|
||||
viewSettings.extruder_opacities = UM.Preferences.getValue("layerview/extruder_opacities").split("|")
|
||||
|
@ -91,5 +91,10 @@ Column
|
||||
target: toolbox
|
||||
onInstallChanged: installed = toolbox.isInstalled(model.id)
|
||||
onMetadataChanged: canUpdate = toolbox.canUpdate(model.id)
|
||||
onFilterChanged:
|
||||
{
|
||||
installed = toolbox.isInstalled(model.id)
|
||||
canUpdate = toolbox.canUpdate(model.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,8 @@ from UM.Extension import Extension
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Version import Version
|
||||
|
||||
import cura
|
||||
from cura import ApplicationMetadata
|
||||
from cura import UltimakerCloudAuthentication
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
from .AuthorsModel import AuthorsModel
|
||||
@ -30,17 +31,14 @@ i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
## The Toolbox class is responsible of communicating with the server through the API
|
||||
class Toolbox(QObject, Extension):
|
||||
DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str
|
||||
DEFAULT_CLOUD_API_VERSION = 1 # type: int
|
||||
|
||||
def __init__(self, application: CuraApplication) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._application = application # type: CuraApplication
|
||||
|
||||
self._sdk_version = None # type: Optional[Union[str, int]]
|
||||
self._cloud_api_version = None # type: Optional[int]
|
||||
self._cloud_api_root = None # type: Optional[str]
|
||||
self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
|
||||
self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: int
|
||||
self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str
|
||||
self._api_url = None # type: Optional[str]
|
||||
|
||||
# Network:
|
||||
@ -182,9 +180,6 @@ class Toolbox(QObject, Extension):
|
||||
def _onAppInitialized(self) -> None:
|
||||
self._plugin_registry = self._application.getPluginRegistry()
|
||||
self._package_manager = self._application.getPackageManager()
|
||||
self._sdk_version = self._getSDKVersion()
|
||||
self._cloud_api_version = self._getCloudAPIVersion()
|
||||
self._cloud_api_root = self._getCloudAPIRoot()
|
||||
self._api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format(
|
||||
cloud_api_root = self._cloud_api_root,
|
||||
cloud_api_version = self._cloud_api_version,
|
||||
@ -195,36 +190,6 @@ class Toolbox(QObject, Extension):
|
||||
"packages": QUrl("{base_url}/packages".format(base_url = self._api_url))
|
||||
}
|
||||
|
||||
# Get the API root for the packages API depending on Cura version settings.
|
||||
def _getCloudAPIRoot(self) -> str:
|
||||
if not hasattr(cura, "CuraVersion"):
|
||||
return self.DEFAULT_CLOUD_API_ROOT
|
||||
if not hasattr(cura.CuraVersion, "CuraCloudAPIRoot"): # type: ignore
|
||||
return self.DEFAULT_CLOUD_API_ROOT
|
||||
if not cura.CuraVersion.CuraCloudAPIRoot: # type: ignore
|
||||
return self.DEFAULT_CLOUD_API_ROOT
|
||||
return cura.CuraVersion.CuraCloudAPIRoot # type: ignore
|
||||
|
||||
# Get the cloud API version from CuraVersion
|
||||
def _getCloudAPIVersion(self) -> int:
|
||||
if not hasattr(cura, "CuraVersion"):
|
||||
return self.DEFAULT_CLOUD_API_VERSION
|
||||
if not hasattr(cura.CuraVersion, "CuraCloudAPIVersion"): # type: ignore
|
||||
return self.DEFAULT_CLOUD_API_VERSION
|
||||
if not cura.CuraVersion.CuraCloudAPIVersion: # type: ignore
|
||||
return self.DEFAULT_CLOUD_API_VERSION
|
||||
return cura.CuraVersion.CuraCloudAPIVersion # type: ignore
|
||||
|
||||
# Get the packages version depending on Cura version settings.
|
||||
def _getSDKVersion(self) -> Union[int, str]:
|
||||
if not hasattr(cura, "CuraVersion"):
|
||||
return self._application.getAPIVersion().getMajor()
|
||||
if not hasattr(cura.CuraVersion, "CuraSDKVersion"): # type: ignore
|
||||
return self._application.getAPIVersion().getMajor()
|
||||
if not cura.CuraVersion.CuraSDKVersion: # type: ignore
|
||||
return self._application.getAPIVersion().getMajor()
|
||||
return cura.CuraVersion.CuraSDKVersion # type: ignore
|
||||
|
||||
@pyqtSlot()
|
||||
def browsePackages(self) -> None:
|
||||
# Create the network manager:
|
||||
@ -270,12 +235,17 @@ class Toolbox(QObject, Extension):
|
||||
|
||||
def _convertPluginMetadata(self, plugin_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
highest_sdk_version_supported = Version(0)
|
||||
for supported_version in plugin_data["plugin"]["supported_sdk_versions"]:
|
||||
if supported_version > highest_sdk_version_supported:
|
||||
highest_sdk_version_supported = supported_version
|
||||
|
||||
formatted = {
|
||||
"package_id": plugin_data["id"],
|
||||
"package_type": "plugin",
|
||||
"display_name": plugin_data["plugin"]["name"],
|
||||
"package_version": plugin_data["plugin"]["version"],
|
||||
"sdk_version": plugin_data["plugin"]["api"],
|
||||
"sdk_version": highest_sdk_version_supported,
|
||||
"author": {
|
||||
"author_id": plugin_data["plugin"]["author"],
|
||||
"display_name": plugin_data["plugin"]["author"]
|
||||
|
@ -1,11 +1,15 @@
|
||||
# Copyright (c) 2017 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from .src import DiscoverUM3Action
|
||||
from .src import UM3OutputDevicePlugin
|
||||
|
||||
|
||||
def getMetaData():
|
||||
return {}
|
||||
|
||||
|
||||
def register(app):
|
||||
return { "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(), "machine_action": DiscoverUM3Action.DiscoverUM3Action()}
|
||||
return {
|
||||
"output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(),
|
||||
"machine_action": DiscoverUM3Action.DiscoverUM3Action()
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ Item
|
||||
{
|
||||
color: modelData && modelData.activeMaterial ? modelData.activeMaterial.color : "#eeeeee" // TODO: Theme!
|
||||
material: modelData && modelData.activeMaterial ? modelData.activeMaterial.name : ""
|
||||
position: modelData && modelData.position ? modelData.position : -1 // Use negative one to create empty extruder number
|
||||
position: modelData && typeof(modelData.position) === "number" ? modelData.position : -1 // Use negative one to create empty extruder number
|
||||
printCore: modelData ? modelData.hotendID : ""
|
||||
|
||||
// Keep things responsive!
|
||||
|
@ -42,8 +42,8 @@ Item
|
||||
{
|
||||
id: externalLinkIcon
|
||||
anchors.verticalCenter: manageQueueLabel.verticalCenter
|
||||
color: UM.Theme.getColor("primary")
|
||||
source: "../svg/icons/external_link.svg"
|
||||
color: UM.Theme.getColor("text_link")
|
||||
source: UM.Theme.getIcon("external_link")
|
||||
width: 16 * screenScaleFactor // TODO: Theme! (Y U NO USE 18 LIKE ALL OTHER ICONS?!)
|
||||
height: 16 * screenScaleFactor // TODO: Theme! (Y U NO USE 18 LIKE ALL OTHER ICONS?!)
|
||||
}
|
||||
@ -56,10 +56,11 @@ Item
|
||||
leftMargin: 6 * screenScaleFactor // TODO: Theme!
|
||||
verticalCenter: externalLinkIcon.verticalCenter
|
||||
}
|
||||
color: UM.Theme.getColor("primary")
|
||||
color: UM.Theme.getColor("text_link")
|
||||
font: UM.Theme.getFont("default") // 12pt, regular
|
||||
linkColor: UM.Theme.getColor("primary")
|
||||
linkColor: UM.Theme.getColor("text_link")
|
||||
text: catalog.i18nc("@label link to connect manager", "Manage queue in Cura Connect")
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
||||
|
||||
|
166
plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py
Normal file
166
plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py
Normal file
@ -0,0 +1,166 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
from time import time
|
||||
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
|
||||
|
||||
from UM.Logger import Logger
|
||||
from cura import UltimakerCloudAuthentication
|
||||
from cura.API import Account
|
||||
from .ToolPathUploader import ToolPathUploader
|
||||
from ..Models import BaseModel
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudError import CloudError
|
||||
from .Models.CloudClusterStatus import CloudClusterStatus
|
||||
from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from .Models.CloudPrintResponse import CloudPrintResponse
|
||||
from .Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
|
||||
|
||||
## The generic type variable used to document the methods below.
|
||||
CloudApiClientModel = TypeVar("CloudApiClientModel", bound = BaseModel)
|
||||
|
||||
|
||||
## The cloud API client is responsible for handling the requests and responses from the cloud.
|
||||
# Each method should only handle models instead of exposing Any HTTP details.
|
||||
class CloudApiClient:
|
||||
|
||||
# The cloud URL to use for this remote cluster.
|
||||
ROOT_PATH = UltimakerCloudAuthentication.CuraCloudAPIRoot
|
||||
CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH)
|
||||
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
|
||||
|
||||
## Initializes a new cloud API client.
|
||||
# \param account: The user's account object
|
||||
# \param on_error: The callback to be called whenever we receive errors from the server.
|
||||
def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None:
|
||||
super().__init__()
|
||||
self._manager = QNetworkAccessManager()
|
||||
self._account = account
|
||||
self._on_error = on_error
|
||||
self._upload = None # type: Optional[ToolPathUploader]
|
||||
# In order to avoid garbage collection we keep the callbacks in this list.
|
||||
self._anti_gc_callbacks = [] # type: List[Callable[[], None]]
|
||||
|
||||
## Gets the account used for the API.
|
||||
@property
|
||||
def account(self) -> Account:
|
||||
return self._account
|
||||
|
||||
## Retrieves all the clusters for the user that is currently logged in.
|
||||
# \param on_finished: The function to be called after the result is parsed.
|
||||
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None:
|
||||
url = "{}/clusters".format(self.CLUSTER_API_ROOT)
|
||||
reply = self._manager.get(self._createEmptyRequest(url))
|
||||
self._addCallback(reply, on_finished, CloudClusterResponse)
|
||||
|
||||
## Retrieves the status of the given cluster.
|
||||
# \param cluster_id: The ID of the cluster.
|
||||
# \param on_finished: The function to be called after the result is parsed.
|
||||
def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None:
|
||||
url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
|
||||
reply = self._manager.get(self._createEmptyRequest(url))
|
||||
self._addCallback(reply, on_finished, CloudClusterStatus)
|
||||
|
||||
## Requests the cloud to register the upload of a print job mesh.
|
||||
# \param request: The request object.
|
||||
# \param on_finished: The function to be called after the result is parsed.
|
||||
def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], Any]
|
||||
) -> None:
|
||||
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
|
||||
body = json.dumps({"data": request.toDict()})
|
||||
reply = self._manager.put(self._createEmptyRequest(url), body.encode())
|
||||
self._addCallback(reply, on_finished, CloudPrintJobResponse)
|
||||
|
||||
## Uploads a print job tool path to the cloud.
|
||||
# \param print_job: The object received after requesting an upload with `self.requestUpload`.
|
||||
# \param mesh: The tool path data to be uploaded.
|
||||
# \param on_finished: The function to be called after the upload is successful.
|
||||
# \param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
|
||||
# \param on_error: A function to be called if the upload fails.
|
||||
def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
|
||||
on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
|
||||
self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error)
|
||||
self._upload.start()
|
||||
|
||||
# Requests a cluster to print the given print job.
|
||||
# \param cluster_id: The ID of the cluster.
|
||||
# \param job_id: The ID of the print job.
|
||||
# \param on_finished: The function to be called after the result is parsed.
|
||||
def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None:
|
||||
url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id)
|
||||
reply = self._manager.post(self._createEmptyRequest(url), b"")
|
||||
self._addCallback(reply, on_finished, CloudPrintResponse)
|
||||
|
||||
## We override _createEmptyRequest in order to add the user credentials.
|
||||
# \param url: The URL to request
|
||||
# \param content_type: The type of the body contents.
|
||||
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
|
||||
request = QNetworkRequest(QUrl(path))
|
||||
if content_type:
|
||||
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
|
||||
if self._account.isLoggedIn:
|
||||
request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode())
|
||||
return request
|
||||
|
||||
## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
|
||||
# \param reply: The reply from the server.
|
||||
# \return A tuple with a status code and a dictionary.
|
||||
@staticmethod
|
||||
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
try:
|
||||
response = bytes(reply.readAll()).decode()
|
||||
return status_code, json.loads(response)
|
||||
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
|
||||
error = CloudError(code=type(err).__name__, title=str(err), http_code=str(status_code),
|
||||
id=str(time()), http_status="500")
|
||||
Logger.logException("e", "Could not parse the stardust response: %s", error)
|
||||
return status_code, {"errors": [error.toDict()]}
|
||||
|
||||
## Parses the given models and calls the correct callback depending on the result.
|
||||
# \param response: The response from the server, after being converted to a dict.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
# \param model_class: The type of the model to convert the response to. It may either be a single record or a list.
|
||||
def _parseModels(self, response: Dict[str, Any],
|
||||
on_finished: Union[Callable[[CloudApiClientModel], Any],
|
||||
Callable[[List[CloudApiClientModel]], Any]],
|
||||
model_class: Type[CloudApiClientModel]) -> None:
|
||||
if "data" in response:
|
||||
data = response["data"]
|
||||
if isinstance(data, list):
|
||||
results = [model_class(**c) for c in data] # type: List[CloudApiClientModel]
|
||||
on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished)
|
||||
on_finished_list(results)
|
||||
else:
|
||||
result = model_class(**data) # type: CloudApiClientModel
|
||||
on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished)
|
||||
on_finished_item(result)
|
||||
elif "errors" in response:
|
||||
self._on_error([CloudError(**error) for error in response["errors"]])
|
||||
else:
|
||||
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
|
||||
|
||||
## Creates a callback function so that it includes the parsing of the response into the correct model.
|
||||
# The callback is added to the 'finished' signal of the reply.
|
||||
# \param reply: The reply that should be listened to.
|
||||
# \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
|
||||
# a list or a single item.
|
||||
# \param model: The type of the model to convert the response to.
|
||||
def _addCallback(self,
|
||||
reply: QNetworkReply,
|
||||
on_finished: Union[Callable[[CloudApiClientModel], Any],
|
||||
Callable[[List[CloudApiClientModel]], Any]],
|
||||
model: Type[CloudApiClientModel],
|
||||
) -> None:
|
||||
def parse() -> None:
|
||||
status_code, response = self._parseReply(reply)
|
||||
self._anti_gc_callbacks.remove(parse)
|
||||
return self._parseModels(response, on_finished, model)
|
||||
|
||||
self._anti_gc_callbacks.append(parse)
|
||||
reply.finished.connect(parse)
|
@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .CloudOutputDevice import CloudOutputDevice
|
||||
|
||||
|
||||
class CloudOutputController(PrinterOutputController):
|
||||
def __init__(self, output_device: "CloudOutputDevice") -> None:
|
||||
super().__init__(output_device)
|
||||
|
||||
# The cloud connection only supports fetching the printer and queue status and adding a job to the queue.
|
||||
# To let the UI know this we mark all features below as False.
|
||||
self.can_pause = False
|
||||
self.can_abort = False
|
||||
self.can_pre_heat_bed = False
|
||||
self.can_pre_heat_hotends = False
|
||||
self.can_send_raw_gcode = False
|
||||
self.can_control_manually = False
|
||||
self.can_update_firmware = False
|
424
plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py
Normal file
424
plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py
Normal file
@ -0,0 +1,424 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import os
|
||||
|
||||
from time import time
|
||||
from typing import Dict, List, Optional, Set, cast
|
||||
|
||||
from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Backend.Backend import BackendState
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Qt.Duration import Duration, DurationFormat
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
from cura.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from .CloudOutputController import CloudOutputController
|
||||
from ..MeshFormatHandler import MeshFormatHandler
|
||||
from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from .CloudProgressMessage import CloudProgressMessage
|
||||
from .CloudApiClient import CloudApiClient
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudClusterStatus import CloudClusterStatus
|
||||
from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from .Models.CloudPrintResponse import CloudPrintResponse
|
||||
from .Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus
|
||||
from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus
|
||||
from .Utils import findChanges, formatDateCompleted, formatTimeCompleted
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
||||
## The cloud output device is a network output device that works remotely but has limited functionality.
|
||||
# Currently it only supports viewing the printer and print job status and adding a new job to the queue.
|
||||
# As such, those methods have been implemented here.
|
||||
# Note that this device represents a single remote cluster, not a list of multiple clusters.
|
||||
class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
# The interval with which the remote clusters are checked
|
||||
CHECK_CLUSTER_INTERVAL = 10.0 # seconds
|
||||
|
||||
# Signal triggered when the print jobs in the queue were changed.
|
||||
printJobsChanged = pyqtSignal()
|
||||
|
||||
# Signal triggered when the selected printer in the UI should be changed.
|
||||
activePrinterChanged = pyqtSignal()
|
||||
|
||||
# Notify can only use signals that are defined by the class that they are in, not inherited ones.
|
||||
# Therefore we create a private signal used to trigger the printersChanged signal.
|
||||
_clusterPrintersChanged = pyqtSignal()
|
||||
|
||||
## Creates a new cloud output device
|
||||
# \param api_client: The client that will run the API calls
|
||||
# \param cluster: The device response received from the cloud API.
|
||||
# \param parent: The optional parent of this output device.
|
||||
def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None:
|
||||
super().__init__(device_id = cluster.cluster_id, address = "",
|
||||
connection_type = ConnectionType.CloudConnection, properties = {}, parent = parent)
|
||||
self._api = api_client
|
||||
self._cluster = cluster
|
||||
|
||||
self._setInterfaceElements()
|
||||
|
||||
self._account = api_client.account
|
||||
|
||||
# We use the Cura Connect monitor tab to get most functionality right away.
|
||||
self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"../../resources/qml/MonitorStage.qml")
|
||||
|
||||
# Trigger the printersChanged signal when the private signal is triggered.
|
||||
self.printersChanged.connect(self._clusterPrintersChanged)
|
||||
|
||||
# We keep track of which printer is visible in the monitor page.
|
||||
self._active_printer = None # type: Optional[PrinterOutputModel]
|
||||
|
||||
# Properties to populate later on with received cloud data.
|
||||
self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
|
||||
self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines.
|
||||
|
||||
# We only allow a single upload at a time.
|
||||
self._progress = CloudProgressMessage()
|
||||
|
||||
# Keep server string of the last generated time to avoid updating models more than once for the same response
|
||||
self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]]
|
||||
self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJobStatus]]
|
||||
|
||||
# A set of the user's job IDs that have finished
|
||||
self._finished_jobs = set() # type: Set[str]
|
||||
|
||||
# Reference to the uploaded print job / mesh
|
||||
self._tool_path = None # type: Optional[bytes]
|
||||
self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
|
||||
|
||||
## Connects this device.
|
||||
def connect(self) -> None:
|
||||
if self.isConnected():
|
||||
return
|
||||
super().connect()
|
||||
Logger.log("i", "Connected to cluster %s", self.key)
|
||||
CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
|
||||
|
||||
## Disconnects the device
|
||||
def disconnect(self) -> None:
|
||||
super().disconnect()
|
||||
Logger.log("i", "Disconnected from cluster %s", self.key)
|
||||
CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
|
||||
|
||||
## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices.
|
||||
def _onBackendStateChange(self, _: BackendState) -> None:
|
||||
self._tool_path = None
|
||||
self._uploaded_print_job = None
|
||||
|
||||
## Gets the cluster response from which this device was created.
|
||||
@property
|
||||
def clusterData(self) -> CloudClusterResponse:
|
||||
return self._cluster
|
||||
|
||||
## Updates the cluster data from the cloud.
|
||||
@clusterData.setter
|
||||
def clusterData(self, value: CloudClusterResponse) -> None:
|
||||
self._cluster = value
|
||||
|
||||
## Checks whether the given network key is found in the cloud's host name
|
||||
def matchesNetworkKey(self, network_key: str) -> bool:
|
||||
# A network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
|
||||
# the host name should then be "ultimakersystem-aabbccdd0011"
|
||||
return network_key.startswith(self.clusterData.host_name)
|
||||
|
||||
## Set all the interface elements and texts for this output device.
|
||||
def _setInterfaceElements(self) -> None:
|
||||
self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'
|
||||
self.setName(self._id)
|
||||
self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
|
||||
self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
|
||||
self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
|
||||
|
||||
## Called when Cura requests an output device to receive a (G-code) file.
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
|
||||
|
||||
# Show an error message if we're already sending a job.
|
||||
if self._progress.visible:
|
||||
message = Message(
|
||||
text = I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job."),
|
||||
title = I18N_CATALOG.i18nc("@info:title", "Cloud error"),
|
||||
lifetime = 10
|
||||
)
|
||||
message.show()
|
||||
return
|
||||
|
||||
if self._uploaded_print_job:
|
||||
# The mesh didn't change, let's not upload it again
|
||||
self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted)
|
||||
return
|
||||
|
||||
# Indicate we have started sending a job.
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
|
||||
if not mesh_format.is_valid:
|
||||
Logger.log("e", "Missing file or mesh writer!")
|
||||
return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job."))
|
||||
|
||||
mesh = mesh_format.getBytes(nodes)
|
||||
|
||||
self._tool_path = mesh
|
||||
request = CloudPrintJobUploadRequest(
|
||||
job_name = file_name or mesh_format.file_extension,
|
||||
file_size = len(mesh),
|
||||
content_type = mesh_format.mime_type,
|
||||
)
|
||||
self._api.requestUpload(request, self._onPrintJobCreated)
|
||||
|
||||
## Called when the network data should be updated.
|
||||
def _update(self) -> None:
|
||||
super()._update()
|
||||
if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL:
|
||||
return # Avoid calling the cloud too often
|
||||
|
||||
Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL)
|
||||
if self._account.isLoggedIn:
|
||||
self.setAuthenticationState(AuthState.Authenticated)
|
||||
self._last_request_time = time()
|
||||
self._api.getClusterStatus(self.key, self._onStatusCallFinished)
|
||||
else:
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
|
||||
## Method called when HTTP request to status endpoint is finished.
|
||||
# Contains both printers and print jobs statuses in a single response.
|
||||
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
|
||||
# Update all data from the cluster.
|
||||
self._last_response_time = time()
|
||||
if self._received_printers != status.printers:
|
||||
self._received_printers = status.printers
|
||||
self._updatePrinters(status.printers)
|
||||
|
||||
if status.print_jobs != self._received_print_jobs:
|
||||
self._received_print_jobs = status.print_jobs
|
||||
self._updatePrintJobs(status.print_jobs)
|
||||
|
||||
## Updates the local list of printers with the list received from the cloud.
|
||||
# \param jobs: The printers received from the cloud.
|
||||
def _updatePrinters(self, printers: List[CloudClusterPrinterStatus]) -> None:
|
||||
previous = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel]
|
||||
received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinterStatus]
|
||||
|
||||
removed_printers, added_printers, updated_printers = findChanges(previous, received)
|
||||
|
||||
for removed_printer in removed_printers:
|
||||
if self._active_printer == removed_printer:
|
||||
self.setActivePrinter(None)
|
||||
self._printers.remove(removed_printer)
|
||||
|
||||
for added_printer in added_printers:
|
||||
self._printers.append(added_printer.createOutputModel(CloudOutputController(self)))
|
||||
|
||||
for model, printer in updated_printers:
|
||||
printer.updateOutputModel(model)
|
||||
|
||||
# Always have an active printer
|
||||
if self._printers and not self._active_printer:
|
||||
self.setActivePrinter(self._printers[0])
|
||||
|
||||
if added_printers or removed_printers:
|
||||
self.printersChanged.emit()
|
||||
|
||||
## Updates the local list of print jobs with the list received from the cloud.
|
||||
# \param jobs: The print jobs received from the cloud.
|
||||
def _updatePrintJobs(self, jobs: List[CloudClusterPrintJobStatus]) -> None:
|
||||
received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJobStatus]
|
||||
previous = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel]
|
||||
|
||||
removed_jobs, added_jobs, updated_jobs = findChanges(previous, received)
|
||||
|
||||
for removed_job in removed_jobs:
|
||||
if removed_job.assignedPrinter:
|
||||
removed_job.assignedPrinter.updateActivePrintJob(None)
|
||||
removed_job.stateChanged.disconnect(self._onPrintJobStateChanged)
|
||||
self._print_jobs.remove(removed_job)
|
||||
|
||||
for added_job in added_jobs:
|
||||
self._addPrintJob(added_job)
|
||||
|
||||
for model, job in updated_jobs:
|
||||
job.updateOutputModel(model)
|
||||
if job.printer_uuid:
|
||||
self._updateAssignedPrinter(model, job.printer_uuid)
|
||||
|
||||
# We only have to update when jobs are added or removed
|
||||
# updated jobs push their changes via their output model
|
||||
if added_jobs or removed_jobs:
|
||||
self.printJobsChanged.emit()
|
||||
|
||||
## Registers a new print job received via the cloud API.
|
||||
# \param job: The print job received.
|
||||
def _addPrintJob(self, job: CloudClusterPrintJobStatus) -> None:
|
||||
model = job.createOutputModel(CloudOutputController(self))
|
||||
model.stateChanged.connect(self._onPrintJobStateChanged)
|
||||
if job.printer_uuid:
|
||||
self._updateAssignedPrinter(model, job.printer_uuid)
|
||||
self._print_jobs.append(model)
|
||||
|
||||
## Handles the event of a change in a print job state
|
||||
def _onPrintJobStateChanged(self) -> None:
|
||||
user_name = self._getUserName()
|
||||
# TODO: confirm that notifications in Cura are still required
|
||||
for job in self._print_jobs:
|
||||
if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name:
|
||||
self._finished_jobs.add(job.key)
|
||||
Message(
|
||||
title = I18N_CATALOG.i18nc("@info:status", "Print finished"),
|
||||
text = (I18N_CATALOG.i18nc("@info:status", "Printer '{printer_name}' has finished printing '{job_name}'.").format(
|
||||
printer_name = job.assignedPrinter.name,
|
||||
job_name = job.name
|
||||
) if job.assignedPrinter else
|
||||
I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.").format(
|
||||
job_name = job.name
|
||||
)),
|
||||
).show()
|
||||
|
||||
## Updates the printer assignment for the given print job model.
|
||||
def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None:
|
||||
printer = next((p for p in self._printers if printer_uuid == p.key), None)
|
||||
if not printer:
|
||||
Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key,
|
||||
[p.key for p in self._printers])
|
||||
return
|
||||
|
||||
printer.updateActivePrintJob(model)
|
||||
model.updateAssignedPrinter(printer)
|
||||
|
||||
## Uploads the mesh when the print job was registered with the cloud API.
|
||||
# \param job_response: The response received from the cloud API.
|
||||
def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None:
|
||||
self._progress.show()
|
||||
self._uploaded_print_job = job_response
|
||||
tool_path = cast(bytes, self._tool_path)
|
||||
self._api.uploadToolPath(job_response, tool_path, self._onPrintJobUploaded, self._progress.update, self._onUploadError)
|
||||
|
||||
## Requests the print to be sent to the printer when we finished uploading the mesh.
|
||||
def _onPrintJobUploaded(self) -> None:
|
||||
self._progress.update(100)
|
||||
print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
|
||||
self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted)
|
||||
|
||||
## Displays the given message if uploading the mesh has failed
|
||||
# \param message: The message to display.
|
||||
def _onUploadError(self, message: str = None) -> None:
|
||||
self._progress.hide()
|
||||
self._uploaded_print_job = None
|
||||
Message(
|
||||
text = message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."),
|
||||
title = I18N_CATALOG.i18nc("@info:title", "Cloud error"),
|
||||
lifetime = 10
|
||||
).show()
|
||||
self.writeError.emit()
|
||||
|
||||
## Shows a message when the upload has succeeded
|
||||
# \param response: The response from the cloud API.
|
||||
def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
|
||||
Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id)
|
||||
self._progress.hide()
|
||||
Message(
|
||||
text = I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."),
|
||||
title = I18N_CATALOG.i18nc("@info:title", "Data Sent"),
|
||||
lifetime = 5
|
||||
).show()
|
||||
self.writeFinished.emit()
|
||||
|
||||
## Gets the remote printers.
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def printers(self) -> List[PrinterOutputModel]:
|
||||
return self._printers
|
||||
|
||||
## Get the active printer in the UI (monitor page).
|
||||
@pyqtProperty(QObject, notify = activePrinterChanged)
|
||||
def activePrinter(self) -> Optional[PrinterOutputModel]:
|
||||
return self._active_printer
|
||||
|
||||
## Set the active printer in the UI (monitor page).
|
||||
@pyqtSlot(QObject)
|
||||
def setActivePrinter(self, printer: Optional[PrinterOutputModel] = None) -> None:
|
||||
if printer != self._active_printer:
|
||||
self._active_printer = printer
|
||||
self.activePrinterChanged.emit()
|
||||
|
||||
@pyqtProperty(int, notify = _clusterPrintersChanged)
|
||||
def clusterSize(self) -> int:
|
||||
return len(self._printers)
|
||||
|
||||
## Get remote print jobs.
|
||||
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
||||
def printJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return self._print_jobs
|
||||
|
||||
## Get remote print jobs that are still in the print queue.
|
||||
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
||||
def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs
|
||||
if print_job.state == "queued" or print_job.state == "error"]
|
||||
|
||||
## Get remote print jobs that are assigned to a printer.
|
||||
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
||||
def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if
|
||||
print_job.assignedPrinter is not None and print_job.state != "queued"]
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def formatDuration(self, seconds: int) -> str:
|
||||
return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getTimeCompleted(self, time_remaining: int) -> str:
|
||||
return formatTimeCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getDateCompleted(self, time_remaining: int) -> str:
|
||||
return formatDateCompleted(time_remaining)
|
||||
|
||||
## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud.
|
||||
# TODO: We fake the methods here to not break the monitor page.
|
||||
|
||||
@pyqtProperty(QUrl, notify = _clusterPrintersChanged)
|
||||
def activeCameraUrl(self) -> "QUrl":
|
||||
return QUrl()
|
||||
|
||||
@pyqtSlot(QUrl)
|
||||
def setActiveCameraUrl(self, camera_url: "QUrl") -> None:
|
||||
pass
|
||||
|
||||
@pyqtProperty(bool, notify = printJobsChanged)
|
||||
def receivedPrintJobs(self) -> bool:
|
||||
return bool(self._print_jobs)
|
||||
|
||||
@pyqtSlot()
|
||||
def openPrintJobControlPanel(self) -> None:
|
||||
pass
|
||||
|
||||
@pyqtSlot()
|
||||
def openPrinterControlPanel(self) -> None:
|
||||
pass
|
||||
|
||||
@pyqtSlot(str)
|
||||
def sendJobToTop(self, print_job_uuid: str) -> None:
|
||||
pass
|
||||
|
||||
@pyqtSlot(str)
|
||||
def deleteJobFromQueue(self, print_job_uuid: str) -> None:
|
||||
pass
|
||||
|
||||
@pyqtSlot(str)
|
||||
def forceSendJob(self, print_job_uuid: str) -> None:
|
||||
pass
|
||||
|
||||
@pyqtProperty("QVariantList", notify = _clusterPrintersChanged)
|
||||
def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
|
||||
return []
|
170
plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py
Normal file
170
plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py
Normal file
@ -0,0 +1,170 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Dict, List
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from cura.API import Account
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
from .CloudApiClient import CloudApiClient
|
||||
from .CloudOutputDevice import CloudOutputDevice
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudError import CloudError
|
||||
from .Utils import findChanges
|
||||
|
||||
|
||||
## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters.
|
||||
# Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code.
|
||||
#
|
||||
# API spec is available on https://api.ultimaker.com/docs/connect/spec/.
|
||||
#
|
||||
class CloudOutputDeviceManager:
|
||||
META_CLUSTER_ID = "um_cloud_cluster_id"
|
||||
|
||||
# The interval with which the remote clusters are checked
|
||||
CHECK_CLUSTER_INTERVAL = 30.0 # seconds
|
||||
|
||||
# The translation catalog for this device.
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Persistent dict containing the remote clusters for the authenticated user.
|
||||
self._remote_clusters = {} # type: Dict[str, CloudOutputDevice]
|
||||
|
||||
application = CuraApplication.getInstance()
|
||||
self._output_device_manager = application.getOutputDeviceManager()
|
||||
|
||||
self._account = application.getCuraAPI().account # type: Account
|
||||
self._api = CloudApiClient(self._account, self._onApiError)
|
||||
|
||||
# Create a timer to update the remote cluster list
|
||||
self._update_timer = QTimer()
|
||||
self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000))
|
||||
self._update_timer.setSingleShot(False)
|
||||
|
||||
self._running = False
|
||||
|
||||
# Called when the uses logs in or out
|
||||
def _onLoginStateChanged(self, is_logged_in: bool) -> None:
|
||||
Logger.log("d", "Log in state changed to %s", is_logged_in)
|
||||
if is_logged_in:
|
||||
if not self._update_timer.isActive():
|
||||
self._update_timer.start()
|
||||
self._getRemoteClusters()
|
||||
else:
|
||||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
|
||||
# Notify that all clusters have disappeared
|
||||
self._onGetRemoteClustersFinished([])
|
||||
|
||||
## Gets all remote clusters from the API.
|
||||
def _getRemoteClusters(self) -> None:
|
||||
Logger.log("d", "Retrieving remote clusters")
|
||||
self._api.getClusters(self._onGetRemoteClustersFinished)
|
||||
|
||||
## Callback for when the request for getting the clusters. is finished.
|
||||
def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None:
|
||||
online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse]
|
||||
|
||||
removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters)
|
||||
|
||||
Logger.log("d", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()])
|
||||
Logger.log("d", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates))
|
||||
|
||||
# Remove output devices that are gone
|
||||
for removed_cluster in removed_devices:
|
||||
if removed_cluster.isConnected():
|
||||
removed_cluster.disconnect()
|
||||
removed_cluster.close()
|
||||
self._output_device_manager.removeOutputDevice(removed_cluster.key)
|
||||
del self._remote_clusters[removed_cluster.key]
|
||||
|
||||
# Add an output device for each new remote cluster.
|
||||
# We only add when is_online as we don't want the option in the drop down if the cluster is not online.
|
||||
for added_cluster in added_clusters:
|
||||
device = CloudOutputDevice(self._api, added_cluster)
|
||||
self._remote_clusters[added_cluster.cluster_id] = device
|
||||
|
||||
for device, cluster in updates:
|
||||
device.clusterData = cluster
|
||||
|
||||
self._connectToActiveMachine()
|
||||
|
||||
## Callback for when the active machine was changed by the user or a new remote cluster was found.
|
||||
def _connectToActiveMachine(self) -> None:
|
||||
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not active_machine:
|
||||
return
|
||||
|
||||
# Remove all output devices that we have registered.
|
||||
# This is needed because when we switch machines we can only leave
|
||||
# output devices that are meant for that machine.
|
||||
for stored_cluster_id in self._remote_clusters:
|
||||
self._output_device_manager.removeOutputDevice(stored_cluster_id)
|
||||
|
||||
# Check if the stored cluster_id for the active machine is in our list of remote clusters.
|
||||
stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID)
|
||||
if stored_cluster_id in self._remote_clusters:
|
||||
device = self._remote_clusters[stored_cluster_id]
|
||||
self._connectToOutputDevice(device)
|
||||
Logger.log("d", "Device connected by metadata cluster ID %s", stored_cluster_id)
|
||||
else:
|
||||
self._connectByNetworkKey(active_machine)
|
||||
|
||||
## Tries to match the local network key to the cloud cluster host name.
|
||||
def _connectByNetworkKey(self, active_machine: GlobalStack) -> None:
|
||||
# Check if the active printer has a local network connection and match this key to the remote cluster.
|
||||
local_network_key = active_machine.getMetaDataEntry("um_network_key")
|
||||
if not local_network_key:
|
||||
return
|
||||
|
||||
device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None)
|
||||
if not device:
|
||||
return
|
||||
|
||||
Logger.log("i", "Found cluster %s with network key %s", device, local_network_key)
|
||||
active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
|
||||
self._connectToOutputDevice(device)
|
||||
|
||||
## Connects to an output device and makes sure it is registered in the output device manager.
|
||||
def _connectToOutputDevice(self, device: CloudOutputDevice) -> None:
|
||||
device.connect()
|
||||
self._output_device_manager.addOutputDevice(device)
|
||||
|
||||
## Handles an API error received from the cloud.
|
||||
# \param errors: The errors received
|
||||
def _onApiError(self, errors: List[CloudError]) -> None:
|
||||
text = ". ".join(e.title for e in errors) # TODO: translate errors
|
||||
message = Message(
|
||||
text = text,
|
||||
title = self.I18N_CATALOG.i18nc("@info:title", "Error"),
|
||||
lifetime = 10
|
||||
)
|
||||
message.show()
|
||||
|
||||
## Starts running the cloud output device manager, thus periodically requesting cloud data.
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
application = CuraApplication.getInstance()
|
||||
self._account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
# When switching machines we check if we have to activate a remote cluster.
|
||||
application.globalContainerStackChanged.connect(self._connectToActiveMachine)
|
||||
self._update_timer.timeout.connect(self._getRemoteClusters)
|
||||
self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn)
|
||||
|
||||
## Stops running the cloud output device manager.
|
||||
def stop(self):
|
||||
if not self._running:
|
||||
return
|
||||
application = CuraApplication.getInstance()
|
||||
self._account.loginStateChanged.disconnect(self._onLoginStateChanged)
|
||||
# When switching machines we check if we have to activate a remote cluster.
|
||||
application.globalContainerStackChanged.disconnect(self._connectToActiveMachine)
|
||||
self._update_timer.timeout.disconnect(self._getRemoteClusters)
|
||||
self._onLoginStateChanged(is_logged_in = False)
|
32
plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py
Normal file
32
plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from UM import i18nCatalog
|
||||
from UM.Message import Message
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
||||
## Class responsible for showing a progress message while a mesh is being uploaded to the cloud.
|
||||
class CloudProgressMessage(Message):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
text = I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"),
|
||||
title = I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"),
|
||||
progress = -1,
|
||||
lifetime = 0,
|
||||
dismissable = False,
|
||||
use_inactivity_timer = False
|
||||
)
|
||||
|
||||
## Shows the progress message.
|
||||
def show(self):
|
||||
self.setProgress(0)
|
||||
super().show()
|
||||
|
||||
## Updates the percentage of the uploaded.
|
||||
# \param percentage: The percentage amount (0-100).
|
||||
def update(self, percentage: int) -> None:
|
||||
if not self._visible:
|
||||
super().show()
|
||||
self.setProgress(percentage)
|
@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Union, TypeVar, Type, List, Any
|
||||
|
||||
from ...Models import BaseModel
|
||||
|
||||
|
||||
## Base class for the models used in the interface with the Ultimaker cloud APIs.
|
||||
class BaseCloudModel(BaseModel):
|
||||
## Checks whether the two models are equal.
|
||||
# \param other: The other model.
|
||||
# \return True if they are equal, False if they are different.
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self.toDict() == other.toDict()
|
||||
|
||||
## Checks whether the two models are different.
|
||||
# \param other: The other model.
|
||||
# \return True if they are different, False if they are the same.
|
||||
def __ne__(self, other) -> bool:
|
||||
return type(self) != type(other) or self.toDict() != other.toDict()
|
||||
|
||||
## Converts the model into a serializable dictionary
|
||||
def toDict(self) -> Dict[str, Any]:
|
||||
return self.__dict__
|
||||
|
||||
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
## Parses a single model.
|
||||
# \param model_class: The model class.
|
||||
# \param values: The value of the model, which is usually a dictionary, but may also be already parsed.
|
||||
# \return An instance of the model_class given.
|
||||
@staticmethod
|
||||
def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T:
|
||||
if isinstance(values, dict):
|
||||
return model_class(**values)
|
||||
return values
|
||||
|
||||
## Parses a list of models.
|
||||
# \param model_class: The model class.
|
||||
# \param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
|
||||
# \return A list of instances of the model_class given.
|
||||
@classmethod
|
||||
def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]:
|
||||
return [cls.parseModel(model_class, value) for value in values]
|
||||
|
||||
## Parses the given date string.
|
||||
# \param date: The date to parse.
|
||||
# \return The parsed date.
|
||||
@staticmethod
|
||||
def parseDate(date: Union[str, datetime]) -> datetime:
|
||||
if isinstance(date, datetime):
|
||||
return date
|
||||
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
|
@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cluster printer
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterBuildPlate(BaseCloudModel):
|
||||
## Create a new build plate
|
||||
# \param type: The type of buildplate glass or aluminium
|
||||
def __init__(self, type: str = "glass", **kwargs) -> None:
|
||||
self.type = type
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Union, Dict, Optional, Any
|
||||
|
||||
from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel
|
||||
from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel
|
||||
from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cloud cluster printer configuration
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintCoreConfiguration(BaseCloudModel):
|
||||
## Creates a new cloud cluster printer configuration object
|
||||
# \param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right.
|
||||
# \param material: The material of a configuration object in a cluster printer. May be in a dict or an object.
|
||||
# \param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'.
|
||||
# \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'.
|
||||
def __init__(self, extruder_index: int,
|
||||
material: Union[None, Dict[str, Any], CloudClusterPrinterConfigurationMaterial],
|
||||
print_core_id: Optional[str] = None, **kwargs) -> None:
|
||||
self.extruder_index = extruder_index
|
||||
self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) if material else None
|
||||
self.print_core_id = print_core_id
|
||||
super().__init__(**kwargs)
|
||||
|
||||
## Updates the given output model.
|
||||
# \param model - The output model to update.
|
||||
def updateOutputModel(self, model: ExtruderOutputModel) -> None:
|
||||
if self.print_core_id is not None:
|
||||
model.updateHotendID(self.print_core_id)
|
||||
|
||||
if self.material:
|
||||
active_material = model.activeMaterial
|
||||
if active_material is None or active_material.guid != self.material.guid:
|
||||
material = self.material.createOutputModel()
|
||||
model.updateActiveMaterial(material)
|
||||
else:
|
||||
model.updateActiveMaterial(None)
|
||||
|
||||
## Creates a configuration model
|
||||
def createConfigurationModel(self) -> ExtruderConfigurationModel:
|
||||
model = ExtruderConfigurationModel(position = self.extruder_index)
|
||||
self.updateConfigurationModel(model)
|
||||
return model
|
||||
|
||||
## Creates a configuration model
|
||||
def updateConfigurationModel(self, model: ExtruderConfigurationModel) -> ExtruderConfigurationModel:
|
||||
model.setHotendID(self.print_core_id)
|
||||
if self.material:
|
||||
model.setMaterial(self.material.createOutputModel())
|
||||
return model
|
@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Model for the types of changes that are needed before a print job can start
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobConfigurationChange(BaseCloudModel):
|
||||
## Creates a new print job constraint.
|
||||
# \param type_of_change: The type of configuration change, one of: "material", "print_core_change"
|
||||
# \param index: The hotend slot or extruder index to change
|
||||
# \param target_id: Target material guid or hotend id
|
||||
# \param origin_id: Original/current material guid or hotend id
|
||||
# \param target_name: Target material name or hotend id
|
||||
# \param origin_name: Original/current material name or hotend id
|
||||
def __init__(self, type_of_change: str, target_id: str, origin_id: str,
|
||||
index: Optional[int] = None, target_name: Optional[str] = None, origin_name: Optional[str] = None,
|
||||
**kwargs) -> None:
|
||||
self.type_of_change = type_of_change
|
||||
self.index = index
|
||||
self.target_id = target_id
|
||||
self.origin_id = origin_id
|
||||
self.target_name = target_name
|
||||
self.origin_name = origin_name
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,16 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cloud cluster print job constraint
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobConstraints(BaseCloudModel):
|
||||
## Creates a new print job constraint.
|
||||
# \param require_printer_name: Unique name of the printer that this job should be printed on.
|
||||
# Should be one of the unique_name field values in the cluster, e.g. 'ultimakersystem-ccbdd30044ec'
|
||||
def __init__(self, require_printer_name: Optional[str] = None, **kwargs) -> None:
|
||||
self.require_printer_name = require_printer_name
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,15 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing the reasons that prevent this job from being printed on the associated printer
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobImpediment(BaseCloudModel):
|
||||
## Creates a new print job constraint.
|
||||
# \param translation_key: A string indicating a reason the print cannot be printed, such as 'does_not_fit_in_build_volume'
|
||||
# \param severity: A number indicating the severity of the problem, with higher being more severe
|
||||
def __init__(self, translation_key: str, severity: int, **kwargs) -> None:
|
||||
self.translation_key = translation_key
|
||||
self.severity = severity
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List, Optional, Union, Dict, Any
|
||||
|
||||
from cura.PrinterOutput.ConfigurationModel import ConfigurationModel
|
||||
from ...UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from ...ConfigurationChangeModel import ConfigurationChangeModel
|
||||
from ..CloudOutputController import CloudOutputController
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from .CloudClusterBuildPlate import CloudClusterBuildPlate
|
||||
from .CloudClusterPrintJobConfigurationChange import CloudClusterPrintJobConfigurationChange
|
||||
from .CloudClusterPrintJobImpediment import CloudClusterPrintJobImpediment
|
||||
from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration
|
||||
from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraints
|
||||
|
||||
|
||||
## Model for the status of a single print job in a cluster.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobStatus(BaseCloudModel):
|
||||
## Creates a new cloud print job status model.
|
||||
# \param assigned_to: The name of the printer this job is assigned to while being queued.
|
||||
# \param configuration: The required print core configurations of this print job.
|
||||
# \param constraints: Print job constraints object.
|
||||
# \param created_at: The timestamp when the job was created in Cura Connect.
|
||||
# \param force: Allow this job to be printed despite of mismatching configurations.
|
||||
# \param last_seen: The number of seconds since this job was checked.
|
||||
# \param machine_variant: The machine type that this job should be printed on.Coincides with the machine_type field
|
||||
# of the printer object.
|
||||
# \param name: The name of the print job. Usually the name of the .gcode file.
|
||||
# \param network_error_count: The number of errors encountered when requesting data for this print job.
|
||||
# \param owner: The name of the user who added the print job to Cura Connect.
|
||||
# \param printer_uuid: UUID of the printer that the job is currently printing on or assigned to.
|
||||
# \param started: Whether the job has started printing or not.
|
||||
# \param status: The status of the print job.
|
||||
# \param time_elapsed: The remaining printing time in seconds.
|
||||
# \param time_total: The total printing time in seconds.
|
||||
# \param uuid: UUID of this print job. Should be used for identification purposes.
|
||||
# \param deleted_at: The time when this print job was deleted.
|
||||
# \param printed_on_uuid: UUID of the printer used to print this job.
|
||||
# \param configuration_changes_required: List of configuration changes the printer this job is associated with
|
||||
# needs to make in order to be able to print this job
|
||||
# \param build_plate: The build plate (type) this job needs to be printed on.
|
||||
# \param compatible_machine_families: Family names of machines suitable for this print job
|
||||
# \param impediments_to_printing: A list of reasons that prevent this job from being printed on the associated
|
||||
# printer
|
||||
def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str,
|
||||
time_total: int, uuid: str,
|
||||
configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]],
|
||||
constraints: List[Union[Dict[str, Any], CloudClusterPrintJobConstraints]],
|
||||
last_seen: Optional[float] = None, network_error_count: Optional[int] = None,
|
||||
owner: Optional[str] = None, printer_uuid: Optional[str] = None, time_elapsed: Optional[int] = None,
|
||||
assigned_to: Optional[str] = None, deleted_at: Optional[str] = None,
|
||||
printed_on_uuid: Optional[str] = None,
|
||||
configuration_changes_required: List[
|
||||
Union[Dict[str, Any], CloudClusterPrintJobConfigurationChange]] = None,
|
||||
build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None,
|
||||
compatible_machine_families: List[str] = None,
|
||||
impediments_to_printing: List[Union[Dict[str, Any], CloudClusterPrintJobImpediment]] = None,
|
||||
**kwargs) -> None:
|
||||
self.assigned_to = assigned_to
|
||||
self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration)
|
||||
self.constraints = self.parseModels(CloudClusterPrintJobConstraints, constraints)
|
||||
self.created_at = created_at
|
||||
self.force = force
|
||||
self.last_seen = last_seen
|
||||
self.machine_variant = machine_variant
|
||||
self.name = name
|
||||
self.network_error_count = network_error_count
|
||||
self.owner = owner
|
||||
self.printer_uuid = printer_uuid
|
||||
self.started = started
|
||||
self.status = status
|
||||
self.time_elapsed = time_elapsed
|
||||
self.time_total = time_total
|
||||
self.uuid = uuid
|
||||
self.deleted_at = deleted_at
|
||||
self.printed_on_uuid = printed_on_uuid
|
||||
|
||||
self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange,
|
||||
configuration_changes_required) \
|
||||
if configuration_changes_required else []
|
||||
self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None
|
||||
self.compatible_machine_families = compatible_machine_families if compatible_machine_families else []
|
||||
self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) \
|
||||
if impediments_to_printing else []
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
## Creates an UM3 print job output model based on this cloud cluster print job.
|
||||
# \param printer: The output model of the printer
|
||||
def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel:
|
||||
model = UM3PrintJobOutputModel(controller, self.uuid, self.name)
|
||||
self.updateOutputModel(model)
|
||||
|
||||
return model
|
||||
|
||||
## Creates a new configuration model
|
||||
def _createConfigurationModel(self) -> ConfigurationModel:
|
||||
extruders = [extruder.createConfigurationModel() for extruder in self.configuration or ()]
|
||||
configuration = ConfigurationModel()
|
||||
configuration.setExtruderConfigurations(extruders)
|
||||
return configuration
|
||||
|
||||
## Updates an UM3 print job output model based on this cloud cluster print job.
|
||||
# \param model: The model to update.
|
||||
def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None:
|
||||
model.updateConfiguration(self._createConfigurationModel())
|
||||
model.updateTimeTotal(self.time_total)
|
||||
model.updateTimeElapsed(self.time_elapsed)
|
||||
model.updateOwner(self.owner)
|
||||
model.updateState(self.status)
|
||||
model.setCompatibleMachineFamilies(self.compatible_machine_families)
|
||||
model.updateTimeTotal(self.time_total)
|
||||
model.updateTimeElapsed(self.time_elapsed)
|
||||
model.updateOwner(self.owner)
|
||||
|
||||
status_set_by_impediment = False
|
||||
for impediment in self.impediments_to_printing:
|
||||
# TODO: impediment.severity is defined as int, this will not work, is there a translation?
|
||||
if impediment.severity == "UNFIXABLE":
|
||||
status_set_by_impediment = True
|
||||
model.updateState("error")
|
||||
break
|
||||
|
||||
if not status_set_by_impediment:
|
||||
model.updateState(self.status)
|
||||
|
||||
model.updateConfigurationChanges(
|
||||
[ConfigurationChangeModel(
|
||||
type_of_change = change.type_of_change,
|
||||
index = change.index if change.index else 0,
|
||||
target_name = change.target_name if change.target_name else "",
|
||||
origin_name = change.origin_name if change.origin_name else "")
|
||||
for change in self.configuration_changes_required])
|
@ -0,0 +1,55 @@
|
||||
from typing import Optional
|
||||
|
||||
from UM.Logger import Logger
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cloud cluster printer configuration
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrinterConfigurationMaterial(BaseCloudModel):
|
||||
## Creates a new material configuration model.
|
||||
# \param brand: The brand of material in this print core, e.g. 'Ultimaker'.
|
||||
# \param color: The color of material in this print core, e.g. 'Blue'.
|
||||
# \param guid: he GUID of the material in this print core, e.g. '506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9'.
|
||||
# \param material: The type of material in this print core, e.g. 'PLA'.
|
||||
def __init__(self, brand: Optional[str] = None, color: Optional[str] = None, guid: Optional[str] = None,
|
||||
material: Optional[str] = None, **kwargs) -> None:
|
||||
self.guid = guid
|
||||
self.brand = brand
|
||||
self.color = color
|
||||
self.material = material
|
||||
super().__init__(**kwargs)
|
||||
|
||||
## Creates a material output model based on this cloud printer material.
|
||||
def createOutputModel(self) -> MaterialOutputModel:
|
||||
material_manager = CuraApplication.getInstance().getMaterialManager()
|
||||
material_group_list = material_manager.getMaterialGroupListByGUID(self.guid) or []
|
||||
|
||||
# Sort the material groups by "is_read_only = True" first, and then the name alphabetically.
|
||||
read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list))
|
||||
non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list))
|
||||
material_group = None
|
||||
if read_only_material_group_list:
|
||||
read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name)
|
||||
material_group = read_only_material_group_list[0]
|
||||
elif non_read_only_material_group_list:
|
||||
non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name)
|
||||
material_group = non_read_only_material_group_list[0]
|
||||
|
||||
if material_group:
|
||||
container = material_group.root_material_node.getContainer()
|
||||
color = container.getMetaDataEntry("color_code")
|
||||
brand = container.getMetaDataEntry("brand")
|
||||
material_type = container.getMetaDataEntry("material")
|
||||
name = container.getName()
|
||||
else:
|
||||
Logger.log("w", "Unable to find material with guid {guid}. Using data as provided by cluster"
|
||||
.format(guid = self.guid))
|
||||
color = self.color
|
||||
brand = self.brand
|
||||
material_type = self.material
|
||||
name = "Empty" if self.material == "empty" else "Unknown"
|
||||
|
||||
return MaterialOutputModel(guid = self.guid, type = material_type, brand = brand, color = color, name = name)
|
@ -0,0 +1,72 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List, Union, Dict, Optional, Any
|
||||
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
from .CloudClusterBuildPlate import CloudClusterBuildPlate
|
||||
from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cluster printer
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrinterStatus(BaseCloudModel):
|
||||
## Creates a new cluster printer status
|
||||
# \param enabled: A printer can be disabled if it should not receive new jobs. By default every printer is enabled.
|
||||
# \param firmware_version: Firmware version installed on the printer. Can differ for each printer in a cluster.
|
||||
# \param friendly_name: Human readable name of the printer. Can be used for identification purposes.
|
||||
# \param ip_address: The IP address of the printer in the local network.
|
||||
# \param machine_variant: The type of printer. Can be 'Ultimaker 3' or 'Ultimaker 3ext'.
|
||||
# \param status: The status of the printer.
|
||||
# \param unique_name: The unique name of the printer in the network.
|
||||
# \param uuid: The unique ID of the printer, also known as GUID.
|
||||
# \param configuration: The active print core configurations of this printer.
|
||||
# \param reserved_by: A printer can be claimed by a specific print job.
|
||||
# \param maintenance_required: Indicates if maintenance is necessary
|
||||
# \param firmware_update_status: Whether the printer's firmware is up-to-date, value is one of: "up_to_date",
|
||||
# "pending_update", "update_available", "update_in_progress", "update_failed", "update_impossible"
|
||||
# \param latest_available_firmware: The version of the latest firmware that is available
|
||||
# \param build_plate: The build plate that is on the printer
|
||||
def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str,
|
||||
status: str, unique_name: str, uuid: str,
|
||||
configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]],
|
||||
reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None,
|
||||
firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None,
|
||||
build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None, **kwargs) -> None:
|
||||
|
||||
self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration)
|
||||
self.enabled = enabled
|
||||
self.firmware_version = firmware_version
|
||||
self.friendly_name = friendly_name
|
||||
self.ip_address = ip_address
|
||||
self.machine_variant = machine_variant
|
||||
self.status = status
|
||||
self.unique_name = unique_name
|
||||
self.uuid = uuid
|
||||
self.reserved_by = reserved_by
|
||||
self.maintenance_required = maintenance_required
|
||||
self.firmware_update_status = firmware_update_status
|
||||
self.latest_available_firmware = latest_available_firmware
|
||||
self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
## Creates a new output model.
|
||||
# \param controller - The controller of the model.
|
||||
def createOutputModel(self, controller: PrinterOutputController) -> PrinterOutputModel:
|
||||
model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version)
|
||||
self.updateOutputModel(model)
|
||||
return model
|
||||
|
||||
## Updates the given output model.
|
||||
# \param model - The output model to update.
|
||||
def updateOutputModel(self, model: PrinterOutputModel) -> None:
|
||||
model.updateKey(self.uuid)
|
||||
model.updateName(self.friendly_name)
|
||||
model.updateType(self.machine_variant)
|
||||
model.updateState(self.status if self.enabled else "disabled")
|
||||
|
||||
for configuration, extruder_output, extruder_config in \
|
||||
zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations):
|
||||
configuration.updateOutputModel(extruder_output)
|
||||
configuration.updateConfigurationModel(extruder_config)
|
@ -0,0 +1,32 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cloud connected cluster.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterResponse(BaseCloudModel):
|
||||
## Creates a new cluster response object.
|
||||
# \param cluster_id: The secret unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
|
||||
# \param host_guid: The unique identifier of the print cluster host, e.g. 'e90ae0ac-1257-4403-91ee-a44c9b7e8050'.
|
||||
# \param host_name: The name of the printer as configured during the Wi-Fi setup. Used as identifier for end users.
|
||||
# \param is_online: Whether this cluster is currently connected to the cloud.
|
||||
# \param status: The status of the cluster authentication (active or inactive).
|
||||
# \param host_version: The firmware version of the cluster host. This is where the Stardust client is running on.
|
||||
def __init__(self, cluster_id: str, host_guid: str, host_name: str, is_online: bool, status: str,
|
||||
host_version: Optional[str] = None, **kwargs) -> None:
|
||||
self.cluster_id = cluster_id
|
||||
self.host_guid = host_guid
|
||||
self.host_name = host_name
|
||||
self.status = status
|
||||
self.is_online = is_online
|
||||
self.host_version = host_version
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
if not self.cluster_id:
|
||||
raise ValueError("cluster_id is required on CloudCluster")
|
@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Union, Any
|
||||
|
||||
from .CloudClusterPrinterStatus import CloudClusterPrinterStatus
|
||||
from .CloudClusterPrintJobStatus import CloudClusterPrintJobStatus
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
# Model that represents the status of the cluster for the cloud
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterStatus(BaseCloudModel):
|
||||
## Creates a new cluster status model object.
|
||||
# \param printers: The latest status of each printer in the cluster.
|
||||
# \param print_jobs: The latest status of each print job in the cluster.
|
||||
# \param generated_time: The datetime when the object was generated on the server-side.
|
||||
def __init__(self,
|
||||
printers: List[Union[CloudClusterPrinterStatus, Dict[str, Any]]],
|
||||
print_jobs: List[Union[CloudClusterPrintJobStatus, Dict[str, Any]]],
|
||||
generated_time: Union[str, datetime],
|
||||
**kwargs) -> None:
|
||||
self.generated_time = self.parseDate(generated_time)
|
||||
self.printers = self.parseModels(CloudClusterPrinterStatus, printers)
|
||||
self.print_jobs = self.parseModels(CloudClusterPrintJobStatus, print_jobs)
|
||||
super().__init__(**kwargs)
|
28
plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py
Normal file
28
plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing errors generated by the cloud servers, according to the JSON-API standard.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudError(BaseCloudModel):
|
||||
## Creates a new error object.
|
||||
# \param id: Unique identifier for this particular occurrence of the problem.
|
||||
# \param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
|
||||
# of the problem, except for purposes of localization.
|
||||
# \param code: An application-specific error code, expressed as a string value.
|
||||
# \param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
|
||||
# value can be localized.
|
||||
# \param http_status: The HTTP status code applicable to this problem, converted to string.
|
||||
# \param meta: Non-standard meta-information about the error, depending on the error code.
|
||||
def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None,
|
||||
meta: Optional[Dict[str, Any]] = None, **kwargs) -> None:
|
||||
self.id = id
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
self.meta = meta
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
# Model that represents the response received from the cloud after requesting to upload a print job
|
||||
# Spec: https://api-staging.ultimaker.com/cura/v1/spec
|
||||
class CloudPrintJobResponse(BaseCloudModel):
|
||||
## Creates a new print job response model.
|
||||
# \param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
|
||||
# \param status: The status of the print job.
|
||||
# \param status_description: Contains more details about the status, e.g. the cause of failures.
|
||||
# \param download_url: A signed URL to download the resulting status. Only available when the job is finished.
|
||||
# \param job_name: The name of the print job.
|
||||
# \param slicing_details: Model for slice information.
|
||||
# \param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
|
||||
# \param content_type: The content type of the print job (e.g. text/plain or application/gzip)
|
||||
# \param generated_time: The datetime when the object was generated on the server-side.
|
||||
def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None,
|
||||
upload_url: Optional[str] = None, content_type: Optional[str] = None,
|
||||
status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None:
|
||||
self.job_id = job_id
|
||||
self.status = status
|
||||
self.download_url = download_url
|
||||
self.job_name = job_name
|
||||
self.upload_url = upload_url
|
||||
self.content_type = content_type
|
||||
self.status_description = status_description
|
||||
# TODO: Implement slicing details
|
||||
self.slicing_details = slicing_details
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,17 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
# Model that represents the request to upload a print job to the cloud
|
||||
# Spec: https://api-staging.ultimaker.com/cura/v1/spec
|
||||
class CloudPrintJobUploadRequest(BaseCloudModel):
|
||||
## Creates a new print job upload request.
|
||||
# \param job_name: The name of the print job.
|
||||
# \param file_size: The size of the file in bytes.
|
||||
# \param content_type: The content type of the print job (e.g. text/plain or application/gzip)
|
||||
def __init__(self, job_name: str, file_size: int, content_type: str, **kwargs) -> None:
|
||||
self.job_name = job_name
|
||||
self.file_size = file_size
|
||||
self.content_type = content_type
|
||||
super().__init__(**kwargs)
|
@ -0,0 +1,23 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
# Model that represents the responses received from the cloud after requesting a job to be printed.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudPrintResponse(BaseCloudModel):
|
||||
## Creates a new print response object.
|
||||
# \param job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
|
||||
# \param status: The status of the print request (queued or failed).
|
||||
# \param generated_time: The datetime when the object was generated on the server-side.
|
||||
# \param cluster_job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
|
||||
def __init__(self, job_id: str, status: str, generated_time: Union[str, datetime],
|
||||
cluster_job_id: Optional[str] = None, **kwargs) -> None:
|
||||
self.job_id = job_id
|
||||
self.status = status
|
||||
self.cluster_job_id = cluster_job_id
|
||||
self.generated_time = self.parseDate(generated_time)
|
||||
super().__init__(**kwargs)
|
2
plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py
Normal file
2
plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
148
plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py
Normal file
148
plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py
Normal file
@ -0,0 +1,148 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# !/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
|
||||
from typing import Optional, Callable, Any, Tuple, cast
|
||||
|
||||
from UM.Logger import Logger
|
||||
from .Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
|
||||
|
||||
## Class responsible for uploading meshes to the cloud in separate requests.
|
||||
class ToolPathUploader:
|
||||
|
||||
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
|
||||
MAX_RETRIES = 10
|
||||
|
||||
# The HTTP codes that should trigger a retry.
|
||||
RETRY_HTTP_CODES = {500, 502, 503, 504}
|
||||
|
||||
# The amount of bytes to send per request
|
||||
BYTES_PER_REQUEST = 256 * 1024
|
||||
|
||||
## Creates a mesh upload object.
|
||||
# \param manager: The network access manager that will handle the HTTP requests.
|
||||
# \param print_job: The print job response that was returned by the cloud after registering the upload.
|
||||
# \param data: The mesh bytes to be uploaded.
|
||||
# \param on_finished: The method to be called when done.
|
||||
# \param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
|
||||
# \param on_error: The method to be called when an error occurs.
|
||||
def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes,
|
||||
on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]
|
||||
) -> None:
|
||||
self._manager = manager
|
||||
self._print_job = print_job
|
||||
self._data = data
|
||||
|
||||
self._on_finished = on_finished
|
||||
self._on_progress = on_progress
|
||||
self._on_error = on_error
|
||||
|
||||
self._sent_bytes = 0
|
||||
self._retries = 0
|
||||
self._finished = False
|
||||
self._reply = None # type: Optional[QNetworkReply]
|
||||
|
||||
## Returns the print job for which this object was created.
|
||||
@property
|
||||
def printJob(self):
|
||||
return self._print_job
|
||||
|
||||
## Creates a network request to the print job upload URL, adding the needed content range header.
|
||||
def _createRequest(self) -> QNetworkRequest:
|
||||
request = QNetworkRequest(QUrl(self._print_job.upload_url))
|
||||
request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type)
|
||||
|
||||
first_byte, last_byte = self._chunkRange()
|
||||
content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
|
||||
request.setRawHeader(b"Content-Range", content_range.encode())
|
||||
Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
|
||||
|
||||
return request
|
||||
|
||||
## Determines the bytes that should be uploaded next.
|
||||
# \return: A tuple with the first and the last byte to upload.
|
||||
def _chunkRange(self) -> Tuple[int, int]:
|
||||
last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST)
|
||||
return self._sent_bytes, last_byte
|
||||
|
||||
## Starts uploading the mesh.
|
||||
def start(self) -> None:
|
||||
if self._finished:
|
||||
# reset state.
|
||||
self._sent_bytes = 0
|
||||
self._retries = 0
|
||||
self._finished = False
|
||||
self._uploadChunk()
|
||||
|
||||
## Stops uploading the mesh, marking it as finished.
|
||||
def stop(self):
|
||||
Logger.log("i", "Stopped uploading")
|
||||
self._finished = True
|
||||
|
||||
## Uploads a chunk of the mesh to the cloud.
|
||||
def _uploadChunk(self) -> None:
|
||||
if self._finished:
|
||||
raise ValueError("The upload is already finished")
|
||||
|
||||
first_byte, last_byte = self._chunkRange()
|
||||
request = self._createRequest()
|
||||
|
||||
# now send the reply and subscribe to the results
|
||||
self._reply = self._manager.put(request, self._data[first_byte:last_byte])
|
||||
self._reply.finished.connect(self._finishedCallback)
|
||||
self._reply.uploadProgress.connect(self._progressCallback)
|
||||
self._reply.error.connect(self._errorCallback)
|
||||
|
||||
## Handles an update to the upload progress
|
||||
# \param bytes_sent: The amount of bytes sent in the current request.
|
||||
# \param bytes_total: The amount of bytes to send in the current request.
|
||||
def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None:
|
||||
Logger.log("i", "Progress callback %s / %s", bytes_sent, bytes_total)
|
||||
if bytes_total:
|
||||
total_sent = self._sent_bytes + bytes_sent
|
||||
self._on_progress(int(total_sent / len(self._data) * 100))
|
||||
|
||||
## Handles an error uploading.
|
||||
def _errorCallback(self) -> None:
|
||||
reply = cast(QNetworkReply, self._reply)
|
||||
body = bytes(reply.readAll()).decode()
|
||||
Logger.log("e", "Received error while uploading: %s", body)
|
||||
self.stop()
|
||||
self._on_error()
|
||||
|
||||
## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
|
||||
def _finishedCallback(self) -> None:
|
||||
reply = cast(QNetworkReply, self._reply)
|
||||
Logger.log("i", "Finished callback %s %s",
|
||||
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
|
||||
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: int
|
||||
|
||||
# check if we should retry the last chunk
|
||||
if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES:
|
||||
self._retries += 1
|
||||
Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString())
|
||||
self._uploadChunk()
|
||||
return
|
||||
|
||||
# Http codes that are not to be retried are assumed to be errors.
|
||||
if status_code > 308:
|
||||
self._errorCallback()
|
||||
return
|
||||
|
||||
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,
|
||||
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
|
||||
self._chunkUploaded()
|
||||
|
||||
## Handles a chunk of data being uploaded, starting the next chunk if needed.
|
||||
def _chunkUploaded(self) -> None:
|
||||
# We got a successful response. Let's start the next chunk or report the upload is finished.
|
||||
first_byte, last_byte = self._chunkRange()
|
||||
self._sent_bytes += last_byte - first_byte
|
||||
if self._sent_bytes >= len(self._data):
|
||||
self.stop()
|
||||
self._on_finished()
|
||||
else:
|
||||
self._uploadChunk()
|
54
plugins/UM3NetworkPrinting/src/Cloud/Utils.py
Normal file
54
plugins/UM3NetworkPrinting/src/Cloud/Utils.py
Normal file
@ -0,0 +1,54 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TypeVar, Dict, Tuple, List
|
||||
|
||||
from UM import i18nCatalog
|
||||
|
||||
T = TypeVar("T")
|
||||
U = TypeVar("U")
|
||||
|
||||
|
||||
## Splits the given dictionaries into three lists (in a tuple):
|
||||
# - `removed`: Items that were in the first argument but removed in the second one.
|
||||
# - `added`: Items that were not in the first argument but were included in the second one.
|
||||
# - `updated`: Items that were in both dictionaries. Both values are given in a tuple.
|
||||
# \param previous: The previous items
|
||||
# \param received: The received items
|
||||
# \return: The tuple (removed, added, updated) as explained above.
|
||||
def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T], List[U], List[Tuple[T, U]]]:
|
||||
previous_ids = set(previous)
|
||||
received_ids = set(received)
|
||||
|
||||
removed_ids = previous_ids.difference(received_ids)
|
||||
new_ids = received_ids.difference(previous_ids)
|
||||
updated_ids = received_ids.intersection(previous_ids)
|
||||
|
||||
removed = [previous[removed_id] for removed_id in removed_ids]
|
||||
added = [received[new_id] for new_id in new_ids]
|
||||
updated = [(previous[updated_id], received[updated_id]) for updated_id in updated_ids]
|
||||
|
||||
return removed, added, updated
|
||||
|
||||
|
||||
def formatTimeCompleted(seconds_remaining: int) -> str:
|
||||
completed = datetime.now() + timedelta(seconds=seconds_remaining)
|
||||
return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute)
|
||||
|
||||
|
||||
def formatDateCompleted(seconds_remaining: int) -> str:
|
||||
now = datetime.now()
|
||||
completed = now + timedelta(seconds=seconds_remaining)
|
||||
days = (completed.date() - now.date()).days
|
||||
i18n = i18nCatalog("cura")
|
||||
|
||||
# If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format
|
||||
if days >= 7:
|
||||
return completed.strftime("%a %b ") + "{day}".format(day = completed.day)
|
||||
# If finishing date is within the next week, use "Monday at HH:MM" format
|
||||
elif days >= 2:
|
||||
return completed.strftime("%a")
|
||||
# If finishing tomorrow, use "tomorrow at HH:MM" format
|
||||
elif days >= 1:
|
||||
return i18n.i18nc("@info:status", "tomorrow")
|
||||
# If finishing today, use "today at HH:MM" format
|
||||
else:
|
||||
return i18n.i18nc("@info:status", "today")
|
0
plugins/UM3NetworkPrinting/src/Cloud/__init__.py
Normal file
0
plugins/UM3NetworkPrinting/src/Cloud/__init__.py
Normal file
@ -1,46 +1,41 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import Any, cast, Optional, Set, Tuple, Union
|
||||
from typing import Any, cast, Tuple, Union, Optional, Dict, List
|
||||
from time import time
|
||||
|
||||
import io # To create the correct buffers for sending data to the printer.
|
||||
import json
|
||||
import os
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.FileHandler.FileWriter import FileWriter # To choose based on the output file mode (text vs. binary).
|
||||
from UM.FileHandler.WriteFileJob import WriteFileJob # To call the file writer asynchronously.
|
||||
from UM.Logger import Logger
|
||||
from UM.Settings.ContainerRegistry import ContainerRegistry
|
||||
from UM.i18n import i18nCatalog
|
||||
|
||||
from UM.Message import Message
|
||||
from UM.Qt.Duration import Duration, DurationFormat
|
||||
from UM.OutputDevice import OutputDeviceError # To show that something went wrong when writing.
|
||||
from UM.Scene.SceneNode import SceneNode # For typing.
|
||||
from UM.Version import Version # To check against firmware versions for support.
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.ConfigurationModel import ConfigurationModel
|
||||
from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
|
||||
from cura.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from .Cloud.Utils import formatTimeCompleted, formatDateCompleted
|
||||
from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController
|
||||
from .SendMaterialJob import SendMaterialJob
|
||||
from .ConfigurationChangeModel import ConfigurationChangeModel
|
||||
from .MeshFormatHandler import MeshFormatHandler
|
||||
from .SendMaterialJob import SendMaterialJob
|
||||
from .UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
|
||||
from PyQt5.QtGui import QDesktopServices, QImage
|
||||
from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject
|
||||
|
||||
from time import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
import io # To create the correct buffers for sending data to the printer.
|
||||
import json
|
||||
import os
|
||||
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
@ -50,9 +45,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
activeCameraUrlChanged = pyqtSignal()
|
||||
receivedPrintJobsChanged = pyqtSignal()
|
||||
|
||||
# This is a bit of a hack, as the notify can only use signals that are defined by the class that they are in.
|
||||
# Inheritance doesn't seem to work. Tying them together does work, but i'm open for better suggestions.
|
||||
clusterPrintersChanged = pyqtSignal()
|
||||
# Notify can only use signals that are defined by the class that they are in, not inherited ones.
|
||||
# Therefore we create a private signal used to trigger the printersChanged signal.
|
||||
_clusterPrintersChanged = pyqtSignal()
|
||||
|
||||
def __init__(self, device_id, address, properties, parent = None) -> None:
|
||||
super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent)
|
||||
@ -60,15 +55,17 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
self._number_of_extruders = 2
|
||||
|
||||
self._dummy_lambdas = ("", {}, io.BytesIO()) #type: Tuple[str, Dict, Union[io.StringIO, io.BytesIO]]
|
||||
self._dummy_lambdas = (
|
||||
"", {}, io.BytesIO()
|
||||
) # type: Tuple[Optional[str], Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]]
|
||||
|
||||
self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
|
||||
self._received_print_jobs = False # type: bool
|
||||
|
||||
self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/MonitorStage.qml")
|
||||
|
||||
# See comments about this hack with the clusterPrintersChanged signal
|
||||
self.printersChanged.connect(self.clusterPrintersChanged)
|
||||
# Trigger the printersChanged signal when the private signal is triggered
|
||||
self.printersChanged.connect(self._clusterPrintersChanged)
|
||||
|
||||
self._accepts_commands = True # type: bool
|
||||
|
||||
@ -101,53 +98,19 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
self._active_camera_url = QUrl() # type: QUrl
|
||||
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
self.sendMaterialProfiles()
|
||||
|
||||
# Formats supported by this application (file types that we can actually write).
|
||||
if file_handler:
|
||||
file_formats = file_handler.getSupportedFileTypesWrite()
|
||||
else:
|
||||
file_formats = CuraApplication.getInstance().getMeshFileHandler().getSupportedFileTypesWrite()
|
||||
|
||||
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
# Create a list from the supported file formats string.
|
||||
if not global_stack:
|
||||
Logger.log("e", "Missing global stack!")
|
||||
return
|
||||
|
||||
machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";")
|
||||
machine_file_formats = [file_type.strip() for file_type in machine_file_formats]
|
||||
# Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format.
|
||||
if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"):
|
||||
machine_file_formats = ["application/x-ufp"] + machine_file_formats
|
||||
|
||||
# Take the intersection between file_formats and machine_file_formats.
|
||||
format_by_mimetype = {format["mime_type"]: format for format in file_formats}
|
||||
file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] #Keep them ordered according to the preference in machine_file_formats.
|
||||
|
||||
if len(file_formats) == 0:
|
||||
Logger.log("e", "There are no file formats available to write with!")
|
||||
raise OutputDeviceError.WriteRequestFailedError(i18n_catalog.i18nc("@info:status", "There are no file formats available to write with!"))
|
||||
preferred_format = file_formats[0]
|
||||
|
||||
# Just take the first file format available.
|
||||
if file_handler is not None:
|
||||
writer = file_handler.getWriterByMimeType(cast(str, preferred_format["mime_type"]))
|
||||
else:
|
||||
writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType(cast(str, preferred_format["mime_type"]))
|
||||
|
||||
if not writer:
|
||||
Logger.log("e", "Unexpected error when trying to get the FileWriter")
|
||||
return
|
||||
mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
|
||||
|
||||
# This function pauses with the yield, waiting on instructions on which printer it needs to print with.
|
||||
if not writer:
|
||||
if not mesh_format.is_valid:
|
||||
Logger.log("e", "Missing file or mesh writer!")
|
||||
return
|
||||
self._sending_job = self._sendPrintJob(writer, preferred_format, nodes)
|
||||
self._sending_job = self._sendPrintJob(mesh_format, nodes)
|
||||
if self._sending_job is not None:
|
||||
self._sending_job.send(None) # Start the generator.
|
||||
|
||||
@ -187,11 +150,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
# greenlet in order to optionally wait for selectPrinter() to select a
|
||||
# printer.
|
||||
# The greenlet yields exactly three times: First time None,
|
||||
# \param writer The file writer to use to create the data.
|
||||
# \param preferred_format A dictionary containing some information about
|
||||
# what format to write to. This is necessary to create the correct buffer
|
||||
# types and file extension and such.
|
||||
def _sendPrintJob(self, writer: FileWriter, preferred_format: Dict, nodes: List[SceneNode]):
|
||||
# \param mesh_format Object responsible for choosing the right kind of format to write with.
|
||||
def _sendPrintJob(self, mesh_format: MeshFormatHandler, nodes: List[SceneNode]):
|
||||
Logger.log("i", "Sending print job to printer.")
|
||||
if self._sending_gcode:
|
||||
self._error_message = Message(
|
||||
@ -205,35 +165,37 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
self._sending_gcode = True
|
||||
|
||||
target_printer = yield #Potentially wait on the user to select a target printer.
|
||||
# Potentially wait on the user to select a target printer.
|
||||
target_printer = yield # type: Optional[str]
|
||||
|
||||
# Using buffering greatly reduces the write time for many lines of gcode
|
||||
|
||||
stream = io.BytesIO() # type: Union[io.BytesIO, io.StringIO]# Binary mode.
|
||||
if preferred_format["mode"] == FileWriter.OutputMode.TextMode:
|
||||
stream = io.StringIO()
|
||||
stream = mesh_format.createStream()
|
||||
|
||||
job = WriteFileJob(writer, stream, nodes, preferred_format["mode"])
|
||||
job = WriteFileJob(mesh_format.writer, stream, nodes, mesh_format.file_mode)
|
||||
|
||||
self._write_job_progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, dismissable = False, progress = -1,
|
||||
title = i18n_catalog.i18nc("@info:title", "Sending Data"), use_inactivity_timer = False)
|
||||
self._write_job_progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"),
|
||||
lifetime = 0, dismissable = False, progress = -1,
|
||||
title = i18n_catalog.i18nc("@info:title", "Sending Data"),
|
||||
use_inactivity_timer = False)
|
||||
self._write_job_progress_message.show()
|
||||
|
||||
self._dummy_lambdas = (target_printer, preferred_format, stream)
|
||||
job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished)
|
||||
|
||||
job.start()
|
||||
|
||||
yield True # Return that we had success!
|
||||
yield # To prevent having to catch the StopIteration exception.
|
||||
if mesh_format.preferred_format is not None:
|
||||
self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream)
|
||||
job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished)
|
||||
job.start()
|
||||
yield True # Return that we had success!
|
||||
yield # To prevent having to catch the StopIteration exception.
|
||||
|
||||
def _sendPrintJobWaitOnWriteJobFinished(self, job: WriteFileJob) -> None:
|
||||
if self._write_job_progress_message:
|
||||
self._write_job_progress_message.hide()
|
||||
|
||||
self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, dismissable = False, progress = -1,
|
||||
self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0,
|
||||
dismissable = False, progress = -1,
|
||||
title = i18n_catalog.i18nc("@info:title", "Sending Data"))
|
||||
self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = None, description = "")
|
||||
self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = None,
|
||||
description = "")
|
||||
self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered)
|
||||
self._progress_message.show()
|
||||
parts = []
|
||||
@ -257,7 +219,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, output))
|
||||
|
||||
self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, on_finished = self._onPostPrintJobFinished, on_progress = self._onUploadPrintJobProgress)
|
||||
self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts,
|
||||
on_finished = self._onPostPrintJobFinished,
|
||||
on_progress = self._onUploadPrintJobProgress)
|
||||
|
||||
@pyqtProperty(QObject, notify = activePrinterChanged)
|
||||
def activePrinter(self) -> Optional[PrinterOutputModel]:
|
||||
@ -291,7 +255,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
# Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
|
||||
# timeout responses if this happens.
|
||||
self._last_response_time = time()
|
||||
if self._progress_message and new_progress > self._progress_message.getProgress():
|
||||
if self._progress_message is not None and new_progress > self._progress_message.getProgress():
|
||||
self._progress_message.show() # Ensure that the message is visible.
|
||||
self._progress_message.setProgress(bytes_sent / bytes_total * 100)
|
||||
|
||||
@ -357,7 +321,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"]
|
||||
|
||||
@pyqtProperty("QVariantList", notify = clusterPrintersChanged)
|
||||
@pyqtProperty("QVariantList", notify = _clusterPrintersChanged)
|
||||
def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
|
||||
printer_count = {} # type: Dict[str, int]
|
||||
for printer in self._printers:
|
||||
@ -370,41 +334,17 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])})
|
||||
return result
|
||||
|
||||
@pyqtProperty("QVariantList", notify=clusterPrintersChanged)
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def printers(self):
|
||||
return self._printers
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def formatDuration(self, seconds: int) -> str:
|
||||
return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getTimeCompleted(self, time_remaining: int) -> str:
|
||||
current_time = time()
|
||||
datetime_completed = datetime.fromtimestamp(current_time + time_remaining)
|
||||
return "{hour:02d}:{minute:02d}".format(hour=datetime_completed.hour, minute=datetime_completed.minute)
|
||||
return formatTimeCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getDateCompleted(self, time_remaining: int) -> str:
|
||||
current_time = time()
|
||||
completed = datetime.fromtimestamp(current_time + time_remaining)
|
||||
today = datetime.fromtimestamp(current_time)
|
||||
|
||||
# If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format
|
||||
if completed.toordinal() > today.toordinal() + 7:
|
||||
return completed.strftime("%a %b ") + "{day}".format(day=completed.day)
|
||||
|
||||
# If finishing date is within the next week, use "Monday at HH:MM" format
|
||||
elif completed.toordinal() > today.toordinal() + 1:
|
||||
return completed.strftime("%a")
|
||||
|
||||
# If finishing tomorrow, use "tomorrow at HH:MM" format
|
||||
elif completed.toordinal() > today.toordinal():
|
||||
return "tomorrow"
|
||||
|
||||
# If finishing today, use "today at HH:MM" format
|
||||
else:
|
||||
return "today"
|
||||
return formatDateCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def sendJobToTop(self, print_job_uuid: str) -> None:
|
||||
|
@ -18,4 +18,3 @@ class ClusterUM3PrinterOutputController(PrinterOutputController):
|
||||
def setJobState(self, job: "PrintJobOutputModel", state: str):
|
||||
data = "{\"action\": \"%s\"}" % state
|
||||
self._output_device.put("print_jobs/%s/action" % job.key, data, on_finished=None)
|
||||
|
||||
|
@ -123,26 +123,33 @@ class DiscoverUM3Action(MachineAction):
|
||||
# stored into the metadata of the currently active machine.
|
||||
@pyqtSlot(QObject)
|
||||
def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None:
|
||||
Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key)
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack:
|
||||
meta_data = global_container_stack.getMetaData()
|
||||
if "um_network_key" in meta_data:
|
||||
previous_network_key= meta_data["um_network_key"]
|
||||
global_container_stack.setMetaDataEntry("um_network_key", printer_device.key)
|
||||
# Delete old authentication data.
|
||||
Logger.log("d", "Removing old authentication id %s for device %s", global_container_stack.getMetaDataEntry("network_authentication_id", None), printer_device.key)
|
||||
global_container_stack.removeMetaDataEntry("network_authentication_id")
|
||||
global_container_stack.removeMetaDataEntry("network_authentication_key")
|
||||
CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "um_network_key", value = previous_network_key, new_value = printer_device.key)
|
||||
if not printer_device:
|
||||
return
|
||||
|
||||
if "connection_type" in meta_data:
|
||||
previous_connection_type = meta_data["connection_type"]
|
||||
global_container_stack.setMetaDataEntry("connection_type", printer_device.getConnectionType().value)
|
||||
CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.getConnectionType().value)
|
||||
else:
|
||||
global_container_stack.setMetaDataEntry("um_network_key", printer_device.key)
|
||||
global_container_stack.setMetaDataEntry("connection_type", printer_device.getConnectionType().value)
|
||||
Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key)
|
||||
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not global_container_stack:
|
||||
return
|
||||
|
||||
meta_data = global_container_stack.getMetaData()
|
||||
if "um_network_key" in meta_data:
|
||||
previous_network_key = meta_data["um_network_key"]
|
||||
global_container_stack.setMetaDataEntry("um_network_key", printer_device.key)
|
||||
# Delete old authentication data.
|
||||
Logger.log("d", "Removing old authentication id %s for device %s",
|
||||
global_container_stack.getMetaDataEntry("network_authentication_id", None), printer_device.key)
|
||||
global_container_stack.removeMetaDataEntry("network_authentication_id")
|
||||
global_container_stack.removeMetaDataEntry("network_authentication_key")
|
||||
CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "um_network_key", value = previous_network_key, new_value = printer_device.key)
|
||||
|
||||
if "connection_type" in meta_data:
|
||||
previous_connection_type = meta_data["connection_type"]
|
||||
global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value)
|
||||
CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.connectionType.value)
|
||||
else:
|
||||
global_container_stack.setMetaDataEntry("um_network_key", printer_device.key)
|
||||
global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value)
|
||||
|
||||
if self._network_plugin:
|
||||
# Ensure that the connection states are refreshed.
|
||||
|
115
plugins/UM3NetworkPrinting/src/MeshFormatHandler.py
Normal file
115
plugins/UM3NetworkPrinting/src/MeshFormatHandler.py
Normal file
@ -0,0 +1,115 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import io
|
||||
from typing import Optional, Dict, Union, List, cast
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.FileHandler.FileWriter import FileWriter
|
||||
from UM.Logger import Logger
|
||||
from UM.OutputDevice import OutputDeviceError # To show that something went wrong when writing.
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Version import Version # To check against firmware versions for support.
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
||||
## This class is responsible for choosing the formats used by the connected clusters.
|
||||
class MeshFormatHandler:
|
||||
|
||||
def __init__(self, file_handler: Optional[FileHandler], firmware_version: str) -> None:
|
||||
self._file_handler = file_handler or CuraApplication.getInstance().getMeshFileHandler()
|
||||
self._preferred_format = self._getPreferredFormat(firmware_version)
|
||||
self._writer = self._getWriter(self.mime_type) if self._preferred_format else None
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return bool(self._writer)
|
||||
|
||||
## Chooses the preferred file format.
|
||||
# \return A dict with the file format details, with the following keys:
|
||||
# {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool}
|
||||
@property
|
||||
def preferred_format(self) -> Optional[Dict[str, Union[str, int, bool]]]:
|
||||
return self._preferred_format
|
||||
|
||||
## Gets the file writer for the given file handler and mime type.
|
||||
# \return A file writer.
|
||||
@property
|
||||
def writer(self) -> Optional[FileWriter]:
|
||||
return self._writer
|
||||
|
||||
@property
|
||||
def mime_type(self) -> str:
|
||||
return cast(str, self._preferred_format["mime_type"])
|
||||
|
||||
## Gets the file mode (FileWriter.OutputMode.TextMode or FileWriter.OutputMode.BinaryMode)
|
||||
@property
|
||||
def file_mode(self) -> int:
|
||||
return cast(int, self._preferred_format["mode"])
|
||||
|
||||
## Gets the file extension
|
||||
@property
|
||||
def file_extension(self) -> str:
|
||||
return cast(str, self._preferred_format["extension"])
|
||||
|
||||
## Creates the right kind of stream based on the preferred format.
|
||||
def createStream(self) -> Union[io.BytesIO, io.StringIO]:
|
||||
if self.file_mode == FileWriter.OutputMode.TextMode:
|
||||
return io.StringIO()
|
||||
else:
|
||||
return io.BytesIO()
|
||||
|
||||
## Writes the mesh and returns its value.
|
||||
def getBytes(self, nodes: List[SceneNode]) -> bytes:
|
||||
if self.writer is None:
|
||||
raise ValueError("There is no writer for the mesh format handler.")
|
||||
stream = self.createStream()
|
||||
self.writer.write(stream, nodes)
|
||||
value = stream.getvalue()
|
||||
if isinstance(value, str):
|
||||
value = value.encode()
|
||||
return value
|
||||
|
||||
## Chooses the preferred file format for the given file handler.
|
||||
# \param firmware_version: The version of the firmware.
|
||||
# \return A dict with the file format details.
|
||||
def _getPreferredFormat(self, firmware_version: str) -> Dict[str, Union[str, int, bool]]:
|
||||
# Formats supported by this application (file types that we can actually write).
|
||||
application = CuraApplication.getInstance()
|
||||
|
||||
file_formats = self._file_handler.getSupportedFileTypesWrite()
|
||||
|
||||
global_stack = application.getGlobalContainerStack()
|
||||
# Create a list from the supported file formats string.
|
||||
if not global_stack:
|
||||
Logger.log("e", "Missing global stack!")
|
||||
return {}
|
||||
|
||||
machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";")
|
||||
machine_file_formats = [file_type.strip() for file_type in machine_file_formats]
|
||||
# Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format.
|
||||
if "application/x-ufp" not in machine_file_formats and Version(firmware_version) >= Version("4.4"):
|
||||
machine_file_formats = ["application/x-ufp"] + machine_file_formats
|
||||
|
||||
# Take the intersection between file_formats and machine_file_formats.
|
||||
format_by_mimetype = {f["mime_type"]: f for f in file_formats}
|
||||
|
||||
# Keep them ordered according to the preference in machine_file_formats.
|
||||
file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats]
|
||||
|
||||
if len(file_formats) == 0:
|
||||
Logger.log("e", "There are no file formats available to write with!")
|
||||
raise OutputDeviceError.WriteRequestFailedError(
|
||||
I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!")
|
||||
)
|
||||
return file_formats[0]
|
||||
|
||||
## Gets the file writer for the given file handler and mime type.
|
||||
# \param mime_type: The mine type.
|
||||
# \return A file writer.
|
||||
def _getWriter(self, mime_type: str) -> Optional[FileWriter]:
|
||||
# Just take the first file format available.
|
||||
return self._file_handler.getWriterByMimeType(mime_type)
|
@ -8,6 +8,7 @@ class BaseModel:
|
||||
self.__dict__.update(kwargs)
|
||||
self.validate()
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
pass
|
||||
|
||||
@ -34,7 +35,9 @@ class LocalMaterial(BaseModel):
|
||||
self.version = version # type: int
|
||||
super().__init__(**kwargs)
|
||||
|
||||
#
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
if not self.GUID:
|
||||
raise ValueError("guid is required on LocalMaterial")
|
||||
if not self.version:
|
||||
|
@ -2,7 +2,7 @@
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, TYPE_CHECKING, Set
|
||||
from typing import Dict, TYPE_CHECKING, Set, Optional
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
|
||||
|
||||
@ -145,7 +145,7 @@ class SendMaterialJob(Job):
|
||||
# \return a dictionary of ClusterMaterial objects by GUID
|
||||
# \throw KeyError Raised when on of the materials does not include a valid guid
|
||||
@classmethod
|
||||
def _parseReply(cls, reply: QNetworkReply) -> Dict[str, ClusterMaterial]:
|
||||
def _parseReply(cls, reply: QNetworkReply) -> Optional[Dict[str, ClusterMaterial]]:
|
||||
try:
|
||||
remote_materials = json.loads(reply.readAll().data().decode("utf-8"))
|
||||
return {material["guid"]: ClusterMaterial(**material) for material in remote_materials}
|
||||
@ -157,6 +157,7 @@ class SendMaterialJob(Job):
|
||||
Logger.log("e", "Request material storage on printer: Printer's answer had an incorrect value.")
|
||||
except TypeError:
|
||||
Logger.log("e", "Request material storage on printer: Printer's answer was missing a required value.")
|
||||
return None
|
||||
|
||||
## Retrieves a list of local materials
|
||||
#
|
||||
@ -182,7 +183,8 @@ class SendMaterialJob(Job):
|
||||
local_material.id = root_material_id
|
||||
|
||||
if local_material.GUID not in result or \
|
||||
local_material.version > result.get(local_material.GUID).version:
|
||||
local_material.GUID not in result or \
|
||||
local_material.version > result[local_material.GUID].version:
|
||||
result[local_material.GUID] = local_material
|
||||
|
||||
except KeyError:
|
||||
|
@ -1,23 +1,22 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
|
||||
from UM.Logger import Logger
|
||||
from UM.Application import Application
|
||||
from UM.Signal import Signal, signalemitter
|
||||
from UM.Version import Version
|
||||
|
||||
from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
|
||||
from PyQt5.QtCore import QUrl
|
||||
|
||||
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
|
||||
import json
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from time import time
|
||||
|
||||
import json
|
||||
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
|
||||
from PyQt5.QtCore import QUrl
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
|
||||
from UM.Logger import Logger
|
||||
from UM.Signal import Signal, signalemitter
|
||||
from UM.Version import Version
|
||||
|
||||
from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
|
||||
from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager
|
||||
|
||||
|
||||
## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
|
||||
@ -31,9 +30,13 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._zero_conf = None
|
||||
self._zero_conf_browser = None
|
||||
|
||||
# Create a cloud output device manager that abstracts all cloud connection logic away.
|
||||
self._cloud_output_device_manager = CloudOutputDeviceManager()
|
||||
|
||||
# Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
|
||||
self.addDeviceSignal.connect(self._onAddDevice)
|
||||
self.removeDeviceSignal.connect(self._onRemoveDevice)
|
||||
@ -83,6 +86,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
## Start looking for devices on network.
|
||||
def start(self):
|
||||
self.startDiscovery()
|
||||
self._cloud_output_device_manager.start()
|
||||
|
||||
def startDiscovery(self):
|
||||
self.stop()
|
||||
@ -114,7 +118,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
if key == um_network_key:
|
||||
if not self._discovered_devices[key].isConnected():
|
||||
Logger.log("d", "Attempting to connect with [%s]" % key)
|
||||
active_machine.setMetaDataEntry("connection_type", self._discovered_devices[key].getConnectionType().value)
|
||||
active_machine.setMetaDataEntry("connection_type", self._discovered_devices[key].connectionType.value)
|
||||
self._discovered_devices[key].connect()
|
||||
self._discovered_devices[key].connectionStateChanged.connect(self._onDeviceConnectionStateChanged)
|
||||
else:
|
||||
@ -140,6 +144,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
if self._zero_conf is not None:
|
||||
Logger.log("d", "zeroconf close...")
|
||||
self._zero_conf.close()
|
||||
self._cloud_output_device_manager.stop()
|
||||
|
||||
def removeManualDevice(self, key, address = None):
|
||||
if key in self._discovered_devices:
|
||||
@ -284,7 +289,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
|
||||
global_container_stack = Application.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"):
|
||||
global_container_stack.setMetaDataEntry("connection_type", device.getConnectionType().value)
|
||||
global_container_stack.setMetaDataEntry("connection_type", device.connectionType.value)
|
||||
device.connect()
|
||||
device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged)
|
||||
|
||||
@ -362,4 +367,4 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
Logger.log("d", "Bonjour service removed: %s" % name)
|
||||
self.removeDeviceSignal.emit(str(name))
|
||||
|
||||
return True
|
||||
return True
|
||||
|
@ -1,13 +1,12 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot
|
||||
from typing import Optional, TYPE_CHECKING, List
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtGui import QImage
|
||||
from typing import List
|
||||
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal
|
||||
|
||||
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel
|
||||
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
from .ConfigurationChangeModel import ConfigurationChangeModel
|
||||
|
||||
|
||||
|
0
plugins/UM3NetworkPrinting/src/__init__.py
Normal file
0
plugins/UM3NetworkPrinting/src/__init__.py
Normal file
12
plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py
Normal file
12
plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def readFixture(fixture_name: str) -> bytes:
|
||||
with open("{}/{}.json".format(os.path.dirname(__file__), fixture_name), "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def parseFixture(fixture_name: str) -> dict:
|
||||
return json.loads(readFixture(fixture_name).decode())
|
@ -0,0 +1,95 @@
|
||||
{
|
||||
"data": {
|
||||
"generated_time": "2018-12-10T08:23:55.110Z",
|
||||
"printers": [
|
||||
{
|
||||
"configuration": [
|
||||
{
|
||||
"extruder_index": 0,
|
||||
"material": {
|
||||
"material": "empty"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
},
|
||||
{
|
||||
"extruder_index": 1,
|
||||
"material": {
|
||||
"material": "empty"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"firmware_version": "5.1.2.20180807",
|
||||
"friendly_name": "Master-Luke",
|
||||
"ip_address": "10.183.1.140",
|
||||
"machine_variant": "Ultimaker 3",
|
||||
"status": "maintenance",
|
||||
"unique_name": "ultimakersystem-ccbdd30044ec",
|
||||
"uuid": "b3a47ea3-1eeb-4323-9626-6f9c3c888f9e"
|
||||
},
|
||||
{
|
||||
"configuration": [
|
||||
{
|
||||
"extruder_index": 0,
|
||||
"material": {
|
||||
"brand": "Generic",
|
||||
"color": "Generic",
|
||||
"guid": "506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9",
|
||||
"material": "PLA"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
},
|
||||
{
|
||||
"extruder_index": 1,
|
||||
"material": {
|
||||
"brand": "Ultimaker",
|
||||
"color": "Red",
|
||||
"guid": "9cfe5bf1-bdc5-4beb-871a-52c70777842d",
|
||||
"material": "PLA"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"firmware_version": "4.3.3.20180529",
|
||||
"friendly_name": "UM-Marijn",
|
||||
"ip_address": "10.183.1.166",
|
||||
"machine_variant": "Ultimaker 3",
|
||||
"status": "idle",
|
||||
"unique_name": "ultimakersystem-ccbdd30058ab",
|
||||
"uuid": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a"
|
||||
}
|
||||
],
|
||||
"print_jobs": [
|
||||
{
|
||||
"assigned_to": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a",
|
||||
"configuration": [
|
||||
{
|
||||
"extruder_index": 0,
|
||||
"material": {
|
||||
"brand": "Ultimaker",
|
||||
"color": "Black",
|
||||
"guid": "3ee70a86-77d8-4b87-8005-e4a1bc57d2ce",
|
||||
"material": "PLA"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
}
|
||||
],
|
||||
"constraints": {},
|
||||
"created_at": "2018-12-10T08:28:04.108Z",
|
||||
"force": false,
|
||||
"last_seen": 500165.109491861,
|
||||
"machine_variant": "Ultimaker 3",
|
||||
"name": "UM3_dragon",
|
||||
"network_error_count": 0,
|
||||
"owner": "Daniel Testing",
|
||||
"started": false,
|
||||
"status": "queued",
|
||||
"time_elapsed": 0,
|
||||
"time_total": 14145,
|
||||
"uuid": "d1c8bd52-5e9f-486a-8c25-a123cc8c7702"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
{
|
||||
"data": [{
|
||||
"cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq",
|
||||
"host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050",
|
||||
"host_name": "ultimakersystem-ccbdd30044ec",
|
||||
"host_version": "5.0.0.20170101",
|
||||
"is_online": true,
|
||||
"status": "active"
|
||||
}, {
|
||||
"cluster_id": "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8",
|
||||
"host_guid": "e0ace90a-91ee-1257-4403-e8050a44c9b7",
|
||||
"host_name": "ultimakersystem-30044ecccbdd",
|
||||
"host_version": "5.1.2.20180807",
|
||||
"is_online": true,
|
||||
"status": "active"
|
||||
}]
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"data": {
|
||||
"cluster_job_id": "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd",
|
||||
"job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=",
|
||||
"status": "queued",
|
||||
"generated_time": "2018-12-10T08:23:55.110Z"
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
{
|
||||
"data": {
|
||||
"content_type": "text/plain",
|
||||
"job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=",
|
||||
"job_name": "Ultimaker Robot v3.0",
|
||||
"status": "uploading",
|
||||
"upload_url": "https://api.ultimaker.com/print-job-upload"
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
105
plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py
Normal file
105
plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py
Normal file
@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from typing import Dict, Tuple, Union, Optional, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Signal import Signal
|
||||
|
||||
|
||||
class FakeSignal:
|
||||
def __init__(self):
|
||||
self._callbacks = []
|
||||
|
||||
def connect(self, callback):
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def disconnect(self, callback):
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def emit(self, *args, **kwargs):
|
||||
for callback in self._callbacks:
|
||||
callback(*args, **kwargs)
|
||||
|
||||
|
||||
## This class can be used to mock the QNetworkManager class and test the code using it.
|
||||
# After patching the QNetworkManager class, requests are prepared before they can be executed.
|
||||
# Any requests not prepared beforehand will cause KeyErrors.
|
||||
class NetworkManagerMock:
|
||||
|
||||
# An enumeration of the supported operations and their code for the network access manager.
|
||||
_OPERATIONS = {
|
||||
"GET": QNetworkAccessManager.GetOperation,
|
||||
"POST": QNetworkAccessManager.PostOperation,
|
||||
"PUT": QNetworkAccessManager.PutOperation,
|
||||
"DELETE": QNetworkAccessManager.DeleteOperation,
|
||||
"HEAD": QNetworkAccessManager.HeadOperation,
|
||||
} # type: Dict[str, int]
|
||||
|
||||
## Initializes the network manager mock.
|
||||
def __init__(self) -> None:
|
||||
# A dict with the prepared replies, using the format {(http_method, url): reply}
|
||||
self.replies = {} # type: Dict[Tuple[str, str], MagicMock]
|
||||
self.request_bodies = {} # type: Dict[Tuple[str, str], bytes]
|
||||
|
||||
# Signals used in the network manager.
|
||||
self.finished = Signal()
|
||||
self.authenticationRequired = Signal()
|
||||
|
||||
## Mock implementation of the get, post, put, delete and head methods from the network manager.
|
||||
# Since the methods are very simple and the same it didn't make sense to repeat the code.
|
||||
# \param method: The method being called.
|
||||
# \return The mocked function, if the method name is known. Defaults to the standard getattr function.
|
||||
def __getattr__(self, method: str) -> Any:
|
||||
## This mock implementation will simply return the reply from the prepared ones.
|
||||
# it raises a KeyError if requests are done without being prepared.
|
||||
def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_):
|
||||
key = method.upper(), request.url().toString()
|
||||
if body:
|
||||
self.request_bodies[key] = body
|
||||
return self.replies[key]
|
||||
|
||||
operation = self._OPERATIONS.get(method.upper())
|
||||
if operation:
|
||||
return doRequest
|
||||
|
||||
# the attribute is not one of the implemented methods, default to the standard implementation.
|
||||
return getattr(super(), method)
|
||||
|
||||
## Prepares a server reply for the given parameters.
|
||||
# \param method: The HTTP method.
|
||||
# \param url: The URL being requested.
|
||||
# \param status_code: The HTTP status code for the response.
|
||||
# \param response: The response body from the server (generally json-encoded).
|
||||
def prepareReply(self, method: str, url: str, status_code: int, response: Union[bytes, dict]) -> None:
|
||||
reply_mock = MagicMock()
|
||||
reply_mock.url().toString.return_value = url
|
||||
reply_mock.operation.return_value = self._OPERATIONS[method]
|
||||
reply_mock.attribute.return_value = status_code
|
||||
reply_mock.finished = FakeSignal()
|
||||
reply_mock.isFinished.return_value = False
|
||||
reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode()
|
||||
self.replies[method, url] = reply_mock
|
||||
Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url)
|
||||
|
||||
## Gets the request that was sent to the network manager for the given method and URL.
|
||||
# \param method: The HTTP method.
|
||||
# \param url: The URL.
|
||||
def getRequestBody(self, method: str, url: str) -> Optional[bytes]:
|
||||
return self.request_bodies.get((method.upper(), url))
|
||||
|
||||
## Emits the signal that the reply is ready to all prepared replies.
|
||||
def flushReplies(self) -> None:
|
||||
for key, reply in self.replies.items():
|
||||
Logger.log("i", "Flushing reply to {} {}", *key)
|
||||
reply.isFinished.return_value = True
|
||||
reply.finished.emit()
|
||||
self.finished.emit(reply)
|
||||
self.reset()
|
||||
|
||||
## Deletes all prepared replies
|
||||
def reset(self) -> None:
|
||||
self.replies.clear()
|
117
plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py
Normal file
117
plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py
Normal file
@ -0,0 +1,117 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot
|
||||
from ...src.Cloud import CloudApiClient
|
||||
from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from ...src.Cloud.Models.CloudClusterStatus import CloudClusterStatus
|
||||
from ...src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from ...src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from ...src.Cloud.Models.CloudError import CloudError
|
||||
from .Fixtures import readFixture, parseFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
|
||||
|
||||
class TestCloudApiClient(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def _errorHandler(self, errors: List[CloudError]):
|
||||
raise Exception("Received unexpected error: {}".format(errors))
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.account = MagicMock()
|
||||
self.account.isLoggedIn.return_value = True
|
||||
|
||||
self.network = NetworkManagerMock()
|
||||
with patch.object(CloudApiClient, 'QNetworkAccessManager', return_value = self.network):
|
||||
self.api = CloudApiClient.CloudApiClient(self.account, self._errorHandler)
|
||||
|
||||
def test_getClusters(self):
|
||||
result = []
|
||||
|
||||
response = readFixture("getClusters")
|
||||
data = parseFixture("getClusters")["data"]
|
||||
|
||||
self.network.prepareReply("GET", CuraCloudAPIRoot + "/connect/v1/clusters", 200, response)
|
||||
# The callback is a function that adds the result of the call to getClusters to the result list
|
||||
self.api.getClusters(lambda clusters: result.extend(clusters))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result)
|
||||
|
||||
def test_getClusterStatus(self):
|
||||
result = []
|
||||
|
||||
response = readFixture("getClusterStatusResponse")
|
||||
data = parseFixture("getClusterStatusResponse")["data"]
|
||||
|
||||
url = CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status"
|
||||
self.network.prepareReply("GET", url, 200, response)
|
||||
self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([CloudClusterStatus(**data)], result)
|
||||
|
||||
def test_requestUpload(self):
|
||||
|
||||
results = []
|
||||
|
||||
response = readFixture("putJobUploadResponse")
|
||||
|
||||
self.network.prepareReply("PUT", CuraCloudAPIRoot + "/cura/v1/jobs/upload", 200, response)
|
||||
request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain")
|
||||
self.api.requestUpload(request, lambda r: results.append(r))
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual(["text/plain"], [r.content_type for r in results])
|
||||
self.assertEqual(["uploading"], [r.status for r in results])
|
||||
|
||||
def test_uploadToolPath(self):
|
||||
|
||||
results = []
|
||||
progress = MagicMock()
|
||||
|
||||
data = parseFixture("putJobUploadResponse")["data"]
|
||||
upload_response = CloudPrintJobResponse(**data)
|
||||
|
||||
# Network client doesn't look into the reply
|
||||
self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
|
||||
|
||||
mesh = ("1234" * 100000).encode()
|
||||
self.api.uploadToolPath(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error)
|
||||
|
||||
for _ in range(10):
|
||||
self.network.flushReplies()
|
||||
self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
|
||||
|
||||
self.assertEqual(["sent"], results)
|
||||
|
||||
def test_requestPrint(self):
|
||||
|
||||
results = []
|
||||
|
||||
response = readFixture("postJobPrintResponse")
|
||||
|
||||
cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8"
|
||||
cluster_job_id = "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd"
|
||||
job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
|
||||
|
||||
self.network.prepareReply("POST",
|
||||
CuraCloudAPIRoot + "/connect/v1/clusters/{}/print/{}"
|
||||
.format(cluster_id, job_id),
|
||||
200, response)
|
||||
|
||||
self.api.requestPrint(cluster_id, job_id, lambda r: results.append(r))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([job_id], [r.job_id for r in results])
|
||||
self.assertEqual([cluster_job_id], [r.cluster_job_id for r in results])
|
||||
self.assertEqual(["queued"], [r.status for r in results])
|
146
plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py
Normal file
146
plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py
Normal file
@ -0,0 +1,146 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
from ...src.Cloud import CloudApiClient
|
||||
from ...src.Cloud.CloudOutputDevice import CloudOutputDevice
|
||||
from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Fixtures import readFixture, parseFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
|
||||
|
||||
class TestCloudOutputDevice(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq"
|
||||
JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
|
||||
HOST_NAME = "ultimakersystem-ccbdd30044ec"
|
||||
HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050"
|
||||
|
||||
STATUS_URL = "{}/connect/v1/clusters/{}/status".format(CuraCloudAPIRoot, CLUSTER_ID)
|
||||
PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(CuraCloudAPIRoot, CLUSTER_ID, JOB_ID)
|
||||
REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(CuraCloudAPIRoot)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = MagicMock()
|
||||
|
||||
self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app),
|
||||
patch("UM.Application.Application.getInstance", return_value=self.app)]
|
||||
for patched_method in self.patches:
|
||||
patched_method.start()
|
||||
|
||||
self.cluster = CloudClusterResponse(self.CLUSTER_ID, self.HOST_GUID, self.HOST_NAME, is_online=True,
|
||||
status="active")
|
||||
|
||||
self.network = NetworkManagerMock()
|
||||
self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken")
|
||||
self.onError = MagicMock()
|
||||
with patch.object(CloudApiClient, "QNetworkAccessManager", return_value = self.network):
|
||||
self._api = CloudApiClient.CloudApiClient(self.account, self.onError)
|
||||
|
||||
self.device = CloudOutputDevice(self._api, self.cluster)
|
||||
self.cluster_status = parseFixture("getClusterStatusResponse")
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse"))
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
super().tearDown()
|
||||
self.network.flushReplies()
|
||||
finally:
|
||||
for patched_method in self.patches:
|
||||
patched_method.stop()
|
||||
|
||||
def test_status(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([PrinterOutputModel, PrinterOutputModel], [type(printer) for printer in self.device.printers])
|
||||
|
||||
controller_fields = {
|
||||
"_output_device": self.device,
|
||||
"can_abort": False,
|
||||
"can_control_manually": False,
|
||||
"can_pause": False,
|
||||
"can_pre_heat_bed": False,
|
||||
"can_pre_heat_hotends": False,
|
||||
"can_send_raw_gcode": False,
|
||||
"can_update_firmware": False,
|
||||
}
|
||||
|
||||
self.assertEqual({printer["uuid"] for printer in self.cluster_status["data"]["printers"]},
|
||||
{printer.key for printer in self.device.printers})
|
||||
self.assertEqual([controller_fields, controller_fields],
|
||||
[printer.getController().__dict__ for printer in self.device.printers])
|
||||
|
||||
self.assertEqual(["UM3PrintJobOutputModel"], [type(printer).__name__ for printer in self.device.printJobs])
|
||||
self.assertEqual({job["uuid"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.key for job in self.device.printJobs})
|
||||
self.assertEqual({job["owner"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.owner for job in self.device.printJobs})
|
||||
self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.name for job in self.device.printJobs})
|
||||
|
||||
def test_remove_print_job(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(1, len(self.device.printJobs))
|
||||
|
||||
self.cluster_status["data"]["print_jobs"].clear()
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
|
||||
|
||||
self.device._last_request_time = None
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual([], self.device.printJobs)
|
||||
|
||||
def test_remove_printers(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(2, len(self.device.printers))
|
||||
|
||||
self.cluster_status["data"]["printers"].clear()
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
|
||||
|
||||
self.device._last_request_time = None
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual([], self.device.printers)
|
||||
|
||||
def test_print_to_cloud(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get
|
||||
|
||||
request_upload_response = parseFixture("putJobUploadResponse")
|
||||
request_print_response = parseFixture("postJobPrintResponse")
|
||||
self.network.prepareReply("PUT", self.REQUEST_UPLOAD_URL, 201, request_upload_response)
|
||||
self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}")
|
||||
self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response)
|
||||
|
||||
file_handler = MagicMock()
|
||||
file_handler.getSupportedFileTypesWrite.return_value = [{
|
||||
"extension": "gcode.gz",
|
||||
"mime_type": "application/gzip",
|
||||
"mode": 2,
|
||||
}]
|
||||
file_handler.getWriterByMimeType.return_value.write.side_effect = \
|
||||
lambda stream, nodes: stream.write(str(nodes).encode())
|
||||
|
||||
scene_nodes = [SceneNode()]
|
||||
expected_mesh = str(scene_nodes).encode()
|
||||
self.device.requestWrite(scene_nodes, file_handler=file_handler, file_name="FileName")
|
||||
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(
|
||||
{"data": {"content_type": "application/gzip", "file_size": len(expected_mesh), "job_name": "FileName"}},
|
||||
json.loads(self.network.getRequestBody("PUT", self.REQUEST_UPLOAD_URL).decode())
|
||||
)
|
||||
self.assertEqual(expected_mesh,
|
||||
self.network.getRequestBody("PUT", request_upload_response["data"]["upload_url"]))
|
||||
|
||||
self.assertIsNone(self.network.getRequestBody("POST", self.PRINT_URL))
|
@ -0,0 +1,123 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager
|
||||
from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot
|
||||
from ...src.Cloud import CloudApiClient
|
||||
from ...src.Cloud import CloudOutputDeviceManager
|
||||
from .Fixtures import parseFixture, readFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock, FakeSignal
|
||||
|
||||
|
||||
class TestCloudOutputDeviceManager(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
URL = CuraCloudAPIRoot + "/connect/v1/clusters"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = MagicMock()
|
||||
self.device_manager = OutputDeviceManager()
|
||||
self.app.getOutputDeviceManager.return_value = self.device_manager
|
||||
|
||||
self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app),
|
||||
patch("UM.Application.Application.getInstance", return_value=self.app)]
|
||||
for patched_method in self.patches:
|
||||
patched_method.start()
|
||||
|
||||
self.network = NetworkManagerMock()
|
||||
self.timer = MagicMock(timeout = FakeSignal())
|
||||
with patch.object(CloudApiClient, "QNetworkAccessManager", return_value = self.network), \
|
||||
patch.object(CloudOutputDeviceManager, "QTimer", return_value = self.timer):
|
||||
self.manager = CloudOutputDeviceManager.CloudOutputDeviceManager()
|
||||
self.clusters_response = parseFixture("getClusters")
|
||||
self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters"))
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self._beforeTearDown()
|
||||
|
||||
self.network.flushReplies()
|
||||
self.manager.stop()
|
||||
for patched_method in self.patches:
|
||||
patched_method.stop()
|
||||
finally:
|
||||
super().tearDown()
|
||||
|
||||
## Before tear down method we check whether the state of the output device manager is what we expect based on the
|
||||
# mocked API response.
|
||||
def _beforeTearDown(self):
|
||||
# let the network send replies
|
||||
self.network.flushReplies()
|
||||
# get the created devices
|
||||
devices = self.device_manager.getOutputDevices()
|
||||
# TODO: Check active device
|
||||
|
||||
response_clusters = self.clusters_response.get("data", [])
|
||||
manager_clusters = sorted([device.clusterData.toDict() for device in self.manager._remote_clusters.values()],
|
||||
key=lambda cluster: cluster['cluster_id'], reverse=True)
|
||||
self.assertEqual(response_clusters, manager_clusters)
|
||||
|
||||
## Runs the initial request to retrieve the clusters.
|
||||
def _loadData(self):
|
||||
self.manager.start()
|
||||
self.network.flushReplies()
|
||||
|
||||
def test_device_is_created(self):
|
||||
# just create the cluster, it is checked at tearDown
|
||||
self._loadData()
|
||||
|
||||
def test_device_is_updated(self):
|
||||
self._loadData()
|
||||
|
||||
# update the cluster from member variable, which is checked at tearDown
|
||||
self.clusters_response["data"][0]["host_name"] = "New host name"
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
|
||||
self.manager._update_timer.timeout.emit()
|
||||
|
||||
def test_device_is_removed(self):
|
||||
self._loadData()
|
||||
|
||||
# delete the cluster from member variable, which is checked at tearDown
|
||||
del self.clusters_response["data"][1]
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
|
||||
self.manager._update_timer.timeout.emit()
|
||||
|
||||
def test_device_connects_by_cluster_id(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
cluster1, cluster2 = self.clusters_response["data"]
|
||||
cluster_id = cluster1["cluster_id"]
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get
|
||||
|
||||
self._loadData()
|
||||
|
||||
self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected())
|
||||
self.assertIsNone(self.device_manager.getOutputDevice(cluster2["cluster_id"]))
|
||||
self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls)
|
||||
|
||||
def test_device_connects_by_network_key(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
|
||||
cluster1, cluster2 = self.clusters_response["data"]
|
||||
network_key = cluster2["host_name"] + ".ultimaker.local"
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get
|
||||
|
||||
self._loadData()
|
||||
|
||||
self.assertIsNone(self.device_manager.getOutputDevice(cluster1["cluster_id"]))
|
||||
self.assertTrue(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected())
|
||||
|
||||
active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"])
|
||||
|
||||
@patch.object(CloudOutputDeviceManager, "Message")
|
||||
def test_api_error(self, message_mock):
|
||||
self.clusters_response = {
|
||||
"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}]
|
||||
}
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
self._loadData()
|
||||
message_mock.return_value.show.assert_called_once_with()
|
2
plugins/UM3NetworkPrinting/tests/Cloud/__init__.py
Normal file
2
plugins/UM3NetworkPrinting/tests/Cloud/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
@ -1,7 +1,6 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from unittest import TestCase, mock
|
||||
@ -14,7 +13,7 @@ from UM.Application import Application
|
||||
from cura.Machines.MaterialGroup import MaterialGroup
|
||||
from cura.Machines.MaterialNode import MaterialNode
|
||||
|
||||
from plugins.UM3NetworkPrinting.src.SendMaterialJob import SendMaterialJob
|
||||
from ..src.SendMaterialJob import SendMaterialJob
|
||||
|
||||
_FILES_MAP = {"generic_pla_white": "/materials/generic_pla_white.xml.fdm_material",
|
||||
"generic_pla_black": "/materials/generic_pla_black.xml.fdm_material",
|
||||
|
2
plugins/UM3NetworkPrinting/tests/__init__.py
Normal file
2
plugins/UM3NetworkPrinting/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
@ -0,0 +1,68 @@
|
||||
import configparser
|
||||
from typing import Tuple, List, Set
|
||||
import io
|
||||
from UM.VersionUpgrade import VersionUpgrade
|
||||
from cura.PrinterOutputDevice import ConnectionType
|
||||
deleted_settings = {"bridge_wall_max_overhang"} # type: Set[str]
|
||||
|
||||
|
||||
class VersionUpgrade35to40(VersionUpgrade):
|
||||
# Upgrades stacks to have the new version number.
|
||||
def upgradeStack(self, serialized: str, filename: str) -> Tuple[List[str], List[str]]:
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
parser.read_string(serialized)
|
||||
|
||||
# Update version number.
|
||||
parser["general"]["version"] = "4"
|
||||
parser["metadata"]["setting_version"] = "6"
|
||||
|
||||
if parser["metadata"].get("um_network_key") is not None or parser["metadata"].get("octoprint_api_key") is not None:
|
||||
# Set the connection type if um_network_key or the octoprint key is set.
|
||||
parser["metadata"]["connection_type"] = str(ConnectionType.NetworkConnection.value)
|
||||
|
||||
result = io.StringIO()
|
||||
parser.write(result)
|
||||
return [filename], [result.getvalue()]
|
||||
pass
|
||||
|
||||
def getCfgVersion(self, serialised: str) -> int:
|
||||
parser = configparser.ConfigParser(interpolation = None)
|
||||
parser.read_string(serialised)
|
||||
format_version = int(parser.get("general", "version")) #Explicitly give an exception when this fails. That means that the file format is not recognised.
|
||||
setting_version = int(parser.get("metadata", "setting_version", fallback = "0"))
|
||||
return format_version * 1000000 + setting_version
|
||||
|
||||
## Upgrades Preferences to have the new version number.
|
||||
def upgradePreferences(self, serialized: str, filename: str) -> Tuple[List[str], List[str]]:
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
parser.read_string(serialized)
|
||||
|
||||
if "metadata" not in parser:
|
||||
parser["metadata"] = {}
|
||||
parser["general"]["version"] = "6"
|
||||
parser["metadata"]["setting_version"] = "6"
|
||||
|
||||
result = io.StringIO()
|
||||
parser.write(result)
|
||||
return [filename], [result.getvalue()]
|
||||
|
||||
## Upgrades instance containers to have the new version
|
||||
# number.
|
||||
def upgradeInstanceContainer(self, serialized: str, filename: str) -> Tuple[List[str], List[str]]:
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
parser.read_string(serialized)
|
||||
|
||||
# Update version number.
|
||||
parser["general"]["version"] = "4"
|
||||
parser["metadata"]["setting_version"] = "6"
|
||||
|
||||
#self._resetConcentric3DInfillPattern(parser)
|
||||
if "values" in parser:
|
||||
for deleted_setting in deleted_settings:
|
||||
if deleted_setting not in parser["values"]:
|
||||
continue
|
||||
del parser["values"][deleted_setting]
|
||||
|
||||
result = io.StringIO()
|
||||
parser.write(result)
|
||||
return [filename], [result.getvalue()]
|
56
plugins/VersionUpgrade/VersionUpgrade35to40/__init__.py
Normal file
56
plugins/VersionUpgrade/VersionUpgrade35to40/__init__.py
Normal file
@ -0,0 +1,56 @@
|
||||
from typing import Dict, Any
|
||||
|
||||
from . import VersionUpgrade35to40
|
||||
|
||||
upgrade = VersionUpgrade35to40.VersionUpgrade35to40()
|
||||
|
||||
|
||||
def getMetaData() -> Dict[str, Any]:
|
||||
return {
|
||||
"version_upgrade": {
|
||||
# From To Upgrade function
|
||||
("preferences", 6000005): ("preferences", 6000006, upgrade.upgradePreferences),
|
||||
|
||||
("definition_changes", 4000005): ("definition_changes", 4000006, upgrade.upgradeInstanceContainer),
|
||||
("quality_changes", 4000005): ("quality_changes", 4000006, upgrade.upgradeInstanceContainer),
|
||||
("quality", 4000005): ("quality", 4000006, upgrade.upgradeInstanceContainer),
|
||||
("user", 4000005): ("user", 4000006, upgrade.upgradeInstanceContainer),
|
||||
|
||||
("machine_stack", 4000005): ("machine_stack", 4000006, upgrade.upgradeStack),
|
||||
("extruder_train", 4000005): ("extruder_train", 4000006, upgrade.upgradeStack),
|
||||
},
|
||||
"sources": {
|
||||
"preferences": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"."}
|
||||
},
|
||||
"machine_stack": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"./machine_instances"}
|
||||
},
|
||||
"extruder_train": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"./extruders"}
|
||||
},
|
||||
"definition_changes": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"./definition_changes"}
|
||||
},
|
||||
"quality_changes": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"./quality_changes"}
|
||||
},
|
||||
"quality": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"./quality"}
|
||||
},
|
||||
"user": {
|
||||
"get_version": upgrade.getCfgVersion,
|
||||
"location": {"./user"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def register(app) -> Dict[str, Any]:
|
||||
return {"version_upgrade": upgrade}
|
8
plugins/VersionUpgrade/VersionUpgrade35to40/plugin.json
Normal file
8
plugins/VersionUpgrade/VersionUpgrade35to40/plugin.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Version Upgrade 3.5 to 4.0",
|
||||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.0",
|
||||
"description": "Upgrades configurations from Cura 3.5 to Cura 4.0.",
|
||||
"api": "6.0",
|
||||
"i18n-catalog": "cura"
|
||||
}
|
@ -16,7 +16,7 @@ def getMetaData():
|
||||
"mimetype": "application/x-ultimaker-material-profile"
|
||||
},
|
||||
"version_upgrade": {
|
||||
("materials", 1000000): ("materials", 1000004, upgrader.upgradeMaterial),
|
||||
("materials", 1000000): ("materials", 1000006, upgrader.upgradeMaterial),
|
||||
},
|
||||
"sources": {
|
||||
"materials": {
|
||||
|
@ -50,6 +50,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CuraDrive": {
|
||||
"package_info": {
|
||||
"package_id": "CuraDrive",
|
||||
"package_type": "plugin",
|
||||
"display_name": "Cura Backups",
|
||||
"description": "Backup and restore your configuration.",
|
||||
"package_version": "1.2.0",
|
||||
"sdk_version": 6,
|
||||
"website": "https://ultimaker.com",
|
||||
"author": {
|
||||
"author_id": "UltimakerPackages",
|
||||
"display_name": "Ultimaker B.V.",
|
||||
"email": "plugins@ultimaker.com",
|
||||
"website": "https://ultimaker.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CuraEngineBackend": {
|
||||
"package_info": {
|
||||
"package_id": "CuraEngineBackend",
|
||||
@ -713,6 +730,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VersionUpgrade35to40": {
|
||||
"package_info": {
|
||||
"package_id": "VersionUpgrade35to40",
|
||||
"package_type": "plugin",
|
||||
"display_name": "Version Upgrade 3.5 to 4.0",
|
||||
"description": "Upgrades configurations from Cura 3.5 to Cura 4.0.",
|
||||
"package_version": "1.0.0",
|
||||
"sdk_version": "6.0",
|
||||
"website": "https://ultimaker.com",
|
||||
"author": {
|
||||
"author_id": "UltimakerPackages",
|
||||
"display_name": "Ultimaker B.V.",
|
||||
"email": "plugins@ultimaker.com",
|
||||
"website": "https://ultimaker.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"X3DReader": {
|
||||
"package_info": {
|
||||
"package_id": "X3DReader",
|
||||
|
@ -16,7 +16,7 @@ Row
|
||||
width: UM.Theme.getSize("account_button").width
|
||||
height: UM.Theme.getSize("account_button").height
|
||||
text: catalog.i18nc("@button", "Create account")
|
||||
onClicked: Qt.openUrlExternally("https://account.ultimaker.com/app/create")
|
||||
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerCloudAccountRootUrl + "/app/create")
|
||||
fixedWidthMode: true
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ Row
|
||||
width: UM.Theme.getSize("account_button").width
|
||||
height: UM.Theme.getSize("account_button").height
|
||||
text: catalog.i18nc("@button", "Manage account")
|
||||
onClicked: Qt.openUrlExternally("https://account.ultimaker.com")
|
||||
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerCloudAccountRootUrl)
|
||||
fixedWidthMode: true
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
import QtGraphicalEffects 1.0 // For the dropshadow
|
||||
|
||||
import UM 1.1 as UM
|
||||
import Cura 1.0 as Cura
|
||||
|
||||
@ -30,6 +31,7 @@ Button
|
||||
property color outlineDisabledColor: outlineColor
|
||||
property alias shadowColor: shadow.color
|
||||
property alias shadowEnabled: shadow.visible
|
||||
property alias busy: busyIndicator.visible
|
||||
|
||||
property alias toolTipContentAlignment: tooltip.contentAlignment
|
||||
|
||||
@ -55,7 +57,7 @@ Button
|
||||
width: visible ? height : 0
|
||||
sourceSize.width: width
|
||||
sourceSize.height: height
|
||||
color: button.hovered ? button.textHoverColor : button.textColor
|
||||
color: button.enabled ? (button.hovered ? button.textHoverColor : button.textColor) : button.textDisabledColor
|
||||
visible: source != "" && !button.isIconOnRightSide
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
@ -117,4 +119,16 @@ Button
|
||||
id: tooltip
|
||||
visible: button.hovered
|
||||
}
|
||||
|
||||
BusyIndicator
|
||||
{
|
||||
id: busyIndicator
|
||||
|
||||
anchors.centerIn: parent
|
||||
|
||||
width: height
|
||||
height: parent.height
|
||||
|
||||
visible: false
|
||||
}
|
||||
}
|
@ -31,6 +31,13 @@ Column
|
||||
id: information
|
||||
width: parent.width
|
||||
height: childrenRect.height
|
||||
|
||||
PrintInformationWidget
|
||||
{
|
||||
id: printInformationPanel
|
||||
visible: !preSlicedData
|
||||
anchors.right: parent.right
|
||||
}
|
||||
|
||||
Column
|
||||
{
|
||||
@ -50,15 +57,7 @@ Column
|
||||
|
||||
text: preSlicedData ? catalog.i18nc("@label", "No time estimation available") : PrintInformation.currentPrintTime.getDisplayString(UM.DurationFormat.Long)
|
||||
source: UM.Theme.getIcon("clock")
|
||||
font: UM.Theme.getFont("large_bold")
|
||||
|
||||
PrintInformationWidget
|
||||
{
|
||||
id: printInformationPanel
|
||||
visible: !preSlicedData
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: parent.contentWidth + UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
font: UM.Theme.getFont("medium_bold")
|
||||
}
|
||||
|
||||
Cura.IconWithText
|
||||
@ -91,43 +90,8 @@ Column
|
||||
return totalWeights + "g · " + totalLengths.toFixed(2) + "m"
|
||||
}
|
||||
source: UM.Theme.getIcon("spool")
|
||||
|
||||
Item
|
||||
{
|
||||
id: additionalComponents
|
||||
width: childrenRect.width
|
||||
anchors.right: parent.right
|
||||
height: parent.height
|
||||
Row
|
||||
{
|
||||
id: additionalComponentsRow
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
}
|
||||
Component.onCompleted: addAdditionalComponents("saveButton")
|
||||
|
||||
Connections
|
||||
{
|
||||
target: CuraApplication
|
||||
onAdditionalComponentsChanged: addAdditionalComponents("saveButton")
|
||||
}
|
||||
|
||||
function addAdditionalComponents (areaId)
|
||||
{
|
||||
if(areaId == "saveButton")
|
||||
{
|
||||
for (var component in CuraApplication.additionalComponents["saveButton"])
|
||||
{
|
||||
CuraApplication.additionalComponents["saveButton"][component].parent = additionalComponentsRow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Item
|
||||
|
@ -110,8 +110,7 @@ Column
|
||||
|
||||
height: parent.height
|
||||
|
||||
anchors.right: additionalComponents.left
|
||||
anchors.rightMargin: additionalComponents.width != 0 ? UM.Theme.getSize("default_margin").width : 0
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
|
||||
text: catalog.i18nc("@button", "Slice")
|
||||
@ -128,45 +127,12 @@ Column
|
||||
height: parent.height
|
||||
anchors.left: parent.left
|
||||
|
||||
anchors.right: additionalComponents.left
|
||||
anchors.rightMargin: additionalComponents.width != 0 ? UM.Theme.getSize("default_margin").width : 0
|
||||
anchors.right: parent.right
|
||||
text: catalog.i18nc("@button", "Cancel")
|
||||
enabled: sliceButton.enabled
|
||||
visible: !sliceButton.visible
|
||||
onClicked: sliceOrStopSlicing()
|
||||
}
|
||||
|
||||
Item
|
||||
{
|
||||
id: additionalComponents
|
||||
width: childrenRect.width
|
||||
anchors.right: parent.right
|
||||
height: parent.height
|
||||
Row
|
||||
{
|
||||
id: additionalComponentsRow
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
}
|
||||
Component.onCompleted: prepareButtons.addAdditionalComponents("saveButton")
|
||||
|
||||
Connections
|
||||
{
|
||||
target: CuraApplication
|
||||
onAdditionalComponentsChanged: prepareButtons.addAdditionalComponents("saveButton")
|
||||
}
|
||||
|
||||
function addAdditionalComponents (areaId)
|
||||
{
|
||||
if(areaId == "saveButton")
|
||||
{
|
||||
for (var component in CuraApplication.additionalComponents["saveButton"])
|
||||
{
|
||||
CuraApplication.additionalComponents["saveButton"][component].parent = additionalComponentsRow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -176,6 +142,11 @@ Column
|
||||
target: UM.Preferences
|
||||
onPreferenceChanged:
|
||||
{
|
||||
if (preference !== "general/auto_slice")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var autoSlice = UM.Preferences.getValue("general/auto_slice")
|
||||
if(prepareButtons.autoSlice != autoSlice)
|
||||
{
|
||||
@ -194,7 +165,7 @@ Column
|
||||
shortcut: "Ctrl+P"
|
||||
onTriggered:
|
||||
{
|
||||
if (prepareButton.enabled)
|
||||
if (sliceButton.enabled)
|
||||
{
|
||||
sliceOrStopSlicing()
|
||||
}
|
||||
|
63
resources/qml/CheckBoxWithTooltip.qml
Normal file
63
resources/qml/CheckBoxWithTooltip.qml
Normal file
@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.1
|
||||
|
||||
import UM 1.3 as UM
|
||||
|
||||
CheckBox
|
||||
{
|
||||
id: checkbox
|
||||
hoverEnabled: true
|
||||
|
||||
property alias tooltip: tooltip.text
|
||||
|
||||
indicator: Rectangle
|
||||
{
|
||||
implicitWidth: UM.Theme.getSize("checkbox").width
|
||||
implicitHeight: UM.Theme.getSize("checkbox").height
|
||||
x: 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: UM.Theme.getColor("main_background")
|
||||
radius: UM.Theme.getSize("checkbox_radius").width
|
||||
border.width: UM.Theme.getSize("default_lining").width
|
||||
border.color: checkbox.hovered ? UM.Theme.getColor("checkbox_border_hover") : UM.Theme.getColor("checkbox_border")
|
||||
|
||||
UM.RecolorImage
|
||||
{
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: Math.round(parent.width / 2.5)
|
||||
height: Math.round(parent.height / 2.5)
|
||||
sourceSize.height: width
|
||||
color: UM.Theme.getColor("checkbox_mark")
|
||||
source: UM.Theme.getIcon("check")
|
||||
opacity: checkbox.checked
|
||||
Behavior on opacity { NumberAnimation { duration: 100; } }
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Label
|
||||
{
|
||||
anchors
|
||||
{
|
||||
left: checkbox.indicator.right
|
||||
leftMargin: UM.Theme.getSize("narrow_margin").width
|
||||
}
|
||||
text: checkbox.text
|
||||
color: UM.Theme.getColor("checkbox_text")
|
||||
font: UM.Theme.getFont("default")
|
||||
renderType: Text.NativeRendering
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
ToolTip
|
||||
{
|
||||
id: tooltip
|
||||
text: ""
|
||||
delay: 500
|
||||
visible: text != "" && checkbox.hovered
|
||||
}
|
||||
}
|
@ -124,16 +124,16 @@ UM.MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
// This is a placehoder for adding a pattern in the header
|
||||
Image
|
||||
{
|
||||
id: backgroundPattern
|
||||
anchors.fill: parent
|
||||
fillMode: Image.Tile
|
||||
source: UM.Theme.getImage("header_pattern")
|
||||
horizontalAlignment: Image.AlignLeft
|
||||
verticalAlignment: Image.AlignTop
|
||||
}
|
||||
// This is a placehoder for adding a pattern in the header
|
||||
Image
|
||||
{
|
||||
id: backgroundPattern
|
||||
anchors.fill: parent
|
||||
fillMode: Image.Tile
|
||||
source: UM.Theme.getImage("header_pattern")
|
||||
horizontalAlignment: Image.AlignLeft
|
||||
verticalAlignment: Image.AlignTop
|
||||
}
|
||||
}
|
||||
|
||||
MainWindowHeader
|
||||
@ -248,6 +248,7 @@ UM.MainWindow
|
||||
|
||||
Cura.ActionPanelWidget
|
||||
{
|
||||
id: actionPanelWidget
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: UM.Theme.getSize("thick_margin").width
|
||||
@ -269,6 +270,39 @@ UM.MainWindow
|
||||
visible: CuraApplication.platformActivity && (main.item == null || !qmlTypeOf(main.item, "QQuickRectangle"))
|
||||
}
|
||||
|
||||
Item
|
||||
{
|
||||
id: additionalComponents
|
||||
width: childrenRect.width
|
||||
anchors.right: actionPanelWidget.left
|
||||
anchors.rightMargin: UM.Theme.getSize("default_margin").width
|
||||
anchors.bottom: actionPanelWidget.bottom
|
||||
anchors.bottomMargin: UM.Theme.getSize("thick_margin").height * 2
|
||||
visible: actionPanelWidget.visible
|
||||
Row
|
||||
{
|
||||
id: additionalComponentsRow
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: contentItem.addAdditionalComponents()
|
||||
|
||||
Connections
|
||||
{
|
||||
target: CuraApplication
|
||||
onAdditionalComponentsChanged: contentItem.addAdditionalComponents("saveButton")
|
||||
}
|
||||
|
||||
function addAdditionalComponents()
|
||||
{
|
||||
for (var component in CuraApplication.additionalComponents["saveButton"])
|
||||
{
|
||||
CuraApplication.additionalComponents["saveButton"][component].parent = additionalComponentsRow
|
||||
}
|
||||
}
|
||||
|
||||
Loader
|
||||
{
|
||||
// A stage can control this area. If nothing is set, it will therefore show the 3D view.
|
||||
|
@ -23,7 +23,7 @@ Item
|
||||
}
|
||||
}
|
||||
|
||||
// This component will appear when there is no configurations (e.g. when losing connection)
|
||||
// This component will appear when there are no configurations (e.g. when losing connection or when they are being loaded)
|
||||
Item
|
||||
{
|
||||
width: parent.width
|
||||
@ -51,7 +51,11 @@ Item
|
||||
anchors.left: icon.right
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: UM.Theme.getSize("default_margin").width
|
||||
text: catalog.i18nc("@label", "Downloading the configurations from the remote printer")
|
||||
// There are two cases that we want to diferenciate, one is when Cura is loading the configurations and the
|
||||
// other when the connection was lost
|
||||
text: Cura.MachineManager.printerConnected ?
|
||||
catalog.i18nc("@label", "Loading available configurations from the printer...") :
|
||||
catalog.i18nc("@label", "The configurations are not available because the printer is disconnected.")
|
||||
color: UM.Theme.getColor("text")
|
||||
font: UM.Theme.getFont("default")
|
||||
renderType: Text.NativeRendering
|
||||
|
@ -64,29 +64,33 @@ Cura.ExpandablePopup
|
||||
// Label for the brand of the material
|
||||
Label
|
||||
{
|
||||
id: brandNameLabel
|
||||
id: typeAndBrandNameLabel
|
||||
|
||||
text: model.material_brand
|
||||
text: model.material_brand + " " + model.material
|
||||
elide: Text.ElideRight
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("text_inactive")
|
||||
color: UM.Theme.getColor("text")
|
||||
renderType: Text.NativeRendering
|
||||
|
||||
anchors
|
||||
{
|
||||
top: extruderIcon.top
|
||||
left: extruderIcon.right
|
||||
leftMargin: UM.Theme.getSize("default_margin").width
|
||||
right: parent.right
|
||||
rightMargin: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
}
|
||||
|
||||
// Label that shows the name of the material
|
||||
// Label that shows the name of the variant
|
||||
Label
|
||||
{
|
||||
text: model.material
|
||||
id: variantLabel
|
||||
|
||||
visible: Cura.MachineManager.hasVariants
|
||||
|
||||
text: model.variant
|
||||
elide: Text.ElideRight
|
||||
font: UM.Theme.getFont("medium")
|
||||
font: UM.Theme.getFont("default_bold")
|
||||
color: UM.Theme.getColor("text")
|
||||
renderType: Text.NativeRendering
|
||||
|
||||
@ -94,9 +98,7 @@ Cura.ExpandablePopup
|
||||
{
|
||||
left: extruderIcon.right
|
||||
leftMargin: UM.Theme.getSize("default_margin").width
|
||||
right: parent.right
|
||||
rightMargin: UM.Theme.getSize("default_margin").width
|
||||
top: brandNameLabel.bottom
|
||||
top: typeAndBrandNameLabel.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -136,9 +138,9 @@ Cura.ExpandablePopup
|
||||
|
||||
onVisibleChanged:
|
||||
{
|
||||
is_connected = Cura.MachineManager.activeMachineHasRemoteConnection && Cura.MachineManager.printerConnected //Re-evaluate.
|
||||
is_connected = Cura.MachineManager.activeMachineHasRemoteConnection && Cura.MachineManager.printerConnected && Cura.MachineManager.printerOutputDevices[0].uniqueConfigurations.length > 0 //Re-evaluate.
|
||||
|
||||
// If the printer is not connected, we switch always to the custom mode. If is connected instead, the auto mode
|
||||
// If the printer is not connected or does not have configurations, we switch always to the custom mode. If is connected instead, the auto mode
|
||||
// or the previous state is selected
|
||||
configuration_method = is_connected ? (manual_selected_method == -1 ? ConfigurationMenu.ConfigurationMethod.Auto : manual_selected_method) : ConfigurationMenu.ConfigurationMethod.Custom
|
||||
}
|
||||
@ -173,6 +175,59 @@ Cura.ExpandablePopup
|
||||
}
|
||||
}
|
||||
|
||||
Item
|
||||
{
|
||||
height: visible ? childrenRect.height: 0
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: UM.Theme.getSize("default_margin").width
|
||||
width: childrenRect.width + UM.Theme.getSize("default_margin").width
|
||||
visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.Custom
|
||||
UM.RecolorImage
|
||||
{
|
||||
id: externalLinkIcon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: UM.Theme.getSize("default_margin").width
|
||||
height: materialInfoLabel.height
|
||||
width: height
|
||||
sourceSize.height: width
|
||||
color: UM.Theme.getColor("text_link")
|
||||
source: UM.Theme.getIcon("external_link")
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: materialInfoLabel
|
||||
wrapMode: Text.WordWrap
|
||||
text: catalog.i18nc("@label", "See the material compatibility chart")
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("text_link")
|
||||
linkColor: UM.Theme.getColor("text_link")
|
||||
anchors.left: externalLinkIcon.right
|
||||
anchors.leftMargin: UM.Theme.getSize("narrow_margin").width
|
||||
renderType: Text.NativeRendering
|
||||
|
||||
MouseArea
|
||||
{
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked:
|
||||
{
|
||||
// open the material URL with web browser
|
||||
var url = "https://ultimaker.com/incoming-links/cura/material-compatibilty"
|
||||
Qt.openUrlExternally(url)
|
||||
}
|
||||
onEntered:
|
||||
{
|
||||
materialInfoLabel.font.underline = true
|
||||
}
|
||||
onExited:
|
||||
{
|
||||
materialInfoLabel.font.underline = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle
|
||||
{
|
||||
id: separator
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user