Organize files better

First things first... organize!
This commit is contained in:
Ian Paschal 2018-09-07 14:11:37 +02:00
parent 49d72f5855
commit 18e0dacda4
32 changed files with 21 additions and 22 deletions

View file

@ -0,0 +1,682 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Any, cast, Optional, Set, Tuple, Union
from UM.FileHandler.FileHandler import FileHandler
from UM.FileHandler.FileWriter import FileWriter # To choose based on the output file mode (text vs. binary).
from UM.FileHandler.WriteFileJob import WriteFileJob # To call the file writer asynchronously.
from UM.Logger import Logger
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.i18n import i18nCatalog
from UM.Message import Message
from UM.Qt.Duration import Duration, DurationFormat
from UM.OutputDevice import OutputDeviceError # To show that something went wrong when writing.
from UM.Scene.SceneNode import SceneNode # For typing.
from UM.Version import Version # To check against firmware versions for support.
from cura.CuraApplication import CuraApplication
from cura.PrinterOutput.ConfigurationModel import ConfigurationModel
from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
from cura.PrinterOutput.NetworkCamera import NetworkCamera
from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController
from .SendMaterialJob import SendMaterialJob
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from PyQt5.QtGui import QDesktopServices, QImage
from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject
from time import time
from datetime import datetime
from typing import Optional, Dict, List
import io # To create the correct buffers for sending data to the printer.
import json
import os
i18n_catalog = i18nCatalog("cura")
class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
printJobsChanged = pyqtSignal()
activePrinterChanged = pyqtSignal()
activeCameraChanged = pyqtSignal()
# This is a bit of a hack, as the notify can only use signals that are defined by the class that they are in.
# Inheritance doesn't seem to work. Tying them together does work, but i'm open for better suggestions.
clusterPrintersChanged = pyqtSignal()
def __init__(self, device_id, address, properties, parent = None) -> None:
super().__init__(device_id = device_id, address = address, properties=properties, parent = parent)
self._api_prefix = "/cluster-api/v1/"
self._number_of_extruders = 2
self._dummy_lambdas = ("", {}, io.BytesIO()) #type: Tuple[str, Dict, Union[io.StringIO, io.BytesIO]]
self._print_jobs = [] # type: List[PrintJobOutputModel]
self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/ClusterMonitorItem.qml")
self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/ClusterControlItem.qml")
# See comments about this hack with the clusterPrintersChanged signal
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[PrintJobOutputModel]
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 = None # type: Optional[NetworkCamera]
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
self.writeStarted.emit(self)
self.sendMaterialProfiles()
# Formats supported by this application (file types that we can actually write).
if file_handler:
file_formats = file_handler.getSupportedFileTypesWrite()
else:
file_formats = CuraApplication.getInstance().getMeshFileHandler().getSupportedFileTypesWrite()
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
# Create a list from the supported file formats string.
if not global_stack:
Logger.log("e", "Missing global stack!")
return
machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";")
machine_file_formats = [file_type.strip() for file_type in machine_file_formats]
# Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format.
if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"):
machine_file_formats = ["application/x-ufp"] + machine_file_formats
# Take the intersection between file_formats and machine_file_formats.
format_by_mimetype = {format["mime_type"]: format for format in file_formats}
file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] #Keep them ordered according to the preference in machine_file_formats.
if len(file_formats) == 0:
Logger.log("e", "There are no file formats available to write with!")
raise OutputDeviceError.WriteRequestFailedError(i18n_catalog.i18nc("@info:status", "There are no file formats available to write with!"))
preferred_format = file_formats[0]
# Just take the first file format available.
if file_handler is not None:
writer = file_handler.getWriterByMimeType(cast(str, preferred_format["mime_type"]))
else:
writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType(cast(str, preferred_format["mime_type"]))
if not writer:
Logger.log("e", "Unexpected error when trying to get the FileWriter")
return
# This function pauses with the yield, waiting on instructions on which printer it needs to print with.
if not writer:
Logger.log("e", "Missing file or mesh writer!")
return
self._sending_job = self._sendPrintJob(writer, preferred_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:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/PrintWindow.qml")
self._printer_selection_dialog = CuraApplication.getInstance().createQmlComponent(path, {"OutputDevice": self})
if self._printer_selection_dialog is not None:
self._printer_selection_dialog.show()
@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 writer The file writer to use to create the data.
# \param preferred_format A dictionary containing some information about
# what format to write to. This is necessary to create the correct buffer
# types and file extension and such.
def _sendPrintJob(self, writer: FileWriter, preferred_format: Dict, nodes: List[SceneNode]):
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
target_printer = yield #Potentially wait on the user to select a target printer.
# Using buffering greatly reduces the write time for many lines of gcode
stream = io.BytesIO() # type: Union[io.BytesIO, io.StringIO]# Binary mode.
if preferred_format["mode"] == FileWriter.OutputMode.TextMode:
stream = io.StringIO()
job = WriteFileJob(writer, stream, nodes, preferred_format["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()
self._dummy_lambdas = (target_printer, preferred_format, stream)
job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished)
job.start()
yield True # Return that we had success!
yield # To prevent having to catch the StopIteration exception.
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 = None, 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 = CuraApplication.getInstance().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
@pyqtProperty(QObject, notify=activeCameraChanged)
def activeCamera(self) -> Optional[NetworkCamera]:
return self._active_camera
@pyqtSlot(QObject)
def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None:
if self._active_printer != printer:
if self._active_printer and self._active_printer.camera:
self._active_printer.camera.stop()
self._active_printer = printer
self.activePrinterChanged.emit()
@pyqtSlot(QObject)
def setActiveCamera(self, camera: Optional[NetworkCamera]) -> None:
if self._active_camera != camera:
if self._active_camera:
self._active_camera.stop()
self._active_camera = camera
if self._active_camera:
self._active_camera.start()
self.activeCameraChanged.emit()
def _onPostPrintJobFinished(self, reply: QNetworkReply) -> None:
if self._progress_message:
self._progress_message.hide()
self._compressing_gcode = False
self._sending_gcode = False
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 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=None,
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
CuraApplication.getInstance().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":
CuraApplication.getInstance().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[PrintJobOutputModel]:
return self._print_jobs
@pyqtProperty("QVariantList", notify = printJobsChanged)
def queuedPrintJobs(self) -> List[PrintJobOutputModel]:
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[PrintJobOutputModel]:
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 formatDuration(self, seconds: int) -> str:
return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
@pyqtSlot(int, result = str)
def getTimeCompleted(self, time_remaining: int) -> str:
current_time = time()
datetime_completed = datetime.fromtimestamp(current_time + time_remaining)
return "{hour:02d}:{minute:02d}".format(hour=datetime_completed.hour, minute=datetime_completed.minute)
@pyqtSlot(int, result = str)
def getDateCompleted(self, time_remaining: int) -> str:
current_time = time()
datetime_completed = datetime.fromtimestamp(current_time + time_remaining)
return (datetime_completed.strftime("%a %b ") + "{day}".format(day=datetime_completed.day)).upper()
@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)
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:
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.setCamera(NetworkCamera("http://" + data["ip_address"] + ":8080/?action=stream"))
self._printers.append(printer)
return printer
def _createPrintJobModel(self, data: Dict[str, Any]) -> PrintJobOutputModel:
print_job = PrintJobOutputModel(output_controller=ClusterUM3PrinterOutputController(self),
key=data["uuid"], name= data["name"])
configuration = ConfigurationModel()
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)
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: PrintJobOutputModel, 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"])
def _createMaterialOutputModel(self, material_data) -> MaterialOutputModel:
containers = ContainerRegistry.getInstance().findInstanceContainers(type="material", GUID=material_data["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:
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 = "Empty" if material_data["material"] == "empty" else "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"])
# 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.updateBuildplateName(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: PrintJobOutputModel) -> 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[PrintJobOutputModel, PrinterOutputModel]], key: str) -> Optional[PrintJobOutputModel]:
for item in lst:
if item.key == key:
return item
return None

