Merge branch 'master' of github.com:Ultimaker/Cura

This commit is contained in:
Jaime van Kessel 2019-02-08 17:38:31 +01:00
commit 93ff63ce6f
17 changed files with 420 additions and 117 deletions

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from base64 import b64encode
from hashlib import sha512
import json import json
import random import random
from hashlib import sha512
from base64 import b64encode
from typing import Dict, Optional
import requests import requests
from typing import Optional
from UM.i18n import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings 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: class AuthorizationHelpers:
def __init__(self, settings: "OAuth2Settings") -> None: def __init__(self, settings: "OAuth2Settings") -> None:
self._settings = settings self._settings = settings
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
@property @property
# The OAuth2 settings object. ## The OAuth2 settings object.
def settings(self) -> "OAuth2Settings": def settings(self) -> "OAuth2Settings":
return self._settings 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 authorization_code: The authorization code from the 1st step.
# \param verification_code: The verification code needed for the PKCE extension. # \param verification_code: The verification code needed for the PKCE
# \return: An AuthenticationResponse object. # extension.
# \return An AuthenticationResponse object.
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse":
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "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 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: # \param refresh_token:
# \return: An AuthenticationResponse object. # \return An AuthenticationResponse object.
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "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 return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
@staticmethod @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. # \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": def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
token_data = None token_data = None
@ -65,27 +67,27 @@ class AuthorizationHelpers:
Logger.log("w", "Could not parse token response data: %s", token_response.text) Logger.log("w", "Could not parse token response data: %s", token_response.text)
if not token_data: 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): 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, return AuthenticationResponse(success = True,
token_type=token_data["token_type"], token_type = token_data["token_type"],
access_token=token_data["access_token"], access_token = token_data["access_token"],
refresh_token=token_data["refresh_token"], refresh_token = token_data["refresh_token"],
expires_in=token_data["expires_in"], expires_in = token_data["expires_in"],
scope=token_data["scope"]) 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. # \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"]: def parseJWT(self, access_token: str) -> Optional["UserProfile"]:
try: try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
"Authorization": "Bearer {}".format(access_token) "Authorization": "Bearer {}".format(access_token)
}) })
except ConnectionError: except requests.exceptions.ConnectionError:
# Connection was suddenly dropped. Nothing we can do about that. # Connection was suddenly dropped. Nothing we can do about that.
Logger.logException("e", "Something failed while attempting to parse the JWT token") Logger.logException("e", "Something failed while attempting to parse the JWT token")
return None return None
@ -103,15 +105,15 @@ class AuthorizationHelpers:
) )
@staticmethod @staticmethod
# Generate a 16-character verification code. ## Generate a 16-character verification code.
# \param code_length: How long should the code be? # \param code_length: How long should the code be?
def generateVerificationCode(code_length: int = 16) -> str: def generateVerificationCode(code_length: int = 16) -> str:
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod @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: # \param verification_code:
# \return: The encrypted code in base64 format. # \return The encrypted code in base64 format.
def generateVerificationCodeChallenge(verification_code: str) -> str: def generateVerificationCodeChallenge(verification_code: str) -> str:
encoded = sha512(verification_code.encode()).digest() encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode() return b64encode(encoded, altchars = b"_-").decode()

View File

