NetworkPrinterOutputDevicePlugin.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # Cura is released under the terms of the AGPLv3 or higher.
  3. from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
  4. from . import NetworkPrinterOutputDevice
  5. from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo # type: ignore
  6. from UM.Logger import Logger
  7. from UM.Signal import Signal, signalemitter
  8. from UM.Application import Application
  9. from UM.Preferences import Preferences
  10. from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager, QNetworkReply
  11. from PyQt5.QtCore import QUrl
  12. import time
  13. import json
  14. ## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
  15. # Zero-Conf is used to detect printers, which are saved in a dict.
  16. # If we discover a printer that has the same key as the active machine instance a connection is made.
  17. @signalemitter
  18. class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
  19. def __init__(self):
  20. super().__init__()
  21. self._zero_conf = None
  22. self._browser = None
  23. self._printers = {}
  24. self._api_version = "1"
  25. self._api_prefix = "/api/v" + self._api_version + "/"
  26. self._network_manager = QNetworkAccessManager()
  27. self._network_manager.finished.connect(self._onNetworkRequestFinished)
  28. # List of old printer names. This is used to ensure that a refresh of zeroconf does not needlessly forces
  29. # authentication requests.
  30. self._old_printers = []
  31. # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
  32. self.addPrinterSignal.connect(self.addPrinter)
  33. self.removePrinterSignal.connect(self.removePrinter)
  34. Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
  35. # Get list of manual printers from preferences
  36. self._preferences = Preferences.getInstance()
  37. self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames
  38. self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
  39. addPrinterSignal = Signal()
  40. removePrinterSignal = Signal()
  41. printerListChanged = Signal()
  42. ## Start looking for devices on network.
  43. def start(self):
  44. self.startDiscovery()
  45. def startDiscovery(self):
  46. self.stop()
  47. if self._browser:
  48. self._browser.cancel()
  49. self._browser = None
  50. self._old_printers = [printer_name for printer_name in self._printers]
  51. self._printers = {}
  52. self.printerListChanged.emit()
  53. # After network switching, one must make a new instance of Zeroconf
  54. # On windows, the instance creation is very fast (unnoticable). Other platforms?
  55. self._zero_conf = Zeroconf()
  56. self._browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.', [self._onServiceChanged])
  57. # Look for manual instances from preference
  58. for address in self._manual_instances:
  59. if address:
  60. self.addManualPrinter(address)
  61. def addManualPrinter(self, address):
  62. if address not in self._manual_instances:
  63. self._manual_instances.append(address)
  64. self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
  65. instance_name = "manual:%s" % address
  66. properties = {
  67. b"name": address.encode("utf-8"),
  68. b"address": address.encode("utf-8"),
  69. b"manual": b"true",
  70. b"incomplete": b"true"
  71. }
  72. if instance_name not in self._printers:
  73. # Add a preliminary printer instance
  74. self.addPrinter(instance_name, address, properties)
  75. self.checkManualPrinter(address)
  76. def removeManualPrinter(self, key, address = None):
  77. if key in self._printers:
  78. if not address:
  79. address = self._printers[key].ipAddress
  80. self.removePrinter(key)
  81. if address in self._manual_instances:
  82. self._manual_instances.remove(address)
  83. self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
  84. def checkManualPrinter(self, address):
  85. # Check if a printer exists at this address
  86. # If a printer responds, it will replace the preliminary printer created above
  87. url = QUrl("http://" + address + self._api_prefix + "system")
  88. name_request = QNetworkRequest(url)
  89. self._network_manager.get(name_request)
  90. ## Handler for all requests that have finished.
  91. def _onNetworkRequestFinished(self, reply):
  92. reply_url = reply.url().toString()
  93. status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
  94. if reply.operation() == QNetworkAccessManager.GetOperation:
  95. if "system" in reply_url: # Name returned from printer.
  96. if status_code == 200:
  97. try:
  98. system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
  99. except json.JSONDecodeError:
  100. Logger.log("e", "Printer returned invalid JSON.")
  101. return
  102. except UnicodeDecodeError:
  103. Logger.log("e", "Printer returned incorrect UTF-8.")
  104. return
  105. address = reply.url().host()
  106. instance_name = "manual:%s" % address
  107. machine = "unknown"
  108. if "variant" in system_info:
  109. variant = system_info["variant"]
  110. if variant == "Ultimaker 3":
  111. machine = "9066"
  112. elif variant == "Ultimaker 3 Extended":
  113. machine = "9511"
  114. properties = {
  115. b"name": system_info["name"].encode("utf-8"),
  116. b"address": address.encode("utf-8"),
  117. b"firmware_version": system_info["firmware"].encode("utf-8"),
  118. b"manual": b"true",
  119. b"machine": machine.encode("utf-8")
  120. }
  121. if instance_name in self._printers:
  122. # Only replace the printer if it is still in the list of (manual) printers
  123. self.removePrinter(instance_name)
  124. self.addPrinter(instance_name, address, properties)
  125. ## Stop looking for devices on network.
  126. def stop(self):
  127. if self._zero_conf is not None:
  128. Logger.log("d", "zeroconf close...")
  129. self._zero_conf.close()
  130. def getPrinters(self):
  131. return self._printers
  132. def reCheckConnections(self):
  133. active_machine = Application.getInstance().getGlobalContainerStack()
  134. if not active_machine:
  135. return
  136. for key in self._printers:
  137. if key == active_machine.getMetaDataEntry("um_network_key"):
  138. if not self._printers[key].isConnected():
  139. Logger.log("d", "Connecting [%s]..." % key)
  140. self._printers[key].connect()
  141. self._printers[key].connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
  142. else:
  143. if self._printers[key].isConnected():
  144. Logger.log("d", "Closing connection [%s]..." % key)
  145. self._printers[key].close()
  146. self._printers[key].connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
  147. ## Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
  148. def addPrinter(self, name, address, properties):
  149. printer = NetworkPrinterOutputDevice.NetworkPrinterOutputDevice(name, address, properties, self._api_prefix)
  150. self._printers[printer.getKey()] = printer
  151. global_container_stack = Application.getInstance().getGlobalContainerStack()
  152. if global_container_stack and printer.getKey() == global_container_stack.getMetaDataEntry("um_network_key"):
  153. if printer.getKey() not in self._old_printers: # Was the printer already connected, but a re-scan forced?
  154. Logger.log("d", "addPrinter, connecting [%s]..." % printer.getKey())
  155. self._printers[printer.getKey()].connect()
  156. printer.connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
  157. self.printerListChanged.emit()
  158. def removePrinter(self, name):
  159. printer = self._printers.pop(name, None)
  160. if printer:
  161. if printer.isConnected():
  162. printer.disconnect()
  163. printer.connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
  164. Logger.log("d", "removePrinter, disconnecting [%s]..." % name)
  165. self.printerListChanged.emit()
  166. ## Handler for when the connection state of one of the detected printers changes
  167. def _onPrinterConnectionStateChanged(self, key):
  168. if key not in self._printers:
  169. return
  170. if self._printers[key].isConnected():
  171. self.getOutputDeviceManager().addOutputDevice(self._printers[key])
  172. else:
  173. self.getOutputDeviceManager().removeOutputDevice(key)
  174. ## Handler for zeroConf detection
  175. def _onServiceChanged(self, zeroconf, service_type, name, state_change):
  176. if state_change == ServiceStateChange.Added:
  177. Logger.log("d", "Bonjour service added: %s" % name)
  178. # First try getting info from zeroconf cache
  179. info = ServiceInfo(service_type, name, properties = {})
  180. for record in zeroconf.cache.entries_with_name(name.lower()):
  181. info.update_record(zeroconf, time.time(), record)
  182. for record in zeroconf.cache.entries_with_name(info.server):
  183. info.update_record(zeroconf, time.time(), record)
  184. if info.address:
  185. break
  186. # Request more data if info is not complete
  187. if not info.address:
  188. Logger.log("d", "Trying to get address of %s", name)
  189. info = zeroconf.get_service_info(service_type, name)
  190. if info:
  191. type_of_device = info.properties.get(b"type", None)
  192. if type_of_device:
  193. if type_of_device == b"printer":
  194. address = '.'.join(map(lambda n: str(n), info.address))
  195. self.addPrinterSignal.emit(str(name), address, info.properties)
  196. else:
  197. Logger.log("w", "The type of the found device is '%s', not 'printer'! Ignoring.." % type_of_device )
  198. else:
  199. Logger.log("w", "Could not get information about %s" % name)
  200. elif state_change == ServiceStateChange.Removed:
  201. Logger.log("d", "Bonjour service removed: %s" % name)
  202. self.removePrinterSignal.emit(str(name))