View file

@ -0,0 +1,21 @@
# 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.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):
data = "{\"action\": \"%s\"}" % state
self._output_device.put("print_jobs/%s/action" % job.key, data, on_finished=None)

View file

@ -0,0 +1,185 @@
# 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 cast, Optional
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 .UM3OutputDevicePlugin import UM3OutputDevicePlugin
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
@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:
meta_data = global_container_stack.getMetaData()
if "connect_group_name" in meta_data:
previous_connect_group_name = meta_data["connect_group_name"]
global_container_stack.setMetaDataEntry("connect_group_name", group_name)
# Find all the places where there is the same group name and change it accordingly
CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connect_group_name", value = previous_connect_group_name, new_value = group_name)
else:
global_container_stack.setMetaDataEntry("connect_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.reCheckConnections()
@pyqtSlot(str)
def setKey(self, key: str) -> None:
Logger.log("d", "Attempting to set the network key of the active machine to %s", key)
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
if global_container_stack:
meta_data = global_container_stack.getMetaData()
if "um_network_key" in meta_data:
previous_network_key= meta_data["um_network_key"]
global_container_stack.setMetaDataEntry("um_network_key", key)
# Delete old authentication data.
Logger.log("d", "Removing old authentication id %s for device %s", global_container_stack.getMetaDataEntry("network_authentication_id", None), key)
global_container_stack.removeMetaDataEntry("network_authentication_id")
global_container_stack.removeMetaDataEntry("network_authentication_key")
CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "um_network_key", value = previous_network_key, new_value = key)
else:
global_container_stack.setMetaDataEntry("um_network_key", key)
if self._network_plugin:
# Ensure that the connection states are refreshed.
self._network_plugin.reCheckConnections()
@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:
return CuraApplication.getInstance().getMachineManager().existNetworkInstances(network_key = key)
@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"))
CuraApplication.getInstance().addAdditionalComponent("machinesDetailPane", self.__additional_components_view.findChild(QObject, "networkPrinterConnectionInfo"))