@ -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. # 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 http.server import BaseHTTPRequestHandler
from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS
from UM.i18n import i18nCatalog
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.OAuth2.Models import ResponseStatus from cura.OAuth2.Models import ResponseStatus
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
catalog = i18nCatalog("cura")
# This handler handles all HTTP requests on the local web server. ## 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. # It also requests the access token for the 2nd stage of the OAuth flow.
class AuthorizationRequestHandler(BaseHTTPRequestHandler): class AuthorizationRequestHandler(BaseHTTPRequestHandler):
def __init__(self, request, client_address, server) -> None: def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server) 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. # 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) self.authorization_callback(token_response)
# Handler for the callback URL redirect. ## Handler for the callback URL redirect.
# \param query: Dict containing the HTTP query parameters. # \param query Dict containing the HTTP query parameters.
# \return: HTTP ResponseData containing a success page to show to the user. # \return HTTP ResponseData containing a success page to show to the user.
def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]:
code = self._queryGet(query, "code") code = self._queryGet(query, "code")
if code and self.authorization_helpers is not None and self.verification_code is not None: 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": elif self._queryGet(query, "error_code") == "user_denied":
# Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog). # Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog).
token_response = AuthenticationResponse( token_response = AuthenticationResponse(
success=False, success = False,
err_message="Please give the required permissions when authorizing this application." err_message = catalog.i18nc("@message", "Please give the required permissions when authorizing this application.")
) )
else: else:
# We don't know what went wrong here, so instruct the user to check the logs. # We don't know what went wrong here, so instruct the user to check the logs.
token_response = AuthenticationResponse( token_response = AuthenticationResponse(
success=False, success = False,
error_message="Something unexpected happened when trying to log in, please try again." error_message = catalog.i18nc("@message", "Something unexpected happened when trying to log in, please try again.")
) )
if self.authorization_helpers is None: if self.authorization_helpers is None:
return ResponseData(), token_response return ResponseData(), token_response
return ResponseData( return ResponseData(
status=HTTP_STATUS["REDIRECT"], status = HTTP_STATUS["REDIRECT"],
data_stream=b"Redirecting...", data_stream = b"Redirecting...",
redirect_uri=self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else redirect_uri = self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response ), token_response
## Handle all other non-existing server calls.
@staticmethod @staticmethod
# Handle all other non-existing server calls.
def _handleNotFound() -> ResponseData: 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: def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None:
self.send_response(status.code, status.message) self.send_response(status.code, status.message)
@ -95,7 +97,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
def _sendData(self, data: bytes) -> None: def _sendData(self, data: bytes) -> None:
self.wfile.write(data) self.wfile.write(data)
## Convenience helper for getting values from a pre-parsed query string
@staticmethod @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] return query_data.get(key, [default])[0]

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from http.server import HTTPServer from http.server import HTTPServer
from typing import Callable, Any, TYPE_CHECKING from typing import Callable, Any, TYPE_CHECKING
@ -8,19 +9,19 @@ if TYPE_CHECKING:
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
# The authorization request callback handler server. ## The authorization request callback handler server.
# This subclass is needed to be able to pass some data to the request handler. # 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 # This cannot be done on the request handler directly as the HTTPServer
# init. # creates an instance of the handler after init.
class AuthorizationRequestServer(HTTPServer): 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: def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore 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: def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None:
self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore 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: def setVerificationCode(self, verification_code: str) -> None:
self.RequestHandlerClass.verification_code = verification_code # type: ignore self.RequestHandlerClass.verification_code = verification_code # type: ignore

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
import webbrowser import webbrowser
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from urllib.parse import urlencode from urllib.parse import urlencode
import requests.exceptions
from UM.Logger import Logger from UM.Logger import Logger
from UM.Signal import Signal from UM.Signal import Signal
@ -17,12 +20,9 @@ if TYPE_CHECKING:
from UM.Preferences import Preferences from UM.Preferences import Preferences
## The authorization service is responsible for handling the login flow,
# storing user credentials and providing account information.
class AuthorizationService: 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. # Emit signal when authentication is completed.
onAuthStateChanged = Signal() onAuthStateChanged = Signal()
@ -44,14 +44,18 @@ class AuthorizationService:
if self._preferences: if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") 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. # If the JWT is not yet parsed, calling this will take care of that.
# \return UserProfile if a user is logged in, None otherwise. # \return UserProfile if a user is logged in, None otherwise.
# \sa _parseJWT # \sa _parseJWT
def getUserProfile(self) -> Optional["UserProfile"]: def getUserProfile(self) -> Optional["UserProfile"]:
if not self._user_profile: if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT. # 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 not self._user_profile and self._auth_data:
# If there is still no user profile from the JWT, we have to log in again. # 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 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. # \return UserProfile if it was able to parse, None otherwise.
def _parseJWT(self) -> Optional["UserProfile"]: def _parseJWT(self) -> Optional["UserProfile"]:
if not self._auth_data or self._auth_data.access_token is None: 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) 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]: def getAccessToken(self) -> Optional[str]:
if not self.getUserProfile(): if not self.getUserProfile():
# We check if we can get the user profile. # We check if we can get the user profile.
@ -95,21 +99,21 @@ class AuthorizationService:
return self._auth_data.access_token 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: def refreshAccessToken(self) -> None:
if self._auth_data is None or self._auth_data.refresh_token is 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.") Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return return
self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) 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: def deleteAuthData(self) -> None:
if self._auth_data is not None: if self._auth_data is not None:
self._storeAuthData() 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: def startAuthorizationFlow(self) -> None:
Logger.log("d", "Starting new OAuth2 flow...") Logger.log("d", "Starting new OAuth2 flow...")
@ -136,16 +140,16 @@ class AuthorizationService:
# Start a local web server to receive the callback URL on. # Start a local web server to receive the callback URL on.
self._server.start(verification_code) self._server.start(verification_code)
# Callback method for the authentication flow. ## Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
if auth_response.success: if auth_response.success:
self._storeAuthData(auth_response) self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in=True) self.onAuthStateChanged.emit(logged_in = True)
else: 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. self._server.stop() # Stop the web server at all times.
# Load authentication data from preferences. ## Load authentication data from preferences.
def loadAuthDataFromPreferences(self) -> None: def loadAuthDataFromPreferences(self) -> None:
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!") 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)) preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
if preferences_data: if preferences_data:
self._auth_data = AuthenticationResponse(**preferences_data) self._auth_data = AuthenticationResponse(**preferences_data)
self.onAuthStateChanged.emit(logged_in=True) self.onAuthStateChanged.emit(logged_in = True)
except ValueError: except ValueError:
Logger.logException("w", "Could not load auth data from preferences") 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: def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!") Logger.log("e", "Unable to save authentication data, since no preference has been set!")

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import threading import threading
from typing import Optional, Callable, Any, TYPE_CHECKING from typing import Optional, Callable, Any, TYPE_CHECKING
@ -14,12 +15,15 @@ if TYPE_CHECKING:
class LocalAuthorizationServer: class LocalAuthorizationServer:
# The local LocalAuthorizationServer takes care of the oauth2 callbacks. ## The local LocalAuthorizationServer takes care of the oauth2 callbacks.
# Once the flow is completed, this server should be closed down again by calling stop() # Once the flow is completed, this server should be closed down again by
# \param auth_helpers: An instance of the authorization helpers class. # calling stop()
# \param auth_state_changed_callback: A callback function to be called when the authorization state changes. # \param auth_helpers An instance of the authorization helpers class.
# \param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped # \param auth_state_changed_callback A callback function to be called when
# at shutdown. Their resources (e.g. open files) may never be released. # 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", def __init__(self, auth_helpers: "AuthorizationHelpers",
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
daemon: bool) -> None: daemon: bool) -> None:
@ -30,8 +34,8 @@ class LocalAuthorizationServer:
self._auth_state_changed_callback = auth_state_changed_callback self._auth_state_changed_callback = auth_state_changed_callback
self._daemon = daemon self._daemon = daemon
# Starts the local web server to handle the authorization callback. ## Starts the local web server to handle the authorization callback.
# \param verification_code: The verification code part of the OAuth2 client identification. # \param verification_code The verification code part of the OAuth2 client identification.
def start(self, verification_code: str) -> None: def start(self, verification_code: str) -> None:
if self._web_server: if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it. # 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 = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon)
self._web_server_thread.start() 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: def stop(self) -> None:
Logger.log("d", "Stopping local oauth2 web server...") Logger.log("d", "Stopping local oauth2 web server...")

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional from typing import Optional
@ -9,7 +9,7 @@ class BaseModel:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
# OAuth OAuth2Settings data template. ## OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel): class OAuth2Settings(BaseModel):
CALLBACK_PORT = None # type: Optional[int] CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str] OAUTH_SERVER_URL = None # type: Optional[str]
@ -21,14 +21,14 @@ class OAuth2Settings(BaseModel):
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
# User profile data template. ## User profile data template.
class UserProfile(BaseModel): class UserProfile(BaseModel):
user_id = None # type: Optional[str] user_id = None # type: Optional[str]
username = None # type: Optional[str] username = None # type: Optional[str]
profile_image_url = None # type: Optional[str] profile_image_url = None # type: Optional[str]
# Authentication data template. ## Authentication data template.
class AuthenticationResponse(BaseModel): class AuthenticationResponse(BaseModel):
"""Data comes from the token response with success flag and error message added.""" """Data comes from the token response with success flag and error message added."""
success = True # type: bool success = True # type: bool
@ -40,23 +40,22 @@ class AuthenticationResponse(BaseModel):
err_message = None # type: Optional[str] err_message = None # type: Optional[str]
# Response status template. ## Response status template.
class ResponseStatus(BaseModel): class ResponseStatus(BaseModel):
code = 200 # type: int code = 200 # type: int
message = "" # type str message = "" # type str
# Response data template. ## Response data template.
class ResponseData(BaseModel): class ResponseData(BaseModel):
status = None # type: ResponseStatus status = None # type: ResponseStatus
data_stream = None # type: Optional[bytes] data_stream = None # type: Optional[bytes]
redirect_uri = None # type: Optional[str] redirect_uri = None # type: Optional[str]
content_type = "text/html" # type: str content_type = "text/html" # type: str
## Possible HTTP responses.
# Possible HTTP responses.
HTTP_STATUS = { HTTP_STATUS = {
"OK": ResponseStatus(code=200, message="OK"), "OK": ResponseStatus(code = 200, message = "OK"),
"NOT_FOUND": ResponseStatus(code=404, message="NOT FOUND"), "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
"REDIRECT": ResponseStatus(code=302, message="REDIRECT") "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")
} }

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.

