123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361 |
- # Copyright (c) 2017 Ultimaker B.V.
- # Cura is released under the terms of the LGPLv3 or higher.
- import time
- import json
- from queue import Queue
- from threading import Event, Thread
- from PyQt5.QtCore import QObject, pyqtSlot
- from PyQt5.QtCore import QUrl
- from PyQt5.QtGui import QDesktopServices
- from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
- from UM.Application import Application
- from UM.Logger import Logger
- from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
- from UM.Preferences import Preferences
- from UM.Signal import Signal, signalemitter
- from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo # type: ignore
- from . import NetworkPrinterOutputDevice, NetworkClusterPrinterOutputDevice
- ## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
- # Zero-Conf is used to detect printers, which are saved in a dict.
- # If we discover a printer that has the same key as the active machine instance a connection is made.
- @signalemitter
- class NetworkPrinterOutputDevicePlugin(QObject, OutputDevicePlugin):
- def __init__(self):
- super().__init__()
- self._zero_conf = None
- self._browser = None
- self._printers = {}
- self._cluster_printers_seen = {} # do not forget a cluster printer when we have seen one, to not 'downgrade' from Connect to legacy printer
- self._api_version = "1"
- self._api_prefix = "/api/v" + self._api_version + "/"
- self._cluster_api_version = "1"
- self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
- self._network_manager = QNetworkAccessManager()
- self._network_manager.finished.connect(self._onNetworkRequestFinished)
- # List of old printer names. This is used to ensure that a refresh of zeroconf does not needlessly forces
- # authentication requests.
- self._old_printers = []
- self._excluded_addresses = ["127.0.0.1"] # Adding a list of not allowed IP addresses. At this moment, just localhost
- # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
- self.addPrinterSignal.connect(self.addPrinter)
- self.removePrinterSignal.connect(self.removePrinter)
- Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
- # Get list of manual printers from preferences
- self._preferences = Preferences.getInstance()
- self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames
- self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
- self._network_requests_buffer = {} # store api responses until data is complete
- # The zeroconf service changed requests are handled in a separate thread, so we can re-schedule the requests
- # which fail to get detailed service info.
- # Any new or re-scheduled requests will be appended to the request queue, and the handling thread will pick
- # them up and process them.
- self._service_changed_request_queue = Queue()
- self._service_changed_request_event = Event()
- self._service_changed_request_thread = Thread(target = self._handleOnServiceChangedRequests,
- daemon = True)
- self._service_changed_request_thread.start()
- addPrinterSignal = Signal()
- removePrinterSignal = Signal()
- printerListChanged = Signal()
- ## Start looking for devices on network.
- def start(self):
- self.startDiscovery()
- def startDiscovery(self):
- self.stop()
- if self._browser:
- self._browser.cancel()
- self._browser = None
- self._old_printers = [printer_name for printer_name in self._printers]
- self._printers = {}
- self.printerListChanged.emit()
- # After network switching, one must make a new instance of Zeroconf
- # On windows, the instance creation is very fast (unnoticable). Other platforms?
- self._zero_conf = Zeroconf()
- self._browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.', [self._appendServiceChangedRequest])
- # Look for manual instances from preference
- for address in self._manual_instances:
- if address:
- self.addManualPrinter(address)
- def addManualPrinter(self, address):
- if address not in self._manual_instances:
- self._manual_instances.append(address)
- self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
- instance_name = "manual:%s" % address
- properties = {
- b"name": address.encode("utf-8"),
- b"address": address.encode("utf-8"),
- b"manual": b"true",
- b"incomplete": b"true"
- }
- if instance_name not in self._printers:
- # Add a preliminary printer instance
- self.addPrinter(instance_name, address, properties)
- self.checkManualPrinter(address)
- self.checkClusterPrinter(address)
- def removeManualPrinter(self, key, address = None):
- if key in self._printers:
- if not address:
- address = self._printers[key].ipAddress
- self.removePrinter(key)
- if address in self._manual_instances:
- self._manual_instances.remove(address)
- self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
- def checkManualPrinter(self, address):
- # Check if a printer exists at this address
- # If a printer responds, it will replace the preliminary printer created above
- # origin=manual is for tracking back the origin of the call
- url = QUrl("http://" + address + self._api_prefix + "system?origin=manual_name")
- name_request = QNetworkRequest(url)
- self._network_manager.get(name_request)
- def checkClusterPrinter(self, address):
- cluster_url = QUrl("http://" + address + self._cluster_api_prefix + "printers/?origin=check_cluster")
- cluster_request = QNetworkRequest(cluster_url)
- self._network_manager.get(cluster_request)
- ## Handler for all requests that have finished.
- def _onNetworkRequestFinished(self, reply):
- reply_url = reply.url().toString()
- status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
- if reply.operation() == QNetworkAccessManager.GetOperation:
- address = reply.url().host()
- if "origin=manual_name" in reply_url: # Name returned from printer.
- if status_code == 200:
- try:
- system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
- except json.JSONDecodeError:
- Logger.log("e", "Printer returned invalid JSON.")
- return
- except UnicodeDecodeError:
- Logger.log("e", "Printer returned incorrect UTF-8.")
- return
- if address not in self._network_requests_buffer:
- self._network_requests_buffer[address] = {}
- self._network_requests_buffer[address]["system"] = system_info
- elif "origin=check_cluster" in reply_url:
- if address not in self._network_requests_buffer:
- self._network_requests_buffer[address] = {}
- if status_code == 200:
- # We know it's a cluster printer
- Logger.log("d", "Cluster printer detected: [%s]", reply.url())
- try:
- cluster_printers_list = json.loads(bytes(reply.readAll()).decode("utf-8"))
- except json.JSONDecodeError:
- Logger.log("e", "Printer returned invalid JSON.")
- return
- except UnicodeDecodeError:
- Logger.log("e", "Printer returned incorrect UTF-8.")
- return
- self._network_requests_buffer[address]["cluster"] = True
- self._network_requests_buffer[address]["cluster_size"] = len(cluster_printers_list)
- else:
- Logger.log("d", "This url is not from a cluster printer: [%s]", reply.url())
- self._network_requests_buffer[address]["cluster"] = False
- # Both the system call and cluster call are finished
- if (address in self._network_requests_buffer and
- "system" in self._network_requests_buffer[address] and
- "cluster" in self._network_requests_buffer[address]):
- instance_name = "manual:%s" % address
- system_info = self._network_requests_buffer[address]["system"]
- machine = "unknown"
- if "variant" in system_info:
- variant = system_info["variant"]
- if variant == "Ultimaker 3":
- machine = "9066"
- elif variant == "Ultimaker 3 Extended":
- machine = "9511"
- properties = {
- b"name": system_info["name"].encode("utf-8"),
- b"address": address.encode("utf-8"),
- b"firmware_version": system_info["firmware"].encode("utf-8"),
- b"manual": b"true",
- b"machine": machine.encode("utf-8")
- }
- if self._network_requests_buffer[address]["cluster"]:
- properties[b"cluster_size"] = self._network_requests_buffer[address]["cluster_size"]
- if instance_name in self._printers:
- # Only replace the printer if it is still in the list of (manual) printers
- self.removePrinter(instance_name)
- self.addPrinter(instance_name, address, properties)
- del self._network_requests_buffer[address]
- ## Stop looking for devices on network.
- def stop(self):
- if self._zero_conf is not None:
- Logger.log("d", "zeroconf close...")
- self._zero_conf.close()
- def getPrinters(self):
- return self._printers
- def reCheckConnections(self):
- active_machine = Application.getInstance().getGlobalContainerStack()
- if not active_machine:
- return
- for key in self._printers:
- if key == active_machine.getMetaDataEntry("um_network_key"):
- if not self._printers[key].isConnected():
- Logger.log("d", "Connecting [%s]..." % key)
- self._printers[key].connect()
- self._printers[key].connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
- else:
- if self._printers[key].isConnected():
- Logger.log("d", "Closing connection [%s]..." % key)
- self._printers[key].close()
- self._printers[key].connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
- ## Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
- def addPrinter(self, name, address, properties):
- cluster_size = int(properties.get(b"cluster_size", -1))
- if cluster_size >= 0:
- printer = NetworkClusterPrinterOutputDevice.NetworkClusterPrinterOutputDevice(
- name, address, properties, self._api_prefix)
- else:
- printer = NetworkPrinterOutputDevice.NetworkPrinterOutputDevice(name, address, properties, self._api_prefix)
- self._printers[printer.getKey()] = printer
- self._cluster_printers_seen[printer.getKey()] = name # Cluster printers that may be temporary unreachable or is rebooted keep being stored here
- global_container_stack = Application.getInstance().getGlobalContainerStack()
- if global_container_stack and printer.getKey() == global_container_stack.getMetaDataEntry("um_network_key"):
- if printer.getKey() not in self._old_printers: # Was the printer already connected, but a re-scan forced?
- Logger.log("d", "addPrinter, connecting [%s]..." % printer.getKey())
- self._printers[printer.getKey()].connect()
- printer.connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
- self.printerListChanged.emit()
- def removePrinter(self, name):
- printer = self._printers.pop(name, None)
- if printer:
- if printer.isConnected():
- printer.disconnect()
- printer.connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
- Logger.log("d", "removePrinter, disconnecting [%s]..." % name)
- self.printerListChanged.emit()
- ## Handler for when the connection state of one of the detected printers changes
- def _onPrinterConnectionStateChanged(self, key):
- if key not in self._printers:
- return
- if self._printers[key].isConnected():
- self.getOutputDeviceManager().addOutputDevice(self._printers[key])
- else:
- self.getOutputDeviceManager().removeOutputDevice(key)
- ## Handler for zeroConf detection.
- # Return True or False indicating if the process succeeded.
- def _onServiceChanged(self, zeroconf, service_type, name, state_change):
- if state_change == ServiceStateChange.Added:
- Logger.log("d", "Bonjour service added: %s" % name)
- # First try getting info from zeroconf cache
- info = ServiceInfo(service_type, name, properties = {})
- for record in zeroconf.cache.entries_with_name(name.lower()):
- info.update_record(zeroconf, time.time(), record)
- for record in zeroconf.cache.entries_with_name(info.server):
- info.update_record(zeroconf, time.time(), record)
- if info.address:
- break
- # Request more data if info is not complete
- if not info.address:
- Logger.log("d", "Trying to get address of %s", name)
- info = zeroconf.get_service_info(service_type, name)
- if info:
- type_of_device = info.properties.get(b"type", None)
- if type_of_device:
- if type_of_device == b"printer":
- address = '.'.join(map(lambda n: str(n), info.address))
- if address in self._excluded_addresses:
- Logger.log("d", "The IP address %s of the printer \'%s\' is not correct. Trying to reconnect.", address, name)
- return False # When getting the localhost IP, then try to reconnect
- self.addPrinterSignal.emit(str(name), address, info.properties)
- else:
- Logger.log("w", "The type of the found device is '%s', not 'printer'! Ignoring.." % type_of_device )
- else:
- Logger.log("w", "Could not get information about %s" % name)
- return False
- elif state_change == ServiceStateChange.Removed:
- Logger.log("d", "Bonjour service removed: %s" % name)
- self.removePrinterSignal.emit(str(name))
- return True
- ## Appends a service changed request so later the handling thread will pick it up and processes it.
- def _appendServiceChangedRequest(self, zeroconf, service_type, name, state_change):
- # append the request and set the event so the event handling thread can pick it up
- item = (zeroconf, service_type, name, state_change)
- self._service_changed_request_queue.put(item)
- self._service_changed_request_event.set()
- def _handleOnServiceChangedRequests(self):
- while True:
- # wait for the event to be set
- self._service_changed_request_event.wait(timeout = 5.0)
- # stop if the application is shutting down
- if Application.getInstance().isShuttingDown():
- return
- self._service_changed_request_event.clear()
- # handle all pending requests
- reschedule_requests = [] # a list of requests that have failed so later they will get re-scheduled
- while not self._service_changed_request_queue.empty():
- request = self._service_changed_request_queue.get()
- zeroconf, service_type, name, state_change = request
- try:
- result = self._onServiceChanged(zeroconf, service_type, name, state_change)
- if not result:
- reschedule_requests.append(request)
- except Exception:
- Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled",
- service_type, name)
- reschedule_requests.append(request)
- # re-schedule the failed requests if any
- if reschedule_requests:
- for request in reschedule_requests:
- self._service_changed_request_queue.put(request)
- @pyqtSlot()
- def openControlPanel(self):
- Logger.log("d", "Opening print jobs web UI...")
- selected_device = self.getOutputDeviceManager().getActiveDevice()
- if isinstance(selected_device, NetworkClusterPrinterOutputDevice.NetworkClusterPrinterOutputDevice):
- QDesktopServices.openUrl(QUrl(selected_device.getPrintJobsUrl()))
|