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 @@ + + + + Group 2 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + Cloud_connection-icon + Created with Sketch. + + + + + + + + \ 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