mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-08-06 05:23:58 -06:00
Merge pull request #6112 from Ultimaker/CS-234_network_plugin_code_quality
CS-234 network plugin code quality
This commit is contained in:
commit
700f406635
90 changed files with 1574 additions and 3736 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -72,3 +72,6 @@ run.sh
|
|||
CuraEngine
|
||||
|
||||
/.coverage
|
||||
|
||||
#Prevents import failures when plugin running tests
|
||||
plugins/__init__.py
|
||||
|
|
|
@ -75,7 +75,7 @@ class DiscoveredPrinter(QObject):
|
|||
def readableMachineType(self) -> str:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
machine_manager = CuraApplication.getInstance().getMachineManager()
|
||||
# In ClusterUM3OutputDevice, when it updates a printer information, it updates the machine type using the field
|
||||
# In LocalClusterOutputDevice, when it updates a printer information, it updates the machine type using the field
|
||||
# "machine_variant", and for some reason, it's not the machine type ID/codename/... but a human-readable string
|
||||
# like "Ultimaker 3". The code below handles this case.
|
||||
if self._hasHumanReadableMachineTypeName(self._machine_type):
|
||||
|
|
|
@ -118,7 +118,7 @@ class GlobalStack(CuraContainerStack):
|
|||
## \sa configuredConnectionTypes
|
||||
def removeConfiguredConnectionType(self, connection_type: int) -> None:
|
||||
configured_connection_types = self.configuredConnectionTypes
|
||||
if connection_type in self.configured_connection_types:
|
||||
if connection_type in configured_connection_types:
|
||||
# Store the values as a string.
|
||||
configured_connection_types.remove(connection_type)
|
||||
self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types]))
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# Copyright (c) 2017 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .src import DiscoverUM3Action
|
||||
from .src import UM3OutputDevicePlugin
|
||||
|
||||
|
||||
|
@ -10,6 +9,5 @@ def getMetaData():
|
|||
|
||||
def register(app):
|
||||
return {
|
||||
"output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(),
|
||||
"machine_action": DiscoverUM3Action.DiscoverUM3Action()
|
||||
"output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin()
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "UM3 Network Connection",
|
||||
"name": "Ultimaker Network Connection",
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Manages network connections to Ultimaker 3 printers.",
|
||||
"description": "Manages network connections to Ultimaker networked printers.",
|
||||
"version": "1.0.1",
|
||||
"api": "6.0",
|
||||
"i18n-catalog": "cura"
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
@ -53,4 +53,4 @@ Rectangle
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -87,4 +87,4 @@ Item
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -35,7 +35,7 @@ Item
|
|||
{
|
||||
height: parent.height
|
||||
width: 32 * screenScaleFactor // Ensure the icon is centered under the extruder icon (same width)
|
||||
|
||||
|
||||
Rectangle
|
||||
{
|
||||
anchors.centerIn: parent
|
||||
|
@ -56,7 +56,7 @@ Item
|
|||
visible: buildplate
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Label
|
||||
{
|
||||
id: buildplateLabel
|
||||
|
@ -72,4 +72,4 @@ Item
|
|||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
@ -23,7 +23,7 @@ Item
|
|||
|
||||
height: centerSection.height
|
||||
width: maximumWidth
|
||||
|
||||
|
||||
// Enable keyboard navigation
|
||||
Keys.onLeftPressed: navigateTo(currentIndex - 1)
|
||||
Keys.onRightPressed: navigateTo(currentIndex + 1)
|
||||
|
@ -131,7 +131,7 @@ Item
|
|||
}
|
||||
}
|
||||
spacing: 60 * screenScaleFactor // TODO: Theme!
|
||||
|
||||
|
||||
Repeater
|
||||
{
|
||||
model: printers
|
||||
|
@ -255,4 +255,4 @@ Item
|
|||
currentIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
@ -29,4 +29,4 @@ Button
|
|||
hoverEnabled: enabled
|
||||
text: "\u22EE" //Unicode Three stacked points.
|
||||
width: 36 * screenScaleFactor // TODO: Theme!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -56,7 +56,7 @@ Item
|
|||
Label
|
||||
{
|
||||
id: materialLabel
|
||||
|
||||
|
||||
color: UM.Theme.getColor("monitor_text_primary")
|
||||
elide: Text.ElideRight
|
||||
font: UM.Theme.getFont("default") // 12pt, regular
|
||||
|
@ -86,7 +86,7 @@ Item
|
|||
Label
|
||||
{
|
||||
id: printCoreLabel
|
||||
|
||||
|
||||
color: UM.Theme.getColor("monitor_text_primary")
|
||||
elide: Text.ElideRight
|
||||
font: UM.Theme.getFont("default_bold") // 12pt, bold
|
||||
|
@ -99,4 +99,4 @@ Item
|
|||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -50,4 +50,4 @@ Item
|
|||
visible: position >= 0
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -42,4 +42,4 @@ Component {
|
|||
z: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Copyright (c) 2019 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.2
|
||||
import QtQuick.Controls 2.0
|
||||
import UM 1.3 as UM
|
||||
|
@ -76,6 +75,7 @@ Item
|
|||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 18 * screenScaleFactor // TODO: Theme!
|
||||
width: UM.Theme.getSize("monitor_column").width
|
||||
|
||||
Rectangle
|
||||
{
|
||||
color: UM.Theme.getColor("monitor_skeleton_loading")
|
||||
|
@ -84,6 +84,7 @@ Item
|
|||
visible: !printJob
|
||||
radius: 2 * screenScaleFactor // TODO: Theme!
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
text: printJob ? OutputDevice.formatDuration(printJob.timeTotal) : ""
|
||||
|
@ -179,13 +180,10 @@ Item
|
|||
id: printerConfiguration
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
buildplate: catalog.i18nc("@label", "Glass")
|
||||
configurations:
|
||||
[
|
||||
base.printJob.configuration.extruderConfigurations[0],
|
||||
base.printJob.configuration.extruderConfigurations[1]
|
||||
]
|
||||
configurations: base.printJob.configuration.extruderConfigurations
|
||||
height: 72 * screenScaleFactor // TODO: Theme!
|
||||
}
|
||||
|
||||
Label {
|
||||
text: printJob && printJob.owner ? printJob.owner : ""
|
||||
color: UM.Theme.getColor("monitor_text_primary")
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -41,7 +41,7 @@ Item
|
|||
UM.RecolorImage
|
||||
{
|
||||
id: ultiBotImage
|
||||
|
||||
|
||||
anchors.centerIn: printJobPreview
|
||||
color: UM.Theme.getColor("monitor_placeholder_image")
|
||||
height: printJobPreview.height
|
||||
|
@ -98,4 +98,4 @@ Item
|
|||
visible: source != ""
|
||||
width: 0.5 * printJobPreview.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
@ -107,4 +107,4 @@ Item
|
|||
verticalAlignment: Text.AlignVCenter
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.3
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -55,4 +55,4 @@ Item
|
|||
anchors.bottom: parent.bottom
|
||||
buildplate: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -186,17 +186,7 @@ Item
|
|||
}
|
||||
printJob: modelData
|
||||
}
|
||||
model:
|
||||
{
|
||||
// When printing over the cloud we don't recieve print jobs until there is one, so
|
||||
// unless there's at least one print job we'll be stuck with skeleton loading
|
||||
// indefinitely.
|
||||
if (Cura.MachineManager.activeMachineIsUsingCloudConnection || OutputDevice.receivedPrintJobs)
|
||||
{
|
||||
return OutputDevice.queuedPrintJobs
|
||||
}
|
||||
return [null, null]
|
||||
}
|
||||
model: OutputDevice.queuedPrintJobs
|
||||
spacing: 6 // TODO: Theme!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -25,7 +25,7 @@ Component
|
|||
}
|
||||
width: maximumWidth
|
||||
color: UM.Theme.getColor("monitor_stage_background")
|
||||
|
||||
|
||||
// Enable keyboard navigation. NOTE: This is done here so that we can also potentially
|
||||
// forward to the queue items in the future. (Deleting selected print job, etc.)
|
||||
Keys.forwardTo: carousel
|
||||
|
@ -50,17 +50,7 @@ Component
|
|||
MonitorCarousel
|
||||
{
|
||||
id: carousel
|
||||
printers:
|
||||
{
|
||||
// When printing over the cloud we don't recieve print jobs until there is one, so
|
||||
// unless there's at least one print job we'll be stuck with skeleton loading
|
||||
// indefinitely.
|
||||
if (Cura.MachineManager.activeMachineIsUsingCloudConnection || OutputDevice.receivedPrintJobs)
|
||||
{
|
||||
return OutputDevice.printers
|
||||
}
|
||||
return [null]
|
||||
}
|
||||
printers: OutputDevice.printers
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
@ -21,4 +21,4 @@ Button {
|
|||
height: visible ? 39 * screenScaleFactor : 0; // TODO: Theme!
|
||||
hoverEnabled: true;
|
||||
width: parent.width;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
|
|
@ -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.
|
||||
|
||||
import QtQuick 2.2
|
||||
|
|
|
@ -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.
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
|
@ -11,18 +11,19 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage
|
|||
from UM.Logger import Logger
|
||||
from cura import UltimakerCloudAuthentication
|
||||
from cura.API import Account
|
||||
|
||||
from .ToolPathUploader import ToolPathUploader
|
||||
from ..Models import BaseModel
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudError import CloudError
|
||||
from .Models.CloudClusterStatus import CloudClusterStatus
|
||||
from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from .Models.CloudPrintResponse import CloudPrintResponse
|
||||
from .Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from ..Models.BaseModel import BaseModel
|
||||
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
|
||||
from ..Models.Http.CloudError import CloudError
|
||||
from ..Models.Http.CloudClusterStatus import CloudClusterStatus
|
||||
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
|
||||
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
|
||||
|
||||
## The generic type variable used to document the methods below.
|
||||
CloudApiClientModel = TypeVar("CloudApiClientModel", bound = BaseModel)
|
||||
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
|
||||
|
||||
|
||||
## The cloud API client is responsible for handling the requests and responses from the cloud.
|
||||
|
@ -69,8 +70,8 @@ class CloudApiClient:
|
|||
## Requests the cloud to register the upload of a print job mesh.
|
||||
# \param request: The request object.
|
||||
# \param on_finished: The function to be called after the result is parsed.
|
||||
def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], Any]
|
||||
) -> None:
|
||||
def requestUpload(self, request: CloudPrintJobUploadRequest,
|
||||
on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
|
||||
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
|
||||
body = json.dumps({"data": request.toDict()})
|
||||
reply = self._manager.put(self._createEmptyRequest(url), body.encode())
|
||||
|
@ -100,14 +101,9 @@ class CloudApiClient:
|
|||
# \param cluster_id: The ID of the cluster.
|
||||
# \param cluster_job_id: The ID of the print job within the cluster.
|
||||
# \param action: The name of the action to execute.
|
||||
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
body = b""
|
||||
if data:
|
||||
try:
|
||||
body = json.dumps({"data": data}).encode()
|
||||
except JSONDecodeError as err:
|
||||
Logger.log("w", "Could not encode body: %s", err)
|
||||
return
|
||||
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
|
||||
data: Optional[Dict[str, Any]] = None) -> None:
|
||||
body = json.dumps({"data": data}).encode() if data else b""
|
||||
url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action)
|
||||
self._manager.post(self._createEmptyRequest(url), body)
|
||||
|
||||
|
@ -171,16 +167,16 @@ class CloudApiClient:
|
|||
reply: QNetworkReply,
|
||||
on_finished: Union[Callable[[CloudApiClientModel], Any],
|
||||
Callable[[List[CloudApiClientModel]], Any]],
|
||||
model: Type[CloudApiClientModel],
|
||||
) -> None:
|
||||
model: Type[CloudApiClientModel]) -> None:
|
||||
def parse() -> None:
|
||||
self._anti_gc_callbacks.remove(parse)
|
||||
|
||||
# Don't try to parse the reply if we didn't get one
|
||||
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
|
||||
return
|
||||
|
||||
status_code, response = self._parseReply(reply)
|
||||
self._anti_gc_callbacks.remove(parse)
|
||||
self._parseModels(response, on_finished, model)
|
||||
return
|
||||
|
||||
self._anti_gc_callbacks.append(parse)
|
||||
reply.finished.connect(parse)
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import os
|
||||
|
||||
from time import time
|
||||
from typing import Dict, List, Optional, Set, cast
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
|
@ -13,29 +11,23 @@ from UM.Backend.Backend import BackendState
|
|||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.PluginRegistry import PluginRegistry
|
||||
from UM.Qt.Duration import Duration, DurationFormat
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Version import Version
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState
|
||||
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from .CloudOutputController import CloudOutputController
|
||||
from ..MeshFormatHandler import MeshFormatHandler
|
||||
from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from .CloudProgressMessage import CloudProgressMessage
|
||||
from .CloudApiClient import CloudApiClient
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudClusterStatus import CloudClusterStatus
|
||||
from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from .Models.CloudPrintResponse import CloudPrintResponse
|
||||
from .Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus
|
||||
from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus
|
||||
from .Utils import formatDateCompleted, formatTimeCompleted
|
||||
from ..ExportFileJob import ExportFileJob
|
||||
from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
|
||||
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
|
||||
from ..Models.Http.CloudClusterStatus import CloudClusterStatus
|
||||
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
|
||||
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
|
||||
from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
@ -44,7 +36,8 @@ I18N_CATALOG = i18nCatalog("cura")
|
|||
# Currently it only supports viewing the printer and print job status and adding a new job to the queue.
|
||||
# As such, those methods have been implemented here.
|
||||
# Note that this device represents a single remote cluster, not a list of multiple clusters.
|
||||
class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
|
||||
|
||||
# The interval with which the remote clusters are checked
|
||||
CHECK_CLUSTER_INTERVAL = 10.0 # seconds
|
||||
|
||||
|
@ -78,44 +71,29 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
|||
b"cluster_size": b"1" # cloud devices are always clusters of at least one
|
||||
}
|
||||
|
||||
super().__init__(device_id=cluster.cluster_id, address="",
|
||||
connection_type=ConnectionType.CloudConnection, properties=properties, parent=parent)
|
||||
super().__init__(
|
||||
device_id=cluster.cluster_id,
|
||||
address="",
|
||||
connection_type=ConnectionType.CloudConnection,
|
||||
properties=properties,
|
||||
parent=parent
|
||||
)
|
||||
|
||||
self._api = api_client
|
||||
self._cluster = cluster
|
||||
|
||||
self._setInterfaceElements()
|
||||
|
||||
self._account = api_client.account
|
||||
|
||||
# We use the Cura Connect monitor tab to get most functionality right away.
|
||||
if PluginRegistry.getInstance() is not None:
|
||||
plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting")
|
||||
if plugin_path is None:
|
||||
Logger.log("e", "Cloud not find plugin path for plugin UM3NetworkPrnting")
|
||||
raise RuntimeError("Cloud not find plugin path for plugin UM3NetworkPrnting")
|
||||
self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml")
|
||||
self._cluster = cluster
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
self._setInterfaceElements()
|
||||
|
||||
# Trigger the printersChanged signal when the private signal is triggered.
|
||||
self.printersChanged.connect(self._clusterPrintersChanged)
|
||||
|
||||
# We keep track of which printer is visible in the monitor page.
|
||||
self._active_printer = None # type: Optional[PrinterOutputModel]
|
||||
|
||||
# Properties to populate later on with received cloud data.
|
||||
self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
|
||||
self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines.
|
||||
|
||||
# We only allow a single upload at a time.
|
||||
self._progress = CloudProgressMessage()
|
||||
|
||||
# Keep server string of the last generated time to avoid updating models more than once for the same response
|
||||
self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]]
|
||||
self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJobStatus]]
|
||||
|
||||
# A set of the user's job IDs that have finished
|
||||
self._finished_jobs = set() # type: Set[str]
|
||||
self._received_printers = None # type: Optional[List[ClusterPrinterStatus]]
|
||||
self._received_print_jobs = None # type: Optional[List[ClusterPrintJobStatus]]
|
||||
|
||||
# Reference to the uploaded print job / mesh
|
||||
# We do this to prevent re-uploading the same file multiple times.
|
||||
self._tool_path = None # type: Optional[bytes]
|
||||
self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
|
||||
|
||||
|
@ -126,9 +104,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
|||
super().connect()
|
||||
Logger.log("i", "Connected to cluster %s", self.key)
|
||||
CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
|
||||
self._update()
|
||||
|
||||
## Disconnects the device
|
||||
def disconnect(self) -> None:
|
||||
if not self.isConnected():
|
||||
return
|
||||
super().disconnect()
|
||||
Logger.log("i", "Disconnected from cluster %s", self.key)
|
||||
CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
|
||||
|
@ -138,52 +119,61 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
|||
self._tool_path = None
|
||||
self._uploaded_print_job = None
|
||||
|
||||
## Gets the cluster response from which this device was created.
|
||||
@property
|
||||
def clusterData(self) -> CloudClusterResponse:
|
||||
return self._cluster
|
||||
|
||||
## Updates the cluster data from the cloud.
|
||||
@clusterData.setter
|
||||
def clusterData(self, value: CloudClusterResponse) -> None:
|
||||
self._cluster = value
|
||||
|
||||
## Checks whether the given network key is found in the cloud's host name
|
||||
def matchesNetworkKey(self, network_key: str) -> bool:
|
||||
# Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
|
||||
# the host name should then be "ultimakersystem-aabbccdd0011"
|
||||
if network_key.startswith(self.clusterData.host_name):
|
||||
return True
|
||||
|
||||
# However, for manually added printers, the local IP address is used in lieu of a proper
|
||||
# network key, so check for that as well
|
||||
if self.clusterData.host_internal_ip is not None and network_key.find(self.clusterData.host_internal_ip):
|
||||
if self.clusterData.host_internal_ip is not None and network_key in self.clusterData.host_internal_ip:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
## Set all the interface elements and texts for this output device.
|
||||
## Set all the interface elements and texts for this output device.
|
||||
def _setInterfaceElements(self) -> None:
|
||||
self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'
|
||||
self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'.
|
||||
self.setName(self._id)
|
||||
self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
|
||||
self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
|
||||
self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
|
||||
|
||||
## Called when the network data should be updated.
|
||||
def _update(self) -> None:
|
||||
super()._update()
|
||||
if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL:
|
||||
return # Avoid calling the cloud too often
|
||||
if self._account.isLoggedIn:
|
||||
self.setAuthenticationState(AuthState.Authenticated)
|
||||
self._last_request_time = time()
|
||||
self._api.getClusterStatus(self.key, self._onStatusCallFinished)
|
||||
else:
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
|
||||
## Method called when HTTP request to status endpoint is finished.
|
||||
# Contains both printers and print jobs statuses in a single response.
|
||||
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
|
||||
# Update all data from the cluster.
|
||||
self._last_response_time = time()
|
||||
if self._received_printers != status.printers:
|
||||
self._received_printers = status.printers
|
||||
self._updatePrinters(status.printers)
|
||||
if status.print_jobs != self._received_print_jobs:
|
||||
self._received_print_jobs = status.print_jobs
|
||||
self._updatePrintJobs(status.print_jobs)
|
||||
|
||||
## Called when Cura requests an output device to receive a (G-code) file.
|
||||
def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None:
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
|
||||
|
||||
# Show an error message if we're already sending a job.
|
||||
if self._progress.visible:
|
||||
message = Message(
|
||||
text=I18N_CATALOG.i18nc("@info:status",
|
||||
"Sending new jobs (temporarily) blocked, still sending the previous print job."),
|
||||
title=I18N_CATALOG.i18nc("@info:title", "Cloud error"),
|
||||
return Message(
|
||||
text=I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."),
|
||||
title=I18N_CATALOG.i18nc("@info:title", "Print error"),
|
||||
lifetime=10
|
||||
)
|
||||
message.show()
|
||||
return
|
||||
).show()
|
||||
|
||||
if self._uploaded_print_job:
|
||||
# The mesh didn't change, let's not upload it again
|
||||
|
@ -193,154 +183,31 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
|||
# Indicate we have started sending a job.
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
|
||||
if not mesh_format.is_valid:
|
||||
Logger.log("e", "Missing file or mesh writer!")
|
||||
return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job."))
|
||||
# Export the scene to the correct file type.
|
||||
job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion)
|
||||
job.finished.connect(self._onPrintJobCreated)
|
||||
job.start()
|
||||
|
||||
mesh = mesh_format.getBytes(nodes)
|
||||
|
||||
self._tool_path = mesh
|
||||
## Handler for when the print job was created locally.
|
||||
# It can now be sent over the cloud.
|
||||
def _onPrintJobCreated(self, job: ExportFileJob) -> None:
|
||||
output = job.getOutput()
|
||||
self._tool_path = output # store the tool path to prevent re-uploading when printing the same file again
|
||||
request = CloudPrintJobUploadRequest(
|
||||
job_name=file_name or mesh_format.file_extension,
|
||||
file_size=len(mesh),
|
||||
content_type=mesh_format.mime_type,
|
||||
job_name=job.getFileName(),
|
||||
file_size=len(output),
|
||||
content_type=job.getMimeType(),
|
||||
)
|
||||
self._api.requestUpload(request, self._onPrintJobCreated)
|
||||
|
||||
## Called when the network data should be updated.
|
||||
def _update(self) -> None:
|
||||
super()._update()
|
||||
if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL:
|
||||
return # Avoid calling the cloud too often
|
||||
|
||||
Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL)
|
||||
if self._account.isLoggedIn:
|
||||
self.setAuthenticationState(AuthState.Authenticated)
|
||||
self._last_request_time = time()
|
||||
self._api.getClusterStatus(self.key, self._onStatusCallFinished)
|
||||
else:
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
|
||||
## Method called when HTTP request to status endpoint is finished.
|
||||
# Contains both printers and print jobs statuses in a single response.
|
||||
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
|
||||
# Update all data from the cluster.
|
||||
self._last_response_time = time()
|
||||
if self._received_printers != status.printers:
|
||||
self._received_printers = status.printers
|
||||
self._updatePrinters(status.printers)
|
||||
|
||||
if status.print_jobs != self._received_print_jobs:
|
||||
self._received_print_jobs = status.print_jobs
|
||||
self._updatePrintJobs(status.print_jobs)
|
||||
|
||||
## Updates the local list of printers with the list received from the cloud.
|
||||
# \param remote_printers: The printers received from the cloud.
|
||||
def _updatePrinters(self, remote_printers: List[CloudClusterPrinterStatus]) -> None:
|
||||
|
||||
# Keep track of the new printers to show.
|
||||
# We create a new list instead of changing the existing one to get the correct order.
|
||||
new_printers = []
|
||||
|
||||
# Check which printers need to be created or updated.
|
||||
for index, printer_data in enumerate(remote_printers):
|
||||
printer = next(iter(printer for printer in self._printers if printer.key == printer_data.uuid), None)
|
||||
if not printer:
|
||||
new_printers.append(printer_data.createOutputModel(CloudOutputController(self)))
|
||||
else:
|
||||
printer_data.updateOutputModel(printer)
|
||||
new_printers.append(printer)
|
||||
|
||||
# Check which printers need to be removed (de-referenced).
|
||||
remote_printers_keys = [printer_data.uuid for printer_data in remote_printers]
|
||||
removed_printers = [printer for printer in self._printers if printer.key not in remote_printers_keys]
|
||||
for removed_printer in removed_printers:
|
||||
if self._active_printer and self._active_printer.key == removed_printer.key:
|
||||
self.setActivePrinter(None)
|
||||
|
||||
self._printers = new_printers
|
||||
if self._printers and not self.activePrinter:
|
||||
self.setActivePrinter(self._printers[0])
|
||||
|
||||
self.printersChanged.emit()
|
||||
|
||||
## Updates the local list of print jobs with the list received from the cloud.
|
||||
# \param remote_jobs: The print jobs received from the cloud.
|
||||
def _updatePrintJobs(self, remote_jobs: List[CloudClusterPrintJobStatus]) -> None:
|
||||
|
||||
# Keep track of the new print jobs to show.
|
||||
# We create a new list instead of changing the existing one to get the correct order.
|
||||
new_print_jobs = []
|
||||
|
||||
# Check which print jobs need to be created or updated.
|
||||
for index, print_job_data in enumerate(remote_jobs):
|
||||
print_job = next(
|
||||
iter(print_job for print_job in self._print_jobs if print_job.key == print_job_data.uuid), None)
|
||||
if not print_job:
|
||||
new_print_jobs.append(self._createPrintJobModel(print_job_data))
|
||||
else:
|
||||
print_job_data.updateOutputModel(print_job)
|
||||
if print_job_data.printer_uuid:
|
||||
self._updateAssignedPrinter(print_job, print_job_data.printer_uuid)
|
||||
new_print_jobs.append(print_job)
|
||||
|
||||
# Check which print job need to be removed (de-referenced).
|
||||
remote_job_keys = [print_job_data.uuid for print_job_data in remote_jobs]
|
||||
removed_jobs = [print_job for print_job in self._print_jobs if print_job.key not in remote_job_keys]
|
||||
for removed_job in removed_jobs:
|
||||
if removed_job.assignedPrinter:
|
||||
removed_job.assignedPrinter.updateActivePrintJob(None)
|
||||
removed_job.stateChanged.disconnect(self._onPrintJobStateChanged)
|
||||
|
||||
self._print_jobs = new_print_jobs
|
||||
self.printJobsChanged.emit()
|
||||
|
||||
## Create a new print job model based on the remote status of the job.
|
||||
# \param remote_job: The remote print job data.
|
||||
def _createPrintJobModel(self, remote_job: CloudClusterPrintJobStatus) -> UM3PrintJobOutputModel:
|
||||
model = remote_job.createOutputModel(CloudOutputController(self))
|
||||
model.stateChanged.connect(self._onPrintJobStateChanged)
|
||||
if remote_job.printer_uuid:
|
||||
self._updateAssignedPrinter(model, remote_job.printer_uuid)
|
||||
return model
|
||||
|
||||
## Handles the event of a change in a print job state
|
||||
def _onPrintJobStateChanged(self) -> None:
|
||||
user_name = self._getUserName()
|
||||
# TODO: confirm that notifications in Cura are still required
|
||||
for job in self._print_jobs:
|
||||
if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name:
|
||||
self._finished_jobs.add(job.key)
|
||||
Message(
|
||||
title=I18N_CATALOG.i18nc("@info:status", "Print finished"),
|
||||
text=(I18N_CATALOG.i18nc("@info:status",
|
||||
"Printer '{printer_name}' has finished printing '{job_name}'.").format(
|
||||
printer_name=job.assignedPrinter.name,
|
||||
job_name=job.name
|
||||
) if job.assignedPrinter else
|
||||
I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.").format(
|
||||
job_name=job.name
|
||||
)),
|
||||
).show()
|
||||
|
||||
## Updates the printer assignment for the given print job model.
|
||||
def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None:
|
||||
printer = next((p for p in self._printers if printer_uuid == p.key), None)
|
||||
if not printer:
|
||||
Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key,
|
||||
[p.key for p in self._printers])
|
||||
return
|
||||
printer.updateActivePrintJob(model)
|
||||
model.updateAssignedPrinter(printer)
|
||||
self._api.requestUpload(request, self._uploadPrintJob)
|
||||
|
||||
## Uploads the mesh when the print job was registered with the cloud API.
|
||||
# \param job_response: The response received from the cloud API.
|
||||
def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None:
|
||||
def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None:
|
||||
if not self._tool_path:
|
||||
return self._onUploadError()
|
||||
self._progress.show()
|
||||
self._uploaded_print_job = job_response
|
||||
tool_path = cast(bytes, self._tool_path)
|
||||
self._api.uploadToolPath(job_response, tool_path, self._onPrintJobUploaded, self._progress.update,
|
||||
self._uploaded_print_job = job_response # store the last uploaded job to prevent re-upload of the same file
|
||||
self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update,
|
||||
self._onUploadError)
|
||||
|
||||
## Requests the print to be sent to the printer when we finished uploading the mesh.
|
||||
|
@ -364,7 +231,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
|||
## Shows a message when the upload has succeeded
|
||||
# \param response: The response from the cloud API.
|
||||
def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
|
||||
Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id)
|
||||
self._progress.hide()
|
||||
Message(
|
||||
text=I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."),
|
||||
|
@ -382,98 +248,37 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
|||
firmware_version = Version([version_number[0], version_number[1], version_number[2]])
|
||||
return firmware_version >= self.PRINT_JOB_ACTIONS_MIN_VERSION
|
||||
|
||||
## Gets the number of printers in the cluster.
|
||||
# We use a minimum of 1 because cloud devices are always a cluster and printer discovery needs it.
|
||||
@pyqtProperty(int, notify=_clusterPrintersChanged)
|
||||
def clusterSize(self) -> int:
|
||||
return max(1, len(self._printers))
|
||||
|
||||
## Gets the remote printers.
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def printers(self) -> List[PrinterOutputModel]:
|
||||
return self._printers
|
||||
|
||||
## Get the active printer in the UI (monitor page).
|
||||
@pyqtProperty(QObject, notify=activePrinterChanged)
|
||||
def activePrinter(self) -> Optional[PrinterOutputModel]:
|
||||
return self._active_printer
|
||||
|
||||
## Set the active printer in the UI (monitor page).
|
||||
@pyqtSlot(QObject)
|
||||
def setActivePrinter(self, printer: Optional[PrinterOutputModel] = None) -> None:
|
||||
if printer != self._active_printer:
|
||||
self._active_printer = printer
|
||||
self.activePrinterChanged.emit()
|
||||
|
||||
## Get remote print jobs.
|
||||
@pyqtProperty("QVariantList", notify=printJobsChanged)
|
||||
def printJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return self._print_jobs
|
||||
|
||||
## Get remote print jobs that are still in the print queue.
|
||||
@pyqtProperty("QVariantList", notify=printJobsChanged)
|
||||
def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs
|
||||
if print_job.state == "queued" or print_job.state == "error"]
|
||||
|
||||
## Get remote print jobs that are assigned to a printer.
|
||||
@pyqtProperty("QVariantList", notify=printJobsChanged)
|
||||
def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if
|
||||
print_job.assignedPrinter is not None and print_job.state != "queued"]
|
||||
|
||||
## Set the remote print job state.
|
||||
def setJobState(self, print_job_uuid: str, state: str) -> None:
|
||||
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, state)
|
||||
|
||||
@pyqtSlot(str)
|
||||
@pyqtSlot(str, name="sendJobToTop")
|
||||
def sendJobToTop(self, print_job_uuid: str) -> None:
|
||||
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "move",
|
||||
{"list": "queued", "to_position": 0})
|
||||
|
||||
@pyqtSlot(str)
|
||||
@pyqtSlot(str, name="deleteJobFromQueue")
|
||||
def deleteJobFromQueue(self, print_job_uuid: str) -> None:
|
||||
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "remove")
|
||||
|
||||
@pyqtSlot(str)
|
||||
@pyqtSlot(str, name="forceSendJob")
|
||||
def forceSendJob(self, print_job_uuid: str) -> None:
|
||||
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "force")
|
||||
|
||||
@pyqtSlot(int, result=str)
|
||||
def formatDuration(self, seconds: int) -> str:
|
||||
return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
|
||||
|
||||
@pyqtSlot(int, result=str)
|
||||
def getTimeCompleted(self, time_remaining: int) -> str:
|
||||
return formatTimeCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result=str)
|
||||
def getDateCompleted(self, time_remaining: int) -> str:
|
||||
return formatDateCompleted(time_remaining)
|
||||
|
||||
@pyqtProperty(bool, notify=printJobsChanged)
|
||||
def receivedPrintJobs(self) -> bool:
|
||||
return bool(self._print_jobs)
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(name="openPrintJobControlPanel")
|
||||
def openPrintJobControlPanel(self) -> None:
|
||||
QDesktopServices.openUrl(QUrl("https://mycloud.ultimaker.com"))
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(name="openPrinterControlPanel")
|
||||
def openPrinterControlPanel(self) -> None:
|
||||
QDesktopServices.openUrl(QUrl("https://mycloud.ultimaker.com"))
|
||||
|
||||
## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud.
|
||||
# TODO: We fake the methods here to not break the monitor page.
|
||||
## Gets the cluster response from which this device was created.
|
||||
@property
|
||||
def clusterData(self) -> CloudClusterResponse:
|
||||
return self._cluster
|
||||
|
||||
@pyqtProperty(QUrl, notify=_clusterPrintersChanged)
|
||||
def activeCameraUrl(self) -> "QUrl":
|
||||
return QUrl()
|
||||
|
||||
@pyqtSlot(QUrl)
|
||||
def setActiveCameraUrl(self, camera_url: "QUrl") -> None:
|
||||
pass
|
||||
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
|
||||
return []
|
||||
## Updates the cluster data from the cloud.
|
||||
@clusterData.setter
|
||||
def clusterData(self, value: CloudClusterResponse) -> None:
|
||||
self._cluster = value
|
||||
|
|
|
@ -1,30 +1,27 @@
|
|||
# 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 Dict, List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Signal import Signal
|
||||
from cura.API import Account
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
|
||||
from .CloudApiClient import CloudApiClient
|
||||
from .CloudOutputDevice import CloudOutputDevice
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudError import CloudError
|
||||
from .Utils import findChanges
|
||||
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
|
||||
|
||||
|
||||
## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters.
|
||||
# Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code.
|
||||
#
|
||||
# API spec is available on https://api.ultimaker.com/docs/connect/spec/.
|
||||
#
|
||||
## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters.
|
||||
# Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code.
|
||||
# API spec is available on https://api.ultimaker.com/docs/connect/spec/.
|
||||
class CloudOutputDeviceManager:
|
||||
|
||||
META_CLUSTER_ID = "um_cloud_cluster_id"
|
||||
META_NETWORK_KEY = "um_network_key"
|
||||
|
||||
# The interval with which the remote clusters are checked
|
||||
CHECK_CLUSTER_INTERVAL = 30.0 # seconds
|
||||
|
@ -32,108 +29,119 @@ class CloudOutputDeviceManager:
|
|||
# The translation catalog for this device.
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
addedCloudCluster = Signal()
|
||||
removedCloudCluster = Signal()
|
||||
# Signal emitted when the list of discovered devices changed.
|
||||
discoveredDevicesChanged = Signal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Persistent dict containing the remote clusters for the authenticated user.
|
||||
self._remote_clusters = {} # type: Dict[str, CloudOutputDevice]
|
||||
|
||||
self._application = CuraApplication.getInstance()
|
||||
self._output_device_manager = self._application.getOutputDeviceManager()
|
||||
|
||||
self._account = self._application.getCuraAPI().account # type: Account
|
||||
self._api = CloudApiClient(self._account, self._onApiError)
|
||||
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
|
||||
self._api = CloudApiClient(self._account, on_error=lambda error: print(error))
|
||||
self._account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
|
||||
# Create a timer to update the remote cluster list
|
||||
self._update_timer = QTimer()
|
||||
self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000))
|
||||
self._update_timer.setSingleShot(False)
|
||||
|
||||
# Ensure we don't start twice.
|
||||
self._running = False
|
||||
|
||||
# Called when the uses logs in or out
|
||||
## Starts running the cloud output device manager, thus periodically requesting cloud data.
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
if not self._account.isLoggedIn:
|
||||
return
|
||||
self._running = True
|
||||
if not self._update_timer.isActive():
|
||||
self._update_timer.start()
|
||||
self._getRemoteClusters()
|
||||
self._update_timer.timeout.connect(self._getRemoteClusters)
|
||||
|
||||
## Stops running the cloud output device manager.
|
||||
def stop(self):
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices.
|
||||
self._update_timer.timeout.disconnect(self._getRemoteClusters)
|
||||
|
||||
## Force refreshing connections.
|
||||
def refreshConnections(self) -> None:
|
||||
self._connectToActiveMachine()
|
||||
|
||||
## Called when the uses logs in or out
|
||||
def _onLoginStateChanged(self, is_logged_in: bool) -> None:
|
||||
Logger.log("d", "Log in state changed to %s", is_logged_in)
|
||||
if is_logged_in:
|
||||
if not self._update_timer.isActive():
|
||||
self._update_timer.start()
|
||||
self._getRemoteClusters()
|
||||
self.start()
|
||||
else:
|
||||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
self.stop()
|
||||
|
||||
# Notify that all clusters have disappeared
|
||||
self._onGetRemoteClustersFinished([])
|
||||
|
||||
## Gets all remote clusters from the API.
|
||||
## Gets all remote clusters from the API.
|
||||
def _getRemoteClusters(self) -> None:
|
||||
Logger.log("d", "Retrieving remote clusters")
|
||||
self._api.getClusters(self._onGetRemoteClustersFinished)
|
||||
|
||||
## Callback for when the request for getting the clusters. is finished.
|
||||
## Callback for when the request for getting the clusters is finished.
|
||||
def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None:
|
||||
online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse]
|
||||
for device_id, cluster_data in online_clusters.items():
|
||||
if device_id not in self._remote_clusters:
|
||||
self._onDeviceDiscovered(cluster_data)
|
||||
else:
|
||||
self._onDiscoveredDeviceUpdated(cluster_data)
|
||||
|
||||
removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters)
|
||||
|
||||
Logger.log("d", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()])
|
||||
Logger.log("d", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates))
|
||||
|
||||
# Remove output devices that are gone
|
||||
for device in removed_devices:
|
||||
if device.isConnected():
|
||||
device.disconnect()
|
||||
device.close()
|
||||
self._output_device_manager.removeOutputDevice(device.key)
|
||||
self._application.getDiscoveredPrintersModel().removeDiscoveredPrinter(device.key)
|
||||
self.removedCloudCluster.emit(device)
|
||||
del self._remote_clusters[device.key]
|
||||
|
||||
# Add an output device for each new remote cluster.
|
||||
# We only add when is_online as we don't want the option in the drop down if the cluster is not online.
|
||||
for cluster in added_clusters:
|
||||
device = CloudOutputDevice(self._api, cluster)
|
||||
self._remote_clusters[cluster.cluster_id] = device
|
||||
self._application.getDiscoveredPrintersModel().addDiscoveredPrinter(
|
||||
device.key,
|
||||
device.key,
|
||||
cluster.friendly_name,
|
||||
self._createMachineFromDiscoveredPrinter,
|
||||
device.printerType,
|
||||
device
|
||||
)
|
||||
self.addedCloudCluster.emit(cluster)
|
||||
|
||||
# Update the output devices
|
||||
for device, cluster in updates:
|
||||
device.clusterData = cluster
|
||||
self._application.getDiscoveredPrintersModel().updateDiscoveredPrinter(
|
||||
device.key,
|
||||
cluster.friendly_name,
|
||||
device.printerType,
|
||||
)
|
||||
removed_device_keys = set(self._remote_clusters.keys()) - set(online_clusters.keys())
|
||||
for device_id in removed_device_keys:
|
||||
self._onDiscoveredDeviceRemoved(device_id)
|
||||
|
||||
def _onDeviceDiscovered(self, cluster_data: CloudClusterResponse) -> None:
|
||||
device = CloudOutputDevice(self._api, cluster_data)
|
||||
CuraApplication.getInstance().getDiscoveredPrintersModel().addDiscoveredPrinter(
|
||||
ip_address=device.key,
|
||||
key=device.getId(),
|
||||
name=device.getName(),
|
||||
create_callback=self._createMachineFromDiscoveredDevice,
|
||||
machine_type=device.printerType,
|
||||
device=device
|
||||
)
|
||||
self._remote_clusters[device.getId()] = device
|
||||
self.discoveredDevicesChanged.emit()
|
||||
self._connectToActiveMachine()
|
||||
|
||||
def _createMachineFromDiscoveredPrinter(self, key: str) -> None:
|
||||
device = self._remote_clusters[key] # type: CloudOutputDevice
|
||||
|
||||
def _onDiscoveredDeviceUpdated(self, cluster_data: CloudClusterResponse) -> None:
|
||||
device = self._remote_clusters.get(cluster_data.cluster_id)
|
||||
if not device:
|
||||
Logger.log("e", "Could not find discovered device with key [%s]", key)
|
||||
return
|
||||
|
||||
group_name = device.clusterData.friendly_name
|
||||
machine_type_id = device.printerType
|
||||
|
||||
Logger.log("i", "Creating machine from cloud device with key = [%s], group name = [%s], printer type = [%s]",
|
||||
key, group_name, machine_type_id)
|
||||
|
||||
CuraApplication.getInstance().getDiscoveredPrintersModel().updateDiscoveredPrinter(
|
||||
ip_address=device.key,
|
||||
name=cluster_data.friendly_name,
|
||||
machine_type=device.printerType
|
||||
)
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
def _onDiscoveredDeviceRemoved(self, device_id: str) -> None:
|
||||
device = self._remote_clusters.pop(device_id, None) # type: Optional[CloudOutputDevice]
|
||||
if not device:
|
||||
return
|
||||
device.disconnect()
|
||||
device.close()
|
||||
CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.key)
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
def _createMachineFromDiscoveredDevice(self, key: str) -> None:
|
||||
device = self._remote_clusters[key]
|
||||
if not device:
|
||||
return
|
||||
|
||||
# The newly added machine is automatically activated.
|
||||
self._application.getMachineManager().addMachine(machine_type_id, group_name)
|
||||
machine_manager = CuraApplication.getInstance().getMachineManager()
|
||||
machine_manager.addMachine(device.printerType, device.clusterData.friendly_name)
|
||||
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not active_machine:
|
||||
return
|
||||
|
||||
active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
|
||||
|
@ -143,69 +151,24 @@ class CloudOutputDeviceManager:
|
|||
if not active_machine:
|
||||
return
|
||||
|
||||
# Remove all output devices that we have registered.
|
||||
# This is needed because when we switch machines we can only leave
|
||||
# output devices that are meant for that machine.
|
||||
for stored_cluster_id in self._remote_clusters:
|
||||
self._output_device_manager.removeOutputDevice(stored_cluster_id)
|
||||
|
||||
# Check if the stored cluster_id for the active machine is in our list of remote clusters.
|
||||
output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
|
||||
stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID)
|
||||
if stored_cluster_id in self._remote_clusters:
|
||||
device = self._remote_clusters[stored_cluster_id]
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
Logger.log("d", "Device connected by metadata cluster ID %s", stored_cluster_id)
|
||||
else:
|
||||
self._connectByNetworkKey(active_machine)
|
||||
|
||||
## Tries to match the local network key to the cloud cluster host name.
|
||||
def _connectByNetworkKey(self, active_machine: GlobalStack) -> None:
|
||||
# Check if the active printer has a local network connection and match this key to the remote cluster.
|
||||
local_network_key = active_machine.getMetaDataEntry("um_network_key")
|
||||
if not local_network_key:
|
||||
return
|
||||
|
||||
device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None)
|
||||
if not device:
|
||||
return
|
||||
|
||||
Logger.log("i", "Found cluster %s with network key %s", device, local_network_key)
|
||||
active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
local_network_key = active_machine.getMetaDataEntry(self.META_NETWORK_KEY)
|
||||
for device in self._remote_clusters.values():
|
||||
if device.key == stored_cluster_id:
|
||||
# Connect to it if the stored ID matches.
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
elif local_network_key and device.matchesNetworkKey(local_network_key):
|
||||
# Connect to it if we can match the local network key that was already present.
|
||||
active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
elif device.key in output_device_manager.getOutputDeviceIds():
|
||||
# Remove device if it is not meant for the active machine.
|
||||
output_device_manager.removeOutputDevice(device.key)
|
||||
|
||||
## Connects to an output device and makes sure it is registered in the output device manager.
|
||||
def _connectToOutputDevice(self, device: CloudOutputDevice, active_machine: GlobalStack) -> None:
|
||||
@staticmethod
|
||||
def _connectToOutputDevice(device: CloudOutputDevice, active_machine: GlobalStack) -> None:
|
||||
device.connect()
|
||||
self._output_device_manager.addOutputDevice(device)
|
||||
active_machine.addConfiguredConnectionType(device.connectionType.value)
|
||||
|
||||
## Handles an API error received from the cloud.
|
||||
# \param errors: The errors received
|
||||
def _onApiError(self, errors: List[CloudError] = None) -> None:
|
||||
Logger.log("w", str(errors))
|
||||
message = Message(
|
||||
text = self.I18N_CATALOG.i18nc("@info:description", "There was an error connecting to the cloud."),
|
||||
title = self.I18N_CATALOG.i18nc("@info:title", "Error"),
|
||||
lifetime = 10
|
||||
)
|
||||
message.show()
|
||||
|
||||
## Starts running the cloud output device manager, thus periodically requesting cloud data.
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
# When switching machines we check if we have to activate a remote cluster.
|
||||
self._application.globalContainerStackChanged.connect(self._connectToActiveMachine)
|
||||
self._update_timer.timeout.connect(self._getRemoteClusters)
|
||||
self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn)
|
||||
|
||||
## Stops running the cloud output device manager.
|
||||
def stop(self):
|
||||
if not self._running:
|
||||
return
|
||||
self._account.loginStateChanged.disconnect(self._onLoginStateChanged)
|
||||
# When switching machines we check if we have to activate a remote cluster.
|
||||
self._application.globalContainerStackChanged.disconnect(self._connectToActiveMachine)
|
||||
self._update_timer.timeout.disconnect(self._getRemoteClusters)
|
||||
self._onLoginStateChanged(is_logged_in = False)
|
||||
CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device)
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
|
||||
## Class representing a cluster printer
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterBuildPlate(BaseCloudModel):
|
||||
## Create a new build plate
|
||||
# \param type: The type of buildplate glass or aluminium
|
||||
def __init__(self, type: str = "glass", **kwargs) -> None:
|
||||
self.type = type
|
||||
super().__init__(**kwargs)
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# !/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from PyQt5.QtCore import QUrl
|
||||
|
@ -6,7 +6,8 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage
|
|||
from typing import Optional, Callable, Any, Tuple, cast
|
||||
|
||||
from UM.Logger import Logger
|
||||
from .Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
|
||||
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
|
||||
|
||||
## Class responsible for uploading meshes to the cloud in separate requests.
|
||||
|
@ -53,7 +54,7 @@ class ToolPathUploader:
|
|||
def _createRequest(self) -> QNetworkRequest:
|
||||
request = QNetworkRequest(QUrl(self._print_job.upload_url))
|
||||
request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type)
|
||||
|
||||
|
||||
first_byte, last_byte = self._chunkRange()
|
||||
content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
|
||||
request.setRawHeader(b"Content-Range", content_range.encode())
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
from datetime import datetime, timedelta
|
||||
from typing import TypeVar, Dict, Tuple, List
|
||||
|
||||
from UM import i18nCatalog
|
||||
|
||||
T = TypeVar("T")
|
||||
U = TypeVar("U")
|
||||
|
||||
|
||||
## Splits the given dictionaries into three lists (in a tuple):
|
||||
# - `removed`: Items that were in the first argument but removed in the second one.
|
||||
# - `added`: Items that were not in the first argument but were included in the second one.
|
||||
# - `updated`: Items that were in both dictionaries. Both values are given in a tuple.
|
||||
# \param previous: The previous items
|
||||
# \param received: The received items
|
||||
# \return: The tuple (removed, added, updated) as explained above.
|
||||
def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T], List[U], List[Tuple[T, U]]]:
|
||||
previous_ids = set(previous)
|
||||
received_ids = set(received)
|
||||
|
||||
removed_ids = previous_ids.difference(received_ids)
|
||||
new_ids = received_ids.difference(previous_ids)
|
||||
updated_ids = received_ids.intersection(previous_ids)
|
||||
|
||||
removed = [previous[removed_id] for removed_id in removed_ids]
|
||||
added = [received[new_id] for new_id in new_ids]
|
||||
updated = [(previous[updated_id], received[updated_id]) for updated_id in updated_ids]
|
||||
|
||||
return removed, added, updated
|
||||
|
||||
|
||||
def formatTimeCompleted(seconds_remaining: int) -> str:
|
||||
completed = datetime.now() + timedelta(seconds=seconds_remaining)
|
||||
return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute)
|
||||
|
||||
|
||||
def formatDateCompleted(seconds_remaining: int) -> str:
|
||||
now = datetime.now()
|
||||
completed = now + timedelta(seconds=seconds_remaining)
|
||||
days = (completed.date() - now.date()).days
|
||||
i18n = i18nCatalog("cura")
|
||||
|
||||
# If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format
|
||||
if days >= 7:
|
||||
return completed.strftime("%a %b ") + "{day}".format(day = completed.day)
|
||||
# If finishing date is within the next week, use "Monday at HH:MM" format
|
||||
elif days >= 2:
|
||||
return completed.strftime("%a")
|
||||
# If finishing tomorrow, use "tomorrow at HH:MM" format
|
||||
elif days >= 1:
|
||||
return i18n.i18nc("@info:status", "tomorrow")
|
||||
# If finishing today, use "today at HH:MM" format
|
||||
else:
|
||||
return i18n.i18nc("@info:status", "today")
|
41
plugins/UM3NetworkPrinting/src/CloudFlowMessage.py
Normal file
41
plugins/UM3NetworkPrinting/src/CloudFlowMessage.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import os
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Message import Message
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
||||
class CloudFlowMessage(Message):
|
||||
|
||||
def __init__(self, address: str) -> None:
|
||||
|
||||
image_path = os.path.join(
|
||||
CuraApplication.getInstance().getPluginRegistry().getPluginPath("UM3NetworkPrinting") or "",
|
||||
"resources", "svg", "cloud-flow-start.svg"
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
text=I18N_CATALOG.i18nc("@info:status",
|
||||
"Send and monitor print jobs from anywhere using your Ultimaker account."),
|
||||
lifetime=0,
|
||||
dismissable=True,
|
||||
option_state=False,
|
||||
image_source=image_path,
|
||||
image_caption=I18N_CATALOG.i18nc("@info:status Ultimaker Cloud should not be translated.",
|
||||
"Connect to Ultimaker Cloud"),
|
||||
)
|
||||
self._address = address
|
||||
self.addAction("", I18N_CATALOG.i18nc("@action", "Get started"), "", "")
|
||||
self.actionTriggered.connect(self._onCloudFlowStarted)
|
||||
|
||||
def _onCloudFlowStarted(self, messageId: str, actionId: str) -> None:
|
||||
QDesktopServices.openUrl(QUrl("http://{}/cloud_connect".format(self._address)))
|
||||
self.hide()
|
|
@ -1,19 +1,14 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .CloudOutputDevice import CloudOutputDevice
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
|
||||
|
||||
class CloudOutputController(PrinterOutputController):
|
||||
def __init__(self, output_device: "CloudOutputDevice") -> None:
|
||||
class ClusterOutputController(PrinterOutputController):
|
||||
|
||||
def __init__(self, output_device: PrinterOutputDevice) -> None:
|
||||
super().__init__(output_device)
|
||||
|
||||
# The cloud connection only supports fetching the printer and queue status and adding a job to the queue.
|
||||
# To let the UI know this we mark all features below as False.
|
||||
self.can_pause = True
|
||||
self.can_abort = True
|
||||
self.can_pre_heat_bed = False
|
||||
|
@ -22,5 +17,5 @@ class CloudOutputController(PrinterOutputController):
|
|||
self.can_control_manually = False
|
||||
self.can_update_firmware = False
|
||||
|
||||
def setJobState(self, job: "PrintJobOutputModel", state: str):
|
||||
def setJobState(self, job: PrintJobOutputModel, state: str):
|
||||
self._output_device.setJobState(job.key, state)
|
|
@ -1,724 +0,0 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import Any, cast, Tuple, Union, Optional, Dict, List
|
||||
from time import time
|
||||
|
||||
import io # To create the correct buffers for sending data to the printer.
|
||||
import json
|
||||
import os
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.FileHandler.WriteFileJob import WriteFileJob # To call the file writer asynchronously.
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.PluginRegistry import PluginRegistry
|
||||
from UM.Qt.Duration import Duration, DurationFormat
|
||||
from UM.Scene.SceneNode import SceneNode # For typing.
|
||||
from UM.Settings.ContainerRegistry import ContainerRegistry
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel
|
||||
from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel
|
||||
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from .Cloud.Utils import formatTimeCompleted, formatDateCompleted
|
||||
from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController
|
||||
from .ConfigurationChangeModel import ConfigurationChangeModel
|
||||
from .MeshFormatHandler import MeshFormatHandler
|
||||
from .SendMaterialJob import SendMaterialJob
|
||||
from .UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
|
||||
from PyQt5.QtGui import QDesktopServices, QImage
|
||||
from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject
|
||||
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
printJobsChanged = pyqtSignal()
|
||||
activePrinterChanged = pyqtSignal()
|
||||
activeCameraUrlChanged = pyqtSignal()
|
||||
receivedPrintJobsChanged = pyqtSignal()
|
||||
|
||||
# Notify can only use signals that are defined by the class that they are in, not inherited ones.
|
||||
# Therefore we create a private signal used to trigger the printersChanged signal.
|
||||
_clusterPrintersChanged = pyqtSignal()
|
||||
|
||||
def __init__(self, device_id, address, properties, parent = None) -> None:
|
||||
super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent)
|
||||
self._api_prefix = "/cluster-api/v1/"
|
||||
|
||||
self._application = CuraApplication.getInstance()
|
||||
|
||||
self._number_of_extruders = 2
|
||||
|
||||
self._dummy_lambdas = (
|
||||
"", {}, io.BytesIO()
|
||||
) # type: Tuple[Optional[str], Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]]
|
||||
|
||||
self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
|
||||
self._received_print_jobs = False # type: bool
|
||||
|
||||
if PluginRegistry.getInstance() is not None:
|
||||
plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting")
|
||||
if plugin_path is None:
|
||||
Logger.log("e", "Cloud not find plugin path for plugin UM3NetworkPrnting")
|
||||
raise RuntimeError("Cloud not find plugin path for plugin UM3NetworkPrnting")
|
||||
self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml")
|
||||
|
||||
# Trigger the printersChanged signal when the private signal is triggered
|
||||
self.printersChanged.connect(self._clusterPrintersChanged)
|
||||
|
||||
self._accepts_commands = True # type: bool
|
||||
|
||||
# Cluster does not have authentication, so default to authenticated
|
||||
self._authentication_state = AuthState.Authenticated
|
||||
|
||||
self._error_message = None # type: Optional[Message]
|
||||
self._write_job_progress_message = None # type: Optional[Message]
|
||||
self._progress_message = None # type: Optional[Message]
|
||||
|
||||
self._active_printer = None # type: Optional[PrinterOutputModel]
|
||||
|
||||
self._printer_selection_dialog = None # type: QObject
|
||||
|
||||
self.setPriority(3) # Make sure the output device gets selected above local file output
|
||||
self.setName(self._id)
|
||||
self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
|
||||
self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
|
||||
|
||||
self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network"))
|
||||
|
||||
self._printer_uuid_to_unique_name_mapping = {} # type: Dict[str, str]
|
||||
|
||||
self._finished_jobs = [] # type: List[UM3PrintJobOutputModel]
|
||||
|
||||
self._cluster_size = int(properties.get(b"cluster_size", 0)) # type: int
|
||||
|
||||
self._latest_reply_handler = None # type: Optional[QNetworkReply]
|
||||
self._sending_job = None
|
||||
|
||||
self._active_camera_url = QUrl() # type: QUrl
|
||||
|
||||
def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None:
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
self.sendMaterialProfiles()
|
||||
|
||||
mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
|
||||
|
||||
# This function pauses with the yield, waiting on instructions on which printer it needs to print with.
|
||||
if not mesh_format.is_valid:
|
||||
Logger.log("e", "Missing file or mesh writer!")
|
||||
return
|
||||
self._sending_job = self._sendPrintJob(mesh_format, nodes)
|
||||
if self._sending_job is not None:
|
||||
self._sending_job.send(None) # Start the generator.
|
||||
|
||||
if len(self._printers) > 1: # We need to ask the user.
|
||||
self._spawnPrinterSelectionDialog()
|
||||
is_job_sent = True
|
||||
else: # Just immediately continue.
|
||||
self._sending_job.send("") # No specifically selected printer.
|
||||
is_job_sent = self._sending_job.send(None)
|
||||
|
||||
def _spawnPrinterSelectionDialog(self):
|
||||
if self._printer_selection_dialog is None:
|
||||
if PluginRegistry.getInstance() is not None:
|
||||
path = os.path.join(
|
||||
PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"),
|
||||
"resources", "qml", "PrintWindow.qml"
|
||||
)
|
||||
self._printer_selection_dialog = self._application.createQmlComponent(path, {"OutputDevice": self})
|
||||
if self._printer_selection_dialog is not None:
|
||||
self._printer_selection_dialog.show()
|
||||
|
||||
## Whether the printer that this output device represents supports print job actions via the local network.
|
||||
@pyqtProperty(bool, constant=True)
|
||||
def supportsPrintJobActions(self) -> bool:
|
||||
return True
|
||||
|
||||
@pyqtProperty(int, constant=True)
|
||||
def clusterSize(self) -> int:
|
||||
return self._cluster_size
|
||||
|
||||
## Allows the user to choose a printer to print with from the printer
|
||||
# selection dialogue.
|
||||
# \param target_printer The name of the printer to target.
|
||||
@pyqtSlot(str)
|
||||
def selectPrinter(self, target_printer: str = "") -> None:
|
||||
if self._sending_job is not None:
|
||||
self._sending_job.send(target_printer)
|
||||
|
||||
@pyqtSlot()
|
||||
def cancelPrintSelection(self) -> None:
|
||||
self._sending_gcode = False
|
||||
|
||||
## Greenlet to send a job to the printer over the network.
|
||||
#
|
||||
# This greenlet gets called asynchronously in requestWrite. It is a
|
||||
# greenlet in order to optionally wait for selectPrinter() to select a
|
||||
# printer.
|
||||
# The greenlet yields exactly three times: First time None,
|
||||
# \param mesh_format Object responsible for choosing the right kind of format to write with.
|
||||
def _sendPrintJob(self, mesh_format: MeshFormatHandler, nodes: List[SceneNode]):
|
||||
Logger.log("i", "Sending print job to printer.")
|
||||
if self._sending_gcode:
|
||||
self._error_message = Message(
|
||||
i18n_catalog.i18nc("@info:status",
|
||||
"Sending new jobs (temporarily) blocked, still sending the previous print job."))
|
||||
self._error_message.show()
|
||||
yield #Wait on the user to select a target printer.
|
||||
yield #Wait for the write job to be finished.
|
||||
yield False #Return whether this was a success or not.
|
||||
yield #Prevent StopIteration.
|
||||
|
||||
self._sending_gcode = True
|
||||
|
||||
# Potentially wait on the user to select a target printer.
|
||||
target_printer = yield # type: Optional[str]
|
||||
|
||||
# Using buffering greatly reduces the write time for many lines of gcode
|
||||
|
||||
stream = mesh_format.createStream()
|
||||
|
||||
job = WriteFileJob(mesh_format.writer, stream, nodes, mesh_format.file_mode)
|
||||
|
||||
self._write_job_progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"),
|
||||
lifetime = 0, dismissable = False, progress = -1,
|
||||
title = i18n_catalog.i18nc("@info:title", "Sending Data"),
|
||||
use_inactivity_timer = False)
|
||||
self._write_job_progress_message.show()
|
||||
|
||||
if mesh_format.preferred_format is not None:
|
||||
self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream)
|
||||
job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished)
|
||||
job.start()
|
||||
yield True # Return that we had success!
|
||||
yield # To prevent having to catch the StopIteration exception.
|
||||
|
||||
def _sendPrintJobWaitOnWriteJobFinished(self, job: WriteFileJob) -> None:
|
||||
if self._write_job_progress_message:
|
||||
self._write_job_progress_message.hide()
|
||||
|
||||
self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0,
|
||||
dismissable = False, progress = -1,
|
||||
title = i18n_catalog.i18nc("@info:title", "Sending Data"))
|
||||
self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = "",
|
||||
description = "")
|
||||
self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered)
|
||||
self._progress_message.show()
|
||||
parts = []
|
||||
|
||||
target_printer, preferred_format, stream = self._dummy_lambdas
|
||||
|
||||
# If a specific printer was selected, it should be printed with that machine.
|
||||
if target_printer:
|
||||
target_printer = self._printer_uuid_to_unique_name_mapping[target_printer]
|
||||
parts.append(self._createFormPart("name=require_printer_name", bytes(target_printer, "utf-8"), "text/plain"))
|
||||
|
||||
# Add user name to the print_job
|
||||
parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"))
|
||||
|
||||
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):
|
||||
output = cast(str, output).encode("utf-8")
|
||||
output = cast(bytes, output)
|
||||
|
||||
parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, output))
|
||||
|
||||
self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts,
|
||||
on_finished = self._onPostPrintJobFinished,
|
||||
on_progress = self._onUploadPrintJobProgress)
|
||||
|
||||
@pyqtProperty(QObject, notify = activePrinterChanged)
|
||||
def activePrinter(self) -> Optional[PrinterOutputModel]:
|
||||
return self._active_printer
|
||||
|
||||
@pyqtSlot(QObject)
|
||||
def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None:
|
||||
if self._active_printer != printer:
|
||||
self._active_printer = printer
|
||||
self.activePrinterChanged.emit()
|
||||
|
||||
@pyqtProperty(QUrl, notify = activeCameraUrlChanged)
|
||||
def activeCameraUrl(self) -> "QUrl":
|
||||
return self._active_camera_url
|
||||
|
||||
@pyqtSlot(QUrl)
|
||||
def setActiveCameraUrl(self, camera_url: "QUrl") -> None:
|
||||
if self._active_camera_url != camera_url:
|
||||
self._active_camera_url = camera_url
|
||||
self.activeCameraUrlChanged.emit()
|
||||
|
||||
def _onPostPrintJobFinished(self, reply: QNetworkReply) -> None:
|
||||
if self._progress_message:
|
||||
self._progress_message.hide()
|
||||
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
|
||||
# Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
|
||||
# timeout responses if this happens.
|
||||
self._last_response_time = time()
|
||||
if self._progress_message is not None and new_progress != self._progress_message.getProgress():
|
||||
self._progress_message.show() # Ensure that the message is visible.
|
||||
self._progress_message.setProgress(bytes_sent / bytes_total * 100)
|
||||
|
||||
# If successfully sent:
|
||||
if bytes_sent == bytes_total:
|
||||
# Show a confirmation to the user so they know the job was sucessful and provide the option to switch to
|
||||
# the monitor tab.
|
||||
self._success_message = Message(
|
||||
i18n_catalog.i18nc("@info:status", "Print job was successfully sent to the printer."),
|
||||
lifetime=5, dismissable=True,
|
||||
title=i18n_catalog.i18nc("@info:title", "Data Sent"))
|
||||
self._success_message.addAction("View", i18n_catalog.i18nc("@action:button", "View in Monitor"), icon = "",
|
||||
description="")
|
||||
self._success_message.actionTriggered.connect(self._successMessageActionTriggered)
|
||||
self._success_message.show()
|
||||
else:
|
||||
if self._progress_message is not None:
|
||||
self._progress_message.setProgress(0)
|
||||
self._progress_message.hide()
|
||||
|
||||
def _progressMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None:
|
||||
if action_id == "Abort":
|
||||
Logger.log("d", "User aborted sending print to remote.")
|
||||
if self._progress_message is not None:
|
||||
self._progress_message.hide()
|
||||
self._compressing_gcode = False
|
||||
self._sending_gcode = False
|
||||
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
|
||||
if self._latest_reply_handler:
|
||||
self._latest_reply_handler.disconnect()
|
||||
self._latest_reply_handler = None
|
||||
|
||||
def _successMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None:
|
||||
if action_id == "View":
|
||||
self._application.getController().setActiveStage("MonitorStage")
|
||||
|
||||
@pyqtSlot()
|
||||
def openPrintJobControlPanel(self) -> None:
|
||||
Logger.log("d", "Opening print job control panel...")
|
||||
QDesktopServices.openUrl(QUrl("http://" + self._address + "/print_jobs"))
|
||||
|
||||
@pyqtSlot()
|
||||
def openPrinterControlPanel(self) -> None:
|
||||
Logger.log("d", "Opening printer control panel...")
|
||||
QDesktopServices.openUrl(QUrl("http://" + self._address + "/printers"))
|
||||
|
||||
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
||||
def printJobs(self)-> List[UM3PrintJobOutputModel]:
|
||||
return self._print_jobs
|
||||
|
||||
@pyqtProperty(bool, notify = receivedPrintJobsChanged)
|
||||
def receivedPrintJobs(self) -> bool:
|
||||
return self._received_print_jobs
|
||||
|
||||
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
||||
def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if print_job.state == "queued" or print_job.state == "error"]
|
||||
|
||||
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
||||
def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"]
|
||||
|
||||
@pyqtProperty("QVariantList", notify = _clusterPrintersChanged)
|
||||
def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
|
||||
printer_count = {} # type: Dict[str, int]
|
||||
for printer in self._printers:
|
||||
if printer.type in printer_count:
|
||||
printer_count[printer.type] += 1
|
||||
else:
|
||||
printer_count[printer.type] = 1
|
||||
result = []
|
||||
for machine_type in printer_count:
|
||||
result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])})
|
||||
return result
|
||||
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def printers(self):
|
||||
return self._printers
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getTimeCompleted(self, time_remaining: int) -> str:
|
||||
return formatTimeCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getDateCompleted(self, time_remaining: int) -> str:
|
||||
return formatDateCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def formatDuration(self, seconds: int) -> str:
|
||||
return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
|
||||
|
||||
@pyqtSlot(str)
|
||||
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 = "{\"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:
|
||||
# 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.
|
||||
self.delete("print_jobs/{uuid}".format(uuid = print_job_uuid), on_finished=None)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def forceSendJob(self, print_job_uuid: str) -> None:
|
||||
data = "{\"force\": true}"
|
||||
self.put("print_jobs/{uuid}".format(uuid=print_job_uuid), data, on_finished=None)
|
||||
|
||||
# Set the remote print job state.
|
||||
def setJobState(self, print_job_uuid: str, state: str) -> None:
|
||||
# We rewrite 'resume' to 'print' here because we are using the old print job action endpoints.
|
||||
action = "print" if state == "resume" else state
|
||||
data = "{\"action\": \"%s\"}" % action
|
||||
self.put("print_jobs/%s/action" % print_job_uuid, data, on_finished=None)
|
||||
|
||||
def _printJobStateChanged(self) -> None:
|
||||
username = self._getUserName()
|
||||
|
||||
if username is None:
|
||||
return # We only want to show notifications if username is set.
|
||||
|
||||
finished_jobs = [job for job in self._print_jobs if job.state == "wait_cleanup"]
|
||||
|
||||
newly_finished_jobs = [job for job in finished_jobs if job not in self._finished_jobs and job.owner == username]
|
||||
for job in newly_finished_jobs:
|
||||
if job.assignedPrinter:
|
||||
job_completed_text = i18n_catalog.i18nc("@info:status", "Printer '{printer_name}' has finished printing '{job_name}'.").format(printer_name=job.assignedPrinter.name, job_name = job.name)
|
||||
else:
|
||||
job_completed_text = i18n_catalog.i18nc("@info:status", "The print job '{job_name}' was finished.").format(job_name = job.name)
|
||||
job_completed_message = Message(text=job_completed_text, title = i18n_catalog.i18nc("@info:status", "Print finished"))
|
||||
job_completed_message.show()
|
||||
|
||||
# Ensure UI gets updated
|
||||
self.printJobsChanged.emit()
|
||||
|
||||
# Keep a list of all completed jobs so we know if something changed next time.
|
||||
self._finished_jobs = finished_jobs
|
||||
|
||||
## Called when the connection to the cluster changes.
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
self.sendMaterialProfiles()
|
||||
|
||||
def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None:
|
||||
reply_url = reply.url().toString()
|
||||
|
||||
uuid = reply_url[reply_url.find("print_jobs/")+len("print_jobs/"):reply_url.rfind("/preview_image")]
|
||||
|
||||
print_job = findByKey(self._print_jobs, uuid)
|
||||
if print_job:
|
||||
image = QImage()
|
||||
image.loadFromData(reply.readAll())
|
||||
print_job.updatePreviewImage(image)
|
||||
|
||||
def _update(self) -> None:
|
||||
super()._update()
|
||||
self.get("printers/", on_finished = self._onGetPrintersDataFinished)
|
||||
self.get("print_jobs/", on_finished = self._onGetPrintJobsFinished)
|
||||
|
||||
for print_job in self._print_jobs:
|
||||
if print_job.getPreviewImage() is None:
|
||||
self.get("print_jobs/{uuid}/preview_image".format(uuid=print_job.key), on_finished=self._onGetPreviewImageFinished)
|
||||
|
||||
def _onGetPrintJobsFinished(self, reply: QNetworkReply) -> None:
|
||||
self._received_print_jobs = True
|
||||
self.receivedPrintJobsChanged.emit()
|
||||
|
||||
if not checkValidGetReply(reply):
|
||||
return
|
||||
|
||||
result = loadJsonFromReply(reply)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
print_jobs_seen = []
|
||||
job_list_changed = False
|
||||
for idx, print_job_data in enumerate(result):
|
||||
print_job = findByKey(self._print_jobs, print_job_data["uuid"])
|
||||
if print_job is None:
|
||||
print_job = self._createPrintJobModel(print_job_data)
|
||||
job_list_changed = True
|
||||
elif not job_list_changed:
|
||||
# Check if the order of the jobs has changed since the last check
|
||||
if self._print_jobs.index(print_job) != idx:
|
||||
job_list_changed = True
|
||||
|
||||
self._updatePrintJob(print_job, print_job_data)
|
||||
|
||||
if print_job.state != "queued" and print_job.state != "error": # Print job should be assigned to a printer.
|
||||
if print_job.state in ["failed", "finished", "aborted", "none"]:
|
||||
# Print job was already completed, so don't attach it to a printer.
|
||||
printer = None
|
||||
else:
|
||||
printer = self._getPrinterByKey(print_job_data["printer_uuid"])
|
||||
else: # The job can "reserve" a printer if some changes are required.
|
||||
printer = self._getPrinterByKey(print_job_data["assigned_to"])
|
||||
|
||||
if printer:
|
||||
printer.updateActivePrintJob(print_job)
|
||||
|
||||
print_jobs_seen.append(print_job)
|
||||
|
||||
# Check what jobs need to be removed.
|
||||
removed_jobs = [print_job for print_job in self._print_jobs if print_job not in print_jobs_seen]
|
||||
|
||||
for removed_job in removed_jobs:
|
||||
job_list_changed = job_list_changed or self._removeJob(removed_job)
|
||||
|
||||
if job_list_changed:
|
||||
# Override the old list with the new list (either because jobs were removed / added or order changed)
|
||||
self._print_jobs = print_jobs_seen
|
||||
self.printJobsChanged.emit() # Do a single emit for all print job changes.
|
||||
|
||||
def _onGetPrintersDataFinished(self, reply: QNetworkReply) -> None:
|
||||
if not checkValidGetReply(reply):
|
||||
return
|
||||
|
||||
result = loadJsonFromReply(reply)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
printer_list_changed = False
|
||||
printers_seen = []
|
||||
|
||||
for printer_data in result:
|
||||
printer = findByKey(self._printers, printer_data["uuid"])
|
||||
|
||||
if printer is None:
|
||||
printer = self._createPrinterModel(printer_data)
|
||||
printer_list_changed = True
|
||||
|
||||
printers_seen.append(printer)
|
||||
|
||||
self._updatePrinter(printer, printer_data)
|
||||
|
||||
removed_printers = [printer for printer in self._printers if printer not in printers_seen]
|
||||
for printer in removed_printers:
|
||||
self._removePrinter(printer)
|
||||
|
||||
if removed_printers or printer_list_changed:
|
||||
self.printersChanged.emit()
|
||||
|
||||
def _createPrinterModel(self, data: Dict[str, Any]) -> PrinterOutputModel:
|
||||
printer = PrinterOutputModel(output_controller = ClusterUM3PrinterOutputController(self),
|
||||
number_of_extruders = self._number_of_extruders)
|
||||
printer.setCameraUrl(QUrl("http://" + data["ip_address"] + ":8080/?action=stream"))
|
||||
self._printers.append(printer)
|
||||
return printer
|
||||
|
||||
def _createPrintJobModel(self, data: Dict[str, Any]) -> UM3PrintJobOutputModel:
|
||||
print_job = UM3PrintJobOutputModel(output_controller=ClusterUM3PrinterOutputController(self),
|
||||
key=data["uuid"], name= data["name"])
|
||||
|
||||
configuration = PrinterConfigurationModel()
|
||||
extruders = [ExtruderConfigurationModel(position = idx) for idx in range(0, self._number_of_extruders)]
|
||||
for index in range(0, self._number_of_extruders):
|
||||
try:
|
||||
extruder_data = data["configuration"][index]
|
||||
except IndexError:
|
||||
continue
|
||||
extruder = extruders[int(data["configuration"][index]["extruder_index"])]
|
||||
extruder.setHotendID(extruder_data.get("print_core_id", ""))
|
||||
extruder.setMaterial(self._createMaterialOutputModel(extruder_data.get("material", {})))
|
||||
|
||||
configuration.setExtruderConfigurations(extruders)
|
||||
configuration.setPrinterType(data.get("machine_variant", ""))
|
||||
print_job.updateConfiguration(configuration)
|
||||
print_job.setCompatibleMachineFamilies(data.get("compatible_machine_families", []))
|
||||
print_job.stateChanged.connect(self._printJobStateChanged)
|
||||
return print_job
|
||||
|
||||
def _updatePrintJob(self, print_job: UM3PrintJobOutputModel, data: Dict[str, Any]) -> None:
|
||||
print_job.updateTimeTotal(data["time_total"])
|
||||
print_job.updateTimeElapsed(data["time_elapsed"])
|
||||
impediments_to_printing = data.get("impediments_to_printing", [])
|
||||
print_job.updateOwner(data["owner"])
|
||||
|
||||
status_set_by_impediment = False
|
||||
for impediment in impediments_to_printing:
|
||||
if impediment["severity"] == "UNFIXABLE":
|
||||
status_set_by_impediment = True
|
||||
print_job.updateState("error")
|
||||
break
|
||||
|
||||
if not status_set_by_impediment:
|
||||
print_job.updateState(data["status"])
|
||||
|
||||
print_job.updateConfigurationChanges(self._createConfigurationChanges(data["configuration_changes_required"]))
|
||||
|
||||
def _createConfigurationChanges(self, data: List[Dict[str, Any]]) -> List[ConfigurationChangeModel]:
|
||||
result = []
|
||||
for change in data:
|
||||
result.append(ConfigurationChangeModel(type_of_change=change["type_of_change"],
|
||||
index=change["index"],
|
||||
target_name=change["target_name"],
|
||||
origin_name=change["origin_name"]))
|
||||
return result
|
||||
|
||||
def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel":
|
||||
material_manager = self._application.getMaterialManager()
|
||||
material_group_list = None
|
||||
|
||||
# Avoid crashing if there is no "guid" field in the metadata
|
||||
material_guid = material_data.get("guid")
|
||||
if material_guid:
|
||||
material_group_list = material_manager.getMaterialGroupListByGUID(material_guid)
|
||||
|
||||
# This can happen if the connected machine has no material in one or more extruders (if GUID is empty), or the
|
||||
# material is unknown to Cura, so we should return an "empty" or "unknown" material model.
|
||||
if material_group_list is None:
|
||||
material_name = i18n_catalog.i18nc("@label:material", "Empty") if len(material_data.get("guid", "")) == 0 \
|
||||
else i18n_catalog.i18nc("@label:material", "Unknown")
|
||||
|
||||
return MaterialOutputModel(guid = material_data.get("guid", ""),
|
||||
type = material_data.get("material", ""),
|
||||
color = material_data.get("color", ""),
|
||||
brand = material_data.get("brand", ""),
|
||||
name = material_data.get("name", material_name)
|
||||
)
|
||||
|
||||
# Sort the material groups by "is_read_only = True" first, and then the name alphabetically.
|
||||
read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list))
|
||||
non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list))
|
||||
material_group = None
|
||||
if read_only_material_group_list:
|
||||
read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name)
|
||||
material_group = read_only_material_group_list[0]
|
||||
elif non_read_only_material_group_list:
|
||||
non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name)
|
||||
material_group = non_read_only_material_group_list[0]
|
||||
|
||||
if material_group:
|
||||
container = material_group.root_material_node.getContainer()
|
||||
color = container.getMetaDataEntry("color_code")
|
||||
brand = container.getMetaDataEntry("brand")
|
||||
material_type = container.getMetaDataEntry("material")
|
||||
name = container.getName()
|
||||
else:
|
||||
Logger.log("w",
|
||||
"Unable to find material with guid {guid}. Using data as provided by cluster".format(
|
||||
guid=material_data["guid"]))
|
||||
color = material_data["color"]
|
||||
brand = material_data["brand"]
|
||||
material_type = material_data["material"]
|
||||
name = i18n_catalog.i18nc("@label:material", "Empty") if material_data["material"] == "empty" \
|
||||
else i18n_catalog.i18nc("@label:material", "Unknown")
|
||||
return MaterialOutputModel(guid = material_data["guid"], type = material_type,
|
||||
brand = brand, color = color, name = name)
|
||||
|
||||
def _updatePrinter(self, printer: PrinterOutputModel, data: Dict[str, Any]) -> None:
|
||||
# For some unknown reason the cluster wants UUID for everything, except for sending a job directly to a printer.
|
||||
# Then we suddenly need the unique name. So in order to not have to mess up all the other code, we save a mapping.
|
||||
self._printer_uuid_to_unique_name_mapping[data["uuid"]] = data["unique_name"]
|
||||
|
||||
definitions = ContainerRegistry.getInstance().findDefinitionContainers(name = data["machine_variant"])
|
||||
if not definitions:
|
||||
Logger.log("w", "Unable to find definition for machine variant %s", data["machine_variant"])
|
||||
return
|
||||
|
||||
machine_definition = definitions[0]
|
||||
|
||||
printer.updateName(data["friendly_name"])
|
||||
printer.updateKey(data["uuid"])
|
||||
printer.updateType(data["machine_variant"])
|
||||
|
||||
if data["status"] != "unreachable":
|
||||
self._application.getDiscoveredPrintersModel().updateDiscoveredPrinter(data["ip_address"],
|
||||
name = data["friendly_name"],
|
||||
machine_type = data["machine_variant"])
|
||||
|
||||
# Do not store the build plate information that comes from connect if the current printer has not build plate information
|
||||
if "build_plate" in data and machine_definition.getMetaDataEntry("has_variant_buildplates", False):
|
||||
printer.updateBuildplate(data["build_plate"]["type"])
|
||||
if not data["enabled"]:
|
||||
printer.updateState("disabled")
|
||||
else:
|
||||
printer.updateState(data["status"])
|
||||
|
||||
for index in range(0, self._number_of_extruders):
|
||||
extruder = printer.extruders[index]
|
||||
try:
|
||||
extruder_data = data["configuration"][index]
|
||||
except IndexError:
|
||||
break
|
||||
|
||||
extruder.updateHotendID(extruder_data.get("print_core_id", ""))
|
||||
|
||||
material_data = extruder_data["material"]
|
||||
if extruder.activeMaterial is None or extruder.activeMaterial.guid != material_data["guid"]:
|
||||
material = self._createMaterialOutputModel(material_data)
|
||||
extruder.updateActiveMaterial(material)
|
||||
|
||||
def _removeJob(self, job: UM3PrintJobOutputModel) -> bool:
|
||||
if job not in self._print_jobs:
|
||||
return False
|
||||
|
||||
if job.assignedPrinter:
|
||||
job.assignedPrinter.updateActivePrintJob(None)
|
||||
job.stateChanged.disconnect(self._printJobStateChanged)
|
||||
self._print_jobs.remove(job)
|
||||
|
||||
return True
|
||||
|
||||
def _removePrinter(self, printer: PrinterOutputModel) -> None:
|
||||
self._printers.remove(printer)
|
||||
if self._active_printer == printer:
|
||||
self._active_printer = None
|
||||
self.activePrinterChanged.emit()
|
||||
|
||||
## Sync the material profiles in Cura with the printer.
|
||||
#
|
||||
# This gets called when connecting to a printer as well as when sending a
|
||||
# print.
|
||||
def sendMaterialProfiles(self) -> None:
|
||||
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"))
|
||||
except json.decoder.JSONDecodeError:
|
||||
Logger.logException("w", "Unable to decode JSON from reply.")
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def checkValidGetReply(reply: QNetworkReply) -> bool:
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
|
||||
if status_code != 200:
|
||||
Logger.log("w", "Got status code {status_code} while trying to get data".format(status_code=status_code))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def findByKey(lst: List[Union[UM3PrintJobOutputModel, PrinterOutputModel]], key: str) -> Optional[UM3PrintJobOutputModel]:
|
||||
for item in lst:
|
||||
if item.key == key:
|
||||
return item
|
||||
return None
|
|
@ -1,20 +0,0 @@
|
|||
# Copyright (c) 2017 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
|
||||
MYPY = False
|
||||
if MYPY:
|
||||
from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel
|
||||
|
||||
|
||||
class ClusterUM3PrinterOutputController(PrinterOutputController):
|
||||
def __init__(self, output_device):
|
||||
super().__init__(output_device)
|
||||
self.can_pre_heat_bed = False
|
||||
self.can_pre_heat_hotends = False
|
||||
self.can_control_manually = False
|
||||
self.can_send_raw_gcode = False
|
||||
|
||||
def setJobState(self, job: "PrintJobOutputModel", state: str) -> None:
|
||||
self._output_device.setJobState(job.key, state)
|
|
@ -1,179 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os.path
|
||||
import time
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtProperty, pyqtSlot, QObject
|
||||
|
||||
from UM.PluginRegistry import PluginRegistry
|
||||
from UM.Logger import Logger
|
||||
from UM.i18n import i18nCatalog
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.MachineAction import MachineAction
|
||||
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
|
||||
|
||||
from .UM3OutputDevicePlugin import UM3OutputDevicePlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
class DiscoverUM3Action(MachineAction):
|
||||
discoveredDevicesChanged = pyqtSignal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("DiscoverUM3Action", catalog.i18nc("@action","Connect via Network"))
|
||||
self._qml_url = "resources/qml/DiscoverUM3Action.qml"
|
||||
|
||||
self._network_plugin = None #type: Optional[UM3OutputDevicePlugin]
|
||||
|
||||
self.__additional_components_view = None #type: Optional[QObject]
|
||||
|
||||
CuraApplication.getInstance().engineCreatedSignal.connect(self._createAdditionalComponentsView)
|
||||
|
||||
self._last_zero_conf_event_time = time.time() #type: float
|
||||
|
||||
# Time to wait after a zero-conf service change before allowing a zeroconf reset
|
||||
self._zero_conf_change_grace_period = 0.25 #type: float
|
||||
|
||||
# Overrides the one in MachineAction.
|
||||
# This requires not attention from the user (any more), so we don't need to show any 'upgrade screens'.
|
||||
def needsUserInteraction(self) -> bool:
|
||||
return False
|
||||
|
||||
@pyqtSlot()
|
||||
def startDiscovery(self):
|
||||
if not self._network_plugin:
|
||||
Logger.log("d", "Starting device discovery.")
|
||||
self._network_plugin = CuraApplication.getInstance().getOutputDeviceManager().getOutputDevicePlugin("UM3NetworkPrinting")
|
||||
self._network_plugin.discoveredDevicesChanged.connect(self._onDeviceDiscoveryChanged)
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
## Re-filters the list of devices.
|
||||
@pyqtSlot()
|
||||
def reset(self):
|
||||
Logger.log("d", "Reset the list of found devices.")
|
||||
if self._network_plugin:
|
||||
self._network_plugin.resetLastManualDevice()
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
@pyqtSlot()
|
||||
def restartDiscovery(self):
|
||||
# Ensure that there is a bit of time after a printer has been discovered.
|
||||
# This is a work around for an issue with Qt 5.5.1 up to Qt 5.7 which can segfault if we do this too often.
|
||||
# It's most likely that the QML engine is still creating delegates, where the python side already deleted or
|
||||
# garbage collected the data.
|
||||
# Whatever the case, waiting a bit ensures that it doesn't crash.
|
||||
if time.time() - self._last_zero_conf_event_time > self._zero_conf_change_grace_period:
|
||||
if not self._network_plugin:
|
||||
self.startDiscovery()
|
||||
else:
|
||||
self._network_plugin.startDiscovery()
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def removeManualDevice(self, key, address):
|
||||
if not self._network_plugin:
|
||||
return
|
||||
|
||||
self._network_plugin.removeManualDevice(key, address)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def setManualDevice(self, key, address):
|
||||
if key != "":
|
||||
# This manual printer replaces a current manual printer
|
||||
self._network_plugin.removeManualDevice(key)
|
||||
|
||||
if address != "":
|
||||
self._network_plugin.addManualDevice(address)
|
||||
|
||||
def _onDeviceDiscoveryChanged(self, *args):
|
||||
self._last_zero_conf_event_time = time.time()
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
@pyqtProperty("QVariantList", notify = discoveredDevicesChanged)
|
||||
def foundDevices(self):
|
||||
if self._network_plugin:
|
||||
|
||||
printers = list(self._network_plugin.getDiscoveredDevices().values())
|
||||
printers.sort(key = lambda k: k.name)
|
||||
return printers
|
||||
else:
|
||||
return []
|
||||
|
||||
@pyqtSlot(str)
|
||||
def setGroupName(self, group_name: str) -> None:
|
||||
Logger.log("d", "Attempting to set the group name of the active machine to %s", group_name)
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack:
|
||||
# Update a GlobalStacks in the same group with the new group name.
|
||||
group_id = global_container_stack.getMetaDataEntry("group_id")
|
||||
machine_manager = CuraApplication.getInstance().getMachineManager()
|
||||
for machine in machine_manager.getMachinesInGroup(group_id):
|
||||
machine.setMetaDataEntry("group_name", group_name)
|
||||
|
||||
# Set the default value for "hidden", which is used when you have a group with multiple types of printers
|
||||
global_container_stack.setMetaDataEntry("hidden", False)
|
||||
|
||||
if self._network_plugin:
|
||||
# Ensure that the connection states are refreshed.
|
||||
self._network_plugin.refreshConnections()
|
||||
|
||||
# Associates the currently active machine with the given printer device. The network connection information will be
|
||||
# stored into the metadata of the currently active machine.
|
||||
@pyqtSlot(QObject)
|
||||
def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None:
|
||||
if self._network_plugin:
|
||||
self._network_plugin.associateActiveMachineWithPrinterDevice(printer_device)
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
def getStoredKey(self) -> str:
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack:
|
||||
meta_data = global_container_stack.getMetaData()
|
||||
if "um_network_key" in meta_data:
|
||||
return global_container_stack.getMetaDataEntry("um_network_key")
|
||||
|
||||
return ""
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
def getLastManualEntryKey(self) -> str:
|
||||
if self._network_plugin:
|
||||
return self._network_plugin.getLastManualDevice()
|
||||
return ""
|
||||
|
||||
@pyqtSlot(str, result = bool)
|
||||
def existsKey(self, key: str) -> bool:
|
||||
metadata_filter = {"um_network_key": key}
|
||||
containers = CuraContainerRegistry.getInstance().findContainerStacks(type="machine", **metadata_filter)
|
||||
return bool(containers)
|
||||
|
||||
@pyqtSlot()
|
||||
def loadConfigurationFromPrinter(self) -> None:
|
||||
machine_manager = CuraApplication.getInstance().getMachineManager()
|
||||
hotend_ids = machine_manager.printerOutputDevices[0].hotendIds
|
||||
for index in range(len(hotend_ids)):
|
||||
machine_manager.printerOutputDevices[0].hotendIdChanged.emit(index, hotend_ids[index])
|
||||
material_ids = machine_manager.printerOutputDevices[0].materialIds
|
||||
for index in range(len(material_ids)):
|
||||
machine_manager.printerOutputDevices[0].materialIdChanged.emit(index, material_ids[index])
|
||||
|
||||
def _createAdditionalComponentsView(self) -> None:
|
||||
Logger.log("d", "Creating additional ui components for UM3.")
|
||||
|
||||
# Create networking dialog
|
||||
plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting")
|
||||
if not plugin_path:
|
||||
return
|
||||
path = os.path.join(plugin_path, "resources/qml/UM3InfoComponents.qml")
|
||||
self.__additional_components_view = CuraApplication.getInstance().createQmlComponent(path, {"manager": self})
|
||||
if not self.__additional_components_view:
|
||||
Logger.log("w", "Could not create ui components for UM3.")
|
||||
return
|
||||
|
||||
# Create extra components
|
||||
CuraApplication.getInstance().addAdditionalComponent("monitorButtons", self.__additional_components_view.findChild(QObject, "networkPrinterConnectButton"))
|
39
plugins/UM3NetworkPrinting/src/ExportFileJob.py
Normal file
39
plugins/UM3NetworkPrinting/src/ExportFileJob.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.FileHandler.WriteFileJob import WriteFileJob
|
||||
from UM.Logger import Logger
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
from .MeshFormatHandler import MeshFormatHandler
|
||||
|
||||
|
||||
## Job that exports the build plate to the correct file format for the target cluster.
|
||||
class ExportFileJob(WriteFileJob):
|
||||
|
||||
def __init__(self, file_handler: Optional[FileHandler], nodes: List[SceneNode], firmware_version: str) -> None:
|
||||
|
||||
self._mesh_format_handler = MeshFormatHandler(file_handler, firmware_version)
|
||||
if not self._mesh_format_handler.is_valid:
|
||||
Logger.log("e", "Missing file or mesh writer!")
|
||||
return
|
||||
|
||||
super().__init__(self._mesh_format_handler.writer, self._mesh_format_handler.createStream(), nodes,
|
||||
self._mesh_format_handler.file_mode)
|
||||
|
||||
# Determine the filename.
|
||||
job_name = CuraApplication.getInstance().getPrintInformation().jobName
|
||||
extension = self._mesh_format_handler.preferred_format.get("extension", "")
|
||||
self.setFileName("{}.{}".format(job_name, extension))
|
||||
|
||||
## Get the mime type of the selected export file type.
|
||||
def getMimeType(self) -> str:
|
||||
return self._mesh_format_handler.mime_type
|
||||
|
||||
## Get the job result as bytes as that is what we need to upload to the cluster.
|
||||
def getOutput(self) -> bytes:
|
||||
output = self.getStream().getvalue()
|
||||
if isinstance(output, str):
|
||||
output = output.encode("utf-8")
|
||||
return output
|
|
@ -1,647 +0,0 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel
|
||||
from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel
|
||||
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from cura.Settings.ContainerManager import ContainerManager
|
||||
from cura.Settings.ExtruderManager import ExtruderManager
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.PluginRegistry import PluginRegistry
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Settings.ContainerRegistry import ContainerRegistry
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkRequest
|
||||
from PyQt5.QtCore import QTimer, QUrl
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
from .LegacyUM3PrinterOutputController import LegacyUM3PrinterOutputController
|
||||
|
||||
from time import time
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## This is the output device for the "Legacy" API of the UM3. All firmware before 4.0.1 uses this API.
|
||||
# Everything after that firmware uses the ClusterUM3Output.
|
||||
# The Legacy output device can only have one printer (whereas the cluster can have 0 to n).
|
||||
#
|
||||
# Authentication is done in a number of steps;
|
||||
# 1. Request an id / key pair by sending the application & user name. (state = authRequested)
|
||||
# 2. Machine sends this back and will display an approve / deny message on screen. (state = AuthReceived)
|
||||
# 3. OutputDevice will poll if the button was pressed.
|
||||
# 4. At this point the machine either has the state Authenticated or AuthenticationDenied.
|
||||
# 5. As a final step, we verify the authentication, as this forces the QT manager to setup the authenticator.
|
||||
class LegacyUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
def __init__(self, device_id, address: str, properties, parent = None) -> None:
|
||||
super().__init__(device_id = device_id, address = address, properties = properties, connection_type = ConnectionType.NetworkConnection, parent = parent)
|
||||
self._api_prefix = "/api/v1/"
|
||||
self._number_of_extruders = 2
|
||||
|
||||
self._authentication_id = None
|
||||
self._authentication_key = None
|
||||
|
||||
self._authentication_counter = 0
|
||||
self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min)
|
||||
|
||||
self._authentication_timer = QTimer()
|
||||
self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval
|
||||
self._authentication_timer.setSingleShot(False)
|
||||
|
||||
self._authentication_timer.timeout.connect(self._onAuthenticationTimer)
|
||||
|
||||
# The messages are created when connect is called the first time.
|
||||
# This ensures that the messages are only created for devices that actually want to connect.
|
||||
self._authentication_requested_message = None
|
||||
self._authentication_failed_message = None
|
||||
self._authentication_succeeded_message = None
|
||||
self._not_authenticated_message = None
|
||||
|
||||
self.authenticationStateChanged.connect(self._onAuthenticationStateChanged)
|
||||
|
||||
self.setPriority(3) # Make sure the output device gets selected above local file output
|
||||
self.setName(self._id)
|
||||
self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
|
||||
self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
|
||||
|
||||
self.setIconName("print")
|
||||
|
||||
self._output_controller = LegacyUM3PrinterOutputController(self)
|
||||
|
||||
def _createMonitorViewFromQML(self) -> None:
|
||||
if self._monitor_view_qml_path is None and PluginRegistry.getInstance() is not None:
|
||||
self._monitor_view_qml_path = os.path.join(
|
||||
PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"),
|
||||
"resources", "qml", "MonitorStage.qml"
|
||||
)
|
||||
super()._createMonitorViewFromQML()
|
||||
|
||||
def _onAuthenticationStateChanged(self):
|
||||
# We only accept commands if we are authenticated.
|
||||
self._setAcceptsCommands(self._authentication_state == AuthState.Authenticated)
|
||||
|
||||
if self._authentication_state == AuthState.Authenticated:
|
||||
self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network."))
|
||||
elif self._authentication_state == AuthState.AuthenticationRequested:
|
||||
self.setConnectionText(i18n_catalog.i18nc("@info:status",
|
||||
"Connected over the network. Please approve the access request on the printer."))
|
||||
elif self._authentication_state == AuthState.AuthenticationDenied:
|
||||
self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network. No access to control the printer."))
|
||||
|
||||
|
||||
def _setupMessages(self):
|
||||
self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status",
|
||||
"Access to the printer requested. Please approve the request on the printer"),
|
||||
lifetime=0, dismissable=False, progress=0,
|
||||
title=i18n_catalog.i18nc("@info:title",
|
||||
"Authentication status"))
|
||||
|
||||
self._authentication_failed_message = Message("", title=i18n_catalog.i18nc("@info:title", "Authentication Status"))
|
||||
self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None,
|
||||
i18n_catalog.i18nc("@info:tooltip", "Re-send the access request"))
|
||||
self._authentication_failed_message.actionTriggered.connect(self._messageCallback)
|
||||
self._authentication_succeeded_message = Message(
|
||||
i18n_catalog.i18nc("@info:status", "Access to the printer accepted"),
|
||||
title=i18n_catalog.i18nc("@info:title", "Authentication Status"))
|
||||
|
||||
self._not_authenticated_message = Message(
|
||||
i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job."),
|
||||
title=i18n_catalog.i18nc("@info:title", "Authentication Status"))
|
||||
self._not_authenticated_message.addAction("Request", i18n_catalog.i18nc("@action:button", "Request Access"),
|
||||
None, i18n_catalog.i18nc("@info:tooltip",
|
||||
"Send access request to the printer"))
|
||||
self._not_authenticated_message.actionTriggered.connect(self._messageCallback)
|
||||
|
||||
def _messageCallback(self, message_id=None, action_id="Retry"):
|
||||
if action_id == "Request" or action_id == "Retry":
|
||||
if self._authentication_failed_message:
|
||||
self._authentication_failed_message.hide()
|
||||
if self._not_authenticated_message:
|
||||
self._not_authenticated_message.hide()
|
||||
|
||||
self._requestAuthentication()
|
||||
|
||||
def connect(self):
|
||||
super().connect()
|
||||
self._setupMessages()
|
||||
global_container = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if global_container:
|
||||
self._authentication_id = global_container.getMetaDataEntry("network_authentication_id", None)
|
||||
self._authentication_key = global_container.getMetaDataEntry("network_authentication_key", None)
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
if self._authentication_requested_message:
|
||||
self._authentication_requested_message.hide()
|
||||
if self._authentication_failed_message:
|
||||
self._authentication_failed_message.hide()
|
||||
if self._authentication_succeeded_message:
|
||||
self._authentication_succeeded_message.hide()
|
||||
self._sending_gcode = False
|
||||
self._compressing_gcode = False
|
||||
self._authentication_timer.stop()
|
||||
|
||||
## Send all material profiles to the printer.
|
||||
def _sendMaterialProfiles(self):
|
||||
Logger.log("i", "Sending material profiles to printer")
|
||||
|
||||
# TODO: Might want to move this to a job...
|
||||
for container in ContainerRegistry.getInstance().findInstanceContainers(type="material"):
|
||||
try:
|
||||
xml_data = container.serialize()
|
||||
if xml_data == "" or xml_data is None:
|
||||
continue
|
||||
|
||||
names = ContainerManager.getInstance().getLinkedMaterials(container.getId())
|
||||
if names:
|
||||
# There are other materials that share this GUID.
|
||||
if not container.isReadOnly():
|
||||
continue # If it's not readonly, it's created by user, so skip it.
|
||||
|
||||
file_name = "none.xml"
|
||||
|
||||
self.postForm("materials", "form-data; name=\"file\";filename=\"%s\"" % file_name, xml_data.encode(), on_finished=None)
|
||||
|
||||
except NotImplementedError:
|
||||
# If the material container is not the most "generic" one it can't be serialized an will raise a
|
||||
# NotImplementedError. We can simply ignore these.
|
||||
pass
|
||||
|
||||
def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None:
|
||||
if not self.activePrinter:
|
||||
# No active printer. Unable to write
|
||||
return
|
||||
|
||||
if self.activePrinter.state not in ["idle", ""]:
|
||||
# Printer is not able to accept commands.
|
||||
return
|
||||
|
||||
if self._authentication_state != AuthState.Authenticated:
|
||||
# Not authenticated, so unable to send job.
|
||||
return
|
||||
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
gcode_dict = getattr(CuraApplication.getInstance().getController().getScene(), "gcode_dict", [])
|
||||
active_build_plate_id = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
|
||||
gcode_list = gcode_dict[active_build_plate_id]
|
||||
|
||||
if not gcode_list:
|
||||
# Unable to find g-code. Nothing to send
|
||||
return
|
||||
|
||||
self._gcode = gcode_list
|
||||
|
||||
errors = self._checkForErrors()
|
||||
if errors:
|
||||
text = i18n_catalog.i18nc("@label", "Unable to start a new print job.")
|
||||
informative_text = i18n_catalog.i18nc("@label",
|
||||
"There is an issue with the configuration of your Ultimaker, which makes it impossible to start the print. "
|
||||
"Please resolve this issues before continuing.")
|
||||
detailed_text = ""
|
||||
for error in errors:
|
||||
detailed_text += error + "\n"
|
||||
|
||||
CuraApplication.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"),
|
||||
text,
|
||||
informative_text,
|
||||
detailed_text,
|
||||
buttons=QMessageBox.Ok,
|
||||
icon=QMessageBox.Critical,
|
||||
callback = self._messageBoxCallback
|
||||
)
|
||||
return # Don't continue; Errors must block sending the job to the printer.
|
||||
|
||||
# There might be multiple things wrong with the configuration. Check these before starting.
|
||||
warnings = self._checkForWarnings()
|
||||
|
||||
if warnings:
|
||||
text = i18n_catalog.i18nc("@label", "Are you sure you wish to print with the selected configuration?")
|
||||
informative_text = i18n_catalog.i18nc("@label",
|
||||
"There is a mismatch between the configuration or calibration of the printer and Cura. "
|
||||
"For the best result, always slice for the PrintCores and materials that are inserted in your printer.")
|
||||
detailed_text = ""
|
||||
for warning in warnings:
|
||||
detailed_text += warning + "\n"
|
||||
|
||||
CuraApplication.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"),
|
||||
text,
|
||||
informative_text,
|
||||
detailed_text,
|
||||
buttons=QMessageBox.Yes + QMessageBox.No,
|
||||
icon=QMessageBox.Question,
|
||||
callback=self._messageBoxCallback
|
||||
)
|
||||
return
|
||||
|
||||
# No warnings or errors, so we're good to go.
|
||||
self._startPrint()
|
||||
|
||||
# Notify the UI that a switch to the print monitor should happen
|
||||
CuraApplication.getInstance().getController().setActiveStage("MonitorStage")
|
||||
|
||||
def _startPrint(self):
|
||||
Logger.log("i", "Sending print job to printer.")
|
||||
if self._sending_gcode:
|
||||
self._error_message = Message(
|
||||
i18n_catalog.i18nc("@info:status",
|
||||
"Sending new jobs (temporarily) blocked, still sending the previous print job."))
|
||||
self._error_message.show()
|
||||
return
|
||||
|
||||
self._sending_gcode = True
|
||||
|
||||
self._send_gcode_start = time()
|
||||
self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1,
|
||||
i18n_catalog.i18nc("@info:title", "Sending Data"))
|
||||
self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "")
|
||||
self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered)
|
||||
self._progress_message.show()
|
||||
|
||||
compressed_gcode = self._compressGCode()
|
||||
if compressed_gcode is None:
|
||||
# Abort was called.
|
||||
return
|
||||
|
||||
file_name = "%s.gcode.gz" % CuraApplication.getInstance().getPrintInformation().jobName
|
||||
self.postForm("print_job", "form-data; name=\"file\";filename=\"%s\"" % file_name, compressed_gcode,
|
||||
on_finished=self._onPostPrintJobFinished)
|
||||
|
||||
return
|
||||
|
||||
def _progressMessageActionTriggered(self, message_id=None, action_id=None):
|
||||
if action_id == "Abort":
|
||||
Logger.log("d", "User aborted sending print to remote.")
|
||||
self._progress_message.hide()
|
||||
self._compressing_gcode = False
|
||||
self._sending_gcode = False
|
||||
CuraApplication.getInstance().getController().setActiveStage("PrepareStage")
|
||||
|
||||
def _onPostPrintJobFinished(self, reply):
|
||||
self._progress_message.hide()
|
||||
self._sending_gcode = False
|
||||
|
||||
def _onUploadPrintJobProgress(self, bytes_sent, bytes_total):
|
||||
if bytes_total > 0:
|
||||
new_progress = bytes_sent / bytes_total * 100
|
||||
# Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
|
||||
# timeout responses if this happens.
|
||||
self._last_response_time = time()
|
||||
if new_progress > self._progress_message.getProgress():
|
||||
self._progress_message.show() # Ensure that the message is visible.
|
||||
self._progress_message.setProgress(bytes_sent / bytes_total * 100)
|
||||
else:
|
||||
self._progress_message.setProgress(0)
|
||||
|
||||
self._progress_message.hide()
|
||||
|
||||
def _messageBoxCallback(self, button):
|
||||
def delayedCallback():
|
||||
if button == QMessageBox.Yes:
|
||||
self._startPrint()
|
||||
else:
|
||||
CuraApplication.getInstance().getController().setActiveStage("PrepareStage")
|
||||
# For some unknown reason Cura on OSX will hang if we do the call back code
|
||||
# immediately without first returning and leaving QML's event system.
|
||||
|
||||
QTimer.singleShot(100, delayedCallback)
|
||||
|
||||
def _checkForErrors(self):
|
||||
errors = []
|
||||
print_information = CuraApplication.getInstance().getPrintInformation()
|
||||
if not print_information.materialLengths:
|
||||
Logger.log("w", "There is no material length information. Unable to check for errors.")
|
||||
return errors
|
||||
|
||||
for index, extruder in enumerate(self.activePrinter.extruders):
|
||||
# Due to airflow issues, both slots must be loaded, regardless if they are actually used or not.
|
||||
if extruder.hotendID == "":
|
||||
# No Printcore loaded.
|
||||
errors.append(i18n_catalog.i18nc("@info:status", "No Printcore loaded in slot {slot_number}".format(slot_number=index + 1)))
|
||||
|
||||
if index < len(print_information.materialLengths) and print_information.materialLengths[index] != 0:
|
||||
# The extruder is by this print.
|
||||
if extruder.activeMaterial is None:
|
||||
# No active material
|
||||
errors.append(i18n_catalog.i18nc("@info:status", "No material loaded in slot {slot_number}".format(slot_number=index + 1)))
|
||||
return errors
|
||||
|
||||
def _checkForWarnings(self):
|
||||
warnings = []
|
||||
print_information = CuraApplication.getInstance().getPrintInformation()
|
||||
|
||||
if not print_information.materialLengths:
|
||||
Logger.log("w", "There is no material length information. Unable to check for warnings.")
|
||||
return warnings
|
||||
|
||||
extruder_manager = ExtruderManager.getInstance()
|
||||
|
||||
for index, extruder in enumerate(self.activePrinter.extruders):
|
||||
if index < len(print_information.materialLengths) and print_information.materialLengths[index] != 0:
|
||||
# The extruder is by this print.
|
||||
|
||||
# TODO: material length check
|
||||
|
||||
# Check if the right Printcore is active.
|
||||
variant = extruder_manager.getExtruderStack(index).findContainer({"type": "variant"})
|
||||
if variant:
|
||||
if variant.getName() != extruder.hotendID:
|
||||
warnings.append(i18n_catalog.i18nc("@label", "Different PrintCore (Cura: {cura_printcore_name}, Printer: {remote_printcore_name}) selected for extruder {extruder_id}".format(cura_printcore_name = variant.getName(), remote_printcore_name = extruder.hotendID, extruder_id = index + 1)))
|
||||
else:
|
||||
Logger.log("w", "Unable to find variant.")
|
||||
|
||||
# Check if the right material is loaded.
|
||||
local_material = extruder_manager.getExtruderStack(index).findContainer({"type": "material"})
|
||||
if local_material:
|
||||
if extruder.activeMaterial.guid != local_material.getMetaDataEntry("GUID"):
|
||||
Logger.log("w", "Extruder %s has a different material (%s) as Cura (%s)", index + 1, extruder.activeMaterial.guid, local_material.getMetaDataEntry("GUID"))
|
||||
warnings.append(i18n_catalog.i18nc("@label", "Different material (Cura: {0}, Printer: {1}) selected for extruder {2}").format(local_material.getName(), extruder.activeMaterial.name, index + 1))
|
||||
else:
|
||||
Logger.log("w", "Unable to find material.")
|
||||
|
||||
return warnings
|
||||
|
||||
def _update(self):
|
||||
if not super()._update():
|
||||
return
|
||||
if self._authentication_state == AuthState.NotAuthenticated:
|
||||
if self._authentication_id is None and self._authentication_key is None:
|
||||
# This machine doesn't have any authentication, so request it.
|
||||
self._requestAuthentication()
|
||||
elif self._authentication_id is not None and self._authentication_key is not None:
|
||||
# We have authentication info, but we haven't checked it out yet. Do so now.
|
||||
self._verifyAuthentication()
|
||||
elif self._authentication_state == AuthState.AuthenticationReceived:
|
||||
# We have an authentication, but it's not confirmed yet.
|
||||
self._checkAuthentication()
|
||||
|
||||
# We don't need authentication for requesting info, so we can go right ahead with requesting this.
|
||||
self.get("printer", on_finished=self._onGetPrinterDataFinished)
|
||||
self.get("print_job", on_finished=self._onGetPrintJobFinished)
|
||||
|
||||
def _resetAuthenticationRequestedMessage(self):
|
||||
if self._authentication_requested_message:
|
||||
self._authentication_requested_message.hide()
|
||||
self._authentication_timer.stop()
|
||||
self._authentication_counter = 0
|
||||
|
||||
def _onAuthenticationTimer(self):
|
||||
self._authentication_counter += 1
|
||||
self._authentication_requested_message.setProgress(
|
||||
self._authentication_counter / self._max_authentication_counter * 100)
|
||||
if self._authentication_counter > self._max_authentication_counter:
|
||||
self._authentication_timer.stop()
|
||||
Logger.log("i", "Authentication timer ended. Setting authentication to denied for printer: %s" % self._id)
|
||||
self.setAuthenticationState(AuthState.AuthenticationDenied)
|
||||
self._resetAuthenticationRequestedMessage()
|
||||
self._authentication_failed_message.show()
|
||||
|
||||
def _verifyAuthentication(self):
|
||||
Logger.log("d", "Attempting to verify authentication")
|
||||
# This will ensure that the "_onAuthenticationRequired" is triggered, which will setup the authenticator.
|
||||
self.get("auth/verify", on_finished=self._onVerifyAuthenticationCompleted)
|
||||
|
||||
def _onVerifyAuthenticationCompleted(self, reply):
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
if status_code == 401:
|
||||
# Something went wrong; We somehow tried to verify authentication without having one.
|
||||
Logger.log("d", "Attempted to verify auth without having one.")
|
||||
self._authentication_id = None
|
||||
self._authentication_key = None
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
elif status_code == 403 and self._authentication_state != AuthState.Authenticated:
|
||||
# If we were already authenticated, we probably got an older message back all of the sudden. Drop that.
|
||||
Logger.log("d",
|
||||
"While trying to verify the authentication state, we got a forbidden response. Our own auth state was %s. ",
|
||||
self._authentication_state)
|
||||
self.setAuthenticationState(AuthState.AuthenticationDenied)
|
||||
self._authentication_failed_message.show()
|
||||
elif status_code == 200:
|
||||
self.setAuthenticationState(AuthState.Authenticated)
|
||||
|
||||
def _checkAuthentication(self):
|
||||
Logger.log("d", "Checking if authentication is correct for id %s and key %s", self._authentication_id, self._getSafeAuthKey())
|
||||
self.get("auth/check/" + str(self._authentication_id), on_finished=self._onCheckAuthenticationFinished)
|
||||
|
||||
def _onCheckAuthenticationFinished(self, reply):
|
||||
if str(self._authentication_id) not in reply.url().toString():
|
||||
Logger.log("w", "Got an old id response.")
|
||||
# Got response for old authentication ID.
|
||||
return
|
||||
try:
|
||||
data = json.loads(bytes(reply.readAll()).decode("utf-8"))
|
||||
except json.decoder.JSONDecodeError:
|
||||
Logger.log("w", "Received an invalid authentication check from printer: Not valid JSON.")
|
||||
return
|
||||
|
||||
if data.get("message", "") == "authorized":
|
||||
Logger.log("i", "Authentication was approved")
|
||||
self.setAuthenticationState(AuthState.Authenticated)
|
||||
self._saveAuthentication()
|
||||
|
||||
# Double check that everything went well.
|
||||
self._verifyAuthentication()
|
||||
|
||||
# Notify the user.
|
||||
self._resetAuthenticationRequestedMessage()
|
||||
self._authentication_succeeded_message.show()
|
||||
elif data.get("message", "") == "unauthorized":
|
||||
Logger.log("i", "Authentication was denied.")
|
||||
self.setAuthenticationState(AuthState.AuthenticationDenied)
|
||||
self._authentication_failed_message.show()
|
||||
|
||||
def _saveAuthentication(self) -> None:
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if self._authentication_key is None:
|
||||
Logger.log("e", "Authentication key is None, nothing to save.")
|
||||
return
|
||||
if self._authentication_id is None:
|
||||
Logger.log("e", "Authentication id is None, nothing to save.")
|
||||
return
|
||||
if global_container_stack:
|
||||
global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key)
|
||||
|
||||
global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id)
|
||||
|
||||
# Force save so we are sure the data is not lost.
|
||||
CuraApplication.getInstance().saveStack(global_container_stack)
|
||||
Logger.log("i", "Authentication succeeded for id %s and key %s", self._authentication_id,
|
||||
self._getSafeAuthKey())
|
||||
else:
|
||||
Logger.log("e", "Unable to save authentication for id %s and key %s", self._authentication_id,
|
||||
self._getSafeAuthKey())
|
||||
|
||||
def _onRequestAuthenticationFinished(self, reply):
|
||||
try:
|
||||
data = json.loads(bytes(reply.readAll()).decode("utf-8"))
|
||||
except json.decoder.JSONDecodeError:
|
||||
Logger.log("w", "Received an invalid authentication request reply from printer: Not valid JSON.")
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
return
|
||||
|
||||
self.setAuthenticationState(AuthState.AuthenticationReceived)
|
||||
self._authentication_id = data["id"]
|
||||
self._authentication_key = data["key"]
|
||||
Logger.log("i", "Got a new authentication ID (%s) and KEY (%s). Waiting for authorization.",
|
||||
self._authentication_id, self._getSafeAuthKey())
|
||||
|
||||
def _requestAuthentication(self):
|
||||
self._authentication_requested_message.show()
|
||||
self._authentication_timer.start()
|
||||
|
||||
# Reset any previous authentication info. If this isn't done, the "Retry" action on the failed message might
|
||||
# give issues.
|
||||
self._authentication_key = None
|
||||
self._authentication_id = None
|
||||
|
||||
self.post("auth/request",
|
||||
json.dumps({"application": "Cura-" + CuraApplication.getInstance().getVersion(),
|
||||
"user": self._getUserName()}),
|
||||
on_finished=self._onRequestAuthenticationFinished)
|
||||
|
||||
self.setAuthenticationState(AuthState.AuthenticationRequested)
|
||||
|
||||
def _onAuthenticationRequired(self, reply, authenticator):
|
||||
if self._authentication_id is not None and self._authentication_key is not None:
|
||||
Logger.log("d",
|
||||
"Authentication was required for printer: %s. Setting up authenticator with ID %s and key %s",
|
||||
self._id, self._authentication_id, self._getSafeAuthKey())
|
||||
authenticator.setUser(self._authentication_id)
|
||||
authenticator.setPassword(self._authentication_key)
|
||||
else:
|
||||
Logger.log("d", "No authentication is available to use for %s, but we did got a request for it.", self._id)
|
||||
|
||||
def _onGetPrintJobFinished(self, reply):
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
|
||||
if not self._printers:
|
||||
return # Ignore the data for now, we don't have info about a printer yet.
|
||||
printer = self._printers[0]
|
||||
|
||||
if status_code == 200:
|
||||
try:
|
||||
result = json.loads(bytes(reply.readAll()).decode("utf-8"))
|
||||
except json.decoder.JSONDecodeError:
|
||||
Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
|
||||
return
|
||||
if printer.activePrintJob is None:
|
||||
print_job = PrintJobOutputModel(output_controller=self._output_controller)
|
||||
printer.updateActivePrintJob(print_job)
|
||||
else:
|
||||
print_job = printer.activePrintJob
|
||||
print_job.updateState(result["state"])
|
||||
print_job.updateTimeElapsed(result["time_elapsed"])
|
||||
print_job.updateTimeTotal(result["time_total"])
|
||||
print_job.updateName(result["name"])
|
||||
elif status_code == 404:
|
||||
# No job found, so delete the active print job (if any!)
|
||||
printer.updateActivePrintJob(None)
|
||||
else:
|
||||
Logger.log("w",
|
||||
"Got status code {status_code} while trying to get printer data".format(status_code=status_code))
|
||||
|
||||
def materialHotendChangedMessage(self, callback):
|
||||
CuraApplication.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Sync with your printer"),
|
||||
i18n_catalog.i18nc("@label",
|
||||
"Would you like to use your current printer configuration in Cura?"),
|
||||
i18n_catalog.i18nc("@label",
|
||||
"The PrintCores and/or materials on your printer differ from those within your current project. For the best result, always slice for the PrintCores and materials that are inserted in your printer."),
|
||||
buttons=QMessageBox.Yes + QMessageBox.No,
|
||||
icon=QMessageBox.Question,
|
||||
callback=callback
|
||||
)
|
||||
|
||||
def _onGetPrinterDataFinished(self, reply):
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
if status_code == 200:
|
||||
try:
|
||||
result = json.loads(bytes(reply.readAll()).decode("utf-8"))
|
||||
except json.decoder.JSONDecodeError:
|
||||
Logger.log("w", "Received an invalid printer state message: Not valid JSON.")
|
||||
return
|
||||
|
||||
if not self._printers:
|
||||
# Quickest way to get the firmware version is to grab it from the zeroconf.
|
||||
firmware_version = self._properties.get(b"firmware_version", b"").decode("utf-8")
|
||||
self._printers = [PrinterOutputModel(output_controller=self._output_controller, number_of_extruders=self._number_of_extruders, firmware_version=firmware_version)]
|
||||
self._printers[0].setCameraUrl(QUrl("http://" + self._address + ":8080/?action=stream"))
|
||||
for extruder in self._printers[0].extruders:
|
||||
extruder.activeMaterialChanged.connect(self.materialIdChanged)
|
||||
extruder.hotendIDChanged.connect(self.hotendIdChanged)
|
||||
self.printersChanged.emit()
|
||||
|
||||
# LegacyUM3 always has a single printer.
|
||||
printer = self._printers[0]
|
||||
printer.updateBedTemperature(result["bed"]["temperature"]["current"])
|
||||
printer.updateTargetBedTemperature(result["bed"]["temperature"]["target"])
|
||||
printer.updateState(result["status"])
|
||||
|
||||
try:
|
||||
# If we're still handling the request, we should ignore remote for a bit.
|
||||
if not printer.getController().isPreheatRequestInProgress():
|
||||
printer.updateIsPreheating(result["bed"]["pre_heat"]["active"])
|
||||
except KeyError:
|
||||
# Older firmwares don't support preheating, so we need to fake it.
|
||||
pass
|
||||
|
||||
head_position = result["heads"][0]["position"]
|
||||
printer.updateHeadPosition(head_position["x"], head_position["y"], head_position["z"])
|
||||
|
||||
for index in range(0, self._number_of_extruders):
|
||||
temperatures = result["heads"][0]["extruders"][index]["hotend"]["temperature"]
|
||||
extruder = printer.extruders[index]
|
||||
extruder.updateTargetHotendTemperature(temperatures["target"])
|
||||
extruder.updateHotendTemperature(temperatures["current"])
|
||||
|
||||
material_guid = result["heads"][0]["extruders"][index]["active_material"]["guid"]
|
||||
|
||||
if extruder.activeMaterial is None or extruder.activeMaterial.guid != material_guid:
|
||||
# Find matching material (as we need to set brand, type & color)
|
||||
containers = ContainerRegistry.getInstance().findInstanceContainers(type="material",
|
||||
GUID=material_guid)
|
||||
if containers:
|
||||
color = containers[0].getMetaDataEntry("color_code")
|
||||
brand = containers[0].getMetaDataEntry("brand")
|
||||
material_type = containers[0].getMetaDataEntry("material")
|
||||
name = containers[0].getName()
|
||||
else:
|
||||
# Unknown material.
|
||||
color = "#00000000"
|
||||
brand = "Unknown"
|
||||
material_type = "Unknown"
|
||||
name = "Unknown"
|
||||
material = MaterialOutputModel(guid=material_guid, type=material_type,
|
||||
brand=brand, color=color, name = name)
|
||||
extruder.updateActiveMaterial(material)
|
||||
|
||||
try:
|
||||
hotend_id = result["heads"][0]["extruders"][index]["hotend"]["id"]
|
||||
except KeyError:
|
||||
hotend_id = ""
|
||||
printer.extruders[index].updateHotendID(hotend_id)
|
||||
|
||||
else:
|
||||
Logger.log("w",
|
||||
"Got status code {status_code} while trying to get printer data".format(status_code = status_code))
|
||||
|
||||
## Convenience function to "blur" out all but the last 5 characters of the auth key.
|
||||
# This can be used to debug print the key, without it compromising the security.
|
||||
def _getSafeAuthKey(self):
|
||||
if self._authentication_key is not None:
|
||||
result = self._authentication_key[-5:]
|
||||
result = "********" + result
|
||||
return result
|
||||
|
||||
return self._authentication_key
|
|
@ -1,96 +0,0 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
from PyQt5.QtCore import QTimer
|
||||
from UM.Version import Version
|
||||
|
||||
MYPY = False
|
||||
if MYPY:
|
||||
from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
|
||||
|
||||
class LegacyUM3PrinterOutputController(PrinterOutputController):
|
||||
def __init__(self, output_device):
|
||||
super().__init__(output_device)
|
||||
self._preheat_bed_timer = QTimer()
|
||||
self._preheat_bed_timer.setSingleShot(True)
|
||||
self._preheat_bed_timer.timeout.connect(self._onPreheatBedTimerFinished)
|
||||
self._preheat_printer = None
|
||||
|
||||
self.can_control_manually = False
|
||||
self.can_send_raw_gcode = False
|
||||
|
||||
# Are we still waiting for a response about preheat?
|
||||
# We need this so we can already update buttons, so it feels more snappy.
|
||||
self._preheat_request_in_progress = False
|
||||
|
||||
def isPreheatRequestInProgress(self):
|
||||
return self._preheat_request_in_progress
|
||||
|
||||
def setJobState(self, job: "PrintJobOutputModel", state: str):
|
||||
data = "{\"target\": \"%s\"}" % state
|
||||
self._output_device.put("print_job/state", data, on_finished=None)
|
||||
|
||||
def setTargetBedTemperature(self, printer: "PrinterOutputModel", temperature: float):
|
||||
data = str(temperature)
|
||||
self._output_device.put("printer/bed/temperature/target", data, on_finished = self._onPutBedTemperatureCompleted)
|
||||
|
||||
def _onPutBedTemperatureCompleted(self, reply):
|
||||
if Version(self._preheat_printer.firmwareVersion) < Version("3.5.92"):
|
||||
# If it was handling a preheat, it isn't anymore.
|
||||
self._preheat_request_in_progress = False
|
||||
|
||||
def _onPutPreheatBedCompleted(self, reply):
|
||||
self._preheat_request_in_progress = False
|
||||
|
||||
def moveHead(self, printer: "PrinterOutputModel", x, y, z, speed):
|
||||
head_pos = printer._head_position
|
||||
new_x = head_pos.x + x
|
||||
new_y = head_pos.y + y
|
||||
new_z = head_pos.z + z
|
||||
data = "{\n\"x\":%s,\n\"y\":%s,\n\"z\":%s\n}" %(new_x, new_y, new_z)
|
||||
self._output_device.put("printer/heads/0/position", data, on_finished=None)
|
||||
|
||||
def homeBed(self, printer):
|
||||
self._output_device.put("printer/heads/0/position/z", "0", on_finished=None)
|
||||
|
||||
def _onPreheatBedTimerFinished(self):
|
||||
self.setTargetBedTemperature(self._preheat_printer, 0)
|
||||
self._preheat_printer.updateIsPreheating(False)
|
||||
self._preheat_request_in_progress = True
|
||||
|
||||
def cancelPreheatBed(self, printer: "PrinterOutputModel"):
|
||||
self.preheatBed(printer, temperature=0, duration=0)
|
||||
self._preheat_bed_timer.stop()
|
||||
printer.updateIsPreheating(False)
|
||||
|
||||
def preheatBed(self, printer: "PrinterOutputModel", temperature, duration):
|
||||
try:
|
||||
temperature = round(temperature) # The API doesn't allow floating point.
|
||||
duration = round(duration)
|
||||
except ValueError:
|
||||
return # Got invalid values, can't pre-heat.
|
||||
|
||||
if duration > 0:
|
||||
data = """{"temperature": "%i", "timeout": "%i"}""" % (temperature, duration)
|
||||
else:
|
||||
data = """{"temperature": "%i"}""" % temperature
|
||||
|
||||
# Real bed pre-heating support is implemented from 3.5.92 and up.
|
||||
|
||||
if Version(printer.firmwareVersion) < Version("3.5.92"):
|
||||
# No firmware-side duration support then, so just set target bed temp and set a timer.
|
||||
self.setTargetBedTemperature(printer, temperature=temperature)
|
||||
self._preheat_bed_timer.setInterval(duration * 1000)
|
||||
self._preheat_bed_timer.start()
|
||||
self._preheat_printer = printer
|
||||
printer.updateIsPreheating(True)
|
||||
return
|
||||
|
||||
self._output_device.put("printer/bed/pre_heat", data, on_finished = self._onPutPreheatBedCompleted)
|
||||
printer.updateIsPreheating(True)
|
||||
self._preheat_request_in_progress = True
|
||||
|
||||
|
|
@ -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.
|
||||
import io
|
||||
from typing import Optional, Dict, Union, List, cast
|
||||
|
@ -32,7 +32,7 @@ class MeshFormatHandler:
|
|||
# \return A dict with the file format details, with the following keys:
|
||||
# {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool}
|
||||
@property
|
||||
def preferred_format(self) -> Optional[Dict[str, Union[str, int, bool]]]:
|
||||
def preferred_format(self) -> Dict[str, Union[str, int, bool]]:
|
||||
return self._preferred_format
|
||||
|
||||
## Gets the file writer for the given file handler and mime type.
|
||||
|
@ -90,6 +90,7 @@ class MeshFormatHandler:
|
|||
|
||||
machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";")
|
||||
machine_file_formats = [file_type.strip() for file_type in machine_file_formats]
|
||||
|
||||
# Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format.
|
||||
if "application/x-ufp" not in machine_file_formats and Version(firmware_version) >= Version("4.4"):
|
||||
machine_file_formats = ["application/x-ufp"] + machine_file_formats
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
|
||||
## Base model that maps kwargs to instance attributes.
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
self.validate()
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
## Class representing a material that was fetched from the cluster API.
|
||||
class ClusterMaterial(BaseModel):
|
||||
def __init__(self, guid: str, version: int, **kwargs) -> None:
|
||||
self.guid = guid # type: str
|
||||
self.version = version # type: int
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.guid:
|
||||
raise ValueError("guid is required on ClusterMaterial")
|
||||
if not self.version:
|
||||
raise ValueError("version is required on ClusterMaterial")
|
||||
|
||||
|
||||
## Class representing a local material that was fetched from the container registry.
|
||||
class LocalMaterial(BaseModel):
|
||||
def __init__(self, GUID: str, id: str, version: int, **kwargs) -> None:
|
||||
self.GUID = GUID # type: str
|
||||
self.id = id # type: str
|
||||
self.version = version # type: int
|
||||
super().__init__(**kwargs)
|
||||
|
||||
#
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
if not self.GUID:
|
||||
raise ValueError("guid is required on LocalMaterial")
|
||||
if not self.version:
|
||||
raise ValueError("version is required on LocalMaterial")
|
||||
if not self.id:
|
||||
raise ValueError("id is required on LocalMaterial")
|
|
@ -1,13 +1,23 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Union, TypeVar, Type, List, Any
|
||||
|
||||
from ...Models import BaseModel
|
||||
from typing import TypeVar, Dict, List, Any, Type, Union
|
||||
|
||||
|
||||
## Base class for the models used in the interface with the Ultimaker cloud APIs.
|
||||
class BaseCloudModel(BaseModel):
|
||||
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
|
||||
T = TypeVar("T", bound="BaseModel")
|
||||
|
||||
|
||||
class BaseModel:
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
self.validate()
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
pass
|
||||
|
||||
## Checks whether the two models are equal.
|
||||
# \param other: The other model.
|
||||
# \return True if they are equal, False if they are different.
|
||||
|
@ -24,9 +34,6 @@ class BaseCloudModel(BaseModel):
|
|||
def toDict(self) -> Dict[str, Any]:
|
||||
return self.__dict__
|
||||
|
||||
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
## Parses a single model.
|
||||
# \param model_class: The model class.
|
||||
# \param values: The value of the model, which is usually a dictionary, but may also be already parsed.
|
16
plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py
Normal file
16
plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
class ClusterMaterial(BaseModel):
|
||||
def __init__(self, guid: str, version: int, **kwargs) -> None:
|
||||
self.guid = guid # type: str
|
||||
self.version = version # type: int
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.guid:
|
||||
raise ValueError("guid is required on ClusterMaterial")
|
||||
if not self.version:
|
||||
raise ValueError("version is required on ClusterMaterial")
|
|
@ -1,17 +1,17 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from PyQt5.QtCore import pyqtProperty, QObject
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot
|
||||
|
||||
BLOCKING_CHANGE_TYPES = [
|
||||
"material_insert", "buildplate_change"
|
||||
]
|
||||
|
||||
|
||||
class ConfigurationChangeModel(QObject):
|
||||
def __init__(self, type_of_change: str, index: int, target_name: str, origin_name: str) -> None:
|
||||
super().__init__()
|
||||
self._type_of_change = type_of_change
|
||||
# enum = ["material", "print_core_change"]
|
||||
self._type_of_change = type_of_change # enum = ["material", "print_core_change"]
|
||||
self._can_override = self._type_of_change not in BLOCKING_CHANGE_TYPES
|
||||
self._index = index
|
||||
self._target_name = target_name
|
||||
|
@ -35,4 +35,4 @@ class ConfigurationChangeModel(QObject):
|
|||
|
||||
@pyqtProperty(bool, constant = True)
|
||||
def canOverride(self) -> bool:
|
||||
return self._can_override
|
||||
return self._can_override
|
|
@ -1,13 +1,13 @@
|
|||
# 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
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing a cloud connected cluster.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterResponse(BaseCloudModel):
|
||||
class CloudClusterResponse(BaseModel):
|
||||
|
||||
## Creates a new cluster response object.
|
||||
# \param cluster_id: The secret unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
|
||||
# \param host_guid: The unique identifier of the print cluster host, e.g. 'e90ae0ac-1257-4403-91ee-a44c9b7e8050'.
|
|
@ -1,26 +1,26 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Union, Any
|
||||
|
||||
from .CloudClusterPrinterStatus import CloudClusterPrinterStatus
|
||||
from .CloudClusterPrintJobStatus import CloudClusterPrintJobStatus
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
from .ClusterPrinterStatus import ClusterPrinterStatus
|
||||
from .ClusterPrintJobStatus import ClusterPrintJobStatus
|
||||
|
||||
|
||||
# Model that represents the status of the cluster for the cloud
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterStatus(BaseCloudModel):
|
||||
class CloudClusterStatus(BaseModel):
|
||||
|
||||
## Creates a new cluster status model object.
|
||||
# \param printers: The latest status of each printer in the cluster.
|
||||
# \param print_jobs: The latest status of each print job in the cluster.
|
||||
# \param generated_time: The datetime when the object was generated on the server-side.
|
||||
def __init__(self,
|
||||
printers: List[Union[CloudClusterPrinterStatus, Dict[str, Any]]],
|
||||
print_jobs: List[Union[CloudClusterPrintJobStatus, Dict[str, Any]]],
|
||||
printers: List[Union[ClusterPrinterStatus, Dict[str, Any]]],
|
||||
print_jobs: List[Union[ClusterPrintJobStatus, Dict[str, Any]]],
|
||||
generated_time: Union[str, datetime],
|
||||
**kwargs) -> None:
|
||||
self.generated_time = self.parseDate(generated_time)
|
||||
self.printers = self.parseModels(CloudClusterPrinterStatus, printers)
|
||||
self.print_jobs = self.parseModels(CloudClusterPrintJobStatus, print_jobs)
|
||||
self.printers = self.parseModels(ClusterPrinterStatus, printers)
|
||||
self.print_jobs = self.parseModels(ClusterPrintJobStatus, print_jobs)
|
||||
super().__init__(**kwargs)
|
|
@ -1,13 +1,13 @@
|
|||
# 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 Dict, Optional, Any
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing errors generated by the cloud servers, according to the JSON-API standard.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudError(BaseCloudModel):
|
||||
class CloudError(BaseModel):
|
||||
|
||||
## Creates a new error object.
|
||||
# \param id: Unique identifier for this particular occurrence of the problem.
|
||||
# \param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
|
|
@ -1,13 +1,13 @@
|
|||
# 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
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
# Model that represents the response received from the cloud after requesting to upload a print job
|
||||
# Spec: https://api-staging.ultimaker.com/cura/v1/spec
|
||||
class CloudPrintJobResponse(BaseCloudModel):
|
||||
class CloudPrintJobResponse(BaseModel):
|
||||
|
||||
## Creates a new print job response model.
|
||||
# \param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
|
||||
# \param status: The status of the print job.
|
||||
|
@ -28,6 +28,5 @@ class CloudPrintJobResponse(BaseCloudModel):
|
|||
self.upload_url = upload_url
|
||||
self.content_type = content_type
|
||||
self.status_description = status_description
|
||||
# TODO: Implement slicing details
|
||||
self.slicing_details = slicing_details
|
||||
super().__init__(**kwargs)
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
# Model that represents the request to upload a print job to the cloud
|
||||
# Spec: https://api-staging.ultimaker.com/cura/v1/spec
|
||||
class CloudPrintJobUploadRequest(BaseCloudModel):
|
||||
class CloudPrintJobUploadRequest(BaseModel):
|
||||
|
||||
## Creates a new print job upload request.
|
||||
# \param job_name: The name of the print job.
|
||||
# \param file_size: The size of the file in bytes.
|
|
@ -1,14 +1,14 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
# Model that represents the responses received from the cloud after requesting a job to be printed.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudPrintResponse(BaseCloudModel):
|
||||
class CloudPrintResponse(BaseModel):
|
||||
|
||||
## Creates a new print response object.
|
||||
# \param job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
|
||||
# \param status: The status of the print request (queued or failed).
|
|
@ -0,0 +1,13 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing a cluster printer
|
||||
class ClusterBuildPlate(BaseModel):
|
||||
|
||||
## Create a new build plate
|
||||
# \param type: The type of build plate glass or aluminium
|
||||
def __init__(self, type: str = "glass", **kwargs) -> None:
|
||||
self.type = type
|
||||
super().__init__(**kwargs)
|
|
@ -1,26 +1,27 @@
|
|||
# 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 Union, Dict, Optional, Any
|
||||
|
||||
from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel
|
||||
from cura.PrinterOutput.Models.ExtruderOutputModel import ExtruderOutputModel
|
||||
from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
from .ClusterPrinterConfigurationMaterial import ClusterPrinterConfigurationMaterial
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing a cloud cluster printer configuration
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintCoreConfiguration(BaseCloudModel):
|
||||
class ClusterPrintCoreConfiguration(BaseModel):
|
||||
|
||||
## Creates a new cloud cluster printer configuration object
|
||||
# \param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right.
|
||||
# \param material: The material of a configuration object in a cluster printer. May be in a dict or an object.
|
||||
# \param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'.
|
||||
# \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'.
|
||||
def __init__(self, extruder_index: int,
|
||||
material: Union[None, Dict[str, Any], CloudClusterPrinterConfigurationMaterial],
|
||||
material: Union[None, Dict[str, Any], ClusterPrinterConfigurationMaterial],
|
||||
print_core_id: Optional[str] = None, **kwargs) -> None:
|
||||
self.extruder_index = extruder_index
|
||||
self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) if material else None
|
||||
self.material = self.parseModel(ClusterPrinterConfigurationMaterial, material) if material else None
|
||||
self.print_core_id = print_core_id
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
# 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
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Model for the types of changes that are needed before a print job can start
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobConfigurationChange(BaseCloudModel):
|
||||
class ClusterPrintJobConfigurationChange(BaseModel):
|
||||
|
||||
## Creates a new print job constraint.
|
||||
# \param type_of_change: The type of configuration change, one of: "material", "print_core_change"
|
||||
# \param index: The hotend slot or extruder index to change
|
|
@ -1,13 +1,13 @@
|
|||
# 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
|
||||
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing a cloud cluster print job constraint
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobConstraints(BaseCloudModel):
|
||||
class ClusterPrintJobConstraints(BaseModel):
|
||||
|
||||
## Creates a new print job constraint.
|
||||
# \param require_printer_name: Unique name of the printer that this job should be printed on.
|
||||
# Should be one of the unique_name field values in the cluster, e.g. 'ultimakersystem-ccbdd30044ec'
|
|
@ -1,13 +1,14 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing the reasons that prevent this job from being printed on the associated printer
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobImpediment(BaseCloudModel):
|
||||
class ClusterPrintJobImpediment(BaseModel):
|
||||
|
||||
## Creates a new print job constraint.
|
||||
# \param translation_key: A string indicating a reason the print cannot be printed, such as 'does_not_fit_in_build_volume'
|
||||
# \param translation_key: A string indicating a reason the print cannot be printed,
|
||||
# such as 'does_not_fit_in_build_volume'
|
||||
# \param severity: A number indicating the severity of the problem, with higher being more severe
|
||||
def __init__(self, translation_key: str, severity: int, **kwargs) -> None:
|
||||
self.translation_key = translation_key
|
|
@ -1,22 +1,25 @@
|
|||
# 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 List, Optional, Union, Dict, Any
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
|
||||
from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel
|
||||
from ...UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from ...ConfigurationChangeModel import ConfigurationChangeModel
|
||||
from ..CloudOutputController import CloudOutputController
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
from .CloudClusterBuildPlate import CloudClusterBuildPlate
|
||||
from .CloudClusterPrintJobConfigurationChange import CloudClusterPrintJobConfigurationChange
|
||||
from .CloudClusterPrintJobImpediment import CloudClusterPrintJobImpediment
|
||||
from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration
|
||||
from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraints
|
||||
|
||||
from .ClusterBuildPlate import ClusterBuildPlate
|
||||
from .ClusterPrintJobConfigurationChange import ClusterPrintJobConfigurationChange
|
||||
from .ClusterPrintJobImpediment import ClusterPrintJobImpediment
|
||||
from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration
|
||||
from .ClusterPrintJobConstraint import ClusterPrintJobConstraints
|
||||
from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from ..ConfigurationChangeModel import ConfigurationChangeModel
|
||||
from ..BaseModel import BaseModel
|
||||
from ...ClusterOutputController import ClusterOutputController
|
||||
|
||||
|
||||
## Model for the status of a single print job in a cluster.
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrintJobStatus(BaseCloudModel):
|
||||
class ClusterPrintJobStatus(BaseModel):
|
||||
|
||||
## Creates a new cloud print job status model.
|
||||
# \param assigned_to: The name of the printer this job is assigned to while being queued.
|
||||
# \param configuration: The required print core configurations of this print job.
|
||||
|
@ -45,21 +48,21 @@ class CloudClusterPrintJobStatus(BaseCloudModel):
|
|||
# printer
|
||||
def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str,
|
||||
time_total: int, uuid: str,
|
||||
configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]],
|
||||
constraints: List[Union[Dict[str, Any], CloudClusterPrintJobConstraints]],
|
||||
configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]],
|
||||
constraints: List[Union[Dict[str, Any], ClusterPrintJobConstraints]],
|
||||
last_seen: Optional[float] = None, network_error_count: Optional[int] = None,
|
||||
owner: Optional[str] = None, printer_uuid: Optional[str] = None, time_elapsed: Optional[int] = None,
|
||||
assigned_to: Optional[str] = None, deleted_at: Optional[str] = None,
|
||||
printed_on_uuid: Optional[str] = None,
|
||||
configuration_changes_required: List[
|
||||
Union[Dict[str, Any], CloudClusterPrintJobConfigurationChange]] = None,
|
||||
build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None,
|
||||
Union[Dict[str, Any], ClusterPrintJobConfigurationChange]] = None,
|
||||
build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None,
|
||||
compatible_machine_families: List[str] = None,
|
||||
impediments_to_printing: List[Union[Dict[str, Any], CloudClusterPrintJobImpediment]] = None,
|
||||
impediments_to_printing: List[Union[Dict[str, Any], ClusterPrintJobImpediment]] = None,
|
||||
**kwargs) -> None:
|
||||
self.assigned_to = assigned_to
|
||||
self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration)
|
||||
self.constraints = self.parseModels(CloudClusterPrintJobConstraints, constraints)
|
||||
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration)
|
||||
self.constraints = self.parseModels(ClusterPrintJobConstraints, constraints)
|
||||
self.created_at = created_at
|
||||
self.force = force
|
||||
self.last_seen = last_seen
|
||||
|
@ -76,19 +79,19 @@ class CloudClusterPrintJobStatus(BaseCloudModel):
|
|||
self.deleted_at = deleted_at
|
||||
self.printed_on_uuid = printed_on_uuid
|
||||
|
||||
self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange,
|
||||
self.configuration_changes_required = self.parseModels(ClusterPrintJobConfigurationChange,
|
||||
configuration_changes_required) \
|
||||
if configuration_changes_required else []
|
||||
self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None
|
||||
self.build_plate = self.parseModel(ClusterBuildPlate, build_plate) if build_plate else None
|
||||
self.compatible_machine_families = compatible_machine_families if compatible_machine_families else []
|
||||
self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) \
|
||||
self.impediments_to_printing = self.parseModels(ClusterPrintJobImpediment, impediments_to_printing) \
|
||||
if impediments_to_printing else []
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
## Creates an UM3 print job output model based on this cloud cluster print job.
|
||||
# \param printer: The output model of the printer
|
||||
def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel:
|
||||
def createOutputModel(self, controller: ClusterOutputController) -> UM3PrintJobOutputModel:
|
||||
model = UM3PrintJobOutputModel(controller, self.uuid, self.name)
|
||||
self.updateOutputModel(model)
|
||||
return model
|
|
@ -1,14 +1,16 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional
|
||||
|
||||
from UM.Logger import Logger
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing a cloud cluster printer configuration
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrinterConfigurationMaterial(BaseCloudModel):
|
||||
## Class representing a cloud cluster printer configuration
|
||||
class ClusterPrinterConfigurationMaterial(BaseModel):
|
||||
|
||||
## Creates a new material configuration model.
|
||||
# \param brand: The brand of material in this print core, e.g. 'Ultimaker'.
|
||||
# \param color: The color of material in this print core, e.g. 'Blue'.
|
||||
|
@ -45,11 +47,9 @@ class CloudClusterPrinterConfigurationMaterial(BaseCloudModel):
|
|||
material_type = container.getMetaDataEntry("material")
|
||||
name = container.getName()
|
||||
else:
|
||||
Logger.log("w", "Unable to find material with guid {guid}. Using data as provided by cluster"
|
||||
.format(guid = self.guid))
|
||||
color = self.color
|
||||
brand = self.brand
|
||||
material_type = self.material
|
||||
name = "Empty" if self.material == "empty" else "Unknown"
|
||||
|
||||
return MaterialOutputModel(guid = self.guid, type = material_type, brand = brand, color = color, name = name)
|
||||
return MaterialOutputModel(guid=self.guid, type=material_type, brand=brand, color=color, name=name)
|
|
@ -1,17 +1,20 @@
|
|||
# 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 List, Union, Dict, Optional, Any
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
from .CloudClusterBuildPlate import CloudClusterBuildPlate
|
||||
from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration
|
||||
from .BaseCloudModel import BaseCloudModel
|
||||
|
||||
from .ClusterBuildPlate import ClusterBuildPlate
|
||||
from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing a cluster printer
|
||||
# Spec: https://api-staging.ultimaker.com/connect/v1/spec
|
||||
class CloudClusterPrinterStatus(BaseCloudModel):
|
||||
class ClusterPrinterStatus(BaseModel):
|
||||
|
||||
## Creates a new cluster printer status
|
||||
# \param enabled: A printer can be disabled if it should not receive new jobs. By default every printer is enabled.
|
||||
# \param firmware_version: Firmware version installed on the printer. Can differ for each printer in a cluster.
|
||||
|
@ -30,12 +33,12 @@ class CloudClusterPrinterStatus(BaseCloudModel):
|
|||
# \param build_plate: The build plate that is on the printer
|
||||
def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str,
|
||||
status: str, unique_name: str, uuid: str,
|
||||
configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]],
|
||||
configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]],
|
||||
reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None,
|
||||
firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None,
|
||||
build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None, **kwargs) -> None:
|
||||
build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None, **kwargs) -> None:
|
||||
|
||||
self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration)
|
||||
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration)
|
||||
self.enabled = enabled
|
||||
self.firmware_version = firmware_version
|
||||
self.friendly_name = friendly_name
|
||||
|
@ -48,7 +51,7 @@ class CloudClusterPrinterStatus(BaseCloudModel):
|
|||
self.maintenance_required = maintenance_required
|
||||
self.firmware_update_status = firmware_update_status
|
||||
self.latest_available_firmware = latest_available_firmware
|
||||
self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None
|
||||
self.build_plate = self.parseModel(ClusterBuildPlate, build_plate) if build_plate else None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
## Creates a new output model.
|
||||
|
@ -66,6 +69,7 @@ class CloudClusterPrinterStatus(BaseCloudModel):
|
|||
model.updateType(self.machine_variant)
|
||||
model.updateState(self.status if self.enabled else "disabled")
|
||||
model.updateBuildplate(self.build_plate.type if self.build_plate else "glass")
|
||||
model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address)))
|
||||
|
||||
for configuration, extruder_output, extruder_config in \
|
||||
zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations):
|
|
@ -0,0 +1,17 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from ..BaseModel import BaseModel
|
||||
|
||||
|
||||
## Class representing the system status of a printer.
|
||||
class PrinterSystemStatus(BaseModel):
|
||||
|
||||
def __init__(self, guid: str, firmware: str, hostname: str, name: str, platform: str, variant: str, **kwargs
|
||||
) -> None:
|
||||
self.guid = guid
|
||||
self.firmware = firmware
|
||||
self.hostname = hostname
|
||||
self.name = name
|
||||
self.platform = platform
|
||||
self.variant = variant
|
||||
super().__init__(**kwargs)
|
21
plugins/UM3NetworkPrinting/src/Models/LocalMaterial.py
Normal file
21
plugins/UM3NetworkPrinting/src/Models/LocalMaterial.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
class LocalMaterial(BaseModel):
|
||||
|
||||
def __init__(self, GUID: str, id: str, version: int, **kwargs) -> None:
|
||||
self.GUID = GUID # type: str
|
||||
self.id = id # type: str
|
||||
self.version = version # type: int
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
if not self.GUID:
|
||||
raise ValueError("guid is required on LocalMaterial")
|
||||
if not self.version:
|
||||
raise ValueError("version is required on LocalMaterial")
|
||||
if not self.id:
|
||||
raise ValueError("id is required on LocalMaterial")
|
|
@ -1,21 +1,22 @@
|
|||
# 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 List
|
||||
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal
|
||||
from PyQt5.QtGui import QImage
|
||||
|
||||
from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel
|
||||
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
||||
|
||||
from .ConfigurationChangeModel import ConfigurationChangeModel
|
||||
|
||||
|
||||
class UM3PrintJobOutputModel(PrintJobOutputModel):
|
||||
configurationChangesChanged = pyqtSignal()
|
||||
|
||||
def __init__(self, output_controller: "PrinterOutputController", key: str = "", name: str = "", parent=None) -> None:
|
||||
def __init__(self, output_controller: PrinterOutputController, key: str = "", name: str = "", parent=None) -> None:
|
||||
super().__init__(output_controller, key, name, parent)
|
||||
self._configuration_changes = [] # type: List[ConfigurationChangeModel]
|
||||
self._configuration_changes = [] # type: List[ConfigurationChangeModel]
|
||||
|
||||
@pyqtProperty("QVariantList", notify=configurationChangesChanged)
|
||||
def configurationChanges(self) -> List[ConfigurationChangeModel]:
|
||||
|
@ -26,3 +27,8 @@ class UM3PrintJobOutputModel(PrintJobOutputModel):
|
|||
return
|
||||
self._configuration_changes = changes
|
||||
self.configurationChangesChanged.emit()
|
||||
|
||||
def updatePreviewImageData(self, data: bytes) -> None:
|
||||
image = QImage()
|
||||
image.loadFromData(data)
|
||||
self.updatePreviewImage(image)
|
0
plugins/UM3NetworkPrinting/src/Models/__init__.py
Normal file
0
plugins/UM3NetworkPrinting/src/Models/__init__.py
Normal file
155
plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py
Normal file
155
plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
from typing import Callable, List, Optional, Dict, Union, Any, Type, cast, TypeVar, Tuple
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
from ..Models.BaseModel import BaseModel
|
||||
from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
|
||||
from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
|
||||
from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus
|
||||
|
||||
|
||||
## The generic type variable used to document the methods below.
|
||||
ClusterApiClientModel = TypeVar("ClusterApiClientModel", bound=BaseModel)
|
||||
|
||||
|
||||
## The ClusterApiClient is responsible for all network calls to local network clusters.
|
||||
class ClusterApiClient:
|
||||
|
||||
PRINTER_API_PREFIX = "/api/v1"
|
||||
CLUSTER_API_PREFIX = "/cluster-api/v1"
|
||||
|
||||
## Initializes a new cluster API client.
|
||||
# \param address: The network address of the cluster to call.
|
||||
# \param on_error: The callback to be called whenever we receive errors from the server.
|
||||
def __init__(self, address: str, on_error: Callable) -> None:
|
||||
super().__init__()
|
||||
self._manager = QNetworkAccessManager()
|
||||
self._address = address
|
||||
self._on_error = on_error
|
||||
# In order to avoid garbage collection we keep the callbacks in this list.
|
||||
self._anti_gc_callbacks = [] # type: List[Callable[[], None]]
|
||||
|
||||
## Get printer system information.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
def getSystem(self, on_finished: Callable) -> None:
|
||||
url = "{}/system/".format(self.PRINTER_API_PREFIX)
|
||||
reply = self._manager.get(self._createEmptyRequest(url))
|
||||
self._addCallback(reply, on_finished, PrinterSystemStatus)
|
||||
|
||||
## Get the printers in the cluster.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None:
|
||||
url = "{}/printers/".format(self.CLUSTER_API_PREFIX)
|
||||
reply = self._manager.get(self._createEmptyRequest(url))
|
||||
self._addCallback(reply, on_finished, ClusterPrinterStatus)
|
||||
|
||||
## Get the print jobs in the cluster.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
def getPrintJobs(self, on_finished: Callable[[List[ClusterPrintJobStatus]], Any]) -> None:
|
||||
url = "{}/print_jobs/".format(self.CLUSTER_API_PREFIX)
|
||||
reply = self._manager.get(self._createEmptyRequest(url))
|
||||
self._addCallback(reply, on_finished, ClusterPrintJobStatus)
|
||||
|
||||
## Move a print job to the top of the queue.
|
||||
def movePrintJobToTop(self, print_job_uuid: str) -> None:
|
||||
url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid)
|
||||
self._manager.post(self._createEmptyRequest(url), json.dumps({"to_position": 0, "list": "queued"}).encode())
|
||||
|
||||
## Delete a print job from the queue.
|
||||
def deletePrintJob(self, print_job_uuid: str) -> None:
|
||||
url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid)
|
||||
self._manager.deleteResource(self._createEmptyRequest(url))
|
||||
|
||||
## Set the state of a print job.
|
||||
def setPrintJobState(self, print_job_uuid: str, state: str) -> None:
|
||||
url = "{}/print_jobs/{}/action".format(self.CLUSTER_API_PREFIX, print_job_uuid)
|
||||
# We rewrite 'resume' to 'print' here because we are using the old print job action endpoints.
|
||||
action = "print" if state == "resume" else state
|
||||
self._manager.put(self._createEmptyRequest(url), json.dumps({"action": action}).encode())
|
||||
|
||||
## Get the preview image data of a print job.
|
||||
def getPrintJobPreviewImage(self, print_job_uuid: str, on_finished: Callable) -> None:
|
||||
url = "{}/print_jobs/{}/preview_image".format(self.CLUSTER_API_PREFIX, print_job_uuid)
|
||||
reply = self._manager.get(self._createEmptyRequest(url))
|
||||
self._addCallback(reply, on_finished)
|
||||
|
||||
## We override _createEmptyRequest in order to add the user credentials.
|
||||
# \param url: The URL to request
|
||||
# \param content_type: The type of the body contents.
|
||||
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
|
||||
url = QUrl("http://" + self._address + path)
|
||||
request = QNetworkRequest(url)
|
||||
if content_type:
|
||||
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
|
||||
return request
|
||||
|
||||
## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
|
||||
# \param reply: The reply from the server.
|
||||
# \return A tuple with a status code and a dictionary.
|
||||
@staticmethod
|
||||
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
try:
|
||||
response = bytes(reply.readAll()).decode()
|
||||
return status_code, json.loads(response)
|
||||
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
|
||||
Logger.logException("e", "Could not parse the cluster response: %s", err)
|
||||
return status_code, {"errors": [err]}
|
||||
|
||||
## Parses the given models and calls the correct callback depending on the result.
|
||||
# \param response: The response from the server, after being converted to a dict.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
# \param model_class: The type of the model to convert the response to. It may either be a single record or a list.
|
||||
def _parseModels(self, response: Dict[str, Any],
|
||||
on_finished: Union[Callable[[ClusterApiClientModel], Any],
|
||||
Callable[[List[ClusterApiClientModel]], Any]],
|
||||
model_class: Type[ClusterApiClientModel]) -> None:
|
||||
if isinstance(response, list):
|
||||
results = [model_class(**c) for c in response] # type: List[ClusterApiClientModel]
|
||||
on_finished_list = cast(Callable[[List[ClusterApiClientModel]], Any], on_finished)
|
||||
on_finished_list(results)
|
||||
else:
|
||||
result = model_class(**response) # type: ClusterApiClientModel
|
||||
on_finished_item = cast(Callable[[ClusterApiClientModel], Any], on_finished)
|
||||
on_finished_item(result)
|
||||
|
||||
## Creates a callback function so that it includes the parsing of the response into the correct model.
|
||||
# The callback is added to the 'finished' signal of the reply.
|
||||
# \param reply: The reply that should be listened to.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
def _addCallback(self,
|
||||
reply: QNetworkReply,
|
||||
on_finished: Union[Callable[[ClusterApiClientModel], Any],
|
||||
Callable[[List[ClusterApiClientModel]], Any]],
|
||||
model: Type[ClusterApiClientModel] = None,
|
||||
) -> None:
|
||||
|
||||
def parse() -> None:
|
||||
self._anti_gc_callbacks.remove(parse)
|
||||
|
||||
# Don't try to parse the reply if we didn't get one
|
||||
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
|
||||
return
|
||||
|
||||
if reply.error() > 0:
|
||||
self._on_error(reply.errorString())
|
||||
return
|
||||
|
||||
# If no parse model is given, simply return the raw data in the callback.
|
||||
if not model:
|
||||
on_finished(reply.readAll())
|
||||
return
|
||||
|
||||
# Otherwise parse the result and return the formatted data in the callback.
|
||||
status_code, response = self._parseReply(reply)
|
||||
self._parseModels(response, on_finished, model)
|
||||
|
||||
self._anti_gc_callbacks.append(parse)
|
||||
reply.finished.connect(parse)
|
|
@ -0,0 +1,172 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Message import Message
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState
|
||||
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from .ClusterApiClient import ClusterApiClient
|
||||
from ..ExportFileJob import ExportFileJob
|
||||
from ..SendMaterialJob import SendMaterialJob
|
||||
from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
||||
class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
|
||||
|
||||
activeCameraUrlChanged = pyqtSignal()
|
||||
|
||||
def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], parent=None) -> None:
|
||||
|
||||
super().__init__(
|
||||
device_id=device_id,
|
||||
address=address,
|
||||
properties=properties,
|
||||
connection_type=ConnectionType.NetworkConnection,
|
||||
parent=parent
|
||||
)
|
||||
|
||||
# API client for making requests to the print cluster.
|
||||
self._cluster_api = ClusterApiClient(address, on_error=lambda error: print(error))
|
||||
# We don't have authentication over local networking, so we're always authenticated.
|
||||
self.setAuthenticationState(AuthState.Authenticated)
|
||||
self._setInterfaceElements()
|
||||
self._active_camera_url = QUrl() # type: QUrl
|
||||
|
||||
## Set all the interface elements and texts for this output device.
|
||||
def _setInterfaceElements(self) -> None:
|
||||
self.setPriority(3) # Make sure the output device gets selected above local file output
|
||||
self.setName(self._id)
|
||||
self.setShortDescription(I18N_CATALOG.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
|
||||
self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print over network"))
|
||||
self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected over the network"))
|
||||
|
||||
## Called when the connection to the cluster changes.
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
self._update()
|
||||
self.sendMaterialProfiles()
|
||||
|
||||
@pyqtProperty(QUrl, notify=activeCameraUrlChanged)
|
||||
def activeCameraUrl(self) -> QUrl:
|
||||
return self._active_camera_url
|
||||
|
||||
@pyqtSlot(QUrl, name="setActiveCameraUrl")
|
||||
def setActiveCameraUrl(self, camera_url: QUrl) -> None:
|
||||
if self._active_camera_url != camera_url:
|
||||
self._active_camera_url = camera_url
|
||||
self.activeCameraUrlChanged.emit()
|
||||
|
||||
@pyqtSlot(name="openPrintJobControlPanel")
|
||||
def openPrintJobControlPanel(self) -> None:
|
||||
QDesktopServices.openUrl(QUrl("http://" + self._address + "/print_jobs"))
|
||||
|
||||
@pyqtSlot(name="openPrinterControlPanel")
|
||||
def openPrinterControlPanel(self) -> None:
|
||||
QDesktopServices.openUrl(QUrl("http://" + self._address + "/printers"))
|
||||
|
||||
@pyqtSlot(str, name="sendJobToTop")
|
||||
def sendJobToTop(self, print_job_uuid: str) -> None:
|
||||
self._cluster_api.movePrintJobToTop(print_job_uuid)
|
||||
|
||||
@pyqtSlot(str, name="deleteJobFromQueue")
|
||||
def deleteJobFromQueue(self, print_job_uuid: str) -> None:
|
||||
self._cluster_api.deletePrintJob(print_job_uuid)
|
||||
|
||||
@pyqtSlot(str, name="forceSendJob")
|
||||
def forceSendJob(self, print_job_uuid: str) -> None:
|
||||
pass # TODO
|
||||
|
||||
## Set the remote print job state.
|
||||
# \param print_job_uuid: The UUID of the print job to set the state for.
|
||||
# \param action: The action to undertake ('pause', 'resume', 'abort').
|
||||
def setJobState(self, print_job_uuid: str, action: str) -> None:
|
||||
self._cluster_api.setPrintJobState(print_job_uuid, action)
|
||||
|
||||
def _update(self) -> None:
|
||||
super()._update()
|
||||
self._cluster_api.getPrinters(self._updatePrinters)
|
||||
self._cluster_api.getPrintJobs(self._updatePrintJobs)
|
||||
self._updatePrintJobPreviewImages()
|
||||
|
||||
## Sync the material profiles in Cura with the printer.
|
||||
# This gets called when connecting to a printer as well as when sending a print.
|
||||
def sendMaterialProfiles(self) -> None:
|
||||
job = SendMaterialJob(device=self)
|
||||
job.run()
|
||||
|
||||
## Send a print job to the cluster.
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
|
||||
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
|
||||
|
||||
# Show an error message if we're already sending a job.
|
||||
if self._progress.visible:
|
||||
return Message(
|
||||
text=I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."),
|
||||
title=I18N_CATALOG.i18nc("@info:title", "Print error"),
|
||||
lifetime=10
|
||||
).show()
|
||||
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
# Make sure the printer is aware of all new materials as the new print job might contain one.
|
||||
self.sendMaterialProfiles()
|
||||
|
||||
# Export the scene to the correct file type.
|
||||
job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion)
|
||||
job.finished.connect(self._onPrintJobCreated)
|
||||
job.start()
|
||||
|
||||
## Handler for when the print job was created locally.
|
||||
# It can now be sent over the network.
|
||||
def _onPrintJobCreated(self, job: ExportFileJob) -> None:
|
||||
self._progress.show()
|
||||
parts = [
|
||||
self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"),
|
||||
self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), job.getOutput())
|
||||
]
|
||||
self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted,
|
||||
on_progress=self._onPrintJobUploadProgress)
|
||||
|
||||
## Handler for print job upload progress.
|
||||
def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None:
|
||||
percentage = (bytes_sent / bytes_total) if bytes_total else 0
|
||||
self._progress.setProgress(percentage * 100)
|
||||
self.writeProgress.emit()
|
||||
|
||||
## Handler for when the print job was fully uploaded to the cluster.
|
||||
def _onPrintUploadCompleted(self, _: QNetworkReply) -> None:
|
||||
self._progress.hide()
|
||||
Message(
|
||||
text=I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."),
|
||||
title=I18N_CATALOG.i18nc("@info:title", "Data Sent"),
|
||||
lifetime=5
|
||||
).show()
|
||||
self.writeFinished.emit()
|
||||
|
||||
## Displays the given message if uploading the mesh has failed
|
||||
# \param message: The message to display.
|
||||
def _onUploadError(self, message: str = None) -> None:
|
||||
self._progress.hide()
|
||||
Message(
|
||||
text=message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."),
|
||||
title=I18N_CATALOG.i18nc("@info:title", "Network error"),
|
||||
lifetime=10
|
||||
).show()
|
||||
self.writeError.emit()
|
||||
|
||||
## Download all the images from the cluster and load their data in the print job models.
|
||||
def _updatePrintJobPreviewImages(self):
|
||||
for print_job in self._print_jobs:
|
||||
if print_job.getPreviewImage() is None:
|
||||
self._cluster_api.getPrintJobPreviewImage(print_job.key, print_job.updatePreviewImageData)
|
|
@ -0,0 +1,205 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Dict, Optional, Callable
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Signal import Signal
|
||||
from UM.Version import Version
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
|
||||
from .ZeroConfClient import ZeroConfClient
|
||||
from .ClusterApiClient import ClusterApiClient
|
||||
from .LocalClusterOutputDevice import LocalClusterOutputDevice
|
||||
from ..CloudFlowMessage import CloudFlowMessage
|
||||
from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus
|
||||
|
||||
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
|
||||
## The LocalClusterOutputDeviceManager is responsible for discovering and managing local networked clusters.
|
||||
class LocalClusterOutputDeviceManager:
|
||||
|
||||
META_NETWORK_KEY = "um_network_key"
|
||||
|
||||
MANUAL_DEVICES_PREFERENCE_KEY = "um3networkprinting/manual_instances"
|
||||
MIN_SUPPORTED_CLUSTER_VERSION = Version("4.0.0")
|
||||
|
||||
# The translation catalog for this device.
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
|
||||
# Signal emitted when the list of discovered devices changed.
|
||||
discoveredDevicesChanged = Signal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
# Persistent dict containing the networked clusters.
|
||||
self._discovered_devices = {} # type: Dict[str, LocalClusterOutputDevice]
|
||||
self._output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
|
||||
|
||||
# Hook up ZeroConf client.
|
||||
self._zero_conf_client = ZeroConfClient()
|
||||
self._zero_conf_client.addedNetworkCluster.connect(self._onDeviceDiscovered)
|
||||
self._zero_conf_client.removedNetworkCluster.connect(self._onDiscoveredDeviceRemoved)
|
||||
|
||||
# Persistent dict containing manually connected clusters.
|
||||
self._manual_instances = {} # type: Dict[str, Optional[Callable]]
|
||||
|
||||
## Start the network discovery.
|
||||
def start(self) -> None:
|
||||
self._zero_conf_client.start()
|
||||
# Load all manual devices.
|
||||
self._manual_instances = self._getStoredManualInstances()
|
||||
for address in self._manual_instances:
|
||||
self.addManualDevice(address)
|
||||
|
||||
## Stop network discovery and clean up discovered devices.
|
||||
def stop(self) -> None:
|
||||
self._zero_conf_client.stop()
|
||||
# Cleanup all manual devices.
|
||||
for instance_name in list(self._discovered_devices):
|
||||
self._onDiscoveredDeviceRemoved(instance_name)
|
||||
|
||||
## Add a networked printer manually by address.
|
||||
def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None:
|
||||
self._manual_instances[address] = callback
|
||||
new_manual_devices = ",".join(self._manual_instances.keys())
|
||||
CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_manual_devices)
|
||||
api_client = ClusterApiClient(address, lambda error: print(error))
|
||||
api_client.getSystem(lambda status: self._onCheckManualDeviceResponse(address, status))
|
||||
|
||||
## Remove a manually added networked printer.
|
||||
def removeManualDevice(self, device_id: str, address: Optional[str] = None) -> None:
|
||||
if device_id not in self._discovered_devices and address is not None:
|
||||
device_id = "manual:{}".format(address)
|
||||
|
||||
if device_id in self._discovered_devices:
|
||||
address = address or self._discovered_devices[device_id].ipAddress
|
||||
self._onDiscoveredDeviceRemoved(device_id)
|
||||
|
||||
if address in self._manual_instances:
|
||||
manual_instance_callback = self._manual_instances.pop(address)
|
||||
new_devices = ",".join(self._manual_instances.keys())
|
||||
CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_devices)
|
||||
if manual_instance_callback:
|
||||
CuraApplication.getInstance().callLater(manual_instance_callback, False, address)
|
||||
|
||||
## Force reset all network device connections.
|
||||
def refreshConnections(self):
|
||||
self._connectToActiveMachine()
|
||||
|
||||
## Callback for when the active machine was changed by the user or a new remote cluster was found.
|
||||
def _connectToActiveMachine(self):
|
||||
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not active_machine:
|
||||
return
|
||||
|
||||
output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
|
||||
stored_device_id = active_machine.getMetaDataEntry(self.META_NETWORK_KEY)
|
||||
for device in self._discovered_devices.values():
|
||||
if device.key == stored_device_id:
|
||||
# Connect to it if the stored key matches.
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
elif device.key in output_device_manager.getOutputDeviceIds():
|
||||
# Remove device if it is not meant for the active machine.
|
||||
CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(device.key)
|
||||
|
||||
## Callback for when a manual device check request was responded to.
|
||||
def _onCheckManualDeviceResponse(self, address: str, status: PrinterSystemStatus) -> None:
|
||||
callback = self._manual_instances.get(address, None)
|
||||
if callback is None:
|
||||
return
|
||||
self._onDeviceDiscovered("manual:{}".format(address), address, {
|
||||
b"name": status.name.encode("utf-8"),
|
||||
b"address": address.encode("utf-8"),
|
||||
b"manual": b"true",
|
||||
b"incomplete": b"true",
|
||||
b"temporary": b"true"
|
||||
})
|
||||
CuraApplication.getInstance().callLater(callback, True, address)
|
||||
|
||||
## Returns a dict of printer BOM numbers to machine types.
|
||||
# These numbers are available in the machine definition already so we just search for them here.
|
||||
@staticmethod
|
||||
def _getPrinterTypeIdentifiers() -> Dict[str, str]:
|
||||
container_registry = CuraApplication.getInstance().getContainerRegistry()
|
||||
ultimaker_machines = container_registry.findContainersMetadata(type="machine", manufacturer="Ultimaker B.V.")
|
||||
found_machine_type_identifiers = {} # type: Dict[str, str]
|
||||
for machine in ultimaker_machines:
|
||||
machine_bom_number = machine.get("firmware_update_info", {}).get("id", None)
|
||||
machine_type = machine.get("id", None)
|
||||
if machine_bom_number and machine_type:
|
||||
found_machine_type_identifiers[str(machine_bom_number)] = machine_type
|
||||
return found_machine_type_identifiers
|
||||
|
||||
## Add a new device.
|
||||
def _onDeviceDiscovered(self, key: str, address: str, properties: Dict[bytes, bytes]) -> None:
|
||||
cluster_size = int(properties.get(b"cluster_size", -1))
|
||||
machine_identifier = properties.get(b"machine", b"").decode("utf-8")
|
||||
printer_type_identifiers = self._getPrinterTypeIdentifiers()
|
||||
|
||||
# Detect the machine type based on the BOM number that is sent over the network.
|
||||
properties[b"printer_type"] = b"Unknown"
|
||||
for bom, p_type in printer_type_identifiers.items():
|
||||
if machine_identifier.startswith(bom):
|
||||
properties[b"printer_type"] = bytes(p_type, encoding="utf8")
|
||||
break
|
||||
|
||||
# We no longer support legacy devices, so check that here.
|
||||
if cluster_size == -1:
|
||||
return
|
||||
|
||||
device = LocalClusterOutputDevice(key, address, properties)
|
||||
CuraApplication.getInstance().getDiscoveredPrintersModel().addDiscoveredPrinter(
|
||||
ip_address=address,
|
||||
key=device.getId(),
|
||||
name=device.getName(),
|
||||
create_callback=self._createMachineFromDiscoveredDevice,
|
||||
machine_type=device.printerType,
|
||||
device=device
|
||||
)
|
||||
self._discovered_devices[device.getId()] = device
|
||||
self.discoveredDevicesChanged.emit()
|
||||
self._connectToActiveMachine()
|
||||
|
||||
## Remove a device.
|
||||
def _onDiscoveredDeviceRemoved(self, device_id: str) -> None:
|
||||
device = self._discovered_devices.pop(device_id, None) # type: Optional[LocalClusterOutputDevice]
|
||||
if not device:
|
||||
return
|
||||
device.close()
|
||||
CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address)
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
## Create a machine instance based on the discovered network printer.
|
||||
def _createMachineFromDiscoveredDevice(self, device_id: str) -> None:
|
||||
device = self._discovered_devices.get(device_id)
|
||||
if device is None:
|
||||
return
|
||||
|
||||
# The newly added machine is automatically activated.
|
||||
CuraApplication.getInstance().getMachineManager().addMachine(device.printerType, device.name)
|
||||
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not active_machine:
|
||||
return
|
||||
active_machine.setMetaDataEntry(self.META_NETWORK_KEY, device.key)
|
||||
active_machine.setMetaDataEntry("group_name", device.name)
|
||||
self._connectToOutputDevice(device, active_machine)
|
||||
CloudFlowMessage(device.ipAddress).show() # Nudge the user to start using Ultimaker Cloud.
|
||||
|
||||
## Load the user-configured manual devices from Cura preferences.
|
||||
def _getStoredManualInstances(self) -> Dict[str, Optional[Callable]]:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
preferences.addPreference(self.MANUAL_DEVICES_PREFERENCE_KEY, "")
|
||||
manual_instances = preferences.getValue(self.MANUAL_DEVICES_PREFERENCE_KEY).split(",")
|
||||
return {address: None for address in manual_instances}
|
||||
|
||||
## Add a device to the current active machine.
|
||||
@staticmethod
|
||||
def _connectToOutputDevice(device: PrinterOutputDevice, active_machine: GlobalStack) -> None:
|
||||
device.connect()
|
||||
active_machine.addConfiguredConnectionType(device.connectionType.value)
|
||||
CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device)
|
140
plugins/UM3NetworkPrinting/src/Network/ZeroConfClient.py
Normal file
140
plugins/UM3NetworkPrinting/src/Network/ZeroConfClient.py
Normal file
|
@ -0,0 +1,140 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from queue import Queue
|
||||
from threading import Thread, Event
|
||||
from time import time
|
||||
from typing import Optional
|
||||
|
||||
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Signal import Signal
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
||||
## The ZeroConfClient handles all network discovery logic.
|
||||
# It emits signals when new network services were found or disappeared.
|
||||
class ZeroConfClient:
|
||||
|
||||
# The discovery protocol name for Ultimaker printers.
|
||||
ZERO_CONF_NAME = u"_ultimaker._tcp.local."
|
||||
|
||||
# Signals emitted when new services were discovered or removed on the network.
|
||||
addedNetworkCluster = Signal()
|
||||
removedNetworkCluster = Signal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._zero_conf = None # type: Optional[Zeroconf]
|
||||
self._zero_conf_browser = None # type: Optional[ServiceBrowser]
|
||||
self._service_changed_request_queue = None # type: Optional[Queue]
|
||||
self._service_changed_request_event = None # type: Optional[Event]
|
||||
self._service_changed_request_thread = None # type: Optional[Thread]
|
||||
|
||||
## The ZeroConf service changed requests are handled in a separate thread so we don't block the UI.
|
||||
# We can also re-schedule the requests when they fail to get detailed service info.
|
||||
# Any new or re-reschedule requests will be appended to the request queue and the thread will process them.
|
||||
def start(self) -> None:
|
||||
self._service_changed_request_queue = Queue()
|
||||
self._service_changed_request_event = Event()
|
||||
self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True)
|
||||
self._service_changed_request_thread.start()
|
||||
self._zero_conf = Zeroconf()
|
||||
self._zero_conf_browser = ServiceBrowser(self._zero_conf, self.ZERO_CONF_NAME, [self._queueService])
|
||||
|
||||
# Cleanup ZeroConf resources.
|
||||
def stop(self) -> None:
|
||||
if self._zero_conf is not None:
|
||||
self._zero_conf.close()
|
||||
self._zero_conf = None
|
||||
if self._zero_conf_browser is not None:
|
||||
self._zero_conf_browser.cancel()
|
||||
self._zero_conf_browser = None
|
||||
|
||||
## Handles a change is discovered network services.
|
||||
def _queueService(self, zeroconf: Zeroconf, service_type, name: str, state_change: ServiceStateChange) -> None:
|
||||
item = (zeroconf, service_type, name, state_change)
|
||||
if not self._service_changed_request_queue or not self._service_changed_request_event:
|
||||
return
|
||||
self._service_changed_request_queue.put(item)
|
||||
self._service_changed_request_event.set()
|
||||
|
||||
## Callback for when a ZeroConf service has changes.
|
||||
def _handleOnServiceChangedRequests(self) -> None:
|
||||
if not self._service_changed_request_queue or not self._service_changed_request_event:
|
||||
return
|
||||
|
||||
while True:
|
||||
# Wait for the event to be set
|
||||
self._service_changed_request_event.wait(timeout=5.0)
|
||||
|
||||
# Stop if the application is shutting down
|
||||
if CuraApplication.getInstance().isShuttingDown():
|
||||
return
|
||||
|
||||
self._service_changed_request_event.clear()
|
||||
|
||||
# Handle all pending requests
|
||||
reschedule_requests = [] # A list of requests that have failed so later they will get re-scheduled
|
||||
while not self._service_changed_request_queue.empty():
|
||||
request = self._service_changed_request_queue.get()
|
||||
zeroconf, service_type, name, state_change = request
|
||||
try:
|
||||
result = self._onServiceChanged(zeroconf, service_type, name, state_change)
|
||||
if not result:
|
||||
reschedule_requests.append(request)
|
||||
except Exception:
|
||||
Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled",
|
||||
service_type, name)
|
||||
reschedule_requests.append(request)
|
||||
|
||||
# Re-schedule the failed requests if any
|
||||
if reschedule_requests:
|
||||
for request in reschedule_requests:
|
||||
self._service_changed_request_queue.put(request)
|
||||
|
||||
## Handler for zeroConf detection.
|
||||
# Return True or False indicating if the process succeeded.
|
||||
# Note that this function can take over 3 seconds to complete. Be careful calling it from the main thread.
|
||||
def _onServiceChanged(self, zero_conf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange
|
||||
) -> bool:
|
||||
if state_change == ServiceStateChange.Added:
|
||||
return self._onServiceAdded(zero_conf, service_type, name)
|
||||
elif state_change == ServiceStateChange.Removed:
|
||||
return self._onServiceRemoved(name)
|
||||
return True
|
||||
|
||||
## Handler for when a ZeroConf service was added.
|
||||
def _onServiceAdded(self, zero_conf: Zeroconf, service_type: str, name: str) -> bool:
|
||||
# First try getting info from zero-conf cache
|
||||
info = ServiceInfo(service_type, name, properties={})
|
||||
for record in zero_conf.cache.entries_with_name(name.lower()):
|
||||
info.update_record(zero_conf, time(), record)
|
||||
|
||||
for record in zero_conf.cache.entries_with_name(info.server):
|
||||
info.update_record(zero_conf, time(), record)
|
||||
if info.address:
|
||||
break
|
||||
|
||||
# Request more data if info is not complete
|
||||
if not info.address:
|
||||
info = zero_conf.get_service_info(service_type, name)
|
||||
|
||||
if info:
|
||||
type_of_device = info.properties.get(b"type", None)
|
||||
if type_of_device:
|
||||
if type_of_device == b"printer":
|
||||
address = '.'.join(map(lambda n: str(n), info.address))
|
||||
self.addedNetworkCluster.emit(str(name), address, info.properties)
|
||||
else:
|
||||
Logger.log("w", "The type of the found device is '%s', not 'printer'." % type_of_device)
|
||||
else:
|
||||
Logger.log("w", "Could not get information about %s" % name)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
## Handler for when a ZeroConf service was removed.
|
||||
def _onServiceRemoved(self, name: str) -> bool:
|
||||
Logger.log("d", "ZeroConf service removed: %s" % name)
|
||||
self.removedNetworkCluster.emit(str(name))
|
||||
return True
|
0
plugins/UM3NetworkPrinting/src/Network/__init__.py
Normal file
0
plugins/UM3NetworkPrinting/src/Network/__init__.py
Normal 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.
|
||||
from UM import i18nCatalog
|
||||
from UM.Message import Message
|
||||
|
@ -8,11 +8,11 @@ I18N_CATALOG = i18nCatalog("cura")
|
|||
|
||||
|
||||
## Class responsible for showing a progress message while a mesh is being uploaded to the cloud.
|
||||
class CloudProgressMessage(Message):
|
||||
class PrintJobUploadProgressMessage(Message):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
title = I18N_CATALOG.i18nc("@info:status", "Sending Print Job"),
|
||||
text = I18N_CATALOG.i18nc("@info:status", "Uploading via Ultimaker Cloud"),
|
||||
text = I18N_CATALOG.i18nc("@info:status", "Uploading print job to printer."),
|
||||
progress = -1,
|
||||
lifetime = 0,
|
||||
dismissable = False,
|
|
@ -1,6 +1,5 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, TYPE_CHECKING, Set, Optional
|
||||
|
@ -10,11 +9,11 @@ from UM.Job import Job
|
|||
from UM.Logger import Logger
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
# Absolute imports don't work in plugins
|
||||
from .Models import ClusterMaterial, LocalMaterial
|
||||
from .Models.ClusterMaterial import ClusterMaterial
|
||||
from .Models.LocalMaterial import LocalMaterial
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ClusterUM3OutputDevice import ClusterUM3OutputDevice
|
||||
from .Network.LocalClusterOutputDevice import LocalClusterOutputDevice
|
||||
|
||||
|
||||
## Asynchronous job to send material profiles to the printer.
|
||||
|
@ -22,9 +21,9 @@ if TYPE_CHECKING:
|
|||
# This way it won't freeze up the interface while sending those materials.
|
||||
class SendMaterialJob(Job):
|
||||
|
||||
def __init__(self, device: "ClusterUM3OutputDevice") -> None:
|
||||
def __init__(self, device: "LocalClusterOutputDevice") -> None:
|
||||
super().__init__()
|
||||
self.device = device # type: ClusterUM3OutputDevice
|
||||
self.device = device # type: LocalClusterOutputDevice
|
||||
|
||||
## Send the request to the printer and register a callback
|
||||
def run(self) -> None:
|
||||
|
|
|
@ -1,657 +1,55 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
import os
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from time import time
|
||||
from typing import Optional, TYPE_CHECKING, Dict, Callable, Union, Any
|
||||
|
||||
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from typing import Optional, Callable
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.OutputDevice.OutputDeviceManager import ManualDeviceAdditionAttempt
|
||||
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
|
||||
from UM.PluginRegistry import PluginRegistry
|
||||
from UM.Signal import Signal, signalemitter
|
||||
from UM.Version import Version
|
||||
|
||||
from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
|
||||
from .Network.LocalClusterOutputDeviceManager import LocalClusterOutputDeviceManager
|
||||
from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager
|
||||
from .Cloud.CloudOutputDevice import CloudOutputDevice # typing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
|
||||
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
#
|
||||
# Represents a request for adding a manual printer. It has the following fields:
|
||||
# - address: The string of the (IP) address of the manual printer
|
||||
# - callback: (Optional) Once the HTTP request to the printer to get printer information is done, whether successful
|
||||
# or not, this callback will be invoked to notify about the result. The callback must have a signature of
|
||||
# func(success: bool, address: str) -> None
|
||||
# - network_reply: This is the QNetworkReply instance for this request if the request has been issued and still in
|
||||
# progress. It is kept here so we can cancel a request when needed.
|
||||
#
|
||||
class ManualPrinterRequest:
|
||||
def __init__(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None:
|
||||
self.address = address
|
||||
self.callback = callback
|
||||
self.network_reply = None # type: Optional["QNetworkReply"]
|
||||
|
||||
|
||||
## 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.
|
||||
# If we discover a printer that has the same key as the active machine instance a connection is made.
|
||||
@signalemitter
|
||||
## This plugin handles the discovery and networking for Ultimaker 3D printers that support network and cloud printing.
|
||||
class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
addDeviceSignal = Signal() # Called '...Signal' to avoid confusion with function-names.
|
||||
removeDeviceSignal = Signal() # Ditto ^^^.
|
||||
discoveredDevicesChanged = Signal()
|
||||
cloudFlowIsPossible = Signal()
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._zero_conf = None
|
||||
self._zero_conf_browser = None
|
||||
|
||||
self._application = CuraApplication.getInstance()
|
||||
# Create a network output device manager that abstracts all network connection logic away.
|
||||
self._network_output_device_manager = LocalClusterOutputDeviceManager()
|
||||
|
||||
# Create a cloud output device manager that abstracts all cloud connection logic away.
|
||||
self._cloud_output_device_manager = CloudOutputDeviceManager()
|
||||
|
||||
# Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
|
||||
self.addDeviceSignal.connect(self._onAddDevice)
|
||||
self.removeDeviceSignal.connect(self._onRemoveDevice)
|
||||
# Refresh network connections when another machine was selected in Cura.
|
||||
# This ensures no output devices are still connected that do not belong to the new active machine.
|
||||
CuraApplication.getInstance().globalContainerStackChanged.connect(self.refreshConnections)
|
||||
|
||||
self._application.globalContainerStackChanged.connect(self.refreshConnections)
|
||||
|
||||
self._discovered_devices = {}
|
||||
|
||||
self._network_manager = QNetworkAccessManager()
|
||||
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 + "/"
|
||||
self._cluster_api_version = "1"
|
||||
self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
|
||||
|
||||
# Get list of manual instances from preferences
|
||||
self._preferences = CuraApplication.getInstance().getPreferences()
|
||||
self._preferences.addPreference("um3networkprinting/manual_instances",
|
||||
"") # A comma-separated list of ip adresses or hostnames
|
||||
|
||||
manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
|
||||
self._manual_instances = {address: ManualPrinterRequest(address)
|
||||
for address in manual_instances} # type: Dict[str, ManualPrinterRequest]
|
||||
|
||||
# Store the last manual entry key
|
||||
self._last_manual_entry_key = "" # type: str
|
||||
|
||||
# The zero-conf service changed requests are handled in a separate thread, so we can re-schedule the requests
|
||||
# which fail to get detailed service info.
|
||||
# Any new or re-scheduled requests will be appended to the request queue, and the handling thread will pick
|
||||
# them up and process them.
|
||||
self._service_changed_request_queue = Queue()
|
||||
self._service_changed_request_event = Event()
|
||||
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
|
||||
|
||||
def getLastManualDevice(self) -> str:
|
||||
return self._last_manual_entry_key
|
||||
|
||||
def resetLastManualDevice(self) -> None:
|
||||
self._last_manual_entry_key = ""
|
||||
|
||||
## Start looking for devices on network.
|
||||
## Start looking for devices in the network and cloud.
|
||||
def start(self):
|
||||
self.startDiscovery()
|
||||
self._network_output_device_manager.start()
|
||||
self._cloud_output_device_manager.start()
|
||||
|
||||
def startDiscovery(self):
|
||||
self.stop()
|
||||
if self._zero_conf_browser:
|
||||
self._zero_conf_browser.cancel()
|
||||
self._zero_conf_browser = None # Force the old ServiceBrowser to be destroyed.
|
||||
|
||||
for instance_name in list(self._discovered_devices):
|
||||
self._onRemoveDevice(instance_name)
|
||||
|
||||
self._zero_conf = Zeroconf()
|
||||
self._zero_conf_browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.',
|
||||
[self._appendServiceChangedRequest])
|
||||
|
||||
# Look for manual instances from preference
|
||||
for address in self._manual_instances:
|
||||
if address:
|
||||
self.addManualDevice(address)
|
||||
self.resetLastManualDevice()
|
||||
|
||||
# TODO: CHANGE TO HOSTNAME
|
||||
def refreshConnections(self):
|
||||
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not active_machine:
|
||||
return
|
||||
|
||||
um_network_key = active_machine.getMetaDataEntry("um_network_key")
|
||||
|
||||
for key in self._discovered_devices:
|
||||
if key == um_network_key:
|
||||
if not self._discovered_devices[key].isConnected():
|
||||
Logger.log("d", "Attempting to connect with [%s]" % key)
|
||||
# It should already be set, but if it actually connects we know for sure it's supported!
|
||||
active_machine.addConfiguredConnectionType(self._discovered_devices[key].connectionType.value)
|
||||
self._discovered_devices[key].connect()
|
||||
self._discovered_devices[key].connectionStateChanged.connect(self._onDeviceConnectionStateChanged)
|
||||
else:
|
||||
self._onDeviceConnectionStateChanged(key)
|
||||
else:
|
||||
if self._discovered_devices[key].isConnected():
|
||||
Logger.log("d", "Attempting to close connection with [%s]" % key)
|
||||
self._discovered_devices[key].close()
|
||||
self._discovered_devices[key].connectionStateChanged.disconnect(self._onDeviceConnectionStateChanged)
|
||||
|
||||
def _onDeviceConnectionStateChanged(self, key):
|
||||
if key not in self._discovered_devices:
|
||||
return
|
||||
if self._discovered_devices[key].isConnected():
|
||||
# Sometimes the status changes after changing the global container and maybe the device doesn't belong to this machine
|
||||
um_network_key = CuraApplication.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key")
|
||||
if key == um_network_key:
|
||||
self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key])
|
||||
self.checkCloudFlowIsPossible(None)
|
||||
else:
|
||||
self.getOutputDeviceManager().removeOutputDevice(key)
|
||||
|
||||
def stop(self):
|
||||
if self._zero_conf is not None:
|
||||
Logger.log("d", "zeroconf close...")
|
||||
self._zero_conf.close()
|
||||
# Stop network and cloud discovery.
|
||||
def stop(self) -> None:
|
||||
self._network_output_device_manager.stop()
|
||||
self._cloud_output_device_manager.stop()
|
||||
|
||||
## Force refreshing the network connections.
|
||||
def refreshConnections(self) -> None:
|
||||
self._network_output_device_manager.refreshConnections()
|
||||
self._cloud_output_device_manager.refreshConnections()
|
||||
|
||||
## Indicate that this plugin supports adding networked printers manually.
|
||||
def canAddManualDevice(self, address: str = "") -> ManualDeviceAdditionAttempt:
|
||||
# This plugin should always be the fallback option (at least try it):
|
||||
return ManualDeviceAdditionAttempt.POSSIBLE
|
||||
|
||||
def removeManualDevice(self, key: str, address: Optional[str] = None) -> None:
|
||||
if key not in self._discovered_devices and address is not None:
|
||||
key = "manual:%s" % address
|
||||
|
||||
if key in self._discovered_devices:
|
||||
if not address:
|
||||
address = self._discovered_devices[key].ipAddress
|
||||
self._onRemoveDevice(key)
|
||||
self.resetLastManualDevice()
|
||||
|
||||
if address in self._manual_instances:
|
||||
manual_printer_request = self._manual_instances.pop(address)
|
||||
self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances.keys()))
|
||||
|
||||
if manual_printer_request.network_reply is not None:
|
||||
manual_printer_request.network_reply.abort()
|
||||
|
||||
if manual_printer_request.callback is not None:
|
||||
self._application.callLater(manual_printer_request.callback, False, address)
|
||||
return ManualDeviceAdditionAttempt.PRIORITY
|
||||
|
||||
## Add a networked printer manually based on its network address.
|
||||
def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None:
|
||||
self._manual_instances[address] = ManualPrinterRequest(address, callback = callback)
|
||||
self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances.keys()))
|
||||
self._network_output_device_manager.addManualDevice(address, callback)
|
||||
|
||||
instance_name = "manual:%s" % address
|
||||
properties = {
|
||||
b"name": address.encode("utf-8"),
|
||||
b"address": address.encode("utf-8"),
|
||||
b"manual": b"true",
|
||||
b"incomplete": b"true",
|
||||
b"temporary": b"true" # Still a temporary device until all the info is retrieved in _onNetworkRequestFinished
|
||||
}
|
||||
|
||||
if instance_name not in self._discovered_devices:
|
||||
# Add a preliminary printer instance
|
||||
self._onAddDevice(instance_name, address, properties)
|
||||
self._last_manual_entry_key = instance_name
|
||||
|
||||
reply = self._checkManualDevice(address)
|
||||
self._manual_instances[address].network_reply = reply
|
||||
|
||||
def _createMachineFromDiscoveredPrinter(self, key: str) -> None:
|
||||
discovered_device = self._discovered_devices.get(key)
|
||||
if discovered_device is None:
|
||||
Logger.log("e", "Could not find discovered device with key [%s]", key)
|
||||
return
|
||||
|
||||
group_name = discovered_device.getProperty("name")
|
||||
machine_type_id = discovered_device.getProperty("printer_type")
|
||||
|
||||
Logger.log("i", "Creating machine from network device with key = [%s], group name = [%s], printer type = [%s]",
|
||||
key, group_name, machine_type_id)
|
||||
|
||||
self._application.getMachineManager().addMachine(machine_type_id, group_name)
|
||||
# connect the new machine to that network printer
|
||||
self.associateActiveMachineWithPrinterDevice(discovered_device)
|
||||
# ensure that the connection states are refreshed.
|
||||
self.refreshConnections()
|
||||
|
||||
def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None:
|
||||
if not printer_device:
|
||||
return
|
||||
|
||||
Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key)
|
||||
|
||||
machine_manager = CuraApplication.getInstance().getMachineManager()
|
||||
global_container_stack = machine_manager.activeMachine
|
||||
if not global_container_stack:
|
||||
return
|
||||
|
||||
for machine in machine_manager.getMachinesInGroup(global_container_stack.getMetaDataEntry("group_id")):
|
||||
machine.setMetaDataEntry("um_network_key", printer_device.key)
|
||||
machine.setMetaDataEntry("group_name", printer_device.name)
|
||||
|
||||
# Delete old authentication data.
|
||||
Logger.log("d", "Removing old authentication id %s for device %s",
|
||||
global_container_stack.getMetaDataEntry("network_authentication_id", None), printer_device.key)
|
||||
|
||||
machine.removeMetaDataEntry("network_authentication_id")
|
||||
machine.removeMetaDataEntry("network_authentication_key")
|
||||
|
||||
# Ensure that these containers do know that they are configured for network connection
|
||||
machine.addConfiguredConnectionType(printer_device.connectionType.value)
|
||||
|
||||
self.refreshConnections()
|
||||
|
||||
def _checkManualDevice(self, address: str) -> "QNetworkReply":
|
||||
# Check if a UM3 family device exists at this address.
|
||||
# If a printer responds, it will replace the preliminary printer created above
|
||||
# origin=manual is for tracking back the origin of the call
|
||||
url = QUrl("http://" + address + self._api_prefix + "system")
|
||||
name_request = QNetworkRequest(url)
|
||||
return self._network_manager.get(name_request)
|
||||
|
||||
def _onNetworkRequestFinished(self, reply: "QNetworkReply") -> None:
|
||||
reply_url = reply.url().toString()
|
||||
|
||||
address = reply.url().host()
|
||||
device = None
|
||||
properties = {} # type: Dict[bytes, bytes]
|
||||
|
||||
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
|
||||
# Either:
|
||||
# - Something went wrong with checking the firmware version!
|
||||
# - Something went wrong with checking the amount of printers the cluster has!
|
||||
# - Couldn't find printer at the address when trying to add it manually.
|
||||
if address in self._manual_instances:
|
||||
key = "manual:" + address
|
||||
self.removeManualDevice(key, address)
|
||||
return
|
||||
|
||||
if "system" in reply_url:
|
||||
try:
|
||||
system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
|
||||
except:
|
||||
Logger.log("e", "Something went wrong converting the JSON.")
|
||||
return
|
||||
|
||||
if address in self._manual_instances:
|
||||
manual_printer_request = self._manual_instances[address]
|
||||
manual_printer_request.network_reply = None
|
||||
if manual_printer_request.callback is not None:
|
||||
self._application.callLater(manual_printer_request.callback, True, address)
|
||||
|
||||
has_cluster_capable_firmware = Version(system_info["firmware"]) > self._min_cluster_version
|
||||
instance_name = "manual:%s" % address
|
||||
properties = {
|
||||
b"name": (system_info["name"] + " (manual)").encode("utf-8"),
|
||||
b"address": address.encode("utf-8"),
|
||||
b"firmware_version": system_info["firmware"].encode("utf-8"),
|
||||
b"manual": b"true",
|
||||
b"machine": str(system_info['hardware']["typeid"]).encode("utf-8")
|
||||
}
|
||||
|
||||
if has_cluster_capable_firmware:
|
||||
# Cluster needs an additional request, before it's completed.
|
||||
properties[b"incomplete"] = b"true"
|
||||
|
||||
# Check if the device is still in the list & re-add it with the updated
|
||||
# information.
|
||||
if instance_name in self._discovered_devices:
|
||||
self._onRemoveDevice(instance_name)
|
||||
self._onAddDevice(instance_name, address, properties)
|
||||
|
||||
if has_cluster_capable_firmware:
|
||||
# We need to request more info in order to figure out the size of the cluster.
|
||||
cluster_url = QUrl("http://" + address + self._cluster_api_prefix + "printers/")
|
||||
cluster_request = QNetworkRequest(cluster_url)
|
||||
self._network_manager.get(cluster_request)
|
||||
|
||||
elif "printers" in reply_url:
|
||||
# So we confirmed that the device is in fact a cluster printer, and we should now know how big it is.
|
||||
try:
|
||||
cluster_printers_list = json.loads(bytes(reply.readAll()).decode("utf-8"))
|
||||
except:
|
||||
Logger.log("e", "Something went wrong converting the JSON.")
|
||||
return
|
||||
instance_name = "manual:%s" % address
|
||||
if instance_name in self._discovered_devices:
|
||||
device = self._discovered_devices[instance_name]
|
||||
properties = device.getProperties().copy()
|
||||
if b"incomplete" in properties:
|
||||
del properties[b"incomplete"]
|
||||
properties[b"cluster_size"] = str(len(cluster_printers_list)).encode("utf-8")
|
||||
self._onRemoveDevice(instance_name)
|
||||
self._onAddDevice(instance_name, address, properties)
|
||||
|
||||
def _onRemoveDevice(self, device_id: str) -> None:
|
||||
device = self._discovered_devices.pop(device_id, None)
|
||||
if device:
|
||||
if device.isConnected():
|
||||
device.disconnect()
|
||||
try:
|
||||
device.connectionStateChanged.disconnect(self._onDeviceConnectionStateChanged)
|
||||
except TypeError:
|
||||
# Disconnect already happened.
|
||||
pass
|
||||
self._application.getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address)
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
## Returns a dict of printer BOM numbers to machine types.
|
||||
# These numbers are available in the machine definition already so we just search for them here.
|
||||
def _getPrinterTypeIdentifiers(self) -> Dict[str, str]:
|
||||
container_registry = self._application.getContainerRegistry()
|
||||
ultimaker_machines = container_registry.findContainersMetadata(type="machine", manufacturer="Ultimaker B.V.")
|
||||
|
||||
found_machine_type_identifiers = {} # type: Dict[str, str]
|
||||
for machine in ultimaker_machines:
|
||||
machine_bom_number = machine.get("firmware_update_info", {}).get("id", None)
|
||||
machine_type = machine.get("id", None)
|
||||
if machine_bom_number and machine_type:
|
||||
found_machine_type_identifiers[str(machine_bom_number)] = machine_type
|
||||
|
||||
return found_machine_type_identifiers
|
||||
|
||||
def _onAddDevice(self, name, address, properties):
|
||||
# Check what kind of device we need to add; Depending on the firmware we either add a "Connect"/"Cluster"
|
||||
# or "Legacy" UM3 device.
|
||||
cluster_size = int(properties.get(b"cluster_size", -1))
|
||||
|
||||
printer_type = properties.get(b"machine", b"").decode("utf-8")
|
||||
printer_type_identifiers = self._getPrinterTypeIdentifiers()
|
||||
|
||||
for key, value in printer_type_identifiers.items():
|
||||
if printer_type.startswith(key):
|
||||
properties[b"printer_type"] = bytes(value, encoding="utf8")
|
||||
break
|
||||
else:
|
||||
properties[b"printer_type"] = b"Unknown"
|
||||
if cluster_size >= 0:
|
||||
device = ClusterUM3OutputDevice.ClusterUM3OutputDevice(name, address, properties)
|
||||
else:
|
||||
device = LegacyUM3OutputDevice.LegacyUM3OutputDevice(name, address, properties)
|
||||
self._application.getDiscoveredPrintersModel().addDiscoveredPrinter(
|
||||
address, device.getId(), properties[b"name"].decode("utf-8"), self._createMachineFromDiscoveredPrinter,
|
||||
properties[b"printer_type"].decode("utf-8"), device)
|
||||
self._discovered_devices[device.getId()] = device
|
||||
self.discoveredDevicesChanged.emit()
|
||||
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"):
|
||||
# Ensure that the configured connection type is set.
|
||||
global_container_stack.addConfiguredConnectionType(device.connectionType.value)
|
||||
device.connect()
|
||||
device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged)
|
||||
|
||||
## Appends a service changed request so later the handling thread will pick it up and processes it.
|
||||
def _appendServiceChangedRequest(self, zeroconf, service_type, name, state_change):
|
||||
# append the request and set the event so the event handling thread can pick it up
|
||||
item = (zeroconf, service_type, name, state_change)
|
||||
self._service_changed_request_queue.put(item)
|
||||
self._service_changed_request_event.set()
|
||||
|
||||
def _handleOnServiceChangedRequests(self):
|
||||
while True:
|
||||
# Wait for the event to be set
|
||||
self._service_changed_request_event.wait(timeout = 5.0)
|
||||
|
||||
# Stop if the application is shutting down
|
||||
if CuraApplication.getInstance().isShuttingDown():
|
||||
return
|
||||
|
||||
self._service_changed_request_event.clear()
|
||||
|
||||
# Handle all pending requests
|
||||
reschedule_requests = [] # A list of requests that have failed so later they will get re-scheduled
|
||||
while not self._service_changed_request_queue.empty():
|
||||
request = self._service_changed_request_queue.get()
|
||||
zeroconf, service_type, name, state_change = request
|
||||
try:
|
||||
result = self._onServiceChanged(zeroconf, service_type, name, state_change)
|
||||
if not result:
|
||||
reschedule_requests.append(request)
|
||||
except Exception:
|
||||
Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled",
|
||||
service_type, name)
|
||||
reschedule_requests.append(request)
|
||||
|
||||
# Re-schedule the failed requests if any
|
||||
if reschedule_requests:
|
||||
for request in reschedule_requests:
|
||||
self._service_changed_request_queue.put(request)
|
||||
|
||||
## Handler for zeroConf detection.
|
||||
# Return True or False indicating if the process succeeded.
|
||||
# Note that this function can take over 3 seconds to complete. Be careful
|
||||
# calling it from the main thread.
|
||||
def _onServiceChanged(self, zero_conf, service_type, name, state_change):
|
||||
if state_change == ServiceStateChange.Added:
|
||||
# First try getting info from zero-conf cache
|
||||
info = ServiceInfo(service_type, name, properties = {})
|
||||
for record in zero_conf.cache.entries_with_name(name.lower()):
|
||||
info.update_record(zero_conf, time(), record)
|
||||
|
||||
for record in zero_conf.cache.entries_with_name(info.server):
|
||||
info.update_record(zero_conf, time(), record)
|
||||
if info.address:
|
||||
break
|
||||
|
||||
# Request more data if info is not complete
|
||||
if not info.address:
|
||||
info = zero_conf.get_service_info(service_type, name)
|
||||
|
||||
if info:
|
||||
type_of_device = info.properties.get(b"type", None)
|
||||
if type_of_device:
|
||||
if type_of_device == b"printer":
|
||||
address = '.'.join(map(lambda n: str(n), info.address))
|
||||
self.addDeviceSignal.emit(str(name), address, info.properties)
|
||||
else:
|
||||
Logger.log("w",
|
||||
"The type of the found device is '%s', not 'printer'! Ignoring.." % type_of_device)
|
||||
else:
|
||||
Logger.log("w", "Could not get information about %s" % name)
|
||||
return False
|
||||
|
||||
elif state_change == ServiceStateChange.Removed:
|
||||
Logger.log("d", "Bonjour service removed: %s" % name)
|
||||
self.removeDeviceSignal.emit(str(name))
|
||||
|
||||
return True
|
||||
|
||||
## Check if the prerequsites are in place to start the cloud flow
|
||||
def checkCloudFlowIsPossible(self, cluster: Optional[CloudOutputDevice]) -> 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 1A: 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 1B: Printer isn't already configured for cloud
|
||||
if active_machine.getMetaDataEntry("cloud_flow_complete", False):
|
||||
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("do_not_show_cloud_message", 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().activeMachineHasNetworkConnection:
|
||||
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._createCloudFlowStartMessage()
|
||||
if self._start_cloud_flow_message and not self._start_cloud_flow_message.visible:
|
||||
self._start_cloud_flow_message.show()
|
||||
|
||||
def _onCloudPrintingConfigured(self, device) -> None:
|
||||
# Hide the cloud flow start message if it was hanging around already
|
||||
# For example: if the user already had the browser openen and made the association themselves
|
||||
if self._start_cloud_flow_message and self._start_cloud_flow_message.visible:
|
||||
self._start_cloud_flow_message.hide()
|
||||
|
||||
# Cloud flow is complete, so show the message
|
||||
if not self._cloud_flow_complete_message:
|
||||
self._createCloudFlowCompleteMessage()
|
||||
if self._cloud_flow_complete_message and not self._cloud_flow_complete_message.visible:
|
||||
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:
|
||||
|
||||
# The active machine _might_ not be the machine that was in the added cloud cluster and
|
||||
# then this will hide the cloud message for the wrong machine. So we only set it if the
|
||||
# host names match between the active machine and the newly added cluster
|
||||
saved_host_name = active_machine.getMetaDataEntry("um_network_key", "").split('.')[0]
|
||||
added_host_name = device.toDict()["host_name"]
|
||||
|
||||
if added_host_name == saved_host_name:
|
||||
active_machine.setMetaDataEntry("do_not_show_cloud_message", True)
|
||||
|
||||
return
|
||||
|
||||
def _onDontAskMeAgain(self, checked: bool) -> None:
|
||||
active_machine = self._application.getMachineManager().activeMachine # type: Optional[GlobalStack]
|
||||
if active_machine:
|
||||
active_machine.setMetaDataEntry("do_not_show_cloud_message", checked)
|
||||
if checked:
|
||||
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:
|
||||
# Hide any left over messages
|
||||
if self._start_cloud_flow_message is not None and self._start_cloud_flow_message.visible:
|
||||
self._start_cloud_flow_message.hide()
|
||||
if self._cloud_flow_complete_message is not None and self._cloud_flow_complete_message.visible:
|
||||
self._cloud_flow_complete_message.hide()
|
||||
|
||||
# Check for cloud flow again with newly selected machine
|
||||
self.checkCloudFlowIsPossible(None)
|
||||
|
||||
def _createCloudFlowStartMessage(self):
|
||||
self._start_cloud_flow_message = Message(
|
||||
text = i18n_catalog.i18nc("@info:status", "Send and monitor print jobs from anywhere using your Ultimaker account."),
|
||||
lifetime = 0,
|
||||
image_source = QUrl.fromLocalFile(os.path.join(
|
||||
PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"),
|
||||
"resources", "svg", "cloud-flow-start.svg"
|
||||
)),
|
||||
image_caption = i18n_catalog.i18nc("@info:status Ultimaker Cloud is a brand name and shouldn't be translated.", "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)
|
||||
|
||||
def _createCloudFlowCompleteMessage(self):
|
||||
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 = QUrl.fromLocalFile(os.path.join(
|
||||
PluginRegistry.getInstance().getPluginPath("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)
|
||||
## Remove a manually connected networked printer.
|
||||
def removeManualDevice(self, key: str, address: Optional[str] = None) -> None:
|
||||
self._network_output_device_manager.removeManualDevice(key, address)
|
||||
|
|
|
@ -0,0 +1,262 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot, QUrl
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Qt.Duration import Duration, DurationFormat
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
|
||||
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
||||
|
||||
from .Utils import formatTimeCompleted, formatDateCompleted
|
||||
from .ClusterOutputController import ClusterOutputController
|
||||
from .PrintJobUploadProgressMessage import PrintJobUploadProgressMessage
|
||||
from .Models.UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from .Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
|
||||
from .Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
|
||||
|
||||
|
||||
## Output device class that forms the basis of Ultimaker networked printer output devices.
|
||||
# Currently used for local networking and cloud printing using Ultimaker Connect.
|
||||
# This base class primarily contains all the Qt properties and slots needed for the monitor page to work.
|
||||
class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
# Signal emitted when the status of the print jobs for this cluster were changed over the network.
|
||||
printJobsChanged = pyqtSignal()
|
||||
|
||||
# Signal emitted when the currently visible printer card in the UI was changed by the user.
|
||||
activePrinterChanged = pyqtSignal()
|
||||
|
||||
# Notify can only use signals that are defined by the class that they are in, not inherited ones.
|
||||
# Therefore we create a private signal used to trigger the printersChanged signal.
|
||||
_clusterPrintersChanged = pyqtSignal()
|
||||
|
||||
# States indicating if a print job is queued.
|
||||
QUEUED_PRINT_JOBS_STATES = {"queued", "error"}
|
||||
|
||||
def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType,
|
||||
parent=None) -> None:
|
||||
super().__init__(device_id=device_id, address=address, properties=properties, connection_type=connection_type,
|
||||
parent=parent)
|
||||
|
||||
# Trigger the printersChanged signal when the private signal is triggered.
|
||||
self.printersChanged.connect(self._clusterPrintersChanged)
|
||||
|
||||
# Keeps track of all printers in the cluster.
|
||||
self._printers = [] # type: List[PrinterOutputModel]
|
||||
|
||||
# Keeps track of all print jobs in the cluster.
|
||||
self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
|
||||
|
||||
# Keep track of the printer currently selected in the UI.
|
||||
self._active_printer = None # type: Optional[PrinterOutputModel]
|
||||
|
||||
# By default we are not authenticated. This state will be changed later.
|
||||
self._authentication_state = AuthState.NotAuthenticated
|
||||
|
||||
# Load the Monitor UI elements.
|
||||
self._loadMonitorTab()
|
||||
|
||||
# The job upload progress message modal.
|
||||
self._progress = PrintJobUploadProgressMessage()
|
||||
|
||||
## The IP address of the printer.
|
||||
@pyqtProperty(str, constant=True)
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
# Get all print jobs for this cluster.
|
||||
@pyqtProperty("QVariantList", notify=printJobsChanged)
|
||||
def printJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return self._print_jobs
|
||||
|
||||
# Get all print jobs for this cluster that are queued.
|
||||
@pyqtProperty("QVariantList", notify=printJobsChanged)
|
||||
def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if print_job.state in self.QUEUED_PRINT_JOBS_STATES]
|
||||
|
||||
# Get all print jobs for this cluster that are currently printing.
|
||||
@pyqtProperty("QVariantList", notify=printJobsChanged)
|
||||
def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
||||
return [print_job for print_job in self._print_jobs if
|
||||
print_job.assignedPrinter is not None and print_job.state not in self.QUEUED_PRINT_JOBS_STATES]
|
||||
|
||||
@pyqtProperty(bool, notify=printJobsChanged)
|
||||
def receivedPrintJobs(self) -> bool:
|
||||
return bool(self._print_jobs)
|
||||
|
||||
# Get the amount of printers in the cluster.
|
||||
@pyqtProperty(int, notify=_clusterPrintersChanged)
|
||||
def clusterSize(self) -> int:
|
||||
return max(1, len(self._printers))
|
||||
|
||||
# Get the amount of printer in the cluster per type.
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
|
||||
printer_count = {} # type: Dict[str, int]
|
||||
for printer in self._printers:
|
||||
if printer.type in printer_count:
|
||||
printer_count[printer.type] += 1
|
||||
else:
|
||||
printer_count[printer.type] = 1
|
||||
result = []
|
||||
for machine_type in printer_count:
|
||||
result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])})
|
||||
return result
|
||||
|
||||
# Get a list of all printers.
|
||||
@pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
|
||||
def printers(self) -> List[PrinterOutputModel]:
|
||||
return self._printers
|
||||
|
||||
# Get the currently active printer in the UI.
|
||||
@pyqtProperty(QObject, notify=activePrinterChanged)
|
||||
def activePrinter(self) -> Optional[PrinterOutputModel]:
|
||||
return self._active_printer
|
||||
|
||||
# Set the currently active printer from the UI.
|
||||
@pyqtSlot(QObject, name="setActivePrinter")
|
||||
def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None:
|
||||
if self.activePrinter == printer:
|
||||
return
|
||||
self._active_printer = printer
|
||||
self.activePrinterChanged.emit()
|
||||
|
||||
## Whether the printer that this output device represents supports print job actions via the local network.
|
||||
@pyqtProperty(bool, constant=True)
|
||||
def supportsPrintJobActions(self) -> bool:
|
||||
return True
|
||||
|
||||
## Set the remote print job state.
|
||||
def setJobState(self, print_job_uuid: str, state: str) -> None:
|
||||
raise NotImplementedError("setJobState must be implemented")
|
||||
|
||||
@pyqtSlot(str, name="sendJobToTop")
|
||||
def sendJobToTop(self, print_job_uuid: str) -> None:
|
||||
raise NotImplementedError("sendJobToTop must be implemented")
|
||||
|
||||
@pyqtSlot(str, name="deleteJobFromQueue")
|
||||
def deleteJobFromQueue(self, print_job_uuid: str) -> None:
|
||||
raise NotImplementedError("deleteJobFromQueue must be implemented")
|
||||
|
||||
@pyqtSlot(str, name="forceSendJob")
|
||||
def forceSendJob(self, print_job_uuid: str) -> None:
|
||||
raise NotImplementedError("forceSendJob must be implemented")
|
||||
|
||||
@pyqtSlot(name="openPrintJobControlPanel")
|
||||
def openPrintJobControlPanel(self) -> None:
|
||||
raise NotImplementedError("openPrintJobControlPanel must be implemented")
|
||||
|
||||
@pyqtSlot(name="openPrinterControlPanel")
|
||||
def openPrinterControlPanel(self) -> None:
|
||||
raise NotImplementedError("openPrinterControlPanel must be implemented")
|
||||
|
||||
@pyqtProperty(QUrl, notify=_clusterPrintersChanged)
|
||||
def activeCameraUrl(self) -> QUrl:
|
||||
return QUrl()
|
||||
|
||||
@pyqtSlot(QUrl, name="setActiveCameraUrl")
|
||||
def setActiveCameraUrl(self, camera_url: QUrl) -> None:
|
||||
pass
|
||||
|
||||
@pyqtSlot(int, result=str, name="getTimeCompleted")
|
||||
def getTimeCompleted(self, time_remaining: int) -> str:
|
||||
return formatTimeCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result=str, name="getDateCompleted")
|
||||
def getDateCompleted(self, time_remaining: int) -> str:
|
||||
return formatDateCompleted(time_remaining)
|
||||
|
||||
@pyqtSlot(int, result=str, name="formatDuration")
|
||||
def formatDuration(self, seconds: int) -> str:
|
||||
return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
|
||||
|
||||
## Load Monitor tab QML.
|
||||
def _loadMonitorTab(self):
|
||||
plugin_registry = CuraApplication.getInstance().getPluginRegistry()
|
||||
if not plugin_registry:
|
||||
Logger.log("e", "Could not get plugin registry")
|
||||
return
|
||||
plugin_path = plugin_registry.getPluginPath("UM3NetworkPrinting")
|
||||
if not plugin_path:
|
||||
Logger.log("e", "Could not get plugin path")
|
||||
return
|
||||
self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml")
|
||||
|
||||
def _updatePrinters(self, remote_printers: List[ClusterPrinterStatus]) -> None:
|
||||
|
||||
# Keep track of the new printers to show.
|
||||
# We create a new list instead of changing the existing one to get the correct order.
|
||||
new_printers = [] # type: List[PrinterOutputModel]
|
||||
|
||||
# Check which printers need to be created or updated.
|
||||
for index, printer_data in enumerate(remote_printers):
|
||||
printer = next(iter(printer for printer in self._printers if printer.key == printer_data.uuid), None)
|
||||
if printer is None:
|
||||
printer = printer_data.createOutputModel(ClusterOutputController(self))
|
||||
else:
|
||||
printer_data.updateOutputModel(printer)
|
||||
new_printers.append(printer)
|
||||
|
||||
# Check which printers need to be removed (de-referenced).
|
||||
remote_printers_keys = [printer_data.uuid for printer_data in remote_printers]
|
||||
removed_printers = [printer for printer in self._printers if printer.key not in remote_printers_keys]
|
||||
for removed_printer in removed_printers:
|
||||
if self._active_printer and self._active_printer.key == removed_printer.key:
|
||||
self.setActivePrinter(None)
|
||||
|
||||
self._printers = new_printers
|
||||
if self._printers and not self.activePrinter:
|
||||
self.setActivePrinter(self._printers[0])
|
||||
|
||||
self.printersChanged.emit()
|
||||
|
||||
## Updates the local list of print jobs with the list received from the cluster.
|
||||
# \param remote_jobs: The print jobs received from the cluster.
|
||||
def _updatePrintJobs(self, remote_jobs: List[ClusterPrintJobStatus]) -> None:
|
||||
|
||||
# Keep track of the new print jobs to show.
|
||||
# We create a new list instead of changing the existing one to get the correct order.
|
||||
new_print_jobs = []
|
||||
|
||||
# Check which print jobs need to be created or updated.
|
||||
for index, print_job_data in enumerate(remote_jobs):
|
||||
print_job = next(
|
||||
iter(print_job for print_job in self._print_jobs if print_job.key == print_job_data.uuid), None)
|
||||
if not print_job:
|
||||
new_print_jobs.append(self._createPrintJobModel(print_job_data))
|
||||
else:
|
||||
print_job_data.updateOutputModel(print_job)
|
||||
if print_job_data.printer_uuid:
|
||||
self._updateAssignedPrinter(print_job, print_job_data.printer_uuid)
|
||||
new_print_jobs.append(print_job)
|
||||
|
||||
# Check which print job need to be removed (de-referenced).
|
||||
remote_job_keys = [print_job_data.uuid for print_job_data in remote_jobs]
|
||||
removed_jobs = [print_job for print_job in self._print_jobs if print_job.key not in remote_job_keys]
|
||||
for removed_job in removed_jobs:
|
||||
if removed_job.assignedPrinter:
|
||||
removed_job.assignedPrinter.updateActivePrintJob(None)
|
||||
|
||||
self._print_jobs = new_print_jobs
|
||||
self.printJobsChanged.emit()
|
||||
|
||||
## Create a new print job model based on the remote status of the job.
|
||||
# \param remote_job: The remote print job data.
|
||||
def _createPrintJobModel(self, remote_job: ClusterPrintJobStatus) -> UM3PrintJobOutputModel:
|
||||
model = remote_job.createOutputModel(ClusterOutputController(self))
|
||||
if remote_job.printer_uuid:
|
||||
self._updateAssignedPrinter(model, remote_job.printer_uuid)
|
||||
return model
|
||||
|
||||
## Updates the printer assignment for the given print job model.
|
||||
def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None:
|
||||
printer = next((p for p in self._printers if printer_uuid == p.key), None)
|
||||
if not printer:
|
||||
return
|
||||
printer.updateActivePrintJob(model)
|
||||
model.updateAssignedPrinter(printer)
|
30
plugins/UM3NetworkPrinting/src/Utils.py
Normal file
30
plugins/UM3NetworkPrinting/src/Utils.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from UM import i18nCatalog
|
||||
|
||||
|
||||
def formatTimeCompleted(seconds_remaining: int) -> str:
|
||||
completed = datetime.now() + timedelta(seconds=seconds_remaining)
|
||||
return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute)
|
||||
|
||||
|
||||
def formatDateCompleted(seconds_remaining: int) -> str:
|
||||
now = datetime.now()
|
||||
completed = now + timedelta(seconds=seconds_remaining)
|
||||
days = (completed.date() - now.date()).days
|
||||
i18n = i18nCatalog("cura")
|
||||
|
||||
# If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format
|
||||
if days >= 7:
|
||||
return completed.strftime("%a %b ") + "{day}".format(day = completed.day)
|
||||
# If finishing date is within the next week, use "Monday at HH:MM" format
|
||||
elif days >= 2:
|
||||
return completed.strftime("%a")
|
||||
# If finishing tomorrow, use "tomorrow at HH:MM" format
|
||||
elif days >= 1:
|
||||
return i18n.i18nc("@info:status", "tomorrow")
|
||||
# If finishing today, use "today at HH:MM" format
|
||||
else:
|
||||
return i18n.i18nc("@info:status", "today")
|
|
@ -1,12 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def readFixture(fixture_name: str) -> bytes:
|
||||
with open("{}/{}.json".format(os.path.dirname(__file__), fixture_name), "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def parseFixture(fixture_name: str) -> dict:
|
||||
return json.loads(readFixture(fixture_name).decode())
|
|
@ -1,95 +0,0 @@
|
|||
{
|
||||
"data": {
|
||||
"generated_time": "2018-12-10T08:23:55.110Z",
|
||||
"printers": [
|
||||
{
|
||||
"configuration": [
|
||||
{
|
||||
"extruder_index": 0,
|
||||
"material": {
|
||||
"material": "empty"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
},
|
||||
{
|
||||
"extruder_index": 1,
|
||||
"material": {
|
||||
"material": "empty"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"firmware_version": "5.1.2.20180807",
|
||||
"friendly_name": "Master-Luke",
|
||||
"ip_address": "10.183.1.140",
|
||||
"machine_variant": "Ultimaker 3",
|
||||
"status": "maintenance",
|
||||
"unique_name": "ultimakersystem-ccbdd30044ec",
|
||||
"uuid": "b3a47ea3-1eeb-4323-9626-6f9c3c888f9e"
|
||||
},
|
||||
{
|
||||
"configuration": [
|
||||
{
|
||||
"extruder_index": 0,
|
||||
"material": {
|
||||
"brand": "Generic",
|
||||
"color": "Generic",
|
||||
"guid": "506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9",
|
||||
"material": "PLA"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
},
|
||||
{
|
||||
"extruder_index": 1,
|
||||
"material": {
|
||||
"brand": "Ultimaker",
|
||||
"color": "Red",
|
||||
"guid": "9cfe5bf1-bdc5-4beb-871a-52c70777842d",
|
||||
"material": "PLA"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"firmware_version": "4.3.3.20180529",
|
||||
"friendly_name": "UM-Marijn",
|
||||
"ip_address": "10.183.1.166",
|
||||
"machine_variant": "Ultimaker 3",
|
||||
"status": "idle",
|
||||
"unique_name": "ultimakersystem-ccbdd30058ab",
|
||||
"uuid": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a"
|
||||
}
|
||||
],
|
||||
"print_jobs": [
|
||||
{
|
||||
"assigned_to": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a",
|
||||
"configuration": [
|
||||
{
|
||||
"extruder_index": 0,
|
||||
"material": {
|
||||
"brand": "Ultimaker",
|
||||
"color": "Black",
|
||||
"guid": "3ee70a86-77d8-4b87-8005-e4a1bc57d2ce",
|
||||
"material": "PLA"
|
||||
},
|
||||
"print_core_id": "AA 0.4"
|
||||
}
|
||||
],
|
||||
"constraints": {},
|
||||
"created_at": "2018-12-10T08:28:04.108Z",
|
||||
"force": false,
|
||||
"last_seen": 500165.109491861,
|
||||
"machine_variant": "Ultimaker 3",
|
||||
"name": "UM3_dragon",
|
||||
"network_error_count": 0,
|
||||
"owner": "Daniel Testing",
|
||||
"started": false,
|
||||
"status": "queued",
|
||||
"time_elapsed": 0,
|
||||
"time_total": 14145,
|
||||
"uuid": "d1c8bd52-5e9f-486a-8c25-a123cc8c7702"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"data": [{
|
||||
"cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq",
|
||||
"host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050",
|
||||
"host_name": "ultimakersystem-ccbdd30044ec",
|
||||
"host_version": "5.0.0.20170101",
|
||||
"is_online": true,
|
||||
"status": "active"
|
||||
}, {
|
||||
"cluster_id": "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8",
|
||||
"host_guid": "e0ace90a-91ee-1257-4403-e8050a44c9b7",
|
||||
"host_name": "ultimakersystem-30044ecccbdd",
|
||||
"host_version": "5.1.2.20180807",
|
||||
"is_online": true,
|
||||
"status": "active"
|
||||
}]
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"data": {
|
||||
"cluster_job_id": "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd",
|
||||
"job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=",
|
||||
"status": "queued",
|
||||
"generated_time": "2018-12-10T08:23:55.110Z"
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"data": {
|
||||
"content_type": "text/plain",
|
||||
"job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=",
|
||||
"job_name": "Ultimaker Robot v3.0",
|
||||
"status": "uploading",
|
||||
"upload_url": "https://api.ultimaker.com/print-job-upload"
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
|
@ -1,105 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from typing import Dict, Tuple, Union, Optional, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Signal import Signal
|
||||
|
||||
|
||||
class FakeSignal:
|
||||
def __init__(self):
|
||||
self._callbacks = []
|
||||
|
||||
def connect(self, callback):
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def disconnect(self, callback):
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def emit(self, *args, **kwargs):
|
||||
for callback in self._callbacks:
|
||||
callback(*args, **kwargs)
|
||||
|
||||
|
||||
## This class can be used to mock the QNetworkManager class and test the code using it.
|
||||
# After patching the QNetworkManager class, requests are prepared before they can be executed.
|
||||
# Any requests not prepared beforehand will cause KeyErrors.
|
||||
class NetworkManagerMock:
|
||||
|
||||
# An enumeration of the supported operations and their code for the network access manager.
|
||||
_OPERATIONS = {
|
||||
"GET": QNetworkAccessManager.GetOperation,
|
||||
"POST": QNetworkAccessManager.PostOperation,
|
||||
"PUT": QNetworkAccessManager.PutOperation,
|
||||
"DELETE": QNetworkAccessManager.DeleteOperation,
|
||||
"HEAD": QNetworkAccessManager.HeadOperation,
|
||||
} # type: Dict[str, int]
|
||||
|
||||
## Initializes the network manager mock.
|
||||
def __init__(self) -> None:
|
||||
# A dict with the prepared replies, using the format {(http_method, url): reply}
|
||||
self.replies = {} # type: Dict[Tuple[str, str], MagicMock]
|
||||
self.request_bodies = {} # type: Dict[Tuple[str, str], bytes]
|
||||
|
||||
# Signals used in the network manager.
|
||||
self.finished = Signal()
|
||||
self.authenticationRequired = Signal()
|
||||
|
||||
## Mock implementation of the get, post, put, delete and head methods from the network manager.
|
||||
# Since the methods are very simple and the same it didn't make sense to repeat the code.
|
||||
# \param method: The method being called.
|
||||
# \return The mocked function, if the method name is known. Defaults to the standard getattr function.
|
||||
def __getattr__(self, method: str) -> Any:
|
||||
## This mock implementation will simply return the reply from the prepared ones.
|
||||
# it raises a KeyError if requests are done without being prepared.
|
||||
def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_):
|
||||
key = method.upper(), request.url().toString()
|
||||
if body:
|
||||
self.request_bodies[key] = body
|
||||
return self.replies[key]
|
||||
|
||||
operation = self._OPERATIONS.get(method.upper())
|
||||
if operation:
|
||||
return doRequest
|
||||
|
||||
# the attribute is not one of the implemented methods, default to the standard implementation.
|
||||
return getattr(super(), method)
|
||||
|
||||
## Prepares a server reply for the given parameters.
|
||||
# \param method: The HTTP method.
|
||||
# \param url: The URL being requested.
|
||||
# \param status_code: The HTTP status code for the response.
|
||||
# \param response: The response body from the server (generally json-encoded).
|
||||
def prepareReply(self, method: str, url: str, status_code: int, response: Union[bytes, dict]) -> None:
|
||||
reply_mock = MagicMock()
|
||||
reply_mock.url().toString.return_value = url
|
||||
reply_mock.operation.return_value = self._OPERATIONS[method]
|
||||
reply_mock.attribute.return_value = status_code
|
||||
reply_mock.finished = FakeSignal()
|
||||
reply_mock.isFinished.return_value = False
|
||||
reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode()
|
||||
self.replies[method, url] = reply_mock
|
||||
Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url)
|
||||
|
||||
## Gets the request that was sent to the network manager for the given method and URL.
|
||||
# \param method: The HTTP method.
|
||||
# \param url: The URL.
|
||||
def getRequestBody(self, method: str, url: str) -> Optional[bytes]:
|
||||
return self.request_bodies.get((method.upper(), url))
|
||||
|
||||
## Emits the signal that the reply is ready to all prepared replies.
|
||||
def flushReplies(self) -> None:
|
||||
for key, reply in self.replies.items():
|
||||
Logger.log("i", "Flushing reply to {} {}", *key)
|
||||
reply.isFinished.return_value = True
|
||||
reply.finished.emit()
|
||||
self.finished.emit(reply)
|
||||
self.reset()
|
||||
|
||||
## Deletes all prepared replies
|
||||
def reset(self) -> None:
|
||||
self.replies.clear()
|
|
@ -1,117 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot
|
||||
from ...src.Cloud import CloudApiClient
|
||||
from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from ...src.Cloud.Models.CloudClusterStatus import CloudClusterStatus
|
||||
from ...src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from ...src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from ...src.Cloud.Models.CloudError import CloudError
|
||||
from .Fixtures import readFixture, parseFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
|
||||
|
||||
class TestCloudApiClient(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def _errorHandler(self, errors: List[CloudError]):
|
||||
raise Exception("Received unexpected error: {}".format(errors))
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.account = MagicMock()
|
||||
self.account.isLoggedIn.return_value = True
|
||||
|
||||
self.network = NetworkManagerMock()
|
||||
with patch.object(CloudApiClient, 'QNetworkAccessManager', return_value = self.network):
|
||||
self.api = CloudApiClient.CloudApiClient(self.account, self._errorHandler)
|
||||
|
||||
def test_getClusters(self):
|
||||
result = []
|
||||
|
||||
response = readFixture("getClusters")
|
||||
data = parseFixture("getClusters")["data"]
|
||||
|
||||
self.network.prepareReply("GET", CuraCloudAPIRoot + "/connect/v1/clusters", 200, response)
|
||||
# The callback is a function that adds the result of the call to getClusters to the result list
|
||||
self.api.getClusters(lambda clusters: result.extend(clusters))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result)
|
||||
|
||||
def test_getClusterStatus(self):
|
||||
result = []
|
||||
|
||||
response = readFixture("getClusterStatusResponse")
|
||||
data = parseFixture("getClusterStatusResponse")["data"]
|
||||
|
||||
url = CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status"
|
||||
self.network.prepareReply("GET", url, 200, response)
|
||||
self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([CloudClusterStatus(**data)], result)
|
||||
|
||||
def test_requestUpload(self):
|
||||
|
||||
results = []
|
||||
|
||||
response = readFixture("putJobUploadResponse")
|
||||
|
||||
self.network.prepareReply("PUT", CuraCloudAPIRoot + "/cura/v1/jobs/upload", 200, response)
|
||||
request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain")
|
||||
self.api.requestUpload(request, lambda r: results.append(r))
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual(["text/plain"], [r.content_type for r in results])
|
||||
self.assertEqual(["uploading"], [r.status for r in results])
|
||||
|
||||
def test_uploadToolPath(self):
|
||||
|
||||
results = []
|
||||
progress = MagicMock()
|
||||
|
||||
data = parseFixture("putJobUploadResponse")["data"]
|
||||
upload_response = CloudPrintJobResponse(**data)
|
||||
|
||||
# Network client doesn't look into the reply
|
||||
self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
|
||||
|
||||
mesh = ("1234" * 100000).encode()
|
||||
self.api.uploadToolPath(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error)
|
||||
|
||||
for _ in range(10):
|
||||
self.network.flushReplies()
|
||||
self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
|
||||
|
||||
self.assertEqual(["sent"], results)
|
||||
|
||||
def test_requestPrint(self):
|
||||
|
||||
results = []
|
||||
|
||||
response = readFixture("postJobPrintResponse")
|
||||
|
||||
cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8"
|
||||
cluster_job_id = "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd"
|
||||
job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
|
||||
|
||||
self.network.prepareReply("POST",
|
||||
CuraCloudAPIRoot + "/connect/v1/clusters/{}/print/{}"
|
||||
.format(cluster_id, job_id),
|
||||
200, response)
|
||||
|
||||
self.api.requestPrint(cluster_id, job_id, lambda r: results.append(r))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([job_id], [r.job_id for r in results])
|
||||
self.assertEqual([cluster_job_id], [r.cluster_job_id for r in results])
|
||||
self.assertEqual(["queued"], [r.status for r in results])
|
|
@ -1,157 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot
|
||||
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
|
||||
from ...src.Cloud import CloudApiClient
|
||||
from ...src.Cloud.CloudOutputDevice import CloudOutputDevice
|
||||
from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Fixtures import readFixture, parseFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
|
||||
|
||||
class TestCloudOutputDevice(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq"
|
||||
JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
|
||||
HOST_NAME = "ultimakersystem-ccbdd30044ec"
|
||||
HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050"
|
||||
HOST_VERSION = "5.2.0"
|
||||
FRIENDLY_NAME = "My Friendly Printer"
|
||||
|
||||
STATUS_URL = "{}/connect/v1/clusters/{}/status".format(CuraCloudAPIRoot, CLUSTER_ID)
|
||||
PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(CuraCloudAPIRoot, CLUSTER_ID, JOB_ID)
|
||||
REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(CuraCloudAPIRoot)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = MagicMock()
|
||||
|
||||
self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app),
|
||||
patch("UM.Application.Application.getInstance", return_value=self.app)]
|
||||
for patched_method in self.patches:
|
||||
patched_method.start()
|
||||
|
||||
self.cluster = CloudClusterResponse(self.CLUSTER_ID, self.HOST_GUID, self.HOST_NAME, is_online=True,
|
||||
status="active", host_version=self.HOST_VERSION,
|
||||
friendly_name=self.FRIENDLY_NAME)
|
||||
|
||||
self.network = NetworkManagerMock()
|
||||
self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken")
|
||||
self.onError = MagicMock()
|
||||
with patch.object(CloudApiClient, "QNetworkAccessManager", return_value = self.network):
|
||||
self._api = CloudApiClient.CloudApiClient(self.account, self.onError)
|
||||
|
||||
self.device = CloudOutputDevice(self._api, self.cluster)
|
||||
self.cluster_status = parseFixture("getClusterStatusResponse")
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse"))
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
super().tearDown()
|
||||
self.network.flushReplies()
|
||||
finally:
|
||||
for patched_method in self.patches:
|
||||
patched_method.stop()
|
||||
|
||||
# We test for these in order to make sure the correct file type is selected depending on the firmware version.
|
||||
def test_properties(self):
|
||||
self.assertEqual(self.device.firmwareVersion, self.HOST_VERSION)
|
||||
self.assertEqual(self.device.name, self.FRIENDLY_NAME)
|
||||
|
||||
def test_status(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual([PrinterOutputModel, PrinterOutputModel], [type(printer) for printer in self.device.printers])
|
||||
|
||||
controller_fields = {
|
||||
"_output_device": self.device,
|
||||
"can_abort": True,
|
||||
"can_control_manually": False,
|
||||
"can_pause": True,
|
||||
"can_pre_heat_bed": False,
|
||||
"can_pre_heat_hotends": False,
|
||||
"can_send_raw_gcode": False,
|
||||
"can_update_firmware": False,
|
||||
}
|
||||
|
||||
self.assertEqual({printer["uuid"] for printer in self.cluster_status["data"]["printers"]},
|
||||
{printer.key for printer in self.device.printers})
|
||||
self.assertEqual([controller_fields, controller_fields],
|
||||
[printer.getController().__dict__ for printer in self.device.printers])
|
||||
|
||||
self.assertEqual(["UM3PrintJobOutputModel"], [type(printer).__name__ for printer in self.device.printJobs])
|
||||
self.assertEqual({job["uuid"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.key for job in self.device.printJobs})
|
||||
self.assertEqual({job["owner"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.owner for job in self.device.printJobs})
|
||||
self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.name for job in self.device.printJobs})
|
||||
|
||||
def test_remove_print_job(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(1, len(self.device.printJobs))
|
||||
|
||||
self.cluster_status["data"]["print_jobs"].clear()
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
|
||||
|
||||
self.device._last_request_time = None
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual([], self.device.printJobs)
|
||||
|
||||
def test_remove_printers(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(2, len(self.device.printers))
|
||||
|
||||
self.cluster_status["data"]["printers"].clear()
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
|
||||
|
||||
self.device._last_request_time = None
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual([], self.device.printers)
|
||||
|
||||
def test_print_to_cloud(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/x-ufp"}.get
|
||||
|
||||
request_upload_response = parseFixture("putJobUploadResponse")
|
||||
request_print_response = parseFixture("postJobPrintResponse")
|
||||
self.network.prepareReply("PUT", self.REQUEST_UPLOAD_URL, 201, request_upload_response)
|
||||
self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}")
|
||||
self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response)
|
||||
|
||||
file_handler = MagicMock()
|
||||
file_handler.getSupportedFileTypesWrite.return_value = [{
|
||||
"extension": "ufp",
|
||||
"mime_type": "application/x-ufp",
|
||||
"mode": 2
|
||||
}, {
|
||||
"extension": "gcode.gz",
|
||||
"mime_type": "application/gzip",
|
||||
"mode": 2,
|
||||
}]
|
||||
file_handler.getWriterByMimeType.return_value.write.side_effect = \
|
||||
lambda stream, nodes: stream.write(str(nodes).encode())
|
||||
|
||||
scene_nodes = [SceneNode()]
|
||||
expected_mesh = str(scene_nodes).encode()
|
||||
self.device.requestWrite(scene_nodes, file_handler=file_handler, file_name="FileName")
|
||||
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(
|
||||
{"data": {"content_type": "application/x-ufp", "file_size": len(expected_mesh), "job_name": "FileName"}},
|
||||
json.loads(self.network.getRequestBody("PUT", self.REQUEST_UPLOAD_URL).decode())
|
||||
)
|
||||
self.assertEqual(expected_mesh,
|
||||
self.network.getRequestBody("PUT", request_upload_response["data"]["upload_url"]))
|
||||
self.assertIsNone(self.network.getRequestBody("POST", self.PRINT_URL))
|
|
@ -1,126 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager
|
||||
from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot
|
||||
from ...src.Cloud import CloudApiClient
|
||||
from ...src.Cloud import CloudOutputDeviceManager
|
||||
from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Fixtures import parseFixture, readFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock, FakeSignal
|
||||
|
||||
|
||||
class TestCloudOutputDeviceManager(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
URL = CuraCloudAPIRoot + "/connect/v1/clusters"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = MagicMock()
|
||||
self.device_manager = OutputDeviceManager()
|
||||
self.app.getOutputDeviceManager.return_value = self.device_manager
|
||||
|
||||
self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app),
|
||||
patch("UM.Application.Application.getInstance", return_value=self.app)]
|
||||
for patched_method in self.patches:
|
||||
patched_method.start()
|
||||
|
||||
self.network = NetworkManagerMock()
|
||||
self.timer = MagicMock(timeout = FakeSignal())
|
||||
with patch.object(CloudApiClient, "QNetworkAccessManager", return_value = self.network), \
|
||||
patch.object(CloudOutputDeviceManager, "QTimer", return_value = self.timer):
|
||||
self.manager = CloudOutputDeviceManager.CloudOutputDeviceManager()
|
||||
self.clusters_response = parseFixture("getClusters")
|
||||
self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters"))
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self._beforeTearDown()
|
||||
|
||||
self.network.flushReplies()
|
||||
self.manager.stop()
|
||||
for patched_method in self.patches:
|
||||
patched_method.stop()
|
||||
finally:
|
||||
super().tearDown()
|
||||
|
||||
## Before tear down method we check whether the state of the output device manager is what we expect based on the
|
||||
# mocked API response.
|
||||
def _beforeTearDown(self):
|
||||
# let the network send replies
|
||||
self.network.flushReplies()
|
||||
# get the created devices
|
||||
devices = self.device_manager.getOutputDevices()
|
||||
# TODO: Check active device
|
||||
|
||||
response_clusters = []
|
||||
for cluster in self.clusters_response.get("data", []):
|
||||
response_clusters.append(CloudClusterResponse(**cluster).toDict())
|
||||
manager_clusters = sorted([device.clusterData.toDict() for device in self.manager._remote_clusters.values()],
|
||||
key=lambda cluster: cluster['cluster_id'], reverse=True)
|
||||
self.assertEqual(response_clusters, manager_clusters)
|
||||
|
||||
## Runs the initial request to retrieve the clusters.
|
||||
def _loadData(self):
|
||||
self.manager.start()
|
||||
self.network.flushReplies()
|
||||
|
||||
def test_device_is_created(self):
|
||||
# just create the cluster, it is checked at tearDown
|
||||
self._loadData()
|
||||
|
||||
def test_device_is_updated(self):
|
||||
self._loadData()
|
||||
|
||||
# update the cluster from member variable, which is checked at tearDown
|
||||
self.clusters_response["data"][0]["host_name"] = "New host name"
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
|
||||
self.manager._update_timer.timeout.emit()
|
||||
|
||||
def test_device_is_removed(self):
|
||||
self._loadData()
|
||||
|
||||
# delete the cluster from member variable, which is checked at tearDown
|
||||
del self.clusters_response["data"][1]
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
|
||||
self.manager._update_timer.timeout.emit()
|
||||
|
||||
def test_device_connects_by_cluster_id(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
cluster1, cluster2 = self.clusters_response["data"]
|
||||
cluster_id = cluster1["cluster_id"]
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get
|
||||
|
||||
self._loadData()
|
||||
|
||||
self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected())
|
||||
self.assertIsNone(self.device_manager.getOutputDevice(cluster2["cluster_id"]))
|
||||
self.assertEqual([], active_machine_mock.setMetaDataEntry.mock_calls)
|
||||
|
||||
def test_device_connects_by_network_key(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
|
||||
cluster1, cluster2 = self.clusters_response["data"]
|
||||
network_key = cluster2["host_name"] + ".ultimaker.local"
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get
|
||||
|
||||
self._loadData()
|
||||
|
||||
self.assertIsNone(self.device_manager.getOutputDevice(cluster1["cluster_id"]))
|
||||
self.assertTrue(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected())
|
||||
|
||||
active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"])
|
||||
|
||||
@patch.object(CloudOutputDeviceManager, "Message")
|
||||
def test_api_error(self, message_mock):
|
||||
self.clusters_response = {
|
||||
"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}]
|
||||
}
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
self._loadData()
|
||||
message_mock.return_value.show.assert_called_once_with()
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
|
@ -1,5 +1,5 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import io
|
||||
import json
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue