diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py
index 762d0db069..b4bf4af83e 100644
--- a/cura/OAuth2/AuthorizationHelpers.py
+++ b/cura/OAuth2/AuthorizationHelpers.py
@@ -1,33 +1,35 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
+
+from base64 import b64encode
+from hashlib import sha512
import json
import random
-from hashlib import sha512
-from base64 import b64encode
-from typing import Dict, Optional
-
import requests
+from typing import Optional
+from UM.i18n import i18nCatalog
from UM.Logger import Logger
-
from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings
+catalog = i18nCatalog("cura")
-# Class containing several helpers to deal with the authorization flow.
+## Class containing several helpers to deal with the authorization flow.
class AuthorizationHelpers:
def __init__(self, settings: "OAuth2Settings") -> None:
self._settings = settings
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
@property
- # The OAuth2 settings object.
+ ## The OAuth2 settings object.
def settings(self) -> "OAuth2Settings":
return self._settings
- # Request the access token from the authorization server.
+ ## Request the access token from the authorization server.
# \param authorization_code: The authorization code from the 1st step.
- # \param verification_code: The verification code needed for the PKCE extension.
- # \return: An AuthenticationResponse object.
+ # \param verification_code: The verification code needed for the PKCE
+ # extension.
+ # \return An AuthenticationResponse object.
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse":
data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
@@ -39,9 +41,9 @@ class AuthorizationHelpers:
}
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
- # Request the access token from the authorization server using a refresh token.
+ ## Request the access token from the authorization server using a refresh token.
# \param refresh_token:
- # \return: An AuthenticationResponse object.
+ # \return An AuthenticationResponse object.
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
@@ -53,9 +55,9 @@ class AuthorizationHelpers:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
@staticmethod
- # Parse the token response from the authorization server into an AuthenticationResponse object.
+ ## Parse the token response from the authorization server into an AuthenticationResponse object.
# \param token_response: The JSON string data response from the authorization server.
- # \return: An AuthenticationResponse object.
+ # \return An AuthenticationResponse object.
def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
token_data = None
@@ -65,27 +67,27 @@ class AuthorizationHelpers:
Logger.log("w", "Could not parse token response data: %s", token_response.text)
if not token_data:
- return AuthenticationResponse(success=False, err_message="Could not read response.")
+ return AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response."))
if token_response.status_code not in (200, 201):
- return AuthenticationResponse(success=False, err_message=token_data["error_description"])
+ return AuthenticationResponse(success = False, err_message = token_data["error_description"])
- return AuthenticationResponse(success=True,
- token_type=token_data["token_type"],
- access_token=token_data["access_token"],
- refresh_token=token_data["refresh_token"],
- expires_in=token_data["expires_in"],
- scope=token_data["scope"])
+ return AuthenticationResponse(success = True,
+ token_type = token_data["token_type"],
+ access_token = token_data["access_token"],
+ refresh_token = token_data["refresh_token"],
+ expires_in = token_data["expires_in"],
+ scope = token_data["scope"])
- # Calls the authentication API endpoint to get the token data.
+ ## Calls the authentication API endpoint to get the token data.
# \param access_token: The encoded JWT token.
- # \return: Dict containing some profile data.
+ # \return Dict containing some profile data.
def parseJWT(self, access_token: str) -> Optional["UserProfile"]:
try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
"Authorization": "Bearer {}".format(access_token)
})
- except ConnectionError:
+ except requests.exceptions.ConnectionError:
# Connection was suddenly dropped. Nothing we can do about that.
Logger.logException("e", "Something failed while attempting to parse the JWT token")
return None
@@ -103,15 +105,15 @@ class AuthorizationHelpers:
)
@staticmethod
- # Generate a 16-character verification code.
+ ## Generate a 16-character verification code.
# \param code_length: How long should the code be?
def generateVerificationCode(code_length: int = 16) -> str:
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod
- # Generates a base64 encoded sha512 encrypted version of a given string.
+ ## Generates a base64 encoded sha512 encrypted version of a given string.
# \param verification_code:
- # \return: The encrypted code in base64 format.
+ # \return The encrypted code in base64 format.
def generateVerificationCodeChallenge(verification_code: str) -> str:
encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode()
diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py
index 7e0a659a56..8f0982ccbf 100644
--- a/cura/OAuth2/AuthorizationRequestHandler.py
+++ b/cura/OAuth2/AuthorizationRequestHandler.py
@@ -1,19 +1,21 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
-from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from http.server import BaseHTTPRequestHandler
+from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS
+from UM.i18n import i18nCatalog
if TYPE_CHECKING:
from cura.OAuth2.Models import ResponseStatus
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
+catalog = i18nCatalog("cura")
-# This handler handles all HTTP requests on the local web server.
-# It also requests the access token for the 2nd stage of the OAuth flow.
+## This handler handles all HTTP requests on the local web server.
+# It also requests the access token for the 2nd stage of the OAuth flow.
class AuthorizationRequestHandler(BaseHTTPRequestHandler):
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
@@ -47,9 +49,9 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
# This will cause the server to shut down, so we do it at the very end of the request handling.
self.authorization_callback(token_response)
- # Handler for the callback URL redirect.
- # \param query: Dict containing the HTTP query parameters.
- # \return: HTTP ResponseData containing a success page to show to the user.
+ ## Handler for the callback URL redirect.
+ # \param query Dict containing the HTTP query parameters.
+ # \return HTTP ResponseData containing a success page to show to the user.
def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]:
code = self._queryGet(query, "code")
if code and self.authorization_helpers is not None and self.verification_code is not None:
@@ -60,30 +62,30 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
elif self._queryGet(query, "error_code") == "user_denied":
# Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog).
token_response = AuthenticationResponse(
- success=False,
- err_message="Please give the required permissions when authorizing this application."
+ success = False,
+ err_message = catalog.i18nc("@message", "Please give the required permissions when authorizing this application.")
)
else:
# We don't know what went wrong here, so instruct the user to check the logs.
token_response = AuthenticationResponse(
- success=False,
- error_message="Something unexpected happened when trying to log in, please try again."
+ success = False,
+ error_message = catalog.i18nc("@message", "Something unexpected happened when trying to log in, please try again.")
)
if self.authorization_helpers is None:
return ResponseData(), token_response
return ResponseData(
- status=HTTP_STATUS["REDIRECT"],
- data_stream=b"Redirecting...",
- redirect_uri=self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else
+ status = HTTP_STATUS["REDIRECT"],
+ data_stream = b"Redirecting...",
+ redirect_uri = self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response
+ ## Handle all other non-existing server calls.
@staticmethod
- # Handle all other non-existing server calls.
def _handleNotFound() -> ResponseData:
- return ResponseData(status=HTTP_STATUS["NOT_FOUND"], content_type="text/html", data_stream=b"Not found.")
+ return ResponseData(status = HTTP_STATUS["NOT_FOUND"], content_type = "text/html", data_stream = b"Not found.")
def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None:
self.send_response(status.code, status.message)
@@ -95,7 +97,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
def _sendData(self, data: bytes) -> None:
self.wfile.write(data)
+ ## Convenience helper for getting values from a pre-parsed query string
@staticmethod
- # Convenience Helper for getting values from a pre-parsed query string
- def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str]=None) -> Optional[str]:
+ def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]:
return query_data.get(key, [default])[0]
diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py
index 288e348ea9..51a8ceba77 100644
--- a/cura/OAuth2/AuthorizationRequestServer.py
+++ b/cura/OAuth2/AuthorizationRequestServer.py
@@ -1,5 +1,6 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
+
from http.server import HTTPServer
from typing import Callable, Any, TYPE_CHECKING
@@ -8,19 +9,19 @@ if TYPE_CHECKING:
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
-# The authorization request callback handler server.
-# This subclass is needed to be able to pass some data to the request handler.
-# This cannot be done on the request handler directly as the HTTPServer creates an instance of the handler after
-# init.
+## The authorization request callback handler server.
+# This subclass is needed to be able to pass some data to the request handler.
+# This cannot be done on the request handler directly as the HTTPServer
+# creates an instance of the handler after init.
class AuthorizationRequestServer(HTTPServer):
- # Set the authorization helpers instance on the request handler.
+ ## Set the authorization helpers instance on the request handler.
def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore
- # Set the authorization callback on the request handler.
+ ## Set the authorization callback on the request handler.
def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None:
self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore
- # Set the verification code on the request handler.
+ ## Set the verification code on the request handler.
def setVerificationCode(self, verification_code: str) -> None:
self.RequestHandlerClass.verification_code = verification_code # type: ignore
diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py
index 1e98dc9cee..5117a2da66 100644
--- a/cura/OAuth2/AuthorizationService.py
+++ b/cura/OAuth2/AuthorizationService.py
@@ -1,9 +1,12 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
+
import json
import webbrowser
from typing import Optional, TYPE_CHECKING
from urllib.parse import urlencode
+import requests.exceptions
+
from UM.Logger import Logger
from UM.Signal import Signal
@@ -17,12 +20,9 @@ if TYPE_CHECKING:
from UM.Preferences import Preferences
+## The authorization service is responsible for handling the login flow,
+# storing user credentials and providing account information.
class AuthorizationService:
- """
- The authorization service is responsible for handling the login flow,
- storing user credentials and providing account information.
- """
-
# Emit signal when authentication is completed.
onAuthStateChanged = Signal()
@@ -44,14 +44,18 @@ class AuthorizationService:
if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
- # Get the user profile as obtained from the JWT (JSON Web Token).
+ ## Get the user profile as obtained from the JWT (JSON Web Token).
# If the JWT is not yet parsed, calling this will take care of that.
# \return UserProfile if a user is logged in, None otherwise.
# \sa _parseJWT
def getUserProfile(self) -> Optional["UserProfile"]:
if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT.
- self._user_profile = self._parseJWT()
+ try:
+ self._user_profile = self._parseJWT()
+ except requests.exceptions.ConnectionError:
+ # Unable to get connection, can't login.
+ return None
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.
@@ -61,7 +65,7 @@ class AuthorizationService:
return self._user_profile
- # Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
+ ## Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
# \return UserProfile if it was able to parse, None otherwise.
def _parseJWT(self) -> Optional["UserProfile"]:
if not self._auth_data or self._auth_data.access_token is None:
@@ -81,7 +85,7 @@ class AuthorizationService:
return self._auth_helpers.parseJWT(self._auth_data.access_token)
- # Get the access token as provided by the repsonse data.
+ ## Get the access token as provided by the repsonse data.
def getAccessToken(self) -> Optional[str]:
if not self.getUserProfile():
# We check if we can get the user profile.
@@ -95,21 +99,21 @@ class AuthorizationService:
return self._auth_data.access_token
- # Try to refresh the access token. This should be used when it has expired.
+ ## Try to refresh the access token. This should be used when it has expired.
def refreshAccessToken(self) -> None:
if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return
self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token))
- self.onAuthStateChanged.emit(logged_in=True)
+ self.onAuthStateChanged.emit(logged_in = True)
- # Delete the authentication data that we have stored locally (eg; logout)
+ ## Delete the authentication data that we have stored locally (eg; logout)
def deleteAuthData(self) -> None:
if self._auth_data is not None:
self._storeAuthData()
- self.onAuthStateChanged.emit(logged_in=False)
+ self.onAuthStateChanged.emit(logged_in = False)
- # Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.
+ ## Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.
def startAuthorizationFlow(self) -> None:
Logger.log("d", "Starting new OAuth2 flow...")
@@ -136,16 +140,16 @@ class AuthorizationService:
# Start a local web server to receive the callback URL on.
self._server.start(verification_code)
- # Callback method for the authentication flow.
+ ## Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
if auth_response.success:
self._storeAuthData(auth_response)
- self.onAuthStateChanged.emit(logged_in=True)
+ self.onAuthStateChanged.emit(logged_in = True)
else:
- self.onAuthenticationError.emit(logged_in=False, error_message=auth_response.err_message)
+ self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
self._server.stop() # Stop the web server at all times.
- # Load authentication data from preferences.
+ ## Load authentication data from preferences.
def loadAuthDataFromPreferences(self) -> None:
if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
@@ -154,11 +158,11 @@ class AuthorizationService:
preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
if preferences_data:
self._auth_data = AuthenticationResponse(**preferences_data)
- self.onAuthStateChanged.emit(logged_in=True)
+ self.onAuthStateChanged.emit(logged_in = True)
except ValueError:
Logger.logException("w", "Could not load auth data from preferences")
- # Store authentication data in preferences.
+ ## Store authentication data in preferences.
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!")
diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py
index 5a282d8135..25b2435012 100644
--- a/cura/OAuth2/LocalAuthorizationServer.py
+++ b/cura/OAuth2/LocalAuthorizationServer.py
@@ -1,5 +1,6 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
+
import threading
from typing import Optional, Callable, Any, TYPE_CHECKING
@@ -14,12 +15,15 @@ if TYPE_CHECKING:
class LocalAuthorizationServer:
- # The local LocalAuthorizationServer takes care of the oauth2 callbacks.
- # Once the flow is completed, this server should be closed down again by calling stop()
- # \param auth_helpers: An instance of the authorization helpers class.
- # \param auth_state_changed_callback: A callback function to be called when the authorization state changes.
- # \param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped
- # at shutdown. Their resources (e.g. open files) may never be released.
+ ## The local LocalAuthorizationServer takes care of the oauth2 callbacks.
+ # Once the flow is completed, this server should be closed down again by
+ # calling stop()
+ # \param auth_helpers An instance of the authorization helpers class.
+ # \param auth_state_changed_callback A callback function to be called when
+ # the authorization state changes.
+ # \param daemon Whether the server thread should be run in daemon mode.
+ # Note: Daemon threads are abruptly stopped at shutdown. Their resources
+ # (e.g. open files) may never be released.
def __init__(self, auth_helpers: "AuthorizationHelpers",
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
daemon: bool) -> None:
@@ -30,8 +34,8 @@ class LocalAuthorizationServer:
self._auth_state_changed_callback = auth_state_changed_callback
self._daemon = daemon
- # Starts the local web server to handle the authorization callback.
- # \param verification_code: The verification code part of the OAuth2 client identification.
+ ## Starts the local web server to handle the authorization callback.
+ # \param verification_code The verification code part of the OAuth2 client identification.
def start(self, verification_code: str) -> None:
if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
@@ -54,7 +58,7 @@ class LocalAuthorizationServer:
self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon)
self._web_server_thread.start()
- # Stops the web server if it was running. It also does some cleanup.
+ ## Stops the web server if it was running. It also does some cleanup.
def stop(self) -> None:
Logger.log("d", "Stopping local oauth2 web server...")
diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py
index 0515e789e6..62b62c42e0 100644
--- a/cura/OAuth2/Models.py
+++ b/cura/OAuth2/Models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
@@ -9,7 +9,7 @@ class BaseModel:
self.__dict__.update(kwargs)
-# OAuth OAuth2Settings data template.
+## OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel):
CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str]
@@ -21,14 +21,14 @@ class OAuth2Settings(BaseModel):
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
-# User profile data template.
+## User profile data template.
class UserProfile(BaseModel):
user_id = None # type: Optional[str]
username = None # type: Optional[str]
profile_image_url = None # type: Optional[str]
-# Authentication data template.
+## Authentication data template.
class AuthenticationResponse(BaseModel):
"""Data comes from the token response with success flag and error message added."""
success = True # type: bool
@@ -40,23 +40,22 @@ class AuthenticationResponse(BaseModel):
err_message = None # type: Optional[str]
-# Response status template.
+## Response status template.
class ResponseStatus(BaseModel):
code = 200 # type: int
message = "" # type str
-# Response data template.
+## Response data template.
class ResponseData(BaseModel):
status = None # type: ResponseStatus
data_stream = None # type: Optional[bytes]
redirect_uri = None # type: Optional[str]
content_type = "text/html" # type: str
-
-# Possible HTTP responses.
+## Possible HTTP responses.
HTTP_STATUS = {
- "OK": ResponseStatus(code=200, message="OK"),
- "NOT_FOUND": ResponseStatus(code=404, message="NOT FOUND"),
- "REDIRECT": ResponseStatus(code=302, message="REDIRECT")
+ "OK": ResponseStatus(code = 200, message = "OK"),
+ "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
+ "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")
}
diff --git a/cura/OAuth2/__init__.py b/cura/OAuth2/__init__.py
index f3f6970c54..d5641e902f 100644
--- a/cura/OAuth2/__init__.py
+++ b/cura/OAuth2/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2018 Ultimaker B.V.
+# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py
index 47a6caf3e5..bbeb87d5e3 100644
--- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py
+++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py
@@ -199,7 +199,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
# \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,
+ def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json",
on_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py
index 13bbd253a1..0d21f1e1b6 100755
--- a/cura/Settings/MachineManager.py
+++ b/cura/Settings/MachineManager.py
@@ -509,6 +509,18 @@ class MachineManager(QObject):
return self._global_container_stack.getId()
return ""
+ @pyqtProperty(str, notify = globalContainerChanged)
+ def activeMachineFirmwareVersion(self) -> str:
+ if not self._printer_output_devices[0]:
+ return ""
+ return self._printer_output_devices[0].firmwareVersion
+
+ @pyqtProperty(str, notify = globalContainerChanged)
+ def activeMachineAddress(self) -> str:
+ if not self._printer_output_devices[0]:
+ return ""
+ return self._printer_output_devices[0].address
+
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
def printerConnected(self) -> bool:
return bool(self._printer_output_devices)
diff --git a/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg
new file mode 100644
index 0000000000..8eba62ecc8
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg
@@ -0,0 +1,27 @@
+
+
\ No newline at end of file
diff --git a/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg
new file mode 100644
index 0000000000..746dc269fd
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg
@@ -0,0 +1,13 @@
+
+
\ No newline at end of file
diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py
index 78944d954b..e081beb99c 100644
--- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py
+++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py
@@ -7,6 +7,7 @@ from PyQt5.QtCore import QTimer
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
+from UM.Signal import Signal, signalemitter
from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.Settings.GlobalStack import GlobalStack
@@ -31,14 +32,17 @@ class CloudOutputDeviceManager:
# The translation catalog for this device.
I18N_CATALOG = i18nCatalog("cura")
+ addedCloudCluster = Signal()
+ removedCloudCluster = Signal()
+
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._application = CuraApplication.getInstance()
+ self._output_device_manager = self._application.getOutputDeviceManager()
- self._account = application.getCuraAPI().account # type: Account
+ self._account = self._application.getCuraAPI().account # type: Account
self._api = CloudApiClient(self._account, self._onApiError)
# Create a timer to update the remote cluster list
@@ -82,6 +86,7 @@ class CloudOutputDeviceManager:
removed_cluster.disconnect()
removed_cluster.close()
self._output_device_manager.removeOutputDevice(removed_cluster.key)
+ self.removedCloudCluster.emit()
del self._remote_clusters[removed_cluster.key]
# Add an output device for each new remote cluster.
@@ -89,6 +94,7 @@ class CloudOutputDeviceManager:
for added_cluster in added_clusters:
device = CloudOutputDevice(self._api, added_cluster)
self._remote_clusters[added_cluster.cluster_id] = device
+ self.addedCloudCluster.emit()
for device, cluster in updates:
device.clusterData = cluster
@@ -152,10 +158,9 @@ class CloudOutputDeviceManager:
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._application.globalContainerStackChanged.connect(self._connectToActiveMachine)
self._update_timer.timeout.connect(self._getRemoteClusters)
self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn)
@@ -163,9 +168,8 @@ class CloudOutputDeviceManager:
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._application.globalContainerStackChanged.disconnect(self._connectToActiveMachine)
self._update_timer.timeout.disconnect(self._getRemoteClusters)
self._onLoginStateChanged(is_logged_in = False)
diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py
index 9d7d979e28..a3b9ab4987 100644
--- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py
+++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py
@@ -54,6 +54,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent)
self._api_prefix = "/cluster-api/v1/"
+ self._application = CuraApplication.getInstance()
+
self._number_of_extruders = 2
self._dummy_lambdas = (
@@ -125,7 +127,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
def _spawnPrinterSelectionDialog(self):
if self._printer_selection_dialog is None:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/PrintWindow.qml")
- self._printer_selection_dialog = CuraApplication.getInstance().createQmlComponent(path, {"OutputDevice": self})
+ self._printer_selection_dialog = self._application.createQmlComponent(path, {"OutputDevice": self})
if self._printer_selection_dialog is not None:
self._printer_selection_dialog.show()
@@ -211,7 +213,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
# Add user name to the print_job
parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"))
- file_name = CuraApplication.getInstance().getPrintInformation().jobName + "." + preferred_format["extension"]
+ file_name = self._application.getPrintInformation().jobName + "." + preferred_format["extension"]
output = stream.getvalue() # Either str or bytes depending on the output mode.
if isinstance(stream, io.StringIO):
@@ -250,6 +252,11 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
self._compressing_gcode = False
self._sending_gcode = False
+ ## The IP address of the printer.
+ @pyqtProperty(str, constant = True)
+ def address(self) -> str:
+ return self._address
+
def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None:
if bytes_total > 0:
new_progress = bytes_sent / bytes_total * 100
@@ -284,7 +291,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
self._progress_message.hide()
self._compressing_gcode = False
self._sending_gcode = False
- CuraApplication.getInstance().getController().setActiveStage("PrepareStage")
+ self._application.getController().setActiveStage("PrepareStage")
# After compressing the sliced model Cura sends data to printer, to stop receiving updates from the request
# the "reply" should be disconnected
@@ -294,7 +301,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
def _successMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None:
if action_id == "View":
- CuraApplication.getInstance().getController().setActiveStage("MonitorStage")
+ self._application.getController().setActiveStage("MonitorStage")
@pyqtSlot()
def openPrintJobControlPanel(self) -> None:
@@ -355,8 +362,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
def sendJobToTop(self, print_job_uuid: str) -> None:
# This function is part of the output device (and not of the printjob output model) as this type of operation
# is a modification of the cluster queue and not of the actual job.
- data = "{\"list\": \"queued\",\"to_position\": 0}"
- self.post("print_jobs/{uuid}/action/move".format(uuid = print_job_uuid), data, on_finished=None)
+ data = "{\"to_position\": 0}"
+ self.put("print_jobs/{uuid}/move_to_position".format(uuid = print_job_uuid), data, on_finished=None)
@pyqtSlot(str)
def deleteJobFromQueue(self, print_job_uuid: str) -> None:
@@ -552,7 +559,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
return result
def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel":
- material_manager = CuraApplication.getInstance().getMaterialManager()
+ material_manager = self._application.getMaterialManager()
material_group_list = None
# Avoid crashing if there is no "guid" field in the metadata
@@ -665,7 +672,6 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
job = SendMaterialJob(device = self)
job.run()
-
def loadJsonFromReply(reply: QNetworkReply) -> Optional[List[Dict[str, Any]]]:
try:
result = json.loads(bytes(reply.readAll()).decode("utf-8"))
diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py
index 7a7670d64c..7cdc50a869 100644
--- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py
+++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py
@@ -7,17 +7,25 @@ from time import time
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
-from PyQt5.QtCore import QUrl
+from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject
+from PyQt5.QtGui import QDesktopServices
from cura.CuraApplication import CuraApplication
+from cura.PrinterOutputDevice import ConnectionType
+from cura.Settings.GlobalStack import GlobalStack # typing
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
from UM.Logger import Logger
from UM.Signal import Signal, signalemitter
from UM.Version import Version
+from UM.Message import Message
+from UM.i18n import i18nCatalog
from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager
+from typing import Optional
+
+i18n_catalog = i18nCatalog("cura")
## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
# Zero-Conf is used to detect printers, which are saved in a dict.
@@ -27,6 +35,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
addDeviceSignal = Signal()
removeDeviceSignal = Signal()
discoveredDevicesChanged = Signal()
+ cloudFlowIsPossible = Signal()
def __init__(self):
super().__init__()
@@ -34,6 +43,8 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self._zero_conf = None
self._zero_conf_browser = None
+ self._application = CuraApplication.getInstance()
+
# Create a cloud output device manager that abstracts all cloud connection logic away.
self._cloud_output_device_manager = CloudOutputDeviceManager()
@@ -41,7 +52,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self.addDeviceSignal.connect(self._onAddDevice)
self.removeDeviceSignal.connect(self._onRemoveDevice)
- CuraApplication.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
+ self._application.globalContainerStackChanged.connect(self.reCheckConnections)
self._discovered_devices = {}
@@ -49,6 +60,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self._network_manager.finished.connect(self._onNetworkRequestFinished)
self._min_cluster_version = Version("4.0.0")
+ self._min_cloud_version = Version("5.2.0")
self._api_version = "1"
self._api_prefix = "/api/v" + self._api_version + "/"
@@ -74,6 +86,26 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True)
self._service_changed_request_thread.start()
+ self._account = self._application.getCuraAPI().account
+
+ # Check if cloud flow is possible when user logs in
+ self._account.loginStateChanged.connect(self.checkCloudFlowIsPossible)
+
+ # Check if cloud flow is possible when user switches machines
+ self._application.globalContainerStackChanged.connect(self._onMachineSwitched)
+
+ # Listen for when cloud flow is possible
+ self.cloudFlowIsPossible.connect(self._onCloudFlowPossible)
+
+ # Listen if cloud cluster was added
+ self._cloud_output_device_manager.addedCloudCluster.connect(self._onCloudPrintingConfigured)
+
+ # Listen if cloud cluster was removed
+ self._cloud_output_device_manager.removedCloudCluster.connect(self.checkCloudFlowIsPossible)
+
+ self._start_cloud_flow_message = None # type: Optional[Message]
+ self._cloud_flow_complete_message = None # type: Optional[Message]
+
def getDiscoveredDevices(self):
return self._discovered_devices
@@ -138,6 +170,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
um_network_key = CuraApplication.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key")
if key == um_network_key:
self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key])
+ self.checkCloudFlowIsPossible()
else:
self.getOutputDeviceManager().removeOutputDevice(key)
@@ -370,3 +403,113 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self.removeDeviceSignal.emit(str(name))
return True
+
+ ## Check if the prerequsites are in place to start the cloud flow
+ def checkCloudFlowIsPossible(self) -> None:
+ Logger.log("d", "Checking if cloud connection is possible...")
+
+ # Pre-Check: Skip if active machine already has been cloud connected or you said don't ask again
+ active_machine = self._application.getMachineManager().activeMachine # type: Optional["GlobalStack"]
+ if active_machine:
+
+ # Check 1: Printer isn't already configured for cloud
+ if ConnectionType.CloudConnection.value in active_machine.configuredConnectionTypes:
+ Logger.log("d", "Active machine was already configured for cloud.")
+ return
+
+ # Check 2: User did not already say "Don't ask me again"
+ if active_machine.getMetaDataEntry("show_cloud_message", "value") is False:
+ Logger.log("d", "Active machine shouldn't ask about cloud anymore.")
+ return
+
+ # Check 3: User is logged in with an Ultimaker account
+ if not self._account.isLoggedIn:
+ Logger.log("d", "Cloud Flow not possible: User not logged in!")
+ return
+
+ # Check 4: Machine is configured for network connectivity
+ if not self._application.getMachineManager().activeMachineHasActiveNetworkConnection:
+ Logger.log("d", "Cloud Flow not possible: Machine is not connected!")
+ return
+
+ # Check 5: Machine has correct firmware version
+ firmware_version = self._application.getMachineManager().activeMachineFirmwareVersion # type: str
+ if not Version(firmware_version) > self._min_cloud_version:
+ Logger.log("d", "Cloud Flow not possible: Machine firmware (%s) is too low! (Requires version %s)",
+ firmware_version,
+ self._min_cloud_version)
+ return
+
+ Logger.log("d", "Cloud flow is possible!")
+ self.cloudFlowIsPossible.emit()
+
+ def _onCloudFlowPossible(self) -> None:
+ # Cloud flow is possible, so show the message
+ if not self._start_cloud_flow_message:
+ self._start_cloud_flow_message = Message(
+ text = i18n_catalog.i18nc("@info:status", "Send and monitor print jobs from anywhere using your Ultimaker account."),
+ image_source = "../../../../../Cura/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg",
+ image_caption = i18n_catalog.i18nc("@info:status", "Connect to Ultimaker Cloud"),
+ option_text = i18n_catalog.i18nc("@action", "Don't ask me again for this printer."),
+ option_state = False
+ )
+ self._start_cloud_flow_message.addAction("", i18n_catalog.i18nc("@action", "Get started"), "", "")
+ self._start_cloud_flow_message.optionToggled.connect(self._onDontAskMeAgain)
+ self._start_cloud_flow_message.actionTriggered.connect(self._onCloudFlowStarted)
+ self._start_cloud_flow_message.show()
+ return
+
+ def _onCloudPrintingConfigured(self) -> None:
+ if self._start_cloud_flow_message:
+ self._start_cloud_flow_message.hide()
+ self._start_cloud_flow_message = None
+
+ # Show the successful pop-up
+ if not self._start_cloud_flow_message:
+ self._cloud_flow_complete_message = Message(
+ text = i18n_catalog.i18nc("@info:status", "You can now send and monitor print jobs from anywhere using your Ultimaker account."),
+ lifetime = 30,
+ image_source = "../../../../../Cura/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg",
+ image_caption = i18n_catalog.i18nc("@info:status", "Connected!")
+ )
+ self._cloud_flow_complete_message.addAction("", i18n_catalog.i18nc("@action", "Review your connection"), "", "", 1) # TODO: Icon
+ self._cloud_flow_complete_message.actionTriggered.connect(self._onReviewCloudConnection)
+ self._cloud_flow_complete_message.show()
+
+ # Set the machine's cloud flow as complete so we don't ask the user again and again for cloud connected printers
+ active_machine = self._application.getMachineManager().activeMachine
+ if active_machine:
+ active_machine.setMetaDataEntry("cloud_flow_complete", True)
+ return
+
+ def _onDontAskMeAgain(self, messageId: str, checked: bool) -> None:
+ active_machine = self._application.getMachineManager().activeMachine # type: Optional["GlobalStack"]
+ if active_machine:
+ active_machine.setMetaDataEntry("show_cloud_message", False)
+ Logger.log("d", "Will not ask the user again to cloud connect for current printer.")
+ return
+
+ def _onCloudFlowStarted(self, messageId: str, actionId: str) -> None:
+ address = self._application.getMachineManager().activeMachineAddress # type: str
+ if address:
+ QDesktopServices.openUrl(QUrl("http://" + address + "/cloud_connect"))
+ if self._start_cloud_flow_message:
+ self._start_cloud_flow_message.hide()
+ self._start_cloud_flow_message = None
+ return
+
+ def _onReviewCloudConnection(self, messageId: str, actionId: str) -> None:
+ address = self._application.getMachineManager().activeMachineAddress # type: str
+ if address:
+ QDesktopServices.openUrl(QUrl("http://" + address + "/settings"))
+ return
+
+ def _onMachineSwitched(self) -> None:
+ if self._start_cloud_flow_message is not None:
+ self._start_cloud_flow_message.hide()
+ self._start_cloud_flow_message = None
+ if self._cloud_flow_complete_message is not None:
+ self._cloud_flow_complete_message.hide()
+ self._cloud_flow_complete_message = None
+
+ self.checkCloudFlowIsPossible()
\ No newline at end of file
diff --git a/resources/quality/ultimaker_s5/um_s5_cc0.6_PLA_Draft_Print.inst.cfg b/resources/quality/ultimaker_s5/um_s5_cc0.6_PLA_Draft_Print.inst.cfg
new file mode 100644
index 0000000000..c3b69d791a
--- /dev/null
+++ b/resources/quality/ultimaker_s5/um_s5_cc0.6_PLA_Draft_Print.inst.cfg
@@ -0,0 +1,43 @@
+[general]
+version = 4
+name = Fast
+definition = ultimaker_s5
+
+[metadata]
+setting_version = 6
+type = quality
+quality_type = draft
+weight = -3
+material = generic_pla
+variant = CC 0.6
+is_experimental = True
+
+[values]
+cool_fan_full_at_height = =layer_height_0 + 2 * layer_height
+cool_fan_speed_max = =100
+cool_min_speed = 2
+gradual_infill_step_height = =3 * layer_height
+infill_line_width = =round(line_width * 0.65 / 0.75, 2)
+infill_pattern = triangles
+line_width = =machine_nozzle_size * 0.9375
+machine_nozzle_cool_down_speed = 0.75
+machine_nozzle_heat_up_speed = 1.6
+material_final_print_temperature = =max(-273.15, material_print_temperature - 15)
+material_initial_print_temperature = =max(-273.15, material_print_temperature - 10)
+material_print_temperature = =default_material_print_temperature + 10
+material_standby_temperature = 100
+prime_tower_enable = True
+retract_at_layer_change = False
+speed_print = 45
+speed_topbottom = =math.ceil(speed_print * 35 / 45)
+speed_wall = =math.ceil(speed_print * 40 / 45)
+speed_wall_x = =speed_wall
+speed_wall_0 = =math.ceil(speed_wall * 35 / 40)
+support_angle = 70
+support_line_width = =line_width * 0.75
+support_pattern = ='triangles'
+support_xy_distance = =wall_line_width_0 * 1.5
+top_bottom_thickness = =layer_height * 4
+wall_line_width = =round(line_width * 0.75 / 0.75, 2)
+wall_line_width_x = =round(wall_line_width * 0.625 / 0.75, 2)
+wall_thickness = =wall_line_width_0 + wall_line_width_x
diff --git a/resources/quality/ultimaker_s5/um_s5_cc0.6_PLA_Fast_Print.inst.cfg b/resources/quality/ultimaker_s5/um_s5_cc0.6_PLA_Fast_Print.inst.cfg
new file mode 100644
index 0000000000..ebadcf8555
--- /dev/null
+++ b/resources/quality/ultimaker_s5/um_s5_cc0.6_PLA_Fast_Print.inst.cfg
@@ -0,0 +1,43 @@
+[general]
+version = 4
+name = Normal
+definition = ultimaker_s5
+
+[metadata]
+setting_version = 6
+type = quality
+quality_type = fast
+weight = -2
+material = generic_pla
+variant = CC 0.6
+is_experimental = True
+
+[values]
+cool_fan_full_at_height = =layer_height_0 + 2 * layer_height
+cool_fan_speed_max = =100
+cool_min_speed = 2
+gradual_infill_step_height = =3 * layer_height
+infill_line_width = =round(line_width * 0.65 / 0.75, 2)
+infill_pattern = triangles
+line_width = =machine_nozzle_size * 0.9375
+machine_nozzle_cool_down_speed = 0.75
+machine_nozzle_heat_up_speed = 1.6
+material_final_print_temperature = =max(-273.15, material_print_temperature - 15)
+material_initial_print_temperature = =max(-273.15, material_print_temperature - 10)
+material_print_temperature = =default_material_print_temperature + 10
+material_standby_temperature = 100
+prime_tower_enable = True
+retract_at_layer_change = False
+speed_print = 45
+speed_topbottom = =math.ceil(speed_print * 35 / 45)
+speed_wall = =math.ceil(speed_print * 40 / 45)
+speed_wall_x = =speed_wall
+speed_wall_0 = =math.ceil(speed_wall * 35 / 40)
+support_angle = 70
+support_line_width = =line_width * 0.75
+support_pattern = ='triangles'
+support_xy_distance = =wall_line_width_0 * 1.5
+top_bottom_thickness = =layer_height * 4
+wall_line_width = =round(line_width * 0.75 / 0.75, 2)
+wall_line_width_x = =round(wall_line_width * 0.625 / 0.75, 2)
+wall_thickness = =wall_line_width_0 + wall_line_width_x
diff --git a/resources/variants/ultimaker3_aa0.8.inst.cfg b/resources/variants/ultimaker3_aa0.8.inst.cfg
index c224c49c43..2d3f210019 100644
--- a/resources/variants/ultimaker3_aa0.8.inst.cfg
+++ b/resources/variants/ultimaker3_aa0.8.inst.cfg
@@ -40,7 +40,7 @@ material_standby_temperature = 100
multiple_mesh_overlap = 0
prime_tower_enable = False
prime_tower_wipe_enabled = True
-retract_at_layer_change = True
+retract_at_layer_change = =not magic_spiralize
retraction_amount = 6.5
retraction_count_max = 25
retraction_extrusion_window = 1