View File

@ -199,7 +199,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
# \param content_type: The content type of the body data. # \param content_type: The content type of the body data.
# \param on_finished: The function to call when the response is received. # \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. # \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_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable[[int, int], None]] = None) -> None: on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager() self._validateManager()

View File

@ -509,6 +509,18 @@ class MachineManager(QObject):
return self._global_container_stack.getId() return self._global_container_stack.getId()
return "" 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) @pyqtProperty(bool, notify = printerConnectedStatusChanged)
def printerConnected(self) -> bool: def printerConnected(self) -> bool:
return bool(self._printer_output_devices) return bool(self._printer_output_devices)

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="291px" height="209px" viewBox="0 0 291 209" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->
<title>Group 2</title>
<desc>Created with Sketch.</desc>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="CC--Cloud-connection-succes" transform="translate(-570.000000, -132.000000)">
<g id="Group-2" transform="translate(570.000000, 132.000000)">
<g id="Icon/-group-printer/-connected" fill="#08073F" fill-rule="nonzero">
<g id="printer-group">
<g id="Group-Copy" transform="translate(0.000000, 0.218255)">
<g id="UM3">
<path d="M151.645765,136.481718 C149.925833,142.069331 149,148.004918 149,154.156745 C149,157.125641 149.215633,160.044173 149.632091,162.897534 L94.5646946,162.897534 C92.6599327,163.073639 90.7551708,163.778061 89.3698894,165.010799 L88.3309283,166.067432 C87.2919673,167.828487 85.2140452,168.180697 83.1361231,168.180697 L73.7854738,168.180697 C73.2659933,168.180697 72.7465127,167.652381 72.7465127,167.124065 L72.7465127,152.918227 L19.3939394,152.918227 C17.7008177,153.074764 16.007696,153.700917 14.7763348,154.796684 L13.8528138,155.735914 C12.9292929,157.301296 11.0822511,157.614372 9.23520921,157.614372 L0.923520928,157.614372 C0.461760462,157.614372 1.42108547e-13,157.144757 1.42108547e-13,156.675142 L1.42108547e-13,9.0596475 C1.42108547e-13,8.59003299 0.461760462,8.12041848 0.923520928,8.12041848 L72.7465127,8.12041848 L72.7465127,1.05663265 C72.7465127,0.528316328 73.2659933,-2.84217094e-14 73.7854738,-2.84217094e-14 L216.815777,-2.84217094e-14 C217.335257,-2.84217094e-14 217.854738,0.528316328 217.854738,1.05663265 L217.854738,8.12041848 L289.677732,8.12041848 C290.139493,8.12041848 290.601253,8.59003299 290.601253,9.0596475 L290.601253,156.675142 C290.601253,157.144757 290.139493,157.614372 289.677732,157.614372 L281.366043,157.614372 C279.672922,157.457833 277.9798,156.831681 276.748439,155.735914 L275.824918,154.796684 C274.901397,153.231303 273.054355,152.918227 271.207313,152.918227 L268.987471,152.918227 C268.818229,144.560013 266.939851,136.621364 263.687558,129.437501 L268.590671,129.437501 C272.900435,129.437501 276.440598,125.837123 276.440598,121.454054 L276.594519,121.454054 L276.594519,24.5569264 C276.594519,22.8350065 275.209237,21.5827012 273.670035,21.5827012 L217.854738,21.5827012 L217.854738,94.8055789 C214.965144,94.3781586 212.008429,94.1567452 209,94.1567452 C206.665515,94.1567452 204.362169,94.2900687 202.097162,94.5495169 L202.097162,21.5827012 L202.097162,18.4910714 C202.097162,16.5539116 200.538721,15.145068 198.807119,15.145068 L161.616164,15.145068 L128.985089,15.145068 L91.6209717,15.145068 C89.7162098,15.145068 88.3309283,16.730017 88.3309283,18.4910714 L88.3309283,21.5827012 L88.3309283,127.50034 C88.3309283,128.164624 88.4032089,128.812928 88.5401496,129.437501 C89.4197136,133.449106 92.9667866,136.481718 97.1620971,136.481718 L128.985089,136.481718 L151.645765,136.481718 Z M72.7465127,129.437501 L72.7465127,21.5827012 L16.7772968,21.5827012 C15.0841751,21.5827012 13.8528138,22.9915447 13.8528138,24.5569264 L13.8528138,121.454054 C13.8528138,125.837123 17.3929774,129.437501 21.7027417,129.437501 L72.7465127,129.437501 Z" id="Combined-Shape"></path>
</g>
</g>
</g>
</g>
<g id="Group-cloud" transform="translate(156.000000, 103.000000)">
<g id="Group" transform="translate(0.394933, 0.724044)">
<circle id="Oval-Copy" fill="#3282FF" cx="52.6050671" cy="52.601776" r="52.1311475"></circle>
<path d="M74.8002981,45.4035747 C74.1684054,39.8133538 69.4292101,35.538479 63.7421759,35.538479 C62.1624441,35.538479 60.8986587,35.8673156 59.6348733,36.5249886 C56.7913562,31.9212773 51.7362146,28.9617486 46.3651267,28.9617486 C37.5186289,28.9617486 30.5678092,36.1961521 30.5678092,45.4035747 C30.5678092,45.4035747 30.5678092,45.4035747 30.5678092,45.7324112 C25.1967213,46.3900842 21.0894188,51.322632 21.0894188,56.9128529 C21.0894188,63.1607468 26.1445604,68.4221311 32.147541,68.4221311 C36.8867362,68.4221311 67.533532,68.4221311 73.2205663,68.4221311 C79.2235469,68.4221311 84.2786885,63.1607468 84.2786885,56.9128529 C84.2786885,50.9937956 80.171386,46.3900842 74.8002981,45.4035747 Z" id="Path" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="186px" height="57px" viewBox="0 0 186 57" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
<title>Cloud_connection-icon</title>
<desc>Created with Sketch.</desc>
<g id="Cloud_connection-icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M38.6261428,52.7109865 L7.48755878,52.7109865 C6.85100215,52.7676651 6.21444551,52.994379 5.75149524,53.3911284 L5.40428251,53.7311992 C5.05706981,54.2979841 4.36264439,54.4113411 3.66821896,54.4113411 L0.543304569,54.4113411 C0.369698215,54.4113411 0.196091859,54.2413055 0.196091859,54.0712703 L0.196091859,0.623463283 C0.196091859,0.453427843 0.369698215,0.283392401 0.543304569,0.283392401 L48.3429212,0.283392401 C48.5165273,0.283392401 48.6901338,0.453427843 48.6901338,0.623463283 L48.6901338,26.0155943 C48.4613867,26.0052354 48.2313048,26 48,26 C46.4042274,26 44.8666558,26.2491876 43.4240742,26.7107738 L43.4240742,6.23463283 C43.4240742,5.61116956 42.9032553,5.15774169 42.3245675,5.15774169 L6.50378945,5.15774169 C5.86723281,5.15774169 5.40428251,5.66784803 5.40428251,6.23463283 L5.40428251,41.3186122 C5.40428251,42.9056095 6.73526457,44.2092147 8.35559054,44.2092147 L33.3440862,44.2092147 C34.087979,47.6221969 35.9937272,50.6011835 38.6261428,52.7109865 Z" id="Combined-Shape" fill="#08073F" fill-rule="nonzero"></path>
<path d="M158.961954,52.7109865 L131.487559,52.7109865 C130.851002,52.7676651 130.214446,52.994379 129.751495,53.3911284 L129.404283,53.7311992 C129.05707,54.2979841 128.362644,54.4113411 127.668219,54.4113411 L124.543305,54.4113411 C124.369698,54.4113411 124.196092,54.2413055 124.196092,54.0712703 L124.196092,0.623463283 C124.196092,0.453427843 124.369698,0.283392401 124.543305,0.283392401 L172.342921,0.283392401 C172.516527,0.283392401 172.690134,0.453427843 172.690134,0.623463283 L172.690134,27.0854877 C172.13468,27.0289729 171.570805,27 171,27 C169.770934,27 168.574002,27.1343278 167.424074,27.3886981 L167.424074,6.23463283 C167.424074,5.61116956 166.903255,5.15774169 166.324567,5.15774169 L130.503789,5.15774169 C129.867233,5.15774169 129.404283,5.66784803 129.404283,6.23463283 L129.404283,41.3186122 C129.404283,42.9056095 130.735265,44.2092147 132.355591,44.2092147 L155.096113,44.2092147 C155.462794,47.4493334 156.859805,50.3873861 158.961954,52.7109865 Z" id="Combined-Shape" fill="#08073F" fill-rule="nonzero"></path>
<path d="M171,56 C163.26057,56 157,49.9481159 157,42.5 C157,35.0518841 163.26057,29 171,29 C178.73943,29 185,35.0518841 185,42.5 C185,49.9481159 178.73943,56 171,56 Z M177.416667,40.7546296 C177.233333,39.1569444 175.858333,37.9351852 174.208333,37.9351852 C173.75,37.9351852 173.383333,38.0291667 173.016667,38.2171296 C172.191667,36.9013889 170.725,36.0555556 169.166667,36.0555556 C166.6,36.0555556 164.583333,38.1231482 164.583333,40.7546296 C164.583333,40.7546296 164.583333,40.7546296 164.583333,40.8486111 C163.025,41.0365741 161.833333,42.4462963 161.833333,44.0439815 C161.833333,45.8296296 163.3,47.3333333 165.041667,47.3333333 C166.416667,47.3333333 175.308333,47.3333333 176.958333,47.3333333 C178.7,47.3333333 180.166667,45.8296296 180.166667,44.0439815 C180.166667,42.3523148 178.975,41.0365741 177.416667,40.7546296 Z" id="Combined-Shape" fill="#3282FF" fill-rule="nonzero"></path>
<path d="M48,54 C40.8202983,54 35,48.1797017 35,41 C35,33.8202983 40.8202983,28 48,28 C55.1797017,28 61,33.8202983 61,41 C61,48.1797017 55.1797017,54 48,54 Z M46.862511,41.4631428 L43.8629783,38.6111022 L41.1067187,41.5099007 L47.0308248,47.1427085 L55.8527121,37.698579 L52.9296286,34.9680877 L46.862511,41.4631428 Z" id="Combined-Shape" fill="#3282FF" fill-rule="nonzero"></path>
<path d="M54.5,25 C53.6715729,25 53,24.3284271 53,23.5 C53,22.6715729 53.6715729,22 54.5,22 C55.3284271,22 56,22.6715729 56,23.5 C56,24.3284271 55.3284271,25 54.5,25 Z M78.5,25 C77.6715729,25 77,24.3284271 77,23.5 C77,22.6715729 77.6715729,22 78.5,22 C79.3284271,22 80,22.6715729 80,23.5 C80,24.3284271 79.3284271,25 78.5,25 Z M102.5,25 C101.671573,25 101,24.3284271 101,23.5 C101,22.6715729 101.671573,22 102.5,22 C103.328427,22 104,22.6715729 104,23.5 C104,24.3284271 103.328427,25 102.5,25 Z M62.5,25 C61.6715729,25 61,24.3284271 61,23.5 C61,22.6715729 61.6715729,22 62.5,22 C63.3284271,22 64,22.6715729 64,23.5 C64,24.3284271 63.3284271,25 62.5,25 Z M86.5,25 C85.6715729,25 85,24.3284271 85,23.5 C85,22.6715729 85.6715729,22 86.5,22 C87.3284271,22 88,22.6715729 88,23.5 C88,24.3284271 87.3284271,25 86.5,25 Z M110.5,25 C109.671573,25 109,24.3284271 109,23.5 C109,22.6715729 109.671573,22 110.5,22 C111.328427,22 112,22.6715729 112,23.5 C112,24.3284271 111.328427,25 110.5,25 Z M70.5,25 C69.6715729,25 69,24.3284271 69,23.5 C69,22.6715729 69.6715729,22 70.5,22 C71.3284271,22 72,22.6715729 72,23.5 C72,24.3284271 71.3284271,25 70.5,25 Z M94.5,25 C93.6715729,25 93,24.3284271 93,23.5 C93,22.6715729 93.6715729,22 94.5,22 C95.3284271,22 96,22.6715729 96,23.5 C96,24.3284271 95.3284271,25 94.5,25 Z M118.5,25 C117.671573,25 117,24.3284271 117,23.5 C117,22.6715729 117.671573,22 118.5,22 C119.328427,22 120,22.6715729 120,23.5 C120,24.3284271 119.328427,25 118.5,25 Z" id="Combined-Shape" fill="#3282FF" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -7,6 +7,7 @@ from PyQt5.QtCore import QTimer
from UM import i18nCatalog from UM import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Signal import Signal, signalemitter
from cura.API import Account from cura.API import Account
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
@ -31,14 +32,17 @@ class CloudOutputDeviceManager:
# The translation catalog for this device. # The translation catalog for this device.
I18N_CATALOG = i18nCatalog("cura") I18N_CATALOG = i18nCatalog("cura")
addedCloudCluster = Signal()
removedCloudCluster = Signal()
def __init__(self) -> None: def __init__(self) -> None:
# Persistent dict containing the remote clusters for the authenticated user. # Persistent dict containing the remote clusters for the authenticated user.
self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] self._remote_clusters = {} # type: Dict[str, CloudOutputDevice]
application = CuraApplication.getInstance() self._application = CuraApplication.getInstance()
self._output_device_manager = application.getOutputDeviceManager() 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) self._api = CloudApiClient(self._account, self._onApiError)
# Create a timer to update the remote cluster list # Create a timer to update the remote cluster list
@ -82,6 +86,7 @@ class CloudOutputDeviceManager:
removed_cluster.disconnect() removed_cluster.disconnect()
removed_cluster.close() removed_cluster.close()
self._output_device_manager.removeOutputDevice(removed_cluster.key) self._output_device_manager.removeOutputDevice(removed_cluster.key)
self.removedCloudCluster.emit()
del self._remote_clusters[removed_cluster.key] del self._remote_clusters[removed_cluster.key]
# Add an output device for each new remote cluster. # Add an output device for each new remote cluster.
@ -89,6 +94,7 @@ class CloudOutputDeviceManager:
for added_cluster in added_clusters: for added_cluster in added_clusters:
device = CloudOutputDevice(self._api, added_cluster) device = CloudOutputDevice(self._api, added_cluster)
self._remote_clusters[added_cluster.cluster_id] = device self._remote_clusters[added_cluster.cluster_id] = device
self.addedCloudCluster.emit()
for device, cluster in updates: for device, cluster in updates:
device.clusterData = cluster device.clusterData = cluster
@ -152,10 +158,9 @@ class CloudOutputDeviceManager:
def start(self): def start(self):
if self._running: if self._running:
return return
application = CuraApplication.getInstance()
self._account.loginStateChanged.connect(self._onLoginStateChanged) self._account.loginStateChanged.connect(self._onLoginStateChanged)
# When switching machines we check if we have to activate a remote cluster. # 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._update_timer.timeout.connect(self._getRemoteClusters)
self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn)
@ -163,9 +168,8 @@ class CloudOutputDeviceManager:
def stop(self): def stop(self):
if not self._running: if not self._running:
return return
application = CuraApplication.getInstance()
self._account.loginStateChanged.disconnect(self._onLoginStateChanged) self._account.loginStateChanged.disconnect(self._onLoginStateChanged)
# When switching machines we check if we have to activate a remote cluster. # 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._update_timer.timeout.disconnect(self._getRemoteClusters)
self._onLoginStateChanged(is_logged_in = False) self._onLoginStateChanged(is_logged_in = False)