View file

@ -0,0 +1,640 @@
from typing import List, Optional
from UM.FileHandler.FileHandler import FileHandler
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
from cura.PrinterOutput.NetworkCamera import NetworkCamera
from cura.Settings.ContainerManager import ContainerManager
from cura.Settings.ExtruderManager import ExtruderManager
from UM.Logger import Logger
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.i18n import i18nCatalog
from UM.Message import Message
from PyQt5.QtNetwork import QNetworkRequest
from PyQt5.QtCore import QTimer
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, 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._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/MonitorItem.qml")
self._output_controller = LegacyUM3PrinterOutputController(self)
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(i18n_catalog.i18nc("@info:status", ""),
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, **kwargs: str) -> 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()}).encode(),
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].setCamera(NetworkCamera("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

View file

@ -0,0 +1,96 @@
# Copyright (c) 2017 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.PrintJobOutputModel import PrintJobOutputModel
from cura.PrinterOutput.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: int):
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

View file

@ -0,0 +1,99 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json #To understand the list of materials from the printer reply.
import os #To walk over material files.
import os.path #To filter on material files.
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest #To listen to the reply from the printer.
from typing import Any, Dict, Set, TYPE_CHECKING
import urllib.parse #For getting material IDs from their file names.
from UM.Job import Job #The interface we're implementing.
from UM.Logger import Logger
from UM.MimeTypeDatabase import MimeTypeDatabase #To strip the extensions of the material profile files.
from UM.Resources import Resources
from UM.Settings.ContainerRegistry import ContainerRegistry #To find the GUIDs of materials.
from cura.CuraApplication import CuraApplication #For the resource types.
if TYPE_CHECKING:
from .ClusterUM3OutputDevice import ClusterUM3OutputDevice
## Asynchronous job to send material profiles to the printer.
#
# This way it won't freeze up the interface while sending those materials.
class SendMaterialJob(Job):
def __init__(self, device: "ClusterUM3OutputDevice") -> None:
super().__init__()
self.device = device #type: ClusterUM3OutputDevice
def run(self) -> None:
self.device.get("materials/", on_finished = self.sendMissingMaterials)
def sendMissingMaterials(self, reply: QNetworkReply) -> None:
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: #Got an error from the HTTP request.
Logger.log("e", "Couldn't request current material storage on printer. Not syncing materials.")
return
remote_materials_list = reply.readAll().data().decode("utf-8")
try:
remote_materials_list = json.loads(remote_materials_list)
except json.JSONDecodeError:
Logger.log("e", "Request material storage on printer: I didn't understand the printer's answer.")
return
try:
remote_materials_by_guid = {material["guid"]: material for material in remote_materials_list} #Index by GUID.
except KeyError:
Logger.log("e", "Request material storage on printer: Printer's answer was missing GUIDs.")
return
container_registry = ContainerRegistry.getInstance()
local_materials_list = filter(lambda material: ("GUID" in material and "version" in material and "id" in material), container_registry.findContainersMetadata(type = "material"))
local_materials_by_guid = {material["GUID"]: material for material in local_materials_list if material["id"] == material["base_file"]}
for material in local_materials_list: #For each GUID get the material with the highest version number.
try:
if int(material["version"]) > local_materials_by_guid[material["GUID"]]["version"]:
local_materials_by_guid[material["GUID"]] = material
except ValueError:
Logger.log("e", "Material {material_id} has invalid version number {number}.".format(material_id = material["id"], number = material["version"]))
continue
materials_to_send = set() #type: Set[Dict[str, Any]]
for guid, material in local_materials_by_guid.items():
if guid not in remote_materials_by_guid:
materials_to_send.add(material["id"])
continue
try:
if int(material["version"]) > remote_materials_by_guid[guid]["version"]:
materials_to_send.add(material["id"])
continue
except KeyError:
Logger.log("e", "Current material storage on printer was an invalid reply (missing version).")
return
for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.MaterialInstanceContainer):
try:
mime_type = MimeTypeDatabase.getMimeTypeForFile(file_path)
except MimeTypeDatabase.MimeTypeNotFoundError:
continue #Not the sort of file we'd like to send then.
_, file_name = os.path.split(file_path)
material_id = urllib.parse.unquote_plus(mime_type.stripExtension(file_name))
if material_id not in materials_to_send:
continue
parts = []
with open(file_path, "rb") as f:
parts.append(self.device._createFormPart("name=\"file\"; filename=\"{file_name}\"".format(file_name = file_name), f.read()))
signature_file_path = file_path + ".sig"
if os.path.exists(signature_file_path):
_, signature_file_name = os.path.split(signature_file_path)
with open(signature_file_path, "rb") as f:
parts.append(self.device._createFormPart("name=\"signature_file\"; filename=\"{file_name}\"".format(file_name = signature_file_name), f.read()))
Logger.log("d", "Syncing material {material_id} with cluster.".format(material_id = material_id))
self.device.postFormWithParts(target = "materials/", parts = parts, on_finished = self.sendingFinished)
def sendingFinished(self, reply: QNetworkReply):
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("e", "Received error code from printer when syncing material: {code}".format(code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)))
Logger.log("e", reply.readAll().data().decode("utf-8"))

