1# Copyright (c) 2018 Ultimaker B.V.
2# Cura is released under the terms of the LGPLv3 or higher.
3
4from UM.FileHandler.FileHandler import FileHandler #For typing.
5from UM.Logger import Logger
6from UM.Scene.SceneNode import SceneNode #For typing.
7from cura.API import Account
8from cura.CuraApplication import CuraApplication
9
10from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice, ConnectionState, ConnectionType
11
12from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator
13from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication
14from time import time
15from typing import Callable, Dict, List, Optional, Union
16from enum import IntEnum
17
18import os  # To get the username
19import gzip
20
21from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
22
23
24class AuthState(IntEnum):
25    NotAuthenticated = 1
26    AuthenticationRequested = 2
27    Authenticated = 3
28    AuthenticationDenied = 4
29    AuthenticationReceived = 5
30
31
32class NetworkedPrinterOutputDevice(PrinterOutputDevice):
33    authenticationStateChanged = pyqtSignal()
34
35    def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None) -> None:
36        super().__init__(device_id = device_id, connection_type = connection_type, parent = parent)
37        self._manager = None    # type: Optional[QNetworkAccessManager]
38        self._timeout_time = 10  # After how many seconds of no response should a timeout occur?
39
40        self._last_response_time = None     # type: Optional[float]
41        self._last_request_time = None      # type: Optional[float]
42
43        self._api_prefix = ""
44        self._address = address
45        self._properties = properties
46        self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(),
47                                       CuraApplication.getInstance().getVersion())
48
49        self._onFinishedCallbacks = {}      # type: Dict[str, Callable[[QNetworkReply], None]]
50        self._authentication_state = AuthState.NotAuthenticated
51
52        # QHttpMultiPart objects need to be kept alive and not garbage collected during the
53        # HTTP which uses them. We hold references to these QHttpMultiPart objects here.
54        self._kept_alive_multiparts = {}        # type: Dict[QNetworkReply, QHttpMultiPart]
55
56        self._sending_gcode = False
57        self._compressing_gcode = False
58        self._gcode = []                    # type: List[str]
59        self._connection_state_before_timeout = None    # type: Optional[ConnectionState]
60
61    def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
62                     file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None:
63        raise NotImplementedError("requestWrite needs to be implemented")
64
65    def setAuthenticationState(self, authentication_state: AuthState) -> None:
66        if self._authentication_state != authentication_state:
67            self._authentication_state = authentication_state
68            self.authenticationStateChanged.emit()
69
70    @pyqtProperty(int, notify = authenticationStateChanged)
71    def authenticationState(self) -> AuthState:
72        return self._authentication_state
73
74    def _compressDataAndNotifyQt(self, data_to_append: str) -> bytes:
75        compressed_data = gzip.compress(data_to_append.encode("utf-8"))
76        self._progress_message.setProgress(-1)  # Tickle the message so that it's clear that it's still being used.
77        QCoreApplication.processEvents()  # Ensure that the GUI does not freeze.
78
79        # Pretend that this is a response, as zipping might take a bit of time.
80        # If we don't do this, the device might trigger a timeout.
81        self._last_response_time = time()
82        return compressed_data
83
84    def _compressGCode(self) -> Optional[bytes]:
85        self._compressing_gcode = True
86
87        max_chars_per_line = int(1024 * 1024 / 4)  # 1/4 MB per line.
88        """Mash the data into single string"""
89        file_data_bytes_list = []
90        batched_lines = []
91        batched_lines_count = 0
92
93        for line in self._gcode:
94            if not self._compressing_gcode:
95                self._progress_message.hide()
96                # Stop trying to zip / send as abort was called.
97                return None
98
99            # if the gcode was read from a gcode file, self._gcode will be a list of all lines in that file.
100            # Compressing line by line in this case is extremely slow, so we need to batch them.
101            batched_lines.append(line)
102            batched_lines_count += len(line)
103
104            if batched_lines_count >= max_chars_per_line:
105                file_data_bytes_list.append(self._compressDataAndNotifyQt("".join(batched_lines)))
106                batched_lines = []
107                batched_lines_count = 0
108
109        # Don't miss the last batch (If any)
110        if len(batched_lines) != 0:
111            file_data_bytes_list.append(self._compressDataAndNotifyQt("".join(batched_lines)))
112
113        self._compressing_gcode = False
114        return b"".join(file_data_bytes_list)
115
116    def _update(self) -> None:
117        if self._last_response_time:
118            time_since_last_response = time() - self._last_response_time
119        else:
120            time_since_last_response = 0
121
122        if self._last_request_time:
123            time_since_last_request = time() - self._last_request_time
124        else:
125            time_since_last_request = float("inf")  # An irrelevantly large number of seconds
126
127        if time_since_last_response > self._timeout_time >= time_since_last_request:
128            # Go (or stay) into timeout.
129            if self._connection_state_before_timeout is None:
130                self._connection_state_before_timeout = self._connection_state
131
132            self.setConnectionState(ConnectionState.Closed)
133
134        elif self._connection_state == ConnectionState.Closed:
135            # Go out of timeout.
136            if self._connection_state_before_timeout is not None:   # sanity check, but it should never be None here
137                self.setConnectionState(self._connection_state_before_timeout)
138                self._connection_state_before_timeout = None
139
140    def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
141        url = QUrl("http://" + self._address + self._api_prefix + target)
142        request = QNetworkRequest(url)
143        if content_type is not None:
144            request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
145        request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
146        return request
147
148    def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
149        """This method was only available privately before, but it was actually called from SendMaterialJob.py.
150
151        We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
152        """
153        return self._createFormPart(content_header, data, content_type)
154
155    def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
156        part = QHttpPart()
157
158        if not content_header.startswith("form-data;"):
159            content_header = "form-data; " + content_header
160        part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header)
161
162        if content_type is not None:
163            part.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
164
165        part.setBody(data)
166        return part
167
168    def _getUserName(self) -> str:
169        """Convenience function to get the username, either from the cloud or from the OS."""
170
171        # check first if we are logged in with the Ultimaker Account
172        account = CuraApplication.getInstance().getCuraAPI().account  # type: Account
173        if account and account.isLoggedIn:
174            return account.userName
175
176        # Otherwise get the username from the US
177        # The code below was copied from the getpass module, as we try to use as little dependencies as possible.
178        for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
179            user = os.environ.get(name)
180            if user:
181                return user
182        return "Unknown User"  # Couldn't find out username.
183
184    def _clearCachedMultiPart(self, reply: QNetworkReply) -> None:
185        if reply in self._kept_alive_multiparts:
186            del self._kept_alive_multiparts[reply]
187
188    def _validateManager(self) -> None:
189        if self._manager is None:
190            self._createNetworkManager()
191        assert (self._manager is not None)
192
193    def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json",
194            on_finished: Optional[Callable[[QNetworkReply], None]] = None,
195            on_progress: Optional[Callable[[int, int], None]] = None) -> None:
196        """Sends a put request to the given path.
197
198        :param url: The path after the API prefix.
199        :param data: The data to be sent in the body
200        :param content_type: The content type of the body data.
201        :param on_finished: The function to call when the response is received.
202        :param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
203        """
204        self._validateManager()
205
206        request = self._createEmptyRequest(url, content_type = content_type)
207        self._last_request_time = time()
208
209        if not self._manager:
210            Logger.log("e", "No network manager was created to execute the PUT call with.")
211            return
212
213        body = data if isinstance(data, bytes) else data.encode()  # type: bytes
214        reply = self._manager.put(request, body)
215        self._registerOnFinishedCallback(reply, on_finished)
216
217        if on_progress is not None:
218            reply.uploadProgress.connect(on_progress)
219
220    def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
221        """Sends a delete request to the given path.
222
223        :param url: The path after the API prefix.
224        :param on_finished: The function to be call when the response is received.
225        """
226        self._validateManager()
227
228        request = self._createEmptyRequest(url)
229        self._last_request_time = time()
230
231        if not self._manager:
232            Logger.log("e", "No network manager was created to execute the DELETE call with.")
233            return
234
235        reply = self._manager.deleteResource(request)
236        self._registerOnFinishedCallback(reply, on_finished)
237
238    def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
239        """Sends a get request to the given path.
240
241        :param url: The path after the API prefix.
242        :param on_finished: The function to be call when the response is received.
243        """
244        self._validateManager()
245
246        request = self._createEmptyRequest(url)
247        self._last_request_time = time()
248
249        if not self._manager:
250            Logger.log("e", "No network manager was created to execute the GET call with.")
251            return
252
253        reply = self._manager.get(request)
254        self._registerOnFinishedCallback(reply, on_finished)
255
256    def post(self, url: str, data: Union[str, bytes],
257             on_finished: Optional[Callable[[QNetworkReply], None]],
258             on_progress: Optional[Callable[[int, int], None]] = None) -> None:
259
260        """Sends a post request to the given path.
261
262        :param url: The path after the API prefix.
263        :param data: The data to be sent in the body
264        :param on_finished: The function to call when the response is received.
265        :param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
266        """
267
268        self._validateManager()
269
270        request = self._createEmptyRequest(url)
271        self._last_request_time = time()
272
273        if not self._manager:
274            Logger.log("e", "Could not find manager.")
275            return
276
277        body = data if isinstance(data, bytes) else data.encode()  # type: bytes
278        reply = self._manager.post(request, body)
279        if on_progress is not None:
280            reply.uploadProgress.connect(on_progress)
281        self._registerOnFinishedCallback(reply, on_finished)
282
283    def postFormWithParts(self, target: str, parts: List[QHttpPart],
284                          on_finished: Optional[Callable[[QNetworkReply], None]],
285                          on_progress: Optional[Callable[[int, int], None]] = None) -> QNetworkReply:
286        self._validateManager()
287        request = self._createEmptyRequest(target, content_type=None)
288        multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
289        for part in parts:
290            multi_post_part.append(part)
291
292        self._last_request_time = time()
293
294        if self._manager is not None:
295            reply = self._manager.post(request, multi_post_part)
296
297            self._kept_alive_multiparts[reply] = multi_post_part
298
299            if on_progress is not None:
300                reply.uploadProgress.connect(on_progress)
301            self._registerOnFinishedCallback(reply, on_finished)
302
303            return reply
304        else:
305            Logger.log("e", "Could not find manager.")
306
307    def postForm(self, target: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None:
308        post_part = QHttpPart()
309        post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data)
310        post_part.setBody(body_data)
311
312        self.postFormWithParts(target, [post_part], on_finished, on_progress)
313
314    def _onAuthenticationRequired(self, reply: QNetworkReply, authenticator: QAuthenticator) -> None:
315        Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString()))
316
317    def _createNetworkManager(self) -> None:
318        Logger.log("d", "Creating network manager")
319        if self._manager:
320            self._manager.finished.disconnect(self._handleOnFinished)
321            self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
322
323        self._manager = QNetworkAccessManager()
324        self._manager.finished.connect(self._handleOnFinished)
325        self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
326
327        if self._properties.get(b"temporary", b"false") != b"true":
328            self._checkCorrectGroupName(self.getId(), self.name)
329
330    def _registerOnFinishedCallback(self, reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
331        if on_finished is not None:
332            self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished
333
334    def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None:
335        """This method checks if the name of the group stored in the definition container is correct.
336
337        After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group
338        then all the container stacks are updated, both the current and the hidden ones.
339        """
340
341        global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
342        active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey()
343        if global_container_stack and device_id == active_machine_network_name:
344            # Check if the group_name is correct. If not, update all the containers connected to the same printer
345            if CuraApplication.getInstance().getMachineManager().activeMachineNetworkGroupName != group_name:
346                metadata_filter = {"um_network_key": active_machine_network_name}
347                containers = CuraContainerRegistry.getInstance().findContainerStacks(type="machine",
348                                                                                     **metadata_filter)
349                for container in containers:
350                    container.setMetaDataEntry("group_name", group_name)
351
352    def _handleOnFinished(self, reply: QNetworkReply) -> None:
353        # Due to garbage collection, we need to cache certain bits of post operations.
354        # As we don't want to keep them around forever, delete them if we get a reply.
355        if reply.operation() == QNetworkAccessManager.PostOperation:
356            self._clearCachedMultiPart(reply)
357
358        if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
359            # No status code means it never even reached remote.
360            return
361
362        self._last_response_time = time()
363
364        if self._connection_state == ConnectionState.Connecting:
365            self.setConnectionState(ConnectionState.Connected)
366
367        callback_key = reply.url().toString() + str(reply.operation())
368        try:
369            if callback_key in self._onFinishedCallbacks:
370                self._onFinishedCallbacks[callback_key](reply)
371        except Exception:
372            Logger.logException("w", "something went wrong with callback")
373
374    @pyqtSlot(str, result=str)
375    def getProperty(self, key: str) -> str:
376        bytes_key = key.encode("utf-8")
377        if bytes_key in self._properties:
378            return self._properties.get(bytes_key, b"").decode("utf-8")
379        else:
380            return ""
381
382    def getProperties(self):
383        return self._properties
384
385    @pyqtProperty(str, constant = True)
386    def key(self) -> str:
387        """Get the unique key of this machine
388
389        :return: key String containing the key of the machine.
390        """
391        return self._id
392
393    @pyqtProperty(str, constant = True)
394    def address(self) -> str:
395        """The IP address of the printer."""
396
397        return self._properties.get(b"address", b"").decode("utf-8")
398
399    @pyqtProperty(str, constant = True)
400    def name(self) -> str:
401        """Name of the printer (as returned from the ZeroConf properties)"""
402
403        return self._properties.get(b"name", b"").decode("utf-8")
404
405    @pyqtProperty(str, constant = True)
406    def firmwareVersion(self) -> str:
407        """Firmware version (as returned from the ZeroConf properties)"""
408
409        return self._properties.get(b"firmware_version", b"").decode("utf-8")
410
411    @pyqtProperty(str, constant = True)
412    def printerType(self) -> str:
413        return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
414
415    @pyqtProperty(str, constant = True)
416    def ipAddress(self) -> str:
417        """IP adress of this printer"""
418
419        return self._address
420