UM3OutputDevicePlugin.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
  4. from UM.Logger import Logger
  5. from UM.Application import Application
  6. from UM.Signal import Signal, signalemitter
  7. from UM.Version import Version
  8. from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice
  9. from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
  10. from PyQt5.QtCore import QUrl
  11. from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
  12. from queue import Queue
  13. from threading import Event, Thread
  14. from time import time
  15. import json
  16. ## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
  17. # Zero-Conf is used to detect printers, which are saved in a dict.
  18. # If we discover a printer that has the same key as the active machine instance a connection is made.
  19. @signalemitter
  20. class UM3OutputDevicePlugin(OutputDevicePlugin):
  21. addDeviceSignal = Signal()
  22. removeDeviceSignal = Signal()
  23. discoveredDevicesChanged = Signal()
  24. def __init__(self):
  25. super().__init__()
  26. self._zero_conf = None
  27. self._zero_conf_browser = None
  28. # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
  29. self.addDeviceSignal.connect(self._onAddDevice)
  30. self.removeDeviceSignal.connect(self._onRemoveDevice)
  31. Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
  32. self._discovered_devices = {}
  33. self._network_manager = QNetworkAccessManager()
  34. self._network_manager.finished.connect(self._onNetworkRequestFinished)
  35. self._min_cluster_version = Version("4.0.0")
  36. self._api_version = "1"
  37. self._api_prefix = "/api/v" + self._api_version + "/"
  38. self._cluster_api_version = "1"
  39. self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
  40. # Get list of manual instances from preferences
  41. self._preferences = Application.getInstance().getPreferences()
  42. self._preferences.addPreference("um3networkprinting/manual_instances",
  43. "") # A comma-separated list of ip adresses or hostnames
  44. self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
  45. # Store the last manual entry key
  46. self._last_manual_entry_key = "" # type: str
  47. # The zero-conf service changed requests are handled in a separate thread, so we can re-schedule the requests
  48. # which fail to get detailed service info.
  49. # Any new or re-scheduled requests will be appended to the request queue, and the handling thread will pick
  50. # them up and process them.
  51. self._service_changed_request_queue = Queue()
  52. self._service_changed_request_event = Event()
  53. self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True)
  54. self._service_changed_request_thread.start()
  55. def getDiscoveredDevices(self):
  56. return self._discovered_devices
  57. def getLastManualDevice(self) -> str:
  58. return self._last_manual_entry_key
  59. def resetLastManualDevice(self) -> None:
  60. self._last_manual_entry_key = ""
  61. ## Start looking for devices on network.
  62. def start(self):
  63. self.startDiscovery()
  64. def startDiscovery(self):
  65. self.stop()
  66. if self._zero_conf_browser:
  67. self._zero_conf_browser.cancel()
  68. self._zero_conf_browser = None # Force the old ServiceBrowser to be destroyed.
  69. for instance_name in list(self._discovered_devices):
  70. self._onRemoveDevice(instance_name)
  71. self._zero_conf = Zeroconf()
  72. self._zero_conf_browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.',
  73. [self._appendServiceChangedRequest])
  74. # Look for manual instances from preference
  75. for address in self._manual_instances:
  76. if address:
  77. self.addManualDevice(address)
  78. self.resetLastManualDevice()
  79. def reCheckConnections(self):
  80. active_machine = Application.getInstance().getGlobalContainerStack()
  81. if not active_machine:
  82. return
  83. um_network_key = active_machine.getMetaDataEntry("um_network_key")
  84. for key in self._discovered_devices:
  85. if key == um_network_key:
  86. if not self._discovered_devices[key].isConnected():
  87. Logger.log("d", "Attempting to connect with [%s]" % key)
  88. self._discovered_devices[key].connect()
  89. self._discovered_devices[key].connectionStateChanged.connect(self._onDeviceConnectionStateChanged)
  90. else:
  91. self._onDeviceConnectionStateChanged(key)
  92. else:
  93. if self._discovered_devices[key].isConnected():
  94. Logger.log("d", "Attempting to close connection with [%s]" % key)
  95. self._discovered_devices[key].close()
  96. self._discovered_devices[key].connectionStateChanged.disconnect(self._onDeviceConnectionStateChanged)
  97. def _onDeviceConnectionStateChanged(self, key):
  98. if key not in self._discovered_devices:
  99. return
  100. if self._discovered_devices[key].isConnected():
  101. # Sometimes the status changes after changing the global container and maybe the device doesn't belong to this machine
  102. um_network_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key")
  103. if key == um_network_key:
  104. self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key])
  105. else:
  106. self.getOutputDeviceManager().removeOutputDevice(key)
  107. def stop(self):
  108. if self._zero_conf is not None:
  109. Logger.log("d", "zeroconf close...")
  110. self._zero_conf.close()
  111. def removeManualDevice(self, key, address = None):
  112. if key in self._discovered_devices:
  113. if not address:
  114. address = self._discovered_devices[key].ipAddress
  115. self._onRemoveDevice(key)
  116. self.resetLastManualDevice()
  117. if address in self._manual_instances:
  118. self._manual_instances.remove(address)
  119. self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
  120. def addManualDevice(self, address):
  121. if address not in self._manual_instances:
  122. self._manual_instances.append(address)
  123. self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
  124. instance_name = "manual:%s" % address
  125. properties = {
  126. b"name": address.encode("utf-8"),
  127. b"address": address.encode("utf-8"),
  128. b"manual": b"true",
  129. b"incomplete": b"true",
  130. b"temporary": b"true" # Still a temporary device until all the info is retrieved in _onNetworkRequestFinished
  131. }
  132. if instance_name not in self._discovered_devices:
  133. # Add a preliminary printer instance
  134. self._onAddDevice(instance_name, address, properties)
  135. self._last_manual_entry_key = instance_name
  136. self._checkManualDevice(address)
  137. def _checkManualDevice(self, address):
  138. # Check if a UM3 family device exists at this address.
  139. # If a printer responds, it will replace the preliminary printer created above
  140. # origin=manual is for tracking back the origin of the call
  141. url = QUrl("http://" + address + self._api_prefix + "system")
  142. name_request = QNetworkRequest(url)
  143. self._network_manager.get(name_request)
  144. def _onNetworkRequestFinished(self, reply):
  145. reply_url = reply.url().toString()
  146. if "system" in reply_url:
  147. if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
  148. # Something went wrong with checking the firmware version!
  149. return
  150. try:
  151. system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
  152. except:
  153. Logger.log("e", "Something went wrong converting the JSON.")
  154. return
  155. address = reply.url().host()
  156. has_cluster_capable_firmware = Version(system_info["firmware"]) > self._min_cluster_version
  157. instance_name = "manual:%s" % address
  158. properties = {
  159. b"name": system_info["name"].encode("utf-8"),
  160. b"address": address.encode("utf-8"),
  161. b"firmware_version": system_info["firmware"].encode("utf-8"),
  162. b"manual": b"true",
  163. b"machine": str(system_info['hardware']["typeid"]).encode("utf-8")
  164. }
  165. if has_cluster_capable_firmware:
  166. # Cluster needs an additional request, before it's completed.
  167. properties[b"incomplete"] = b"true"
  168. # Check if the device is still in the list & re-add it with the updated
  169. # information.
  170. if instance_name in self._discovered_devices:
  171. self._onRemoveDevice(instance_name)
  172. self._onAddDevice(instance_name, address, properties)
  173. if has_cluster_capable_firmware:
  174. # We need to request more info in order to figure out the size of the cluster.
  175. cluster_url = QUrl("http://" + address + self._cluster_api_prefix + "printers/")
  176. cluster_request = QNetworkRequest(cluster_url)
  177. self._network_manager.get(cluster_request)
  178. elif "printers" in reply_url:
  179. if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
  180. # Something went wrong with checking the amount of printers the cluster has!
  181. return
  182. # So we confirmed that the device is in fact a cluster printer, and we should now know how big it is.
  183. try:
  184. cluster_printers_list = json.loads(bytes(reply.readAll()).decode("utf-8"))
  185. except:
  186. Logger.log("e", "Something went wrong converting the JSON.")
  187. return
  188. address = reply.url().host()
  189. instance_name = "manual:%s" % address
  190. if instance_name in self._discovered_devices:
  191. device = self._discovered_devices[instance_name]
  192. properties = device.getProperties().copy()
  193. if b"incomplete" in properties:
  194. del properties[b"incomplete"]
  195. properties[b'cluster_size'] = len(cluster_printers_list)
  196. self._onRemoveDevice(instance_name)
  197. self._onAddDevice(instance_name, address, properties)
  198. def _onRemoveDevice(self, device_id):
  199. device = self._discovered_devices.pop(device_id, None)
  200. if device:
  201. if device.isConnected():
  202. device.disconnect()
  203. try:
  204. device.connectionStateChanged.disconnect(self._onDeviceConnectionStateChanged)
  205. except TypeError:
  206. # Disconnect already happened.
  207. pass
  208. self.discoveredDevicesChanged.emit()
  209. def _onAddDevice(self, name, address, properties):
  210. # Check what kind of device we need to add; Depending on the firmware we either add a "Connect"/"Cluster"
  211. # or "Legacy" UM3 device.
  212. cluster_size = int(properties.get(b"cluster_size", -1))
  213. if cluster_size >= 0:
  214. device = ClusterUM3OutputDevice.ClusterUM3OutputDevice(name, address, properties)
  215. else:
  216. device = LegacyUM3OutputDevice.LegacyUM3OutputDevice(name, address, properties)
  217. self._discovered_devices[device.getId()] = device
  218. self.discoveredDevicesChanged.emit()
  219. global_container_stack = Application.getInstance().getGlobalContainerStack()
  220. if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"):
  221. device.connect()
  222. device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged)
  223. ## Appends a service changed request so later the handling thread will pick it up and processes it.
  224. def _appendServiceChangedRequest(self, zeroconf, service_type, name, state_change):
  225. # append the request and set the event so the event handling thread can pick it up
  226. item = (zeroconf, service_type, name, state_change)
  227. self._service_changed_request_queue.put(item)
  228. self._service_changed_request_event.set()
  229. def _handleOnServiceChangedRequests(self):
  230. while True:
  231. # Wait for the event to be set
  232. self._service_changed_request_event.wait(timeout = 5.0)
  233. # Stop if the application is shutting down
  234. if Application.getInstance().isShuttingDown():
  235. return
  236. self._service_changed_request_event.clear()
  237. # Handle all pending requests
  238. reschedule_requests = [] # A list of requests that have failed so later they will get re-scheduled
  239. while not self._service_changed_request_queue.empty():
  240. request = self._service_changed_request_queue.get()
  241. zeroconf, service_type, name, state_change = request
  242. try:
  243. result = self._onServiceChanged(zeroconf, service_type, name, state_change)
  244. if not result:
  245. reschedule_requests.append(request)
  246. except Exception:
  247. Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled",
  248. service_type, name)
  249. reschedule_requests.append(request)
  250. # Re-schedule the failed requests if any
  251. if reschedule_requests:
  252. for request in reschedule_requests:
  253. self._service_changed_request_queue.put(request)
  254. ## Handler for zeroConf detection.
  255. # Return True or False indicating if the process succeeded.
  256. # Note that this function can take over 3 seconds to complete. Be carefull calling it from the main thread.
  257. def _onServiceChanged(self, zero_conf, service_type, name, state_change):
  258. if state_change == ServiceStateChange.Added:
  259. Logger.log("d", "Bonjour service added: %s" % name)
  260. # First try getting info from zero-conf cache
  261. info = ServiceInfo(service_type, name, properties={})
  262. for record in zero_conf.cache.entries_with_name(name.lower()):
  263. info.update_record(zero_conf, time(), record)
  264. for record in zero_conf.cache.entries_with_name(info.server):
  265. info.update_record(zero_conf, time(), record)
  266. if info.address:
  267. break
  268. # Request more data if info is not complete
  269. if not info.address:
  270. Logger.log("d", "Trying to get address of %s", name)
  271. info = zero_conf.get_service_info(service_type, name)
  272. if info:
  273. type_of_device = info.properties.get(b"type", None)
  274. if type_of_device:
  275. if type_of_device == b"printer":
  276. address = '.'.join(map(lambda n: str(n), info.address))
  277. self.addDeviceSignal.emit(str(name), address, info.properties)
  278. else:
  279. Logger.log("w",
  280. "The type of the found device is '%s', not 'printer'! Ignoring.." % type_of_device)
  281. else:
  282. Logger.log("w", "Could not get information about %s" % name)
  283. return False
  284. elif state_change == ServiceStateChange.Removed:
  285. Logger.log("d", "Bonjour service removed: %s" % name)
  286. self.removeDeviceSignal.emit(str(name))
  287. return True