View File

@ -54,6 +54,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent) super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent)
self._api_prefix = "/cluster-api/v1/" self._api_prefix = "/cluster-api/v1/"
self._application = CuraApplication.getInstance()
self._number_of_extruders = 2 self._number_of_extruders = 2
self._dummy_lambdas = ( self._dummy_lambdas = (
@ -125,7 +127,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
def _spawnPrinterSelectionDialog(self): def _spawnPrinterSelectionDialog(self):
if self._printer_selection_dialog is None: if self._printer_selection_dialog is None:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/PrintWindow.qml") 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: if self._printer_selection_dialog is not None:
self._printer_selection_dialog.show() self._printer_selection_dialog.show()
@ -211,7 +213,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
# Add user name to the print_job # Add user name to the print_job
parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) 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. output = stream.getvalue() # Either str or bytes depending on the output mode.
if isinstance(stream, io.StringIO): if isinstance(stream, io.StringIO):
@ -250,6 +252,11 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
self._compressing_gcode = False self._compressing_gcode = False
self._sending_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: def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None:
if bytes_total > 0: if bytes_total > 0:
new_progress = bytes_sent / bytes_total * 100 new_progress = bytes_sent / bytes_total * 100
@ -284,7 +291,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
self._progress_message.hide() self._progress_message.hide()
self._compressing_gcode = False self._compressing_gcode = False
self._sending_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 # After compressing the sliced model Cura sends data to printer, to stop receiving updates from the request
# the "reply" should be disconnected # 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: def _successMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None:
if action_id == "View": if action_id == "View":
CuraApplication.getInstance().getController().setActiveStage("MonitorStage") self._application.getController().setActiveStage("MonitorStage")
@pyqtSlot() @pyqtSlot()
def openPrintJobControlPanel(self) -> None: def openPrintJobControlPanel(self) -> None:
@ -355,8 +362,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
def sendJobToTop(self, print_job_uuid: str) -> None: 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 # 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. # is a modification of the cluster queue and not of the actual job.
data = "{\"list\": \"queued\",\"to_position\": 0}" data = "{\"to_position\": 0}"
self.post("print_jobs/{uuid}/action/move".format(uuid = print_job_uuid), data, on_finished=None) self.put("print_jobs/{uuid}/move_to_position".format(uuid = print_job_uuid), data, on_finished=None)
@pyqtSlot(str) @pyqtSlot(str)
def deleteJobFromQueue(self, print_job_uuid: str) -> None: def deleteJobFromQueue(self, print_job_uuid: str) -> None:
@ -552,7 +559,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
return result return result
def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel":
material_manager = CuraApplication.getInstance().getMaterialManager() material_manager = self._application.getMaterialManager()
material_group_list = None material_group_list = None
# Avoid crashing if there is no "guid" field in the metadata # Avoid crashing if there is no "guid" field in the metadata
@ -665,7 +672,6 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
job = SendMaterialJob(device = self) job = SendMaterialJob(device = self)
job.run() job.run()
def loadJsonFromReply(reply: QNetworkReply) -> Optional[List[Dict[str, Any]]]: def loadJsonFromReply(reply: QNetworkReply) -> Optional[List[Dict[str, Any]]]:
try: try:
result = json.loads(bytes(reply.readAll()).decode("utf-8")) result = json.loads(bytes(reply.readAll()).decode("utf-8"))

View File

@ -7,17 +7,25 @@ from time import time
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager 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.CuraApplication import CuraApplication
from cura.PrinterOutputDevice import ConnectionType
from cura.Settings.GlobalStack import GlobalStack # typing
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
from UM.Logger import Logger from UM.Logger import Logger
from UM.Signal import Signal, signalemitter from UM.Signal import Signal, signalemitter
from UM.Version import Version from UM.Version import Version
from UM.Message import Message
from UM.i18n import i18nCatalog
from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager 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. ## 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. # Zero-Conf is used to detect printers, which are saved in a dict.
@ -27,6 +35,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
addDeviceSignal = Signal() addDeviceSignal = Signal()
removeDeviceSignal = Signal() removeDeviceSignal = Signal()
discoveredDevicesChanged = Signal() discoveredDevicesChanged = Signal()
cloudFlowIsPossible = Signal()
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -34,6 +43,8 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self._zero_conf = None self._zero_conf = None
self._zero_conf_browser = None self._zero_conf_browser = None
self._application = CuraApplication.getInstance()
# Create a cloud output device manager that abstracts all cloud connection logic away. # Create a cloud output device manager that abstracts all cloud connection logic away.
self._cloud_output_device_manager = CloudOutputDeviceManager() self._cloud_output_device_manager = CloudOutputDeviceManager()
@ -41,7 +52,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self.addDeviceSignal.connect(self._onAddDevice) self.addDeviceSignal.connect(self._onAddDevice)
self.removeDeviceSignal.connect(self._onRemoveDevice) self.removeDeviceSignal.connect(self._onRemoveDevice)
CuraApplication.getInstance().globalContainerStackChanged.connect(self.reCheckConnections) self._application.globalContainerStackChanged.connect(self.reCheckConnections)
self._discovered_devices = {} self._discovered_devices = {}
@ -49,6 +60,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self._network_manager.finished.connect(self._onNetworkRequestFinished) self._network_manager.finished.connect(self._onNetworkRequestFinished)
self._min_cluster_version = Version("4.0.0") self._min_cluster_version = Version("4.0.0")
self._min_cloud_version = Version("5.2.0")
self._api_version = "1" self._api_version = "1"
self._api_prefix = "/api/v" + self._api_version + "/" 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 = Thread(target=self._handleOnServiceChangedRequests, daemon=True)
self._service_changed_request_thread.start() 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): def getDiscoveredDevices(self):
return self._discovered_devices return self._discovered_devices
@ -138,6 +170,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
um_network_key = CuraApplication.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key") um_network_key = CuraApplication.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key")
if key == um_network_key: if key == um_network_key:
self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key]) self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key])
self.checkCloudFlowIsPossible()
else: else:
self.getOutputDeviceManager().removeOutputDevice(key) self.getOutputDeviceManager().removeOutputDevice(key)
@ -370,3 +403,113 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self.removeDeviceSignal.emit(str(name)) self.removeDeviceSignal.emit(str(name))
return True 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()

View File

@ -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

View File

@ -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

View File

@ -40,7 +40,7 @@ material_standby_temperature = 100
multiple_mesh_overlap = 0 multiple_mesh_overlap = 0
prime_tower_enable = False prime_tower_enable = False
prime_tower_wipe_enabled = True prime_tower_wipe_enabled = True
retract_at_layer_change = True retract_at_layer_change = =not magic_spiralize
retraction_amount = 6.5 retraction_amount = 6.5
retraction_count_max = 25 retraction_count_max = 25
retraction_extrusion_window = 1 retraction_extrusion_window = 1