View file

@ -0,0 +1,352 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
from UM.Logger import Logger
from UM.Application import Application
from UM.Signal import Signal, signalemitter
from UM.Version import Version
from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
from PyQt5.QtCore import QUrl
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
from queue import Queue
from threading import Event, Thread
from time import time
import json
## 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
class UM3OutputDevicePlugin(OutputDevicePlugin):
addDeviceSignal = Signal()
removeDeviceSignal = Signal()
discoveredDevicesChanged = Signal()
def __init__(self):
super().__init__()
self._zero_conf = None
self._zero_conf_browser = None
# 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)
Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
self._discovered_devices = {}
self._network_manager = QNetworkAccessManager()
self._network_manager.finished.connect(self._onNetworkRequestFinished)
self._min_cluster_version = Version("4.0.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 = Application.getInstance().getPreferences()
self._preferences.addPreference("um3networkprinting/manual_instances",
"") # A comma-separated list of ip adresses or hostnames
self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
# 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()
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.
def start(self):
self.startDiscovery()
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()
def reCheckConnections(self):
active_machine = Application.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)
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 = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key")
if key == um_network_key:
self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key])
else:
self.getOutputDeviceManager().removeOutputDevice(key)
def stop(self):
if self._zero_conf is not None:
Logger.log("d", "zeroconf close...")
self._zero_conf.close()
def removeManualDevice(self, key, address = None):
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:
self._manual_instances.remove(address)
self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
def addManualDevice(self, address):
if address not in self._manual_instances:
self._manual_instances.append(address)
self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
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
self._checkManualDevice(address)
def _checkManualDevice(self, address):
# 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)
self._network_manager.get(name_request)
def _onNetworkRequestFinished(self, reply):
reply_url = reply.url().toString()
if "system" in reply_url:
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
# Something went wrong with checking the firmware version!
return
try:
system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
except:
Logger.log("e", "Something went wrong converting the JSON.")
return
address = reply.url().host()
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:
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
# Something went wrong with checking the amount of printers the cluster has!
return
# 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
address = reply.url().host()
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'] = len(cluster_printers_list)
self._onRemoveDevice(instance_name)
self._onAddDevice(instance_name, address, properties)
def _onRemoveDevice(self, device_id):
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.discoveredDevicesChanged.emit()
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))
if cluster_size >= 0:
device = ClusterUM3OutputDevice.ClusterUM3OutputDevice(name, address, properties)
else:
device = LegacyUM3OutputDevice.LegacyUM3OutputDevice(name, address, properties)
self._discovered_devices[device.getId()] = device
self.discoveredDevicesChanged.emit()
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"):
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 Application.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 carefull calling it from the main thread.
def _onServiceChanged(self, zero_conf, service_type, name, state_change):
if state_change == ServiceStateChange.Added:
Logger.log("d", "Bonjour service added: %s" % name)
# 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:
Logger.log("d", "Trying to get address of %s", name)
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