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