diff --git a/cura/CrashHandler.py b/cura/CrashHandler.py index 6e6da99b0f..1d85a1da54 100644 --- a/cura/CrashHandler.py +++ b/cura/CrashHandler.py @@ -12,9 +12,10 @@ import json import ssl import urllib.request import urllib.error -import shutil -from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, Qt, QUrl +import certifi + +from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, QUrl from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QTextEdit, QGroupBox, QCheckBox, QPushButton from PyQt5.QtGui import QDesktopServices @@ -22,7 +23,6 @@ from UM.Application import Application from UM.Logger import Logger from UM.View.GL.OpenGL import OpenGL from UM.i18n import i18nCatalog -from UM.Platform import Platform from UM.Resources import Resources catalog = i18nCatalog("cura") @@ -352,11 +352,13 @@ class CrashHandler: # Convert data to bytes binary_data = json.dumps(self.data).encode("utf-8") + # CURA-6698 Create an SSL context and use certifi CA certificates for verification. + context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLSv1_2) + context.load_verify_locations(cafile = certifi.where()) # Submit data - kwoptions = {"data": binary_data, "timeout": 5} - - if Platform.isOSX(): - kwoptions["context"] = ssl._create_unverified_context() + kwoptions = {"data": binary_data, + "timeout": 5, + "context": context} Logger.log("i", "Sending crash report info to [%s]...", self.crash_url) if not self.has_started: diff --git a/cura/Machines/Models/DiscoveredPrintersModel.py b/cura/Machines/Models/DiscoveredPrintersModel.py index 33e0b7a4d9..a1b68ee1ae 100644 --- a/cura/Machines/Models/DiscoveredPrintersModel.py +++ b/cura/Machines/Models/DiscoveredPrintersModel.py @@ -75,7 +75,7 @@ class DiscoveredPrinter(QObject): def readableMachineType(self) -> str: from cura.CuraApplication import CuraApplication machine_manager = CuraApplication.getInstance().getMachineManager() - # In LocalClusterOutputDevice, when it updates a printer information, it updates the machine type using the field + # In NetworkOutputDevice, 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): diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 27041b1f80..95ea47112e 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -2,15 +2,19 @@ # Cura is released under the terms of the LGPLv3 or higher. import json -import webbrowser from datetime import datetime, timedelta +import os from typing import Optional, TYPE_CHECKING from urllib.parse import urlencode + import requests.exceptions +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices from UM.Logger import Logger from UM.Message import Message +from UM.Platform import Platform from UM.Signal import Signal from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer @@ -163,7 +167,7 @@ class AuthorizationService: }) # Open the authorization page in a new browser window. - webbrowser.open_new("{}?{}".format(self._auth_url, query_string)) + QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string))) # Start a local web server to receive the callback URL on. self._server.start(verification_code) diff --git a/cura/PrinterOutput/Models/ExtruderConfigurationModel.py b/cura/PrinterOutput/Models/ExtruderConfigurationModel.py index 5b4cb5d6f5..04a3c95afd 100644 --- a/cura/PrinterOutput/Models/ExtruderConfigurationModel.py +++ b/cura/PrinterOutput/Models/ExtruderConfigurationModel.py @@ -25,7 +25,7 @@ class ExtruderConfigurationModel(QObject): return self._position def setMaterial(self, material: Optional[MaterialOutputModel]) -> None: - if self._hotend_id != material: + if self._material != material: self._material = material self.extruderConfigurationChanged.emit() @@ -33,7 +33,7 @@ class ExtruderConfigurationModel(QObject): def activeMaterial(self) -> Optional[MaterialOutputModel]: return self._material - @pyqtProperty(QObject, fset=setMaterial, notify=extruderConfigurationChanged) + @pyqtProperty(QObject, fset = setMaterial, notify = extruderConfigurationChanged) def material(self) -> Optional[MaterialOutputModel]: return self._material diff --git a/cura/PrinterOutput/Models/PrinterConfigurationModel.py b/cura/PrinterOutput/Models/PrinterConfigurationModel.py index 47b9532080..52c7b6f960 100644 --- a/cura/PrinterOutput/Models/PrinterConfigurationModel.py +++ b/cura/PrinterOutput/Models/PrinterConfigurationModel.py @@ -58,6 +58,14 @@ class PrinterConfigurationModel(QObject): return False return self._printer_type != "" + def hasAnyMaterialLoaded(self) -> bool: + if not self.isValid(): + return False + for configuration in self._extruder_configurations: + if configuration.activeMaterial and configuration.activeMaterial.type != "empty": + return True + return False + def __str__(self): message_chunks = [] message_chunks.append("Printer type: " + self._printer_type) diff --git a/cura/PrinterOutput/Models/PrinterOutputModel.py b/cura/PrinterOutput/Models/PrinterOutputModel.py index 13fe85e674..a1a23201fb 100644 --- a/cura/PrinterOutput/Models/PrinterOutputModel.py +++ b/cura/PrinterOutput/Models/PrinterOutputModel.py @@ -7,6 +7,7 @@ from UM.Math.Vector import Vector from cura.PrinterOutput.Peripheral import Peripheral from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel from cura.PrinterOutput.Models.ExtruderOutputModel import ExtruderOutputModel +from UM.Logger import Logger if TYPE_CHECKING: from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel @@ -37,7 +38,7 @@ class PrinterOutputModel(QObject): self._controller = output_controller self._controller.canUpdateFirmwareChanged.connect(self._onControllerCanUpdateFirmwareChanged) self._extruders = [ExtruderOutputModel(printer = self, position = i) for i in range(number_of_extruders)] - self._printer_configuration = PrinterConfigurationModel() # Indicates the current configuration setup in this printer + self._active_printer_configuration = PrinterConfigurationModel() # Indicates the current configuration setup in this printer self._head_position = Vector(0, 0, 0) self._active_print_job = None # type: Optional[PrintJobOutputModel] self._firmware_version = firmware_version @@ -47,8 +48,10 @@ class PrinterOutputModel(QObject): self._buildplate = "" self._peripherals = [] # type: List[Peripheral] - self._printer_configuration.extruderConfigurations = [extruder.extruderConfiguration for extruder in - self._extruders] + self._active_printer_configuration.extruderConfigurations = [extruder.extruderConfiguration for extruder in + self._extruders] + self._active_printer_configuration.configurationChanged.connect(self.configurationChanged) + self._available_printer_configurations = [] # type: List[PrinterConfigurationModel] self._camera_url = QUrl() # type: QUrl @@ -81,7 +84,7 @@ class PrinterOutputModel(QObject): def updateType(self, printer_type: str) -> None: if self._printer_type != printer_type: self._printer_type = printer_type - self._printer_configuration.printerType = self._printer_type + self._active_printer_configuration.printerType = self._printer_type self.typeChanged.emit() self.configurationChanged.emit() @@ -92,7 +95,7 @@ class PrinterOutputModel(QObject): def updateBuildplate(self, buildplate: str) -> None: if self._buildplate != buildplate: self._buildplate = buildplate - self._printer_configuration.buildplateConfiguration = self._buildplate + self._active_printer_configuration.buildplateConfiguration = self._buildplate self.buildplateChanged.emit() self.configurationChanged.emit() @@ -290,18 +293,18 @@ class PrinterOutputModel(QObject): def _onControllerCanUpdateFirmwareChanged(self) -> None: self.canUpdateFirmwareChanged.emit() - # Returns the configuration (material, variant and buildplate) of the current printer + # Returns the active configuration (material, variant and buildplate) of the current printer @pyqtProperty(QObject, notify = configurationChanged) def printerConfiguration(self) -> Optional[PrinterConfigurationModel]: - if self._printer_configuration.isValid(): - return self._printer_configuration + if self._active_printer_configuration.isValid(): + return self._active_printer_configuration return None peripheralsChanged = pyqtSignal() @pyqtProperty(str, notify = peripheralsChanged) def peripherals(self) -> str: - return ", ".join(*[peripheral.name for peripheral in self._peripherals]) + return ", ".join([peripheral.name for peripheral in self._peripherals]) def addPeripheral(self, peripheral: Peripheral) -> None: self._peripherals.append(peripheral) @@ -309,4 +312,29 @@ class PrinterOutputModel(QObject): def removePeripheral(self, peripheral: Peripheral) -> None: self._peripherals.remove(peripheral) - self.peripheralsChanged.emit() \ No newline at end of file + self.peripheralsChanged.emit() + + availableConfigurationsChanged = pyqtSignal() + + # The availableConfigurations are configuration options that a printer can switch to, but doesn't currently have + # active (eg; Automatic tool changes, material loaders, etc). + @pyqtProperty("QVariantList", notify = availableConfigurationsChanged) + def availableConfigurations(self) -> List[PrinterConfigurationModel]: + return self._available_printer_configurations + + def addAvailableConfiguration(self, new_configuration: PrinterConfigurationModel) -> None: + if new_configuration not in self._available_printer_configurations: + self._available_printer_configurations.append(new_configuration) + self.availableConfigurationsChanged.emit() + + def removeAvailableConfiguration(self, config_to_remove: PrinterConfigurationModel) -> None: + try: + self._available_printer_configurations.remove(config_to_remove) + except ValueError: + Logger.log("w", "Unable to remove configuration that isn't in the list of available configurations") + else: + self.availableConfigurationsChanged.emit() + + def setAvailableConfigurations(self, new_configurations: List[PrinterConfigurationModel]) -> None: + self._available_printer_configurations = new_configurations + self.availableConfigurationsChanged.emit() diff --git a/cura/PrinterOutput/PrinterOutputDevice.py b/cura/PrinterOutput/PrinterOutputDevice.py index d4a37b3d68..980ee7864d 100644 --- a/cura/PrinterOutput/PrinterOutputDevice.py +++ b/cura/PrinterOutput/PrinterOutputDevice.py @@ -220,20 +220,25 @@ class PrinterOutputDevice(QObject, OutputDevice): return self._unique_configurations def _updateUniqueConfigurations(self) -> None: - self._unique_configurations = sorted( - {printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None}, - key=lambda config: config.printerType, - ) - self.uniqueConfigurationsChanged.emit() + all_configurations = set() + for printer in self._printers: + if printer.printerConfiguration is not None and printer.printerConfiguration.hasAnyMaterialLoaded(): + all_configurations.add(printer.printerConfiguration) + all_configurations.update(printer.availableConfigurations) + new_configurations = sorted(all_configurations, key = lambda config: config.printerType or "") + if new_configurations != self._unique_configurations: + self._unique_configurations = new_configurations + self.uniqueConfigurationsChanged.emit() # Returns the unique configurations of the printers within this output device @pyqtProperty("QStringList", notify = uniqueConfigurationsChanged) def uniquePrinterTypes(self) -> List[str]: - return list(sorted(set([configuration.printerType for configuration in self._unique_configurations]))) + return list(sorted(set([configuration.printerType or "" for configuration in self._unique_configurations]))) def _onPrintersChanged(self) -> None: for printer in self._printers: printer.configurationChanged.connect(self._updateUniqueConfigurations) + printer.availableConfigurationsChanged.connect(self._updateUniqueConfigurations) # At this point there may be non-updated configurations self._updateUniqueConfigurations() diff --git a/cura/Settings/CuraContainerRegistry.py b/cura/Settings/CuraContainerRegistry.py index bc0d99ead9..314adeeb54 100644 --- a/cura/Settings/CuraContainerRegistry.py +++ b/cura/Settings/CuraContainerRegistry.py @@ -9,7 +9,6 @@ from typing import Any, cast, Dict, Optional, List, Union from PyQt5.QtWidgets import QMessageBox from UM.Decorators import override -from UM.PluginObject import PluginObject from UM.Settings.ContainerFormatError import ContainerFormatError from UM.Settings.Interfaces import ContainerInterface from UM.Settings.ContainerRegistry import ContainerRegistry @@ -21,7 +20,6 @@ from UM.Logger import Logger from UM.Message import Message from UM.Platform import Platform from UM.PluginRegistry import PluginRegistry # For getting the possible profile writers to write with. -from UM.Util import parseBool from UM.Resources import Resources from cura.ReaderWriters.ProfileWriter import ProfileWriter @@ -29,6 +27,7 @@ from . import ExtruderStack from . import GlobalStack import cura.CuraApplication +from cura.Settings.cura_empty_instance_containers import empty_quality_container from cura.Machines.QualityManager import getMachineDefinitionIDForQualitySearch from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader @@ -389,13 +388,33 @@ class CuraContainerRegistry(ContainerRegistry): # successfully imported but then fail to show up. quality_manager = cura.CuraApplication.CuraApplication.getInstance()._quality_manager quality_group_dict = quality_manager.getQualityGroupsForMachineDefinition(global_stack) - if quality_type not in quality_group_dict: + # "not_supported" profiles can be imported. + if quality_type != empty_quality_container.getMetaDataEntry("quality_type") and quality_type not in quality_group_dict: return catalog.i18nc("@info:status", "Could not find a quality type {0} for the current configuration.", quality_type) ContainerRegistry.getInstance().addContainer(profile) return None + @override(ContainerRegistry) + def saveDirtyContainers(self) -> None: + # Lock file for "more" atomically loading and saving to/from config dir. + with self.lockFile(): + # Save base files first + for instance in self.findDirtyContainers(container_type=InstanceContainer): + if instance.getMetaDataEntry("removed"): + continue + if instance.getId() == instance.getMetaData().get("base_file"): + self.saveContainer(instance) + + for instance in self.findDirtyContainers(container_type=InstanceContainer): + if instance.getMetaDataEntry("removed"): + continue + self.saveContainer(instance) + + for stack in self.findContainerStacks(): + self.saveContainer(stack) + ## Gets a list of profile writer plugins # \return List of tuples of (plugin_id, meta_data). def _getIOPlugins(self, io_type): diff --git a/cura_app.py b/cura_app.py index 3599f127cc..b2cd317243 100755 --- a/cura_app.py +++ b/cura_app.py @@ -60,6 +60,14 @@ if Platform.isWindows() and hasattr(sys, "frozen"): except KeyError: pass +# GITHUB issue #6194: https://github.com/Ultimaker/Cura/issues/6194 +# With AppImage 2 on Linux, the current working directory will be somewhere in /tmp//usr, which is owned +# by root. For some reason, QDesktopServices.openUrl() requires to have a usable current working directory, +# otherwise it doesn't work. This is a workaround on Linux that before we call QDesktopServices.openUrl(), we +# switch to a directory where the user has the ownership. +if Platform.isLinux() and hasattr(sys, "frozen"): + os.chdir(os.path.expanduser("~")) + # WORKAROUND: GITHUB-704 GITHUB-708 # It looks like setuptools creates a .pth file in # the default /usr/lib which causes the default site-packages diff --git a/plugins/3MFReader/ThreeMFWorkspaceReader.py b/plugins/3MFReader/ThreeMFWorkspaceReader.py index 0e1b0d73a7..b9b69d1ae0 100755 --- a/plugins/3MFReader/ThreeMFWorkspaceReader.py +++ b/plugins/3MFReader/ThreeMFWorkspaceReader.py @@ -59,6 +59,9 @@ class MachineInfo: self.container_id = None self.name = None self.definition_id = None + + self.metadata_dict = {} # type: Dict[str, str] + self.quality_type = None self.custom_quality_name = None self.quality_changes_info = None @@ -342,6 +345,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader): global_stack_id = self._stripFileToId(global_stack_file) serialized = archive.open(global_stack_file).read().decode("utf-8") machine_name = self._getMachineNameFromSerializedStack(serialized) + self._machine_info.metadata_dict = self._getMetaDataDictFromSerializedStack(serialized) + stacks = self._container_registry.findContainerStacks(name = machine_name, type = "machine") self._is_same_machine_type = True existing_global_stack = None @@ -832,7 +837,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader): self._machine_info.quality_changes_info.name = quality_changes_name - def _clearStack(self, stack): + @staticmethod + def _clearStack(stack): application = CuraApplication.getInstance() stack.definitionChanges.clear() @@ -978,6 +984,11 @@ class ThreeMFWorkspaceReader(WorkspaceReader): extruder_stack.setMetaDataEntry("enabled", "True") extruder_stack.setMetaDataEntry("enabled", str(extruder_info.enabled)) + # Set metadata fields that are missing from the global stack + for key, value in self._machine_info.metadata_dict.items(): + if key not in global_stack.getMetaData(): + global_stack.setMetaDataEntry(key, value) + def _updateActiveMachine(self, global_stack): # Actually change the active machine. machine_manager = Application.getInstance().getMachineManager() @@ -986,6 +997,11 @@ class ThreeMFWorkspaceReader(WorkspaceReader): machine_manager.setActiveMachine(global_stack.getId()) + # Set metadata fields that are missing from the global stack + for key, value in self._machine_info.metadata_dict.items(): + if key not in global_stack.getMetaData(): + global_stack.setMetaDataEntry(key, value) + if self._quality_changes_to_apply: quality_changes_group_dict = quality_manager.getQualityChangesGroups(global_stack) if self._quality_changes_to_apply not in quality_changes_group_dict: @@ -1012,7 +1028,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader): # Notify everything/one that is to notify about changes. global_stack.containersChanged.emit(global_stack.getTop()) - def _stripFileToId(self, file): + @staticmethod + def _stripFileToId(file): mime_type = MimeTypeDatabase.getMimeTypeForFile(file) file = mime_type.stripExtension(file) return file.replace("Cura/", "") @@ -1021,7 +1038,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader): return self._container_registry.getContainerForMimeType(MimeTypeDatabase.getMimeType("application/x-ultimaker-material-profile")) ## Get the list of ID's of all containers in a container stack by partially parsing it's serialized data. - def _getContainerIdListFromSerialized(self, serialized): + @staticmethod + def _getContainerIdListFromSerialized(serialized): parser = ConfigParser(interpolation = None, empty_lines_in_values = False) parser.read_string(serialized) @@ -1042,12 +1060,20 @@ class ThreeMFWorkspaceReader(WorkspaceReader): return container_ids - def _getMachineNameFromSerializedStack(self, serialized): + @staticmethod + def _getMachineNameFromSerializedStack(serialized): parser = ConfigParser(interpolation = None, empty_lines_in_values = False) parser.read_string(serialized) return parser["general"].get("name", "") - def _getMaterialLabelFromSerialized(self, serialized): + @staticmethod + def _getMetaDataDictFromSerializedStack(serialized: str) -> Dict[str, str]: + parser = ConfigParser(interpolation = None, empty_lines_in_values = False) + parser.read_string(serialized) + return dict(parser["metadata"]) + + @staticmethod + def _getMaterialLabelFromSerialized(serialized): data = ET.fromstring(serialized) metadata = data.iterfind("./um:metadata/um:name/um:label", {"um": "http://www.ultimaker.com/material"}) for entry in metadata: diff --git a/plugins/CuraEngineBackend/CuraEngineBackend.py b/plugins/CuraEngineBackend/CuraEngineBackend.py index ae18e76e5a..2aae1fff21 100755 --- a/plugins/CuraEngineBackend/CuraEngineBackend.py +++ b/plugins/CuraEngineBackend/CuraEngineBackend.py @@ -671,14 +671,20 @@ class CuraEngineBackend(QObject, Backend): # # \param message The protobuf message containing g-code, encoded as UTF-8. def _onGCodeLayerMessage(self, message: Arcus.PythonMessage) -> None: - self._scene.gcode_dict[self._start_slice_job_build_plate].append(message.data.decode("utf-8", "replace")) #type: ignore #Because we generate this attribute dynamically. + try: + self._scene.gcode_dict[self._start_slice_job_build_plate].append(message.data.decode("utf-8", "replace")) #type: ignore #Because we generate this attribute dynamically. + except KeyError: # Can occur if the g-code has been cleared while a slice message is still arriving from the other end. + pass # Throw the message away. ## Called when a g-code prefix message is received from the engine. # # \param message The protobuf message containing the g-code prefix, # encoded as UTF-8. def _onGCodePrefixMessage(self, message: Arcus.PythonMessage) -> None: - self._scene.gcode_dict[self._start_slice_job_build_plate].insert(0, message.data.decode("utf-8", "replace")) #type: ignore #Because we generate this attribute dynamically. + try: + self._scene.gcode_dict[self._start_slice_job_build_plate].insert(0, message.data.decode("utf-8", "replace")) #type: ignore #Because we generate this attribute dynamically. + except KeyError: # Can occur if the g-code has been cleared while a slice message is still arriving from the other end. + pass # Throw the message away. ## Creates a new socket connection. def _createSocket(self, protocol_file: str = None) -> None: diff --git a/plugins/FirmwareUpdateChecker/FirmwareUpdateCheckerJob.py b/plugins/FirmwareUpdateChecker/FirmwareUpdateCheckerJob.py index ad10a4f075..f286662bc4 100644 --- a/plugins/FirmwareUpdateChecker/FirmwareUpdateCheckerJob.py +++ b/plugins/FirmwareUpdateChecker/FirmwareUpdateCheckerJob.py @@ -10,6 +10,9 @@ from UM.Version import Version import urllib.request from urllib.error import URLError from typing import Dict, Optional +import ssl + +import certifi from .FirmwareUpdateCheckerLookup import FirmwareUpdateCheckerLookup, getSettingsKeyForMachine from .FirmwareUpdateCheckerMessage import FirmwareUpdateCheckerMessage @@ -39,8 +42,12 @@ class FirmwareUpdateCheckerJob(Job): result = self.STRING_ZERO_VERSION try: + # CURA-6698 Create an SSL context and use certifi CA certificates for verification. + context = ssl.SSLContext(protocol = ssl.PROTOCOL_TLSv1_2) + context.load_verify_locations(cafile = certifi.where()) + request = urllib.request.Request(url, headers = self._headers) - response = urllib.request.urlopen(request) + response = urllib.request.urlopen(request, context = context) result = response.read().decode("utf-8") except URLError: Logger.log("w", "Could not reach '{0}', if this URL is old, consider removal.".format(url)) @@ -104,7 +111,7 @@ class FirmwareUpdateCheckerJob(Job): # because the new version of Cura will be release before the firmware and we don't want to # notify the user when no new firmware version is available. if (checked_version != "") and (checked_version != current_version): - Logger.log("i", "Showing firmware update message for new version: {version}".format(current_version)) + Logger.log("i", "Showing firmware update message for new version: {version}".format(version = current_version)) message = FirmwareUpdateCheckerMessage(machine_id, self._machine_name, self._lookups.getRedirectUserUrl()) message.actionTriggered.connect(self._callback) @@ -113,7 +120,7 @@ class FirmwareUpdateCheckerJob(Job): Logger.log("i", "No machine with name {0} in list of firmware to check.".format(self._machine_name)) except Exception as e: - Logger.log("w", "Failed to check for new version: %s", e) + Logger.logException("w", "Failed to check for new version: %s", e) if not self.silent: Message(i18n_catalog.i18nc("@info", "Could not access update information.")).show() return diff --git a/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml b/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml index 2556eb3a9c..d817450f41 100644 --- a/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml +++ b/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml @@ -142,6 +142,18 @@ Item forceUpdateOnChangeFunction: forceUpdateFunction } + Cura.SimpleCheckBox // "Heated build volume" + { + id: heatedVolumeCheckBox + containerStackId: machineStackId + settingKey: "machine_heated_build_volume" + settingStoreIndex: propertyStoreIndex + labelText: catalog.i18nc("@label", "Heated build volume") + labelFont: base.labelFont + labelWidth: base.labelWidth + forceUpdateOnChangeFunction: forceUpdateFunction + } + Cura.ComboBoxWithOptions // "G-code flavor" { id: gcodeFlavorComboBox diff --git a/plugins/SimulationView/SimulationView.py b/plugins/SimulationView/SimulationView.py index 20471f9763..72bf1274ea 100644 --- a/plugins/SimulationView/SimulationView.py +++ b/plugins/SimulationView/SimulationView.py @@ -83,9 +83,13 @@ class SimulationView(CuraView): self._simulationview_composite_shader = None # type: Optional["ShaderProgram"] self._old_composite_shader = None # type: Optional["ShaderProgram"] + self._max_feedrate = sys.float_info.min + self._min_feedrate = sys.float_info.max + self._max_thickness = sys.float_info.min + self._min_thickness = sys.float_info.max + self._global_container_stack = None # type: Optional[ContainerStack] - self._proxy = SimulationViewProxy() - self._controller.getScene().getRoot().childrenChanged.connect(self._onSceneChanged) + self._proxy = None self._resetSettings() self._legend_items = None @@ -104,7 +108,6 @@ class SimulationView(CuraView): Application.getInstance().getPreferences().addPreference("layerview/show_skin", True) Application.getInstance().getPreferences().addPreference("layerview/show_infill", True) - Application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged) self._updateWithPreferences() self._solid_layers = int(Application.getInstance().getPreferences().getValue("view/top_layer_count")) @@ -180,8 +183,7 @@ class SimulationView(CuraView): def _onSceneChanged(self, node: "SceneNode") -> None: if node.getMeshData() is None: - self.resetLayerData() - + return self.setActivity(False) self.calculateMaxLayers() self.calculateMaxPathsOnLayer(self._current_layer_num) @@ -441,6 +443,8 @@ class SimulationView(CuraView): ## Hackish way to ensure the proxy is already created, which ensures that the layerview.qml is already created # as this caused some issues. def getProxy(self, engine, script_engine): + if self._proxy is None: + self._proxy = SimulationViewProxy(self) return self._proxy def endRendering(self) -> None: @@ -460,6 +464,10 @@ class SimulationView(CuraView): return True if event.type == Event.ViewActivateEvent: + # Start listening to changes. + Application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged) + self._controller.getScene().getRoot().childrenChanged.connect(self._onSceneChanged) + # FIX: on Max OS X, somehow QOpenGLContext.currentContext() can become None during View switching. # This can happen when you do the following steps: # 1. Start Cura @@ -506,6 +514,8 @@ class SimulationView(CuraView): self._composite_pass.setCompositeShader(self._simulationview_composite_shader) elif event.type == Event.ViewDeactivateEvent: + self._controller.getScene().getRoot().childrenChanged.disconnect(self._onSceneChanged) + Application.getInstance().getPreferences().preferenceChanged.disconnect(self._onPreferencesChanged) self._wireprint_warning_message.hide() Application.getInstance().globalContainerStackChanged.disconnect(self._onGlobalStackChanged) if self._global_container_stack: diff --git a/plugins/SimulationView/SimulationViewProxy.py b/plugins/SimulationView/SimulationViewProxy.py index a84b151983..58a004cc31 100644 --- a/plugins/SimulationView/SimulationViewProxy.py +++ b/plugins/SimulationView/SimulationViewProxy.py @@ -1,21 +1,24 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty from UM.FlameProfiler import pyqtSlot from UM.Application import Application -import SimulationView +if TYPE_CHECKING: + from .SimulationView import SimulationView class SimulationViewProxy(QObject): - def __init__(self, parent=None): + def __init__(self, simulation_view: "SimulationView", parent=None): super().__init__(parent) + self._simulation_view = simulation_view self._current_layer = 0 self._controller = Application.getInstance().getController() self._controller.activeViewChanged.connect(self._onActiveViewChanged) - self._onActiveViewChanged() self.is_simulationView_selected = False + self._onActiveViewChanged() currentLayerChanged = pyqtSignal() currentPathChanged = pyqtSignal() @@ -28,182 +31,112 @@ class SimulationViewProxy(QObject): @pyqtProperty(bool, notify=activityChanged) def layerActivity(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getActivity() - return False + return self._simulation_view.getActivity() @pyqtProperty(int, notify=maxLayersChanged) def numLayers(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMaxLayers() - return 0 + return self._simulation_view.getMaxLayers() @pyqtProperty(int, notify=currentLayerChanged) def currentLayer(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getCurrentLayer() - return 0 + return self._simulation_view.getCurrentLayer() @pyqtProperty(int, notify=currentLayerChanged) def minimumLayer(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMinimumLayer() - return 0 + return self._simulation_view.getMinimumLayer() @pyqtProperty(int, notify=maxPathsChanged) def numPaths(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMaxPaths() - return 0 + return self._simulation_view.getMaxPaths() @pyqtProperty(int, notify=currentPathChanged) def currentPath(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getCurrentPath() - return 0 + return self._simulation_view.getCurrentPath() @pyqtProperty(int, notify=currentPathChanged) def minimumPath(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMinimumPath() - return 0 + return self._simulation_view.getMinimumPath() @pyqtProperty(bool, notify=busyChanged) def busy(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.isBusy() - return False + return self._simulation_view.isBusy() @pyqtProperty(bool, notify=preferencesChanged) def compatibilityMode(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getCompatibilityMode() - return False + return self._simulation_view.getCompatibilityMode() + + @pyqtProperty(int, notify=globalStackChanged) + def extruderCount(self): + return self._simulation_view.getExtruderCount() @pyqtSlot(int) def setCurrentLayer(self, layer_num): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setLayer(layer_num) + self._simulation_view.setLayer(layer_num) @pyqtSlot(int) def setMinimumLayer(self, layer_num): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setMinimumLayer(layer_num) + self._simulation_view.setMinimumLayer(layer_num) @pyqtSlot(int) def setCurrentPath(self, path_num): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setPath(path_num) + self._simulation_view.setPath(path_num) @pyqtSlot(int) def setMinimumPath(self, path_num): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setMinimumPath(path_num) + self._simulation_view.setMinimumPath(path_num) @pyqtSlot(int) def setSimulationViewType(self, layer_view_type): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setSimulationViewType(layer_view_type) + self._simulation_view.setSimulationViewType(layer_view_type) @pyqtSlot(result=int) def getSimulationViewType(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getSimulationViewType() - return 0 + return self._simulation_view.getSimulationViewType() @pyqtSlot(bool) def setSimulationRunning(self, running): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setSimulationRunning(running) + self._simulation_view.setSimulationRunning(running) @pyqtSlot(result=bool) def getSimulationRunning(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.isSimulationRunning() - return False + return self._simulation_view.isSimulationRunning() @pyqtSlot(result=float) def getMinFeedrate(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMinFeedrate() - return 0 + return self._simulation_view.getMinFeedrate() @pyqtSlot(result=float) def getMaxFeedrate(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMaxFeedrate() - return 0 + return self._simulation_view.getMaxFeedrate() @pyqtSlot(result=float) def getMinThickness(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMinThickness() - return 0 + return self._simulation_view.getMinThickness() @pyqtSlot(result=float) def getMaxThickness(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getMaxThickness() - return 0 + return self._simulation_view.getMaxThickness() # Opacity 0..1 @pyqtSlot(int, float) def setExtruderOpacity(self, extruder_nr, opacity): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setExtruderOpacity(extruder_nr, opacity) + self._simulation_view.setExtruderOpacity(extruder_nr, opacity) @pyqtSlot(int) def setShowTravelMoves(self, show): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setShowTravelMoves(show) + self._simulation_view.setShowTravelMoves(show) @pyqtSlot(int) def setShowHelpers(self, show): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setShowHelpers(show) + self._simulation_view.setShowHelpers(show) @pyqtSlot(int) def setShowSkin(self, show): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setShowSkin(show) + self._simulation_view.setShowSkin(show) @pyqtSlot(int) def setShowInfill(self, show): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - active_view.setShowInfill(show) - - @pyqtProperty(int, notify=globalStackChanged) - def extruderCount(self): - active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - return active_view.getExtruderCount() - return 0 + self._simulation_view.setShowInfill(show) def _layerActivityChanged(self): self.activityChanged.emit() @@ -236,24 +169,25 @@ class SimulationViewProxy(QObject): def _onActiveViewChanged(self): active_view = self._controller.getActiveView() - if isinstance(active_view, SimulationView.SimulationView.SimulationView): - # remove other connection if once the SimulationView was created. - if self.is_simulationView_selected: - active_view.currentLayerNumChanged.disconnect(self._onLayerChanged) - active_view.currentPathNumChanged.disconnect(self._onPathChanged) - active_view.maxLayersChanged.disconnect(self._onMaxLayersChanged) - active_view.maxPathsChanged.disconnect(self._onMaxPathsChanged) - active_view.busyChanged.disconnect(self._onBusyChanged) - active_view.activityChanged.disconnect(self._onActivityChanged) - active_view.globalStackChanged.disconnect(self._onGlobalStackChanged) - active_view.preferencesChanged.disconnect(self._onPreferencesChanged) - + if active_view == self._simulation_view: + self._simulation_view.currentLayerNumChanged.connect(self._onLayerChanged) + self._simulation_view.currentPathNumChanged.connect(self._onPathChanged) + self._simulation_view.maxLayersChanged.connect(self._onMaxLayersChanged) + self._simulation_view.maxPathsChanged.connect(self._onMaxPathsChanged) + self._simulation_view.busyChanged.connect(self._onBusyChanged) + self._simulation_view.activityChanged.connect(self._onActivityChanged) + self._simulation_view.globalStackChanged.connect(self._onGlobalStackChanged) + self._simulation_view.preferencesChanged.connect(self._onPreferencesChanged) self.is_simulationView_selected = True - active_view.currentLayerNumChanged.connect(self._onLayerChanged) - active_view.currentPathNumChanged.connect(self._onPathChanged) - active_view.maxLayersChanged.connect(self._onMaxLayersChanged) - active_view.maxPathsChanged.connect(self._onMaxPathsChanged) - active_view.busyChanged.connect(self._onBusyChanged) - active_view.activityChanged.connect(self._onActivityChanged) - active_view.globalStackChanged.connect(self._onGlobalStackChanged) - active_view.preferencesChanged.connect(self._onPreferencesChanged) + elif self.is_simulationView_selected: + # Disconnect all of em again. + self.is_simulationView_selected = False + self._simulation_view.currentLayerNumChanged.disconnect(self._onLayerChanged) + self._simulation_view.currentPathNumChanged.disconnect(self._onPathChanged) + self._simulation_view.maxLayersChanged.disconnect(self._onMaxLayersChanged) + self._simulation_view.maxPathsChanged.disconnect(self._onMaxPathsChanged) + self._simulation_view.busyChanged.disconnect(self._onBusyChanged) + self._simulation_view.activityChanged.disconnect(self._onActivityChanged) + self._simulation_view.globalStackChanged.disconnect(self._onGlobalStackChanged) + self._simulation_view.preferencesChanged.disconnect(self._onPreferencesChanged) + diff --git a/plugins/Toolbox/resources/qml/ToolboxDownloadsPage.qml b/plugins/Toolbox/resources/qml/ToolboxDownloadsPage.qml index 5ea24d17ba..57fb3a9279 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDownloadsPage.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDownloadsPage.qml @@ -10,9 +10,11 @@ ScrollView clip: true width: parent.width height: parent.height + contentHeight: mainColumn.height Column { + id: mainColumn width: base.width spacing: UM.Theme.getSize("default_margin").height diff --git a/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml b/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml index f600083f36..b27416e199 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml @@ -331,7 +331,7 @@ Cura.MachineAction Label { - text: catalog.i18nc("@label", "Enter the IP address or hostname of your printer on the network.") + text: catalog.i18nc("@label", "Enter the IP address of your printer on the network.") width: parent.width wrapMode: Text.WordWrap renderType: Text.NativeRendering diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml index 14e95559ec..c01f778bba 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml @@ -64,6 +64,7 @@ Item visible: printJob // FIXED-LINE-HEIGHT: + width: parent.width height: parent.height verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering @@ -241,11 +242,10 @@ Item enabled: !contextMenuButton.enabled } - // TODO: uncomment this tooltip as soon as the required firmware is released - // MonitorInfoBlurb - // { - // id: contextMenuDisabledInfo - // text: catalog.i18nc("@info", "Please update your printer's firmware to manage the queue remotely.") - // target: contextMenuButton - // } + MonitorInfoBlurb + { + id: contextMenuDisabledInfo + text: catalog.i18nc("@info", "Please update your printer's firmware to manage the queue remotely.") + target: contextMenuButton + } } diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml index 0175d5a2ad..9242abacdd 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml @@ -250,13 +250,12 @@ Item enabled: !contextMenuButton.enabled } - // TODO: uncomment this tooltip as soon as the required firmware is released - // MonitorInfoBlurb - // { - // id: contextMenuDisabledInfo - // text: catalog.i18nc("@info", "Please update your printer's firmware to manage the queue remotely.") - // target: contextMenuButton - // } + MonitorInfoBlurb + { + id: contextMenuDisabledInfo + text: catalog.i18nc("@info", "Please update your printer's firmware to manage the queue remotely.") + target: contextMenuButton + } CameraButton { @@ -495,6 +494,25 @@ Item implicitWidth: 96 * screenScaleFactor // TODO: Theme! visible: printer && printer.activePrintJob && printer.activePrintJob.configurationChanges.length > 0 && !printerStatus.visible onClicked: base.enabled ? overrideConfirmationDialog.open() : {} + enabled: OutputDevice.supportsPrintJobActions + } + + // For cloud printing, add this mouse area over the disabled details button to indicate that it's not available + MouseArea + { + id: detailsButtonDisabledButtonArea + anchors.fill: detailsButton + hoverEnabled: detailsButton.visible && !detailsButton.enabled + onEntered: overrideButtonDisabledInfo.open() + onExited: overrideButtonDisabledInfo.close() + enabled: !detailsButton.enabled + } + + MonitorInfoBlurb + { + id: overrideButtonDisabledInfo + text: catalog.i18nc("@info", "Please update your printer's firmware to manage the queue remotely.") + target: detailsButton } } diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml index b70759454a..ce692168c3 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml @@ -102,7 +102,6 @@ Item elide: Text.ElideRight font: UM.Theme.getFont("medium") // 14pt, regular anchors.verticalCenter: parent.verticalCenter - width: 600 * screenScaleFactor // TODO: Theme! (Should match column size) // FIXED-LINE-HEIGHT: height: 18 * screenScaleFactor // TODO: Theme! @@ -186,7 +185,14 @@ Item } printJob: modelData } - model: OutputDevice.queuedPrintJobs + model: + { + if (OutputDevice.receivedData) + { + return OutputDevice.queuedPrintJobs + } + return [null, null] + } spacing: 6 // TODO: Theme! } } diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml index 58e4263d2d..47c45f8b11 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml @@ -50,7 +50,14 @@ Component MonitorCarousel { id: carousel - printers: OutputDevice.printers + printers: + { + if (OutputDevice.receivedData) + { + return OutputDevice.printers + } + return [null] + } } } diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 21a7f4aa57..ed8d22a478 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -56,7 +56,7 @@ class CloudApiClient: ## Retrieves all the clusters for the user that is currently logged in. # \param on_finished: The function to be called after the result is parsed. def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: - url = "{}/clusters".format(self.CLUSTER_API_ROOT) + url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT) reply = self._manager.get(self._createEmptyRequest(url)) self._addCallback(reply, on_finished, CloudClusterResponse) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index fc514d1fca..75e2b30ff1 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -42,20 +42,18 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): # The interval with which the remote cluster is checked. # We can do this relatively often as this API call is quite fast. - CHECK_CLUSTER_INTERVAL = 8.0 # seconds + CHECK_CLUSTER_INTERVAL = 10.0 # seconds + + # Override the network response timeout in seconds after which we consider the device offline. + # For cloud this needs to be higher because the interval at which we check the status is higher as well. + NETWORK_RESPONSE_CONSIDER_OFFLINE = 15.0 # seconds # The minimum version of firmware that support print job actions over cloud. PRINT_JOB_ACTIONS_MIN_VERSION = Version("5.3.0") - # Signal triggered when the print jobs in the queue were changed. - printJobsChanged = pyqtSignal() - - # Signal triggered when the selected printer in the UI should be changed. - 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() + _cloudClusterPrintersChanged = pyqtSignal() ## Creates a new cloud output device # \param api_client: The client that will run the API calls @@ -89,7 +87,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._setInterfaceElements() # Trigger the printersChanged signal when the private signal is triggered. - self.printersChanged.connect(self._clusterPrintersChanged) + self.printersChanged.connect(self._cloudClusterPrintersChanged) # 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[ClusterPrinterStatus]] @@ -144,8 +142,9 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): ## 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 time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL: + return # avoid calling the cloud too often + self._time_of_last_request = time() if self._account.isLoggedIn: self.setAuthenticationState(AuthState.Authenticated) self._last_request_time = time() @@ -156,9 +155,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): ## 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._responseReceived() + if status.printers != self._received_printers: self._received_printers = status.printers self._updatePrinters(status.printers) if status.print_jobs != self._received_print_jobs: @@ -232,7 +230,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self.writeError.emit() ## Whether the printer that this output device represents supports print job actions via the cloud. - @pyqtProperty(bool, notify=_clusterPrintersChanged) + @pyqtProperty(bool, notify=_cloudClusterPrintersChanged) def supportsPrintJobActions(self) -> bool: if not self._printers: return False @@ -274,7 +272,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): @clusterData.setter def clusterData(self, value: CloudClusterResponse) -> None: self._cluster = value - + ## Gets the URL on which to monitor the cluster via the cloud. @property def clusterCloudUrl(self) -> str: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index e6cd98426f..168d209db8 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -161,15 +161,17 @@ class CloudOutputDeviceManager: 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. - @staticmethod - def _connectToOutputDevice(device: CloudOutputDevice, active_machine: GlobalStack) -> None: + def _connectToOutputDevice(self, device: CloudOutputDevice, machine: GlobalStack) -> None: + machine.setName(device.name) + machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + machine.setMetaDataEntry("group_name", device.name) + device.connect() - active_machine.addConfiguredConnectionType(device.connectionType.value) + machine.addConfiguredConnectionType(device.connectionType.value) CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device) diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py index 24c9a577f9..e11d2be2d2 100644 --- a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py @@ -9,7 +9,8 @@ from .ClusterPrinterConfigurationMaterial import ClusterPrinterConfigurationMate from ..BaseModel import BaseModel -## Class representing a cloud cluster printer configuration +## Class representing a cloud cluster printer configuration +# Also used for representing slots in a Material Station (as from Cura's perspective these are the same). class ClusterPrintCoreConfiguration(BaseModel): ## Creates a new cloud cluster printer configuration object @@ -18,7 +19,7 @@ class ClusterPrintCoreConfiguration(BaseModel): # \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], ClusterPrinterConfigurationMaterial], + material: Union[None, Dict[str, Any], ClusterPrinterConfigurationMaterial] = None, print_core_id: Optional[str] = None, **kwargs) -> None: self.extruder_index = extruder_index self.material = self.parseModel(ClusterPrinterConfigurationMaterial, material) if material else None diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py index 8b35fb7b5a..e54d99f1e6 100644 --- a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py @@ -101,6 +101,7 @@ class ClusterPrintJobStatus(BaseModel): extruders = [extruder.createConfigurationModel() for extruder in self.configuration or ()] configuration = PrinterConfigurationModel() configuration.setExtruderConfigurations(extruders) + configuration.setPrinterType(self.machine_variant) return configuration ## Updates an UM3 print job output model based on this cloud cluster print job. diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStation.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStation.py new file mode 100644 index 0000000000..295044b957 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStation.py @@ -0,0 +1,23 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Union, Dict, Any, List + +from ..BaseModel import BaseModel +from .ClusterPrinterMaterialStationSlot import ClusterPrinterMaterialStationSlot + + +## Class representing the data of a Material Station in the cluster. +class ClusterPrinterMaterialStation(BaseModel): + + ## Creates a new Material Station status. + # \param status: The status of the material station. + # \param: supported: Whether the material station is supported on this machine or not. + # \param material_slots: The active slots configurations of this material station. + def __init__(self, status: str, supported: bool = False, + material_slots: Union[None, Dict[str, Any], ClusterPrinterMaterialStationSlot] = None, + **kwargs) -> None: + self.status = status + self.supported = supported + self.material_slots = self.parseModels(ClusterPrinterMaterialStationSlot, material_slots)\ + if material_slots else [] # type: List[ClusterPrinterMaterialStationSlot] + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py new file mode 100644 index 0000000000..2e6bb6e7a5 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py @@ -0,0 +1,17 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration + + +## Class representing the data of a single slot in the material station. +class ClusterPrinterMaterialStationSlot(ClusterPrintCoreConfiguration): + + ## Create a new material station slot object. + # \param slot_index: The index of the slot in the material station (ranging 0 to 5). + # \param compatible: Whether the configuration is compatible with the print core. + # \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data). + def __init__(self, slot_index: int, compatible: bool, material_remaining: float, **kwargs): + self.slot_index = slot_index + self.compatible = compatible + self.material_remaining = material_remaining + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py index 7ab2082451..841cfd9fa1 100644 --- a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py @@ -1,14 +1,18 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from itertools import product from typing import List, Union, Dict, Optional, Any from PyQt5.QtCore import QUrl +from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel from .ClusterBuildPlate import ClusterBuildPlate from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration +from .ClusterPrinterMaterialStation import ClusterPrinterMaterialStation +from .ClusterPrinterMaterialStationSlot import ClusterPrinterMaterialStationSlot from ..BaseModel import BaseModel @@ -26,17 +30,19 @@ class ClusterPrinterStatus(BaseModel): # \param uuid: The unique ID of the printer, also known as GUID. # \param configuration: The active print core configurations of this printer. # \param reserved_by: A printer can be claimed by a specific print job. - # \param maintenance_required: Indicates if maintenance is necessary + # \param maintenance_required: Indicates if maintenance is necessary. # \param firmware_update_status: Whether the printer's firmware is up-to-date, value is one of: "up_to_date", - # "pending_update", "update_available", "update_in_progress", "update_failed", "update_impossible" - # \param latest_available_firmware: The version of the latest firmware that is available - # \param build_plate: The build plate that is on the printer + # "pending_update", "update_available", "update_in_progress", "update_failed", "update_impossible". + # \param latest_available_firmware: The version of the latest firmware that is available. + # \param build_plate: The build plate that is on the printer. + # \param material_station: The material station 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], 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], ClusterBuildPlate] = None, **kwargs) -> None: + build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None, + material_station: Union[Dict[str, Any], ClusterPrinterMaterialStation] = None, **kwargs) -> None: self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration) self.enabled = enabled @@ -52,6 +58,8 @@ class ClusterPrinterStatus(BaseModel): self.firmware_update_status = firmware_update_status self.latest_available_firmware = latest_available_firmware self.build_plate = self.parseModel(ClusterBuildPlate, build_plate) if build_plate else None + self.material_station = self.parseModel(ClusterPrinterMaterialStation, + material_station) if material_station else None super().__init__(**kwargs) ## Creates a new output model. @@ -71,8 +79,53 @@ class ClusterPrinterStatus(BaseModel): model.updateBuildplate(self.build_plate.type if self.build_plate else "glass") model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address))) - if model.printerConfiguration is not None: - for configuration, extruder_output, extruder_config in \ - zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations): - configuration.updateOutputModel(extruder_output) - configuration.updateConfigurationModel(extruder_config) + # Set the possible configurations based on whether a Material Station is present or not. + if self.material_station is not None and len(self.material_station.material_slots): + self._updateAvailableConfigurations(model) + if self.configuration is not None: + self._updateActiveConfiguration(model) + + def _updateActiveConfiguration(self, model: PrinterOutputModel) -> None: + configurations = zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations) + for configuration, extruder_output, extruder_config in configurations: + configuration.updateOutputModel(extruder_output) + configuration.updateConfigurationModel(extruder_config) + + def _updateAvailableConfigurations(self, model: PrinterOutputModel) -> None: + # Generate a list of configurations for the left extruder. + left_configurations = [slot for slot in self.material_station.material_slots if self._isSupportedConfiguration( + slot = slot, + extruder_index = 0 + )] + # Generate a list of configurations for the right extruder. + right_configurations = [slot for slot in self.material_station.material_slots if self._isSupportedConfiguration( + slot = slot, + extruder_index = 1 + )] + # Create a list of all available combinations between both print cores. + available_configurations = [self._createAvailableConfigurationFromPrinterConfiguration( + left_slot = left_slot, + right_slot = right_slot, + printer_configuration = model.printerConfiguration + ) for left_slot, right_slot in product(left_configurations, right_configurations)] + # Let Cura know which available configurations there are. + model.setAvailableConfigurations(available_configurations) + + ## Check if a configuration is supported in order to make it selectable by the user. + # We filter out any slot that is not supported by the extruder index, print core type or if the material is empty. + @staticmethod + def _isSupportedConfiguration(slot: ClusterPrinterMaterialStationSlot, extruder_index: int) -> bool: + return slot.extruder_index == extruder_index and slot.compatible and slot.material and \ + slot.material_remaining != 0 + + @staticmethod + def _createAvailableConfigurationFromPrinterConfiguration(left_slot: ClusterPrinterMaterialStationSlot, + right_slot: ClusterPrinterMaterialStationSlot, + printer_configuration: PrinterConfigurationModel + ) -> PrinterConfigurationModel: + available_configuration = PrinterConfigurationModel() + available_configuration.setExtruderConfigurations([left_slot.createConfigurationModel(), + right_slot.createConfigurationModel()]) + available_configuration.setPrinterType(printer_configuration.printerType) + available_configuration.setBuildplateConfiguration(printer_configuration.buildplateConfiguration) + return available_configuration diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index 3925ac364e..982c3a885d 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -62,6 +62,11 @@ class ClusterApiClient: 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()) + + ## Override print job configuration and force it to be printed. + def forcePrintJob(self, print_job_uuid: str) -> None: + url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) + self._manager.put(self._createEmptyRequest(url), json.dumps({"force": True}).encode()) ## Delete a print job from the queue. def deletePrintJob(self, print_job_uuid: str) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index 2c1ac2279d..3d71429ef8 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -38,16 +38,13 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): parent=parent ) - # API client for making requests to the print cluster. - self._cluster_api = ClusterApiClient(address, on_error=lambda error: print(error)) + self._cluster_api = None # type: Optional[ClusterApiClient] + # 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 - # Get the printers of this cluster to check if this device is a group host or not. - self._cluster_api.getPrinters(self._updatePrinters) - ## 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 @@ -81,26 +78,26 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): @pyqtSlot(str, name="sendJobToTop") def sendJobToTop(self, print_job_uuid: str) -> None: - self._cluster_api.movePrintJobToTop(print_job_uuid) + self._getApiClient().movePrintJobToTop(print_job_uuid) @pyqtSlot(str, name="deleteJobFromQueue") def deleteJobFromQueue(self, print_job_uuid: str) -> None: - self._cluster_api.deletePrintJob(print_job_uuid) + self._getApiClient().deletePrintJob(print_job_uuid) @pyqtSlot(str, name="forceSendJob") def forceSendJob(self, print_job_uuid: str) -> None: - pass # TODO + self._getApiClient().forcePrintJob(print_job_uuid) ## 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) + self._getApiClient().setPrintJobState(print_job_uuid, action) def _update(self) -> None: super()._update() - self._cluster_api.getPrinters(self._updatePrinters) - self._cluster_api.getPrintJobs(self._updatePrintJobs) + self._getApiClient().getPrinters(self._updatePrinters) + self._getApiClient().getPrintJobs(self._updatePrintJobs) self._updatePrintJobPreviewImages() ## Sync the material profiles in Cura with the printer. @@ -162,4 +159,10 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): 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) + self._getApiClient().getPrintJobPreviewImage(print_job.key, print_job.updatePreviewImageData) + + ## Get the API client instance. + def _getApiClient(self) -> ClusterApiClient: + if not self._cluster_api: + self._cluster_api = ClusterApiClient(self.address, on_error=lambda error: print(error)) + return self._cluster_api diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py index 6c45fd9fc0..e5ae7b83ac 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py @@ -236,7 +236,7 @@ class LocalClusterOutputDeviceManager: machine.setName(device.name) machine.setMetaDataEntry(self.META_NETWORK_KEY, device.key) machine.setMetaDataEntry("group_name", device.name) - + device.connect() machine.addConfiguredConnectionType(device.connectionType.value) CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device) diff --git a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterAction.py b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterAction.py index 5a37e1aeba..8c5f5c12ea 100644 --- a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterAction.py +++ b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterAction.py @@ -18,7 +18,7 @@ I18N_CATALOG = i18nCatalog("cura") ## Machine action that allows to connect the active machine to a networked devices. # TODO: in the future this should be part of the new discovery workflow baked into Cura. class UltimakerNetworkedPrinterAction(MachineAction): - + # Signal emitted when discovered devices have changed. discoveredDevicesChanged = pyqtSignal() @@ -34,58 +34,54 @@ class UltimakerNetworkedPrinterAction(MachineAction): ## Start listening to network discovery events via the plugin. @pyqtSlot(name = "startDiscovery") def startDiscovery(self) -> None: - network_plugin = self._getNetworkPlugin() - network_plugin.discoveredDevicesChanged.connect(self._onDeviceDiscoveryChanged) + self._networkPlugin.discoveredDevicesChanged.connect(self._onDeviceDiscoveryChanged) self.discoveredDevicesChanged.emit() # trigger at least once to populate the list ## Reset the discovered devices. @pyqtSlot(name = "reset") def reset(self) -> None: - self.restartDiscovery() + self.discoveredDevicesChanged.emit() # trigger to reset the list ## Reset the discovered devices. @pyqtSlot(name = "restartDiscovery") def restartDiscovery(self) -> None: - network_plugin = self._getNetworkPlugin() - network_plugin.startDiscovery() + self._networkPlugin.startDiscovery() self.discoveredDevicesChanged.emit() # trigger to reset the list ## Remove a manually added device. @pyqtSlot(str, str, name = "removeManualDevice") def removeManualDevice(self, key: str, address: str) -> None: - network_plugin = self._getNetworkPlugin() - network_plugin.removeManualDevice(key, address) + self._networkPlugin.removeManualDevice(key, address) ## Add a new manual device. Can replace an existing one by key. @pyqtSlot(str, str, name = "setManualDevice") def setManualDevice(self, key: str, address: str) -> None: - network_plugin = self._getNetworkPlugin() if key != "": - network_plugin.removeManualDevice(key) + self._networkPlugin.removeManualDevice(key) if address != "": - network_plugin.addManualDevice(address) + self._networkPlugin.addManualDevice(address) ## Get the devices discovered in the local network sorted by name. @pyqtProperty("QVariantList", notify = discoveredDevicesChanged) def foundDevices(self): - network_plugin = self._getNetworkPlugin() - discovered_devices = list(network_plugin.getDiscoveredDevices().values()) + discovered_devices = list(self._networkPlugin.getDiscoveredDevices().values()) discovered_devices.sort(key = lambda d: d.name) return discovered_devices ## Connect a device selected in the list with the active machine. @pyqtSlot(QObject, name = "associateActiveMachineWithPrinterDevice") def associateActiveMachineWithPrinterDevice(self, device: LocalClusterOutputDevice) -> None: - network_plugin = self._getNetworkPlugin() - network_plugin.associateActiveMachineWithPrinterDevice(device) + self._networkPlugin.associateActiveMachineWithPrinterDevice(device) ## Callback for when the list of discovered devices in the plugin was changed. def _onDeviceDiscoveryChanged(self) -> None: self.discoveredDevicesChanged.emit() ## Get the network manager from the plugin. - def _getNetworkPlugin(self) -> UM3OutputDevicePlugin: + @property + def _networkPlugin(self) -> UM3OutputDevicePlugin: if not self._network_plugin: - plugin = CuraApplication.getInstance().getOutputDeviceManager().getOutputDevicePlugin("UM3NetworkPrinting") - self._network_plugin = cast(UM3OutputDevicePlugin, plugin) + output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() + network_plugin = output_device_manager.getOutputDevicePlugin("UM3NetworkPrinting") + self._network_plugin = cast(UM3OutputDevicePlugin, network_plugin) return self._network_plugin diff --git a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py index f2c24e4802..02ce91800d 100644 --- a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py @@ -26,7 +26,7 @@ from .Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus # 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): - + META_NETWORK_KEY = "um_network_key" META_CLUSTER_ID = "um_cloud_cluster_id" @@ -42,21 +42,23 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): # States indicating if a print job is queued. QUEUED_PRINT_JOBS_STATES = {"queued", "error"} - + # Time in seconds since last network response after which we consider this device offline. # We set this a bit higher than some of the other intervals to make sure they don't overlap. - NETWORK_RESPONSE_CONSIDER_OFFLINE = 12.0 + NETWORK_RESPONSE_CONSIDER_OFFLINE = 10.0 # seconds 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 the last network response to determine if we are still connected. self._time_of_last_response = time() + self._time_of_last_request = time() # Set the display name from the properties self.setName(self.getProperty("name")) @@ -101,15 +103,18 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): 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) + @pyqtProperty(bool, notify=_clusterPrintersChanged) + def receivedData(self) -> bool: + return self._has_received_printers # Get the amount of printers in the cluster. @pyqtProperty(int, notify=_clusterPrintersChanged) def clusterSize(self) -> int: if not self._has_received_printers: - return 1 # prevent false positives when discovering new devices + discovered_size = self.getProperty("cluster_size") + if discovered_size == "": + return 1 # prevent false positives for new devices + return int(discovered_size) return len(self._printers) # Get the amount of printer in the cluster per type. @@ -294,6 +299,8 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): print_job_data.updateOutputModel(print_job) if print_job_data.printer_uuid: self._updateAssignedPrinter(print_job, print_job_data.printer_uuid) + if print_job_data.assigned_to: + self._updateAssignedPrinter(print_job, print_job_data.assigned_to) new_print_jobs.append(print_job) # Check which print job need to be removed (de-referenced). @@ -312,6 +319,8 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): model = remote_job.createOutputModel(ClusterOutputController(self)) if remote_job.printer_uuid: self._updateAssignedPrinter(model, remote_job.printer_uuid) + if remote_job.assigned_to: + self._updateAssignedPrinter(model, remote_job.assigned_to) return model ## Updates the printer assignment for the given print job model. diff --git a/plugins/VersionUpgrade/VersionUpgrade42to43/VersionUpgrade42to43.py b/plugins/VersionUpgrade/VersionUpgrade42to43/VersionUpgrade42to43.py index 71b665ad7c..6bcf43dc71 100644 --- a/plugins/VersionUpgrade/VersionUpgrade42to43/VersionUpgrade42to43.py +++ b/plugins/VersionUpgrade/VersionUpgrade42to43/VersionUpgrade42to43.py @@ -14,6 +14,40 @@ _renamed_profiles = {"generic_pla_0.4_coarse": "jbo_generic_pla_0.4_coarse", "generic_petg_0.4_medium": "jbo_generic_petg_medium", } +# - The variant "imade3d jellybox 0.4 mm 2-fans" for machine definition "imade3d_jellybox" +# is now "0.4 mm" for machine definition "imade3d jellybox_2". +# - Materials "imade3d_petg_green" and "imade3d_petg_pink" are now "imade3d_petg_175". +# - Materials "imade3d_pla_green" and "imade3d_pla_pink" are now "imade3d_petg_175". +# +# Note: Theoretically, the old material profiles with "_2-fans" at the end should be updated to: +# - machine definition: imade3d_jellybox_2 +# - variant: 0.4 mm (for jellybox 2) +# - material: (as an example) imade3d_petg_175_imade3d_jellybox_2_0.4_mm +# +# But this involves changing the definition of the global stack and the extruder stacks, which can cause more trouble +# than what we can fix. So, here, we update all material variants, regardless of having "_2-fans" at the end or not, to +# jellybox_0.4_mm. +# +_renamed_material_profiles = { # PETG + "imade3d_petg_green": "imade3d_petg_175", + "imade3d_petg_green_imade3d_jellybox": "imade3d_petg_175_imade3d_jellybox", + "imade3d_petg_green_imade3d_jellybox_0.4_mm": "imade3d_petg_175_imade3d_jellybox_0.4_mm", + "imade3d_petg_green_imade3d_jellybox_0.4_mm_2-fans": "imade3d_petg_175_imade3d_jellybox_0.4_mm", + "imade3d_petg_pink": "imade3d_petg_175", + "imade3d_petg_pink_imade3d_jellybox": "imade3d_petg_175_imade3d_jellybox", + "imade3d_petg_pink_imade3d_jellybox_0.4_mm": "imade3d_petg_175_imade3d_jellybox_0.4_mm", + "imade3d_petg_pink_imade3d_jellybox_0.4_mm_2-fans": "imade3d_petg_175_imade3d_jellybox_0.4_mm", + # PLA + "imade3d_pla_green": "imade3d_pla_175", + "imade3d_pla_green_imade3d_jellybox": "imade3d_pla_175_imade3d_jellybox", + "imade3d_pla_green_imade3d_jellybox_0.4_mm": "imade3d_pla_175_imade3d_jellybox_0.4_mm", + "imade3d_pla_green_imade3d_jellybox_0.4_mm_2-fans": "imade3d_pla_175_imade3d_jellybox_0.4_mm", + "imade3d_pla_pink": "imade3d_pla_175", + "imade3d_pla_pink_imade3d_jellybox": "imade3d_pla_175_imade3d_jellybox", + "imade3d_pla_pink_imade3d_jellybox_0.4_mm": "imade3d_pla_175_imade3d_jellybox_0.4_mm", + "imade3d_pla_pink_imade3d_jellybox_0.4_mm_2-fans": "imade3d_pla_175_imade3d_jellybox_0.4_mm", + } + _removed_settings = { "start_layers_at_same_position" } @@ -114,8 +148,8 @@ class VersionUpgrade42to43(VersionUpgrade): parser["containers"]["2"] = _renamed_profiles[parser["containers"]["2"]] material_id = parser["containers"]["3"] - if material_id.endswith("_2-fans"): - parser["containers"]["3"] = material_id.replace("_2-fans", "") + if material_id in _renamed_material_profiles: + parser["containers"]["3"] = _renamed_material_profiles[material_id] variant_id = parser["containers"]["4"] if variant_id.endswith("_2-fans"): diff --git a/resources/definitions/creality_base.def.json b/resources/definitions/creality_base.def.json index 440e93a948..de51cb1a53 100644 --- a/resources/definitions/creality_base.def.json +++ b/resources/definitions/creality_base.def.json @@ -107,8 +107,8 @@ "cool_fan_enabled": { "value": true }, "cool_min_layer_time": { "value": 10 }, - "adhesion_type": { "value": "'none' if support_enable else 'skirt'" }, - "brim_replaces_support": { "value": false}, + "adhesion_type": { "value": "'skirt'" }, + "brim_replaces_support": { "value": false }, "skirt_gap": { "value": 10.0 }, "skirt_line_count": { "value": 4 }, diff --git a/resources/definitions/creality_ender3.def.json b/resources/definitions/creality_ender3.def.json index e00e6eab63..4b7da65e4e 100644 --- a/resources/definitions/creality_ender3.def.json +++ b/resources/definitions/creality_ender3.def.json @@ -2,6 +2,11 @@ "name": "Creality Ender-3", "version": 2, "inherits": "creality_base", + "metadata": { + "quality_definition": "creality_base", + "visible": true, + "platform": "creality_ender3.stl" + }, "overrides": { "machine_name": { "default_value": "Creality Ender-3" }, "machine_width": { "default_value": 220 }, @@ -23,10 +28,5 @@ }, "gantry_height": { "value": 25 } - - }, - "metadata": { - "quality_definition": "creality_base", - "visible": true } } \ No newline at end of file diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index df96381317..f3143e08aa 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -205,6 +205,16 @@ "settable_per_extruder": false, "settable_per_meshgroup": false }, + "machine_heated_build_volume": + { + "label": "Has Build Volume Temperature Stabilization", + "description": "Whether the machine is able to stabilize the build volume temperature.", + "default_value": false, + "type": "bool", + "settable_per_mesh": false, + "settable_per_extruder": false, + "settable_per_meshgroup": false + }, "machine_center_is_zero": { "label": "Is Center Origin", @@ -2088,7 +2098,7 @@ "default_value": 210, "minimum_value_warning": "0", "maximum_value_warning": "285", - "enabled": "machine_nozzle_temp_enabled", + "enabled": false, "settable_per_extruder": true, "settable_per_mesh": false, "minimum_value": "-273.15" @@ -2104,7 +2114,7 @@ "minimum_value": "-273.15", "minimum_value_warning": "0", "maximum_value_warning": "285", - "enabled": true, + "enabled": "machine_heated_build_volume", "settable_per_mesh": false, "settable_per_extruder": false }, @@ -2191,9 +2201,9 @@ "resolve": "max(extruderValues('default_material_bed_temperature'))", "default_value": 60, "minimum_value": "-273.15", - "minimum_value_warning": "0", + "minimum_value_warning": "build_volume_temperature", "maximum_value_warning": "130", - "enabled": "machine_heated_bed and machine_gcode_flavor != \"UltiGCode\"", + "enabled": false, "settable_per_mesh": false, "settable_per_extruder": false, "settable_per_meshgroup": false @@ -2208,7 +2218,7 @@ "value": "default_material_bed_temperature", "resolve": "max(extruderValues('material_bed_temperature'))", "minimum_value": "-273.15", - "minimum_value_warning": "0", + "minimum_value_warning": "build_volume_temperature", "maximum_value_warning": "130", "enabled": "machine_heated_bed and machine_gcode_flavor != \"UltiGCode\"", "settable_per_mesh": false, @@ -2225,7 +2235,7 @@ "default_value": 60, "value": "resolveOrValue('material_bed_temperature')", "minimum_value": "-273.15", - "minimum_value_warning": "max(extruderValues('material_bed_temperature'))", + "minimum_value_warning": "max(build_volume_temperature, max(extruderValues('material_bed_temperature')))", "maximum_value_warning": "130", "enabled": "machine_heated_bed and machine_gcode_flavor != \"UltiGCode\"", "settable_per_mesh": false, diff --git a/resources/definitions/jgaurora_a3s.def.json b/resources/definitions/jgaurora_a3s.def.json new file mode 100644 index 0000000000..bd8d0bd0e3 --- /dev/null +++ b/resources/definitions/jgaurora_a3s.def.json @@ -0,0 +1,93 @@ +{ + "name": "JGAurora A3S", + "version": 2, + "inherits": "fdmprinter", + "metadata": { + "visible": true, + "author": "Samuel Pinches", + "manufacturer": "JGAurora", + "file_formats": "text/x-gcode", + "preferred_quality_type": "normal", + "machine_extruder_trains": + { + "0": "jgaurora_a3s_extruder_0" + } + }, + "overrides": { + "machine_name": { + "default_value": "JGAurora A3S" + }, + "machine_start_gcode": { + "default_value": "; -- START GCODE --\nG21 ;set units to millimetres\nG90 ;set to absolute positioning\nM106 S0 ;set fan speed to zero (turned off)\nG28 ;home all axis\nG92 E0 ;zero the extruded length\nG1 Z1 F1000 ;move up slightly\nG1 X60.0 Z0 E9.0 F1000.0;intro line\nG1 X100.0 E21.5 F1000.0 ;continue line\nG92 E0 ;zero the extruded length again\n; -- end of START GCODE --" + }, + "machine_end_gcode": { + "default_value": "; -- END GCODE --\nM104 S0 ;turn off nozzle heater\nM140 S0 ;turn off bed heater\nG91 ;set to relative positioning\nG1 E-10 F300 ;retract the filament slightly\nG90 ;set to absolute positioning\nG28 X0 ;move to the X-axis origin (Home)\nG0 Y200 F600 ;bring the bed to the front for easy print removal\nM84 ;turn off stepper motors\n; -- end of END GCODE --" + }, + "machine_width": { + "default_value": 205 + }, + "machine_height": { + "default_value": 205 + }, + "machine_depth": { + "default_value": 205 + }, + "machine_heated_bed": { + "default_value": true + }, + "machine_center_is_zero": { + "default_value": false + }, + "gantry_height": { + "value": "10" + }, + "machine_gcode_flavor": { + "default_value": "RepRap (Marlin/Sprinter)" + }, + "material_diameter": { + "default_value": 1.75 + }, + "material_print_temperature": { + "default_value": 210 + }, + "material_bed_temperature": { + "default_value": 65 + }, + "layer_height_0": { + "default_value": 0.12 + }, + "wall_thickness": { + "default_value": 1.2 + }, + "speed_print": { + "default_value": 35 + }, + "speed_infill": { + "default_value": 40 + }, + "speed_wall": { + "default_value": 30 + }, + "speed_topbottom": { + "default_value": 20 + }, + "speed_travel": { + "default_value": 100 + }, + "speed_layer_0": { + "default_value": 12 + }, + "support_enable": { + "default_value": true + }, + "retraction_enable": { + "default_value": true + }, + "retraction_amount": { + "default_value": 8 + }, + "retraction_speed": { + "default_value": 45 + } + } +} diff --git a/resources/definitions/ultimaker3.def.json b/resources/definitions/ultimaker3.def.json index ae36d6a3ae..bd7e96448a 100644 --- a/resources/definitions/ultimaker3.def.json +++ b/resources/definitions/ultimaker3.def.json @@ -30,7 +30,7 @@ "id": 9066, "check_urls": [ - "http://software.ultimaker.com/releases/firmware/9066/stable/um-update.swu.version" + "https://software.ultimaker.com/releases/firmware/9066/stable/um-update.swu.version" ], "update_url": "https://ultimaker.com/firmware" } diff --git a/resources/definitions/ultimaker3_extended.def.json b/resources/definitions/ultimaker3_extended.def.json index b3fe48ca11..c0d099366d 100644 --- a/resources/definitions/ultimaker3_extended.def.json +++ b/resources/definitions/ultimaker3_extended.def.json @@ -27,7 +27,7 @@ "id": 9511, "check_urls": [ - "http://software.ultimaker.com/releases/firmware/9066/stable/um-update.swu.version" + "https://software.ultimaker.com/releases/firmware/9066/stable/um-update.swu.version" ], "update_url": "https://ultimaker.com/firmware" } diff --git a/resources/definitions/ultimaker_s5.def.json b/resources/definitions/ultimaker_s5.def.json index 38d761f875..81b3a704ff 100644 --- a/resources/definitions/ultimaker_s5.def.json +++ b/resources/definitions/ultimaker_s5.def.json @@ -33,7 +33,7 @@ "weight": -2, "firmware_update_info": { "id": 9051, - "check_urls": ["http://software.ultimaker.com/releases/firmware/9051/stable/um-update.swu.version"], + "check_urls": ["https://software.ultimaker.com/releases/firmware/9051/stable/um-update.swu.version"], "update_url": "https://ultimaker.com/firmware" } }, @@ -44,6 +44,7 @@ "machine_depth": { "default_value": 240 }, "machine_height": { "default_value": 300 }, "machine_heated_bed": { "default_value": true }, + "machine_heated_build_volume": { "default_value": true }, "machine_nozzle_heat_up_speed": { "default_value": 1.4 }, "machine_nozzle_cool_down_speed": { "default_value": 0.8 }, "machine_head_with_fans_polygon": diff --git a/resources/extruders/jgaurora_a3s_extruder_0.def.json b/resources/extruders/jgaurora_a3s_extruder_0.def.json new file mode 100644 index 0000000000..430867b38b --- /dev/null +++ b/resources/extruders/jgaurora_a3s_extruder_0.def.json @@ -0,0 +1,16 @@ +{ + "id": "jgaurora_a3s_extruder_0", + "version": 2, + "name": "Extruder 1", + "inherits": "fdmextruder", + "metadata": { + "machine": "jgaurora_a3s", + "position": "0" + }, + + "overrides": { + "extruder_nr": { "default_value": 0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 } + } +} diff --git a/resources/i18n/fr_FR/fdmprinter.def.json.po b/resources/i18n/fr_FR/fdmprinter.def.json.po index 80a36ffa5e..5e7190fc47 100644 --- a/resources/i18n/fr_FR/fdmprinter.def.json.po +++ b/resources/i18n/fr_FR/fdmprinter.def.json.po @@ -1223,7 +1223,7 @@ msgstr "Imprimer les parties du modèle qui sont horizontalement plus fines que #: fdmprinter.def.json msgctxt "xy_offset label" msgid "Horizontal Expansion" -msgstr "Vitesse d’impression horizontale" +msgstr "Expansion horizontale" #: fdmprinter.def.json msgctxt "xy_offset description" diff --git a/resources/meshes/creality_ender3.stl b/resources/meshes/creality_ender3.stl new file mode 100644 index 0000000000..b1fd101aad Binary files /dev/null and b/resources/meshes/creality_ender3.stl differ diff --git a/resources/qml/Settings/SettingCheckBox.qml b/resources/qml/Settings/SettingCheckBox.qml index 694c4125ea..f5100eab74 100644 --- a/resources/qml/Settings/SettingCheckBox.qml +++ b/resources/qml/Settings/SettingCheckBox.qml @@ -112,7 +112,7 @@ SettingItem return UM.Theme.getColor("setting_validation_warning"); } // Validation is OK. - if (control.containsMouse || control.activeFocus) + if (control.containsMouse || control.activeFocus || hovered) { return UM.Theme.getColor("setting_control_border_highlight") } diff --git a/resources/qml/Settings/SettingComboBox.qml b/resources/qml/Settings/SettingComboBox.qml index 6fcc1951a4..0b7f494a7d 100644 --- a/resources/qml/Settings/SettingComboBox.qml +++ b/resources/qml/Settings/SettingComboBox.qml @@ -12,7 +12,6 @@ SettingItem { id: base property var focusItem: control - contents: Cura.ComboBox { id: control @@ -21,6 +20,7 @@ SettingItem textRole: "value" anchors.fill: parent + highlighted: base.hovered onActivated: { diff --git a/resources/qml/WelcomePages/AddPrinterByIpContent.qml b/resources/qml/WelcomePages/AddPrinterByIpContent.qml index 4aec5879c1..5ab0217f01 100644 --- a/resources/qml/WelcomePages/AddPrinterByIpContent.qml +++ b/resources/qml/WelcomePages/AddPrinterByIpContent.qml @@ -99,7 +99,7 @@ Item font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") renderType: Text.NativeRendering - text: catalog.i18nc("@label", "Enter the IP address or hostname of your printer on the network.") + text: catalog.i18nc("@label", "Enter the IP address of your printer on the network.") } Item diff --git a/resources/qml/Widgets/ComboBox.qml b/resources/qml/Widgets/ComboBox.qml index 6ce7c6da45..d1edcca69c 100644 --- a/resources/qml/Widgets/ComboBox.qml +++ b/resources/qml/Widgets/ComboBox.qml @@ -14,7 +14,7 @@ import Cura 1.1 as Cura ComboBox { id: control - + property bool highlighted: False background: Rectangle { color: @@ -24,7 +24,7 @@ ComboBox return UM.Theme.getColor("setting_control_disabled") } - if (control.hovered || control.activeFocus) + if (control.hovered || control.activeFocus || control.highlighted) { return UM.Theme.getColor("setting_control_highlight") } @@ -41,7 +41,7 @@ ComboBox return UM.Theme.getColor("setting_control_disabled_border") } - if (control.hovered || control.activeFocus) + if (control.hovered || control.activeFocus || control.highlighted) { return UM.Theme.getColor("setting_control_border_highlight") } diff --git a/tests/PrinterOutput/Models/TestPrinterOutputModel.py b/tests/PrinterOutput/Models/TestPrinterOutputModel.py index 3fdb61adbd..9848e0a5fa 100644 --- a/tests/PrinterOutput/Models/TestPrinterOutputModel.py +++ b/tests/PrinterOutput/Models/TestPrinterOutputModel.py @@ -5,11 +5,14 @@ from unittest.mock import MagicMock import pytest from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel +from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel +from cura.PrinterOutput.Peripheral import Peripheral test_validate_data_get_set = [ {"attribute": "name", "value": "YAY"}, {"attribute": "targetBedTemperature", "value": 192}, + {"attribute": "cameraUrl", "value": "YAY!"} ] test_validate_data_get_update = [ @@ -22,6 +25,7 @@ test_validate_data_get_update = [ {"attribute": "targetBedTemperature", "value": 9001}, {"attribute": "activePrintJob", "value": PrintJobOutputModel(MagicMock())}, {"attribute": "state", "value": "BEEPBOOP"}, + ] @@ -79,3 +83,67 @@ def test_getAndUpdate(data): getattr(model, "update" + attribute)(data["value"]) # The signal should not fire again assert signal.emit.call_count == 1 + + +def test_peripherals(): + model = PrinterOutputModel(MagicMock()) + model.peripheralsChanged = MagicMock() + + peripheral = MagicMock(spec=Peripheral) + peripheral.name = "test" + peripheral2 = MagicMock(spec=Peripheral) + peripheral2.name = "test2" + + model.addPeripheral(peripheral) + assert model.peripheralsChanged.emit.call_count == 1 + model.addPeripheral(peripheral2) + assert model.peripheralsChanged.emit.call_count == 2 + + assert model.peripherals == "test, test2" + + model.removePeripheral(peripheral) + assert model.peripheralsChanged.emit.call_count == 3 + assert model.peripherals == "test2" + + +def test_availableConfigurations_addConfiguration(): + model = PrinterOutputModel(MagicMock()) + + configuration = MagicMock(spec = PrinterConfigurationModel) + + model.addAvailableConfiguration(configuration) + assert model.availableConfigurations == [configuration] + + +def test_availableConfigurations_addConfigTwice(): + model = PrinterOutputModel(MagicMock()) + + configuration = MagicMock(spec=PrinterConfigurationModel) + + model.setAvailableConfigurations([configuration]) + assert model.availableConfigurations == [configuration] + + # Adding it again should not have any effect + model.addAvailableConfiguration(configuration) + assert model.availableConfigurations == [configuration] + + +def test_availableConfigurations_removeConfig(): + model = PrinterOutputModel(MagicMock()) + + configuration = MagicMock(spec=PrinterConfigurationModel) + + model.addAvailableConfiguration(configuration) + model.removeAvailableConfiguration(configuration) + assert model.availableConfigurations == [] + + +def test_removeAlreadyRemovedConfiguration(): + model = PrinterOutputModel(MagicMock()) + + configuration = MagicMock(spec=PrinterConfigurationModel) + model.availableConfigurationsChanged = MagicMock() + model.removeAvailableConfiguration(configuration) + assert model.availableConfigurationsChanged.emit.call_count == 0 + assert model.availableConfigurations == [] + diff --git a/tests/PrinterOutput/TestPrinterOutputDevice.py b/tests/PrinterOutput/TestPrinterOutputDevice.py index 4c12a34859..7a9e4e2cc5 100644 --- a/tests/PrinterOutput/TestPrinterOutputDevice.py +++ b/tests/PrinterOutput/TestPrinterOutputDevice.py @@ -1,6 +1,12 @@ from unittest.mock import MagicMock import pytest +from unittest.mock import patch + +from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel +from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel +from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel +from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice test_validate_data_get_set = [ @@ -8,10 +14,15 @@ test_validate_data_get_set = [ {"attribute": "connectionState", "value": 1}, ] +@pytest.fixture() +def printer_output_device(): + with patch("UM.Application.Application.getInstance"): + return PrinterOutputDevice("whatever") + @pytest.mark.parametrize("data", test_validate_data_get_set) -def test_getAndSet(data): - model = PrinterOutputDevice("whatever") +def test_getAndSet(data, printer_output_device): + model = printer_output_device # Convert the first letter into a capital attribute = list(data["attribute"]) @@ -35,3 +46,43 @@ def test_getAndSet(data): getattr(model, "set" + attribute)(data["value"]) # The signal should not fire again assert signal.emit.call_count == 1 + + +def test_uniqueConfigurations(printer_output_device): + printer = PrinterOutputModel(MagicMock()) + # Add a printer and fire the signal that ensures they get hooked up correctly. + printer_output_device._printers = [printer] + printer_output_device._onPrintersChanged() + + assert printer_output_device.uniqueConfigurations == [] + configuration = PrinterConfigurationModel() + printer.addAvailableConfiguration(configuration) + + assert printer_output_device.uniqueConfigurations == [configuration] + + # Once the type of printer is set, it's active configuration counts as being set. + # In that case, that should also be added to the list of available configurations + printer.updateType("blarg!") + loaded_material = MaterialOutputModel(guid = "", type = "PLA", color = "Blue", brand = "Generic", name = "Blue PLA") + loaded_left_extruder = ExtruderConfigurationModel(0) + loaded_left_extruder.setMaterial(loaded_material) + loaded_right_extruder = ExtruderConfigurationModel(1) + loaded_right_extruder.setMaterial(loaded_material) + printer.printerConfiguration.setExtruderConfigurations([loaded_left_extruder, loaded_right_extruder]) + assert printer_output_device.uniqueConfigurations == [configuration, printer.printerConfiguration] + + +def test_uniqueConfigurations_empty_is_filtered_out(printer_output_device): + printer = PrinterOutputModel(MagicMock()) + # Add a printer and fire the signal that ensures they get hooked up correctly. + printer_output_device._printers = [printer] + printer_output_device._onPrintersChanged() + + printer.updateType("blarg!") + empty_material = MaterialOutputModel(guid = "", type = "empty", color = "empty", brand = "Generic", name = "Empty") + empty_left_extruder = ExtruderConfigurationModel(0) + empty_left_extruder.setMaterial(empty_material) + empty_right_extruder = ExtruderConfigurationModel(1) + empty_right_extruder.setMaterial(empty_material) + printer.printerConfiguration.setExtruderConfigurations([empty_left_extruder, empty_right_extruder]) + assert printer_output_device.uniqueConfigurations == [] diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 358ed5afbb..1e305c6549 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -1,9 +1,10 @@ -import webbrowser from datetime import datetime from unittest.mock import MagicMock, patch import requests +from PyQt5.QtGui import QDesktopServices + from UM.Preferences import Preferences from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.AuthorizationService import AuthorizationService @@ -172,12 +173,12 @@ def test_storeAuthData(get_user_profile) -> None: @patch.object(LocalAuthorizationServer, "stop") @patch.object(LocalAuthorizationServer, "start") -@patch.object(webbrowser, "open_new") -def test_localAuthServer(webbrowser_open, start_auth_server, stop_auth_server) -> None: +@patch.object(QDesktopServices, "openUrl") +def test_localAuthServer(QDesktopServices_openUrl, start_auth_server, stop_auth_server) -> None: preferences = Preferences() authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) authorization_service.startAuthorizationFlow() - assert webbrowser_open.call_count == 1 + assert QDesktopServices_openUrl.call_count == 1 # Ensure that the Authorization service tried to start the server. assert start_auth_server.call_count == 1