1# Copyright (c) 2019 Ultimaker B.V. 2# Cura is released under the terms of the LGPLv3 or higher. 3 4from collections import defaultdict 5import threading 6from typing import Any, Dict, Optional, Set, TYPE_CHECKING, List 7import uuid 8 9from PyQt5.QtCore import pyqtProperty, pyqtSlot, pyqtSignal 10 11from UM.Decorators import deprecated, override 12from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase 13from UM.Settings.ContainerStack import ContainerStack 14from UM.Settings.SettingInstance import InstanceState 15from UM.Settings.ContainerRegistry import ContainerRegistry 16from UM.Settings.Interfaces import PropertyEvaluationContext 17from UM.Logger import Logger 18from UM.Resources import Resources 19from UM.Platform import Platform 20from UM.Util import parseBool 21 22import cura.CuraApplication 23from cura.PrinterOutput.PrinterOutputDevice import ConnectionType 24 25from . import Exceptions 26from .CuraContainerStack import CuraContainerStack 27 28if TYPE_CHECKING: 29 from cura.Settings.ExtruderStack import ExtruderStack 30 31 32class GlobalStack(CuraContainerStack): 33 """Represents the Global or Machine stack and its related containers.""" 34 35 def __init__(self, container_id: str) -> None: 36 super().__init__(container_id) 37 38 self.setMetaDataEntry("type", "machine") # For backward compatibility 39 40 # TL;DR: If Cura is looking for printers that belong to the same group, it should use "group_id". 41 # Each GlobalStack by default belongs to a group which is identified via "group_id". This group_id is used to 42 # figure out which GlobalStacks are in the printer cluster for example without knowing the implementation 43 # details such as the um_network_key or some other identifier that's used by the underlying device plugin. 44 self.setMetaDataEntry("group_id", str(uuid.uuid4())) # Assign a new GlobalStack to a unique group by default 45 46 self._extruders = {} # type: Dict[str, "ExtruderStack"] 47 48 # This property is used to track which settings we are calculating the "resolve" for 49 # and if so, to bypass the resolve to prevent an infinite recursion that would occur 50 # if the resolve function tried to access the same property it is a resolve for. 51 # Per thread we have our own resolving_settings, or strange things sometimes occur. 52 self._resolving_settings = defaultdict(set) #type: Dict[str, Set[str]] # keys are thread names 53 54 # Since the metadatachanged is defined in container stack, we can't use it here as a notifier for pyqt 55 # properties. So we need to tie them together like this. 56 self.metaDataChanged.connect(self.configuredConnectionTypesChanged) 57 58 self.setDirty(False) 59 60 extrudersChanged = pyqtSignal() 61 configuredConnectionTypesChanged = pyqtSignal() 62 63 @pyqtProperty("QVariantMap", notify = extrudersChanged) 64 @deprecated("Please use extruderList instead.", "4.4") 65 def extruders(self) -> Dict[str, "ExtruderStack"]: 66 """Get the list of extruders of this stack. 67 68 :return: The extruders registered with this stack. 69 """ 70 71 return self._extruders 72 73 @pyqtProperty("QVariantList", notify = extrudersChanged) 74 def extruderList(self) -> List["ExtruderStack"]: 75 result_tuple_list = sorted(list(self._extruders.items()), key=lambda x: int(x[0])) 76 result_list = [item[1] for item in result_tuple_list] 77 78 machine_extruder_count = self.getProperty("machine_extruder_count", "value") 79 return result_list[:machine_extruder_count] 80 81 @pyqtProperty(int, constant = True) 82 def maxExtruderCount(self): 83 return len(self.getMetaDataEntry("machine_extruder_trains")) 84 85 @pyqtProperty(bool, notify=configuredConnectionTypesChanged) 86 def supportsNetworkConnection(self): 87 return self.getMetaDataEntry("supports_network_connection", False) 88 89 @classmethod 90 def getLoadingPriority(cls) -> int: 91 return 2 92 93 @pyqtProperty("QVariantList", notify=configuredConnectionTypesChanged) 94 def configuredConnectionTypes(self) -> List[int]: 95 """The configured connection types can be used to find out if the global 96 97 stack is configured to be connected with a printer, without having to 98 know all the details as to how this is exactly done (and without 99 actually setting the stack to be active). 100 101 This data can then in turn also be used when the global stack is active; 102 If we can't get a network connection, but it is configured to have one, 103 we can display a different icon to indicate the difference. 104 """ 105 # Requesting it from the metadata actually gets them as strings (as that's what you get from serializing). 106 # But we do want them returned as a list of ints (so the rest of the code can directly compare) 107 connection_types = self.getMetaDataEntry("connection_type", "").split(",") 108 result = [] 109 for connection_type in connection_types: 110 if connection_type != "": 111 try: 112 result.append(int(connection_type)) 113 except ValueError: 114 # We got invalid data, probably a None. 115 pass 116 return result 117 118 # Returns a boolean indicating if this machine has a remote connection. A machine is considered as remotely 119 # connected if its connection types contain one of the following values: 120 # - ConnectionType.NetworkConnection 121 # - ConnectionType.CloudConnection 122 @pyqtProperty(bool, notify = configuredConnectionTypesChanged) 123 def hasRemoteConnection(self) -> bool: 124 has_remote_connection = False 125 126 for connection_type in self.configuredConnectionTypes: 127 has_remote_connection |= connection_type in [ConnectionType.NetworkConnection.value, 128 ConnectionType.CloudConnection.value] 129 return has_remote_connection 130 131 def addConfiguredConnectionType(self, connection_type: int) -> None: 132 """:sa configuredConnectionTypes""" 133 134 configured_connection_types = self.configuredConnectionTypes 135 if connection_type not in configured_connection_types: 136 # Store the values as a string. 137 configured_connection_types.append(connection_type) 138 self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types])) 139 140 def removeConfiguredConnectionType(self, connection_type: int) -> None: 141 """:sa configuredConnectionTypes""" 142 143 configured_connection_types = self.configuredConnectionTypes 144 if connection_type in configured_connection_types: 145 # Store the values as a string. 146 configured_connection_types.remove(connection_type) 147 self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types])) 148 149 @classmethod 150 def getConfigurationTypeFromSerialized(cls, serialized: str) -> Optional[str]: 151 configuration_type = super().getConfigurationTypeFromSerialized(serialized) 152 if configuration_type == "machine": 153 return "machine_stack" 154 return configuration_type 155 156 def getIntentCategory(self) -> str: 157 intent_category = "default" 158 for extruder in self.extruderList: 159 category = extruder.intent.getMetaDataEntry("intent_category", "default") 160 if category != "default" and category != intent_category: 161 intent_category = category 162 return intent_category 163 164 def getBuildplateName(self) -> Optional[str]: 165 name = None 166 if self.variant.getId() != "empty_variant": 167 name = self.variant.getName() 168 return name 169 170 @pyqtProperty(str, constant = True) 171 def preferred_output_file_formats(self) -> str: 172 return self.getMetaDataEntry("file_formats") 173 174 def addExtruder(self, extruder: ContainerStack) -> None: 175 """Add an extruder to the list of extruders of this stack. 176 177 :param extruder: The extruder to add. 178 179 :raise Exceptions.TooManyExtrudersError: Raised when trying to add an extruder while we 180 already have the maximum number of extruders. 181 """ 182 183 position = extruder.getMetaDataEntry("position") 184 if position is None: 185 Logger.log("w", "No position defined for extruder {extruder}, cannot add it to stack {stack}", extruder = extruder.id, stack = self.id) 186 return 187 188 if any(item.getId() == extruder.id for item in self._extruders.values()): 189 Logger.log("w", "Extruder [%s] has already been added to this stack [%s]", extruder.id, self.getId()) 190 return 191 192 self._extruders[position] = extruder 193 self.extrudersChanged.emit() 194 Logger.log("i", "Extruder[%s] added to [%s] at position [%s]", extruder.id, self.id, position) 195 196 @override(ContainerStack) 197 def getProperty(self, key: str, property_name: str, context: Optional[PropertyEvaluationContext] = None) -> Any: 198 """Overridden from ContainerStack 199 200 This will return the value of the specified property for the specified setting, 201 unless the property is "value" and that setting has a "resolve" function set. 202 When a resolve is set, it will instead try and execute the resolve first and 203 then fall back to the normal "value" property. 204 205 :param key: The setting key to get the property of. 206 :param property_name: The property to get the value of. 207 208 :return: The value of the property for the specified setting, or None if not found. 209 """ 210 211 if not self.definition.findDefinitions(key = key): 212 return None 213 214 if context: 215 context.pushContainer(self) 216 217 # Handle the "resolve" property. 218 #TODO: Why the hell does this involve threading? 219 # Answer: Because if multiple threads start resolving properties that have the same underlying properties that's 220 # related, without taking a note of which thread a resolve paths belongs to, they can bump into each other and 221 # generate unexpected behaviours. 222 if self._shouldResolve(key, property_name, context): 223 current_thread = threading.current_thread() 224 self._resolving_settings[current_thread.name].add(key) 225 resolve = super().getProperty(key, "resolve", context) 226 self._resolving_settings[current_thread.name].remove(key) 227 if resolve is not None: 228 return resolve 229 230 # Handle the "limit_to_extruder" property. 231 limit_to_extruder = super().getProperty(key, "limit_to_extruder", context) 232 if limit_to_extruder is not None: 233 if limit_to_extruder == -1: 234 limit_to_extruder = int(cura.CuraApplication.CuraApplication.getInstance().getMachineManager().defaultExtruderPosition) 235 limit_to_extruder = str(limit_to_extruder) 236 if limit_to_extruder is not None and limit_to_extruder != "-1" and limit_to_extruder in self._extruders: 237 if super().getProperty(key, "settable_per_extruder", context): 238 result = self._extruders[str(limit_to_extruder)].getProperty(key, property_name, context) 239 if result is not None: 240 if context: 241 context.popContainer() 242 return result 243 else: 244 Logger.log("e", "Setting {setting} has limit_to_extruder but is not settable per extruder!", setting = key) 245 246 result = super().getProperty(key, property_name, context) 247 if context: 248 context.popContainer() 249 return result 250 251 @override(ContainerStack) 252 def setNextStack(self, stack: CuraContainerStack, connect_signals: bool = True) -> None: 253 """Overridden from ContainerStack 254 255 This will simply raise an exception since the Global stack cannot have a next stack. 256 """ 257 258 raise Exceptions.InvalidOperationError("Global stack cannot have a next stack!") 259 260 # Determine whether or not we should try to get the "resolve" property instead of the 261 # requested property. 262 def _shouldResolve(self, key: str, property_name: str, context: Optional[PropertyEvaluationContext] = None) -> bool: 263 if property_name != "value": 264 # Do not try to resolve anything but the "value" property 265 return False 266 267 if not self.definition.getProperty(key, "resolve"): 268 # If there isn't a resolve set for this setting, there isn't anything to do here. 269 return False 270 271 current_thread = threading.current_thread() 272 if key in self._resolving_settings[current_thread.name]: 273 # To prevent infinite recursion, if getProperty is called with the same key as 274 # we are already trying to resolve, we should not try to resolve again. Since 275 # this can happen multiple times when trying to resolve a value, we need to 276 # track all settings that are being resolved. 277 return False 278 279 if self.hasUserValue(key): 280 # When the user has explicitly set a value, we should ignore any resolve and just return that value. 281 return False 282 283 return True 284 285 def isValid(self) -> bool: 286 """Perform some sanity checks on the global stack 287 288 Sanity check for extruders; they must have positions 0 and up to machine_extruder_count - 1 289 """ 290 container_registry = ContainerRegistry.getInstance() 291 extruder_trains = container_registry.findContainerStacks(type = "extruder_train", machine = self.getId()) 292 293 machine_extruder_count = self.getProperty("machine_extruder_count", "value") 294 extruder_check_position = set() 295 for extruder_train in extruder_trains: 296 extruder_position = extruder_train.getMetaDataEntry("position") 297 extruder_check_position.add(extruder_position) 298 299 for check_position in range(machine_extruder_count): 300 if str(check_position) not in extruder_check_position: 301 return False 302 return True 303 304 def getHeadAndFansCoordinates(self): 305 return self.getProperty("machine_head_with_fans_polygon", "value") 306 307 @pyqtProperty(bool, constant = True) 308 def hasMaterials(self) -> bool: 309 return parseBool(self.getMetaDataEntry("has_materials", False)) 310 311 @pyqtProperty(bool, constant = True) 312 def hasVariants(self) -> bool: 313 return parseBool(self.getMetaDataEntry("has_variants", False)) 314 315 @pyqtProperty(bool, constant = True) 316 def hasVariantBuildplates(self) -> bool: 317 return parseBool(self.getMetaDataEntry("has_variant_buildplates", False)) 318 319 @pyqtSlot(result = str) 320 def getDefaultFirmwareName(self) -> str: 321 """Get default firmware file name if one is specified in the firmware""" 322 323 machine_has_heated_bed = self.getProperty("machine_heated_bed", "value") 324 325 baudrate = 250000 326 if Platform.isLinux(): 327 # Linux prefers a baudrate of 115200 here because older versions of 328 # pySerial did not support a baudrate of 250000 329 baudrate = 115200 330 331 # If a firmware file is available, it should be specified in the definition for the printer 332 hex_file = self.getMetaDataEntry("firmware_file", None) 333 if machine_has_heated_bed: 334 hex_file = self.getMetaDataEntry("firmware_hbk_file", hex_file) 335 336 if not hex_file: 337 Logger.log("w", "There is no firmware for machine %s.", self.getBottom().id) 338 return "" 339 340 try: 341 return Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.Firmware, hex_file.format(baudrate=baudrate)) 342 except FileNotFoundError: 343 Logger.log("w", "Firmware file %s not found.", hex_file) 344 return "" 345 346 def getName(self) -> str: 347 return self._metadata.get("group_name", self._metadata.get("name", "")) 348 349 def setName(self, name: "str") -> None: 350 super().setName(name) 351 352 nameChanged = pyqtSignal() 353 name = pyqtProperty(str, fget=getName, fset=setName, notify=nameChanged) 354 355 356 357## private: 358global_stack_mime = MimeType( 359 name = "application/x-cura-globalstack", 360 comment = "Cura Global Stack", 361 suffixes = ["global.cfg"] 362) 363 364MimeTypeDatabase.addMimeType(global_stack_mime) 365ContainerRegistry.addContainerTypeByName(GlobalStack, "global_stack", global_stack_mime.name) 366