NetworkPrinterOutputDevicePlugin.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import time
  4. import json
  5. from queue import Queue
  6. from threading import Event, Thread
  7. from PyQt5.QtCore import QObject, pyqtSlot
  8. from PyQt5.QtCore import QUrl
  9. from PyQt5.QtGui import QDesktopServices
  10. from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
  11. from UM.Application import Application
  12. from UM.Logger import Logger
  13. from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
  14. from UM.Preferences import Preferences
  15. from UM.Signal import Signal, signalemitter
  16. from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo # type: ignore
  17. from . import NetworkPrinterOutputDevice, NetworkClusterPrinterOutputDevice
  18. ## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
  19. # Zero-Conf is used to detect printers, which are saved in a dict.
  20. # If we discover a printer that has the same key as the active machine instance a connection is made.
  21. @signalemitter
  22. class NetworkPrinterOutputDevicePlugin(QObject, OutputDevicePlugin):
  23. def __init__(self):
  24. super().__init__()
  25. self._zero_conf = None
  26. self._browser = None
  27. self._printers = {}
  28. self._cluster_printers_seen = {} # do not forget a cluster printer when we have seen one, to not 'downgrade' from Connect to legacy printer
  29. self._api_version = "1"
  30. self._api_prefix = "/api/v" + self._api_version + "/"
  31. self._cluster_api_version = "1"
  32. self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
  33. self._network_manager = QNetworkAccessManager()
  34. self._network_manager.finished.connect(self._onNetworkRequestFinished)
  35. # List of old printer names. This is used to ensure that a refresh of zeroconf does not needlessly forces
  36. # authentication requests.
  37. self._old_printers = []
  38. self._excluded_addresses = ["127.0.0.1"] # Adding a list of not allowed IP addresses. At this moment, just localhost
  39. # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
  40. self.addPrinterSignal.connect(self.addPrinter)
  41. self.removePrinterSignal.connect(self.removePrinter)
  42. Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
  43. # Get list of manual printers from preferences
  44. self._preferences = Preferences.getInstance()
  45. self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames
  46. self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
  47. self._network_requests_buffer = {} # store api responses until data is complete
  48. # The zeroconf service changed requests are handled in a separate thread, so we can re-schedule the requests
  49. # which fail to get detailed service info.
  50. # Any new or re-scheduled requests will be appended to the request queue, and the handling thread will pick
  51. # them up and process them.
  52. self._service_changed_request_queue = Queue()
  53. self._service_changed_request_event = Event()
  54. self._service_changed_request_thread = Thread(target = self._handleOnServiceChangedRequests,
  55. daemon = True)
  56. self._service_changed_request_thread.start()
  57. addPrinterSignal = Signal()
  58. removePrinterSignal = Signal()
  59. printerListChanged = Signal()
  60. ## Start looking for devices on network.
  61. def start(self):
  62. self.startDiscovery()
  63. def startDiscovery(self):
  64. self.stop()
  65. if self._browser:
  66. self._browser.cancel()
  67. self._browser = None
  68. self._old_printers = [printer_name for printer_name in self._printers]
  69. self._printers = {}
  70. self.printerListChanged.emit()
  71. # After network switching, one must make a new instance of Zeroconf
  72. # On windows, the instance creation is very fast (unnoticable). Other platforms?
  73. self._zero_conf = Zeroconf()
  74. self._browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.', [self._appendServiceChangedRequest])
  75. # Look for manual instances from preference
  76. for address in self._manual_instances:
  77. if address:
  78. self.addManualPrinter(address)
  79. def addManualPrinter(self, address):
  80. if address not in self._manual_instances:
  81. self._manual_instances.append(address)
  82. self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
  83. instance_name = "manual:%s" % address
  84. properties = {
  85. b"name": address.encode("utf-8"),
  86. b"address": address.encode("utf-8"),
  87. b"manual": b"true",
  88. b"incomplete": b"true"
  89. }
  90. if instance_name not in self._printers:
  91. # Add a preliminary printer instance
  92. self.addPrinter(instance_name, address, properties)
  93. self.checkManualPrinter(address)
  94. self.checkClusterPrinter(address)
  95. def removeManualPrinter(self, key, address = None):
  96. if key in self._printers:
  97. if not address:
  98. address = self._printers[key].ipAddress
  99. self.removePrinter(key)
  100. if address in self._manual_instances:
  101. self._manual_instances.remove(address)
  102. self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
  103. def checkManualPrinter(self, address):
  104. # Check if a printer exists at this address
  105. # If a printer responds, it will replace the preliminary printer created above
  106. # origin=manual is for tracking back the origin of the call
  107. url = QUrl("http://" + address + self._api_prefix + "system?origin=manual_name")
  108. name_request = QNetworkRequest(url)
  109. self._network_manager.get(name_request)
  110. def checkClusterPrinter(self, address):
  111. cluster_url = QUrl("http://" + address + self._cluster_api_prefix + "printers/?origin=check_cluster")
  112. cluster_request = QNetworkRequest(cluster_url)
  113. self._network_manager.get(cluster_request)
  114. ## Handler for all requests that have finished.
  115. def _onNetworkRequestFinished(self, reply):
  116. reply_url = reply.url().toString()
  117. status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
  118. if reply.operation() == QNetworkAccessManager.GetOperation:
  119. address = reply.url().host()
  120. if "origin=manual_name" in reply_url: # Name returned from printer.
  121. if status_code == 200:
  122. try:
  123. system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
  124. except json.JSONDecodeError:
  125. Logger.log("e", "Printer returned invalid JSON.")
  126. return
  127. except UnicodeDecodeError:
  128. Logger.log("e", "Printer returned incorrect UTF-8.")
  129. return
  130. if address not in self._network_requests_buffer:
  131. self._network_requests_buffer[address] = {}
  132. self._network_requests_buffer[address]["system"] = system_info
  133. elif "origin=check_cluster" in reply_url:
  134. if address not in self._network_requests_buffer:
  135. self._network_requests_buffer[address] = {}
  136. if status_code == 200:
  137. # We know it's a cluster printer
  138. Logger.log("d", "Cluster printer detected: [%s]", reply.url())
  139. try:
  140. cluster_printers_list = json.loads(bytes(reply.readAll()).decode("utf-8"))
  141. except json.JSONDecodeError:
  142. Logger.log("e", "Printer returned invalid JSON.")
  143. return
  144. except UnicodeDecodeError:
  145. Logger.log("e", "Printer returned incorrect UTF-8.")
  146. return
  147. self._network_requests_buffer[address]["cluster"] = True
  148. self._network_requests_buffer[address]["cluster_size"] = len(cluster_printers_list)
  149. else:
  150. Logger.log("d", "This url is not from a cluster printer: [%s]", reply.url())
  151. self._network_requests_buffer[address]["cluster"] = False
  152. # Both the system call and cluster call are finished
  153. if (address in self._network_requests_buffer and
  154. "system" in self._network_requests_buffer[address] and
  155. "cluster" in self._network_requests_buffer[address]):
  156. instance_name = "manual:%s" % address
  157. system_info = self._network_requests_buffer[address]["system"]
  158. machine = "unknown"
  159. if "variant" in system_info:
  160. variant = system_info["variant"]
  161. if variant == "Ultimaker 3":
  162. machine = "9066"
  163. elif variant == "Ultimaker 3 Extended":
  164. machine = "9511"
  165. properties = {
  166. b"name": system_info["name"].encode("utf-8"),
  167. b"address": address.encode("utf-8"),
  168. b"firmware_version": system_info["firmware"].encode("utf-8"),
  169. b"manual": b"true",
  170. b"machine": machine.encode("utf-8")
  171. }
  172. if self._network_requests_buffer[address]["cluster"]:
  173. properties[b"cluster_size"] = self._network_requests_buffer[address]["cluster_size"]
  174. if instance_name in self._printers:
  175. # Only replace the printer if it is still in the list of (manual) printers
  176. self.removePrinter(instance_name)
  177. self.addPrinter(instance_name, address, properties)
  178. del self._network_requests_buffer[address]
  179. ## Stop looking for devices on network.
  180. def stop(self):
  181. if self._zero_conf is not None:
  182. Logger.log("d", "zeroconf close...")
  183. self._zero_conf.close()
  184. def getPrinters(self):
  185. return self._printers
  186. def reCheckConnections(self):
  187. active_machine = Application.getInstance().getGlobalContainerStack()
  188. if not active_machine:
  189. return
  190. for key in self._printers:
  191. if key == active_machine.getMetaDataEntry("um_network_key"):
  192. if not self._printers[key].isConnected():
  193. Logger.log("d", "Connecting [%s]..." % key)
  194. self._printers[key].connect()
  195. self._printers[key].connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
  196. else:
  197. if self._printers[key].isConnected():
  198. Logger.log("d", "Closing connection [%s]..." % key)
  199. self._printers[key].close()
  200. self._printers[key].connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
  201. ## Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
  202. def addPrinter(self, name, address, properties):
  203. cluster_size = int(properties.get(b"cluster_size", -1))
  204. if cluster_size >= 0:
  205. printer = NetworkClusterPrinterOutputDevice.NetworkClusterPrinterOutputDevice(
  206. name, address, properties, self._api_prefix)
  207. else:
  208. printer = NetworkPrinterOutputDevice.NetworkPrinterOutputDevice(name, address, properties, self._api_prefix)
  209. self._printers[printer.getKey()] = printer
  210. self._cluster_printers_seen[printer.getKey()] = name # Cluster printers that may be temporary unreachable or is rebooted keep being stored here
  211. global_container_stack = Application.getInstance().getGlobalContainerStack()
  212. if global_container_stack and printer.getKey() == global_container_stack.getMetaDataEntry("um_network_key"):
  213. if printer.getKey() not in self._old_printers: # Was the printer already connected, but a re-scan forced?
  214. Logger.log("d", "addPrinter, connecting [%s]..." % printer.getKey())
  215. self._printers[printer.getKey()].connect()
  216. printer.connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
  217. self.printerListChanged.emit()
  218. def removePrinter(self, name):
  219. printer = self._printers.pop(name, None)
  220. if printer:
  221. if printer.isConnected():
  222. printer.disconnect()
  223. printer.connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
  224. Logger.log("d", "removePrinter, disconnecting [%s]..." % name)
  225. self.printerListChanged.emit()
  226. ## Handler for when the connection state of one of the detected printers changes
  227. def _onPrinterConnectionStateChanged(self, key):
  228. if key not in self._printers:
  229. return
  230. if self._printers[key].isConnected():
  231. self.getOutputDeviceManager().addOutputDevice(self._printers[key])
  232. else:
  233. self.getOutputDeviceManager().removeOutputDevice(key)
  234. ## Handler for zeroConf detection.
  235. # Return True or False indicating if the process succeeded.
  236. def _onServiceChanged(self, zeroconf, service_type, name, state_change):
  237. if state_change == ServiceStateChange.Added:
  238. Logger.log("d", "Bonjour service added: %s" % name)
  239. # First try getting info from zeroconf cache
  240. info = ServiceInfo(service_type, name, properties = {})
  241. for record in zeroconf.cache.entries_with_name(name.lower()):
  242. info.update_record(zeroconf, time.time(), record)
  243. for record in zeroconf.cache.entries_with_name(info.server):
  244. info.update_record(zeroconf, time.time(), record)
  245. if info.address:
  246. break
  247. # Request more data if info is not complete
  248. if not info.address:
  249. Logger.log("d", "Trying to get address of %s", name)
  250. info = zeroconf.get_service_info(service_type, name)
  251. if info:
  252. type_of_device = info.properties.get(b"type", None)
  253. if type_of_device:
  254. if type_of_device == b"printer":
  255. address = '.'.join(map(lambda n: str(n), info.address))
  256. if address in self._excluded_addresses:
  257. Logger.log("d", "The IP address %s of the printer \'%s\' is not correct. Trying to reconnect.", address, name)
  258. return False # When getting the localhost IP, then try to reconnect
  259. self.addPrinterSignal.emit(str(name), address, info.properties)
  260. else:
  261. Logger.log("w", "The type of the found device is '%s', not 'printer'! Ignoring.." % type_of_device )
  262. else:
  263. Logger.log("w", "Could not get information about %s" % name)
  264. return False
  265. elif state_change == ServiceStateChange.Removed:
  266. Logger.log("d", "Bonjour service removed: %s" % name)
  267. self.removePrinterSignal.emit(str(name))
  268. return True
  269. ## Appends a service changed request so later the handling thread will pick it up and processes it.
  270. def _appendServiceChangedRequest(self, zeroconf, service_type, name, state_change):
  271. # append the request and set the event so the event handling thread can pick it up
  272. item = (zeroconf, service_type, name, state_change)
  273. self._service_changed_request_queue.put(item)
  274. self._service_changed_request_event.set()
  275. def _handleOnServiceChangedRequests(self):
  276. while True:
  277. # wait for the event to be set
  278. self._service_changed_request_event.wait(timeout = 5.0)
  279. # stop if the application is shutting down
  280. if Application.getInstance().isShuttingDown():
  281. return
  282. self._service_changed_request_event.clear()
  283. # handle all pending requests
  284. reschedule_requests = [] # a list of requests that have failed so later they will get re-scheduled
  285. while not self._service_changed_request_queue.empty():
  286. request = self._service_changed_request_queue.get()
  287. zeroconf, service_type, name, state_change = request
  288. try:
  289. result = self._onServiceChanged(zeroconf, service_type, name, state_change)
  290. if not result:
  291. reschedule_requests.append(request)
  292. except Exception:
  293. Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled",
  294. service_type, name)
  295. reschedule_requests.append(request)
  296. # re-schedule the failed requests if any
  297. if reschedule_requests:
  298. for request in reschedule_requests:
  299. self._service_changed_request_queue.put(request)
  300. @pyqtSlot()
  301. def openControlPanel(self):
  302. Logger.log("d", "Opening print jobs web UI...")
  303. selected_device = self.getOutputDeviceManager().getActiveDevice()
  304. if isinstance(selected_device, NetworkClusterPrinterOutputDevice.NetworkClusterPrinterOutputDevice):
  305. QDesktopServices.openUrl(QUrl(selected_device.getPrintJobsUrl()))