class OctoPrintOutputDevicePlugin(OutputDevicePlugin):
    def __init__(self) -> None:
        super().__init__()
        self._zero_conf = None  # type: Optional[Zeroconf]
        self._browser = None  # type: Optional[ServiceBrowser]
        self._instances = {}  # type: Dict[str, OctoPrintOutputDevice]

        # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
        self.addInstanceSignal.connect(self.addInstance)
        self.removeInstanceSignal.connect(self.removeInstance)
        Application.getInstance().globalContainerStackChanged.connect(
            self.reCheckConnections)

        # Load custom instances from preferences
        self._preferences = Application.getInstance().getPreferences()
        self._preferences.addPreference("octoprint/manual_instances", "{}")

        self._preferences.addPreference("octoprint/use_zeroconf", True)

        try:
            self._manual_instances = json.loads(
                self._preferences.getValue("octoprint/manual_instances"))
        except ValueError:
            self._manual_instances = {}  # type: Dict[str, Any]
        if not isinstance(self._manual_instances, dict):
            self._manual_instances = {}  # type: Dict[str, Any]

        self._name_regex = re.compile('OctoPrint instance (".*"\.|on )(.*)\.')

        self._keep_alive_timer = QTimer()
        self._keep_alive_timer.setInterval(2000)
        self._keep_alive_timer.setSingleShot(True)
        self._keep_alive_timer.timeout.connect(self._keepDiscoveryAlive)
        self._consecutive_zeroconf_restarts = 0

    addInstanceSignal = Signal()
    removeInstanceSignal = Signal()
    instanceListChanged = Signal()

    ##  Start looking for devices on network.
    def start(self) -> None:
        self.startDiscovery()

    def startDiscovery(self) -> None:
        # Clean up previous discovery components and results
        if self._zero_conf:
            self._zero_conf.close()
            self._zero_conf = None  # type: Optional[Zeroconf]

        if self._browser:
            self._browser.cancel()
            self._browser = None  # type: Optional[ServiceBrowser]
            self._printers = []  # type: List[PrinterOutputModel]

        instance_keys = list(self._instances.keys())
        for key in instance_keys:
            self.removeInstance(key)

        # Add manual instances from preference
        for name, properties in self._manual_instances.items():
            additional_properties = {
                b"path": properties["path"].encode("utf-8"),
                b"useHttps":
                b"true" if properties.get("useHttps", False) else b"false",
                b"userName": properties.get("userName", "").encode("utf-8"),
                b"password": properties.get("password", "").encode("utf-8"),
                b"manual": b"true",
            }  # These additional properties use bytearrays to mimick the output of zeroconf
            self.addInstance(name, properties["address"], properties["port"],
                             additional_properties)

        self.instanceListChanged.emit()

        # Don't start zeroconf discovery if it is disabled
        if not self._preferences.getValue("octoprint/use_zeroconf"):
            self._keep_alive_timer.stop()
            return

        try:
            self._zero_conf = Zeroconf()
        except Exception:
            self._zero_conf = None  # type: Optional[Zeroconf]
            self._keep_alive_timer.stop()
            Logger.logException(
                "e",
                "Failed to create Zeroconf instance. Auto-discovery will not work."
            )

        if self._zero_conf:
            self._browser = ServiceBrowser(self._zero_conf,
                                           u"_octoprint._tcp.local.",
                                           [self._onServiceChanged])
            if self._browser and self._browser.is_alive():
                self._keep_alive_timer.start()
            else:
                Logger.log(
                    "w",
                    "Failed to create Zeroconf browser. Auto-discovery will not work.",
                )
                self._keep_alive_timer.stop()

    def _keepDiscoveryAlive(self) -> None:
        if not self._browser or not self._browser.is_alive():
            if self._consecutive_zeroconf_restarts < 5:
                Logger.log(
                    "w",
                    "Zeroconf discovery has died, restarting discovery of OctoPrint instances.",
                )
                self._consecutive_zeroconf_restarts += 1
                self.startDiscovery()
            else:
                if self._zero_conf:
                    self._zero_conf.close()
                    self._zero_conf = None  # type: Optional[Zeroconf]
                Logger.log(
                    "e",
                    "Giving up restarting Zeroconf browser after 5 consecutive attempts. Auto-discovery will not work.",
                )
        else:
            # ZeroConf has been alive and well for the past 2 seconds
            self._consecutive_zeroconf_restarts = 0
            self._keep_alive_timer.start()

    def addManualInstance(
        self,
        name: str,
        address: str,
        port: int,
        path: str,
        useHttps: bool = False,
        userName: str = "",
        password: str = "",
    ) -> None:
        self._manual_instances[name] = {
            "address": address,
            "port": port,
            "path": path,
            "useHttps": useHttps,
            "userName": userName,
            "password": password,
        }
        self._preferences.setValue("octoprint/manual_instances",
                                   json.dumps(self._manual_instances))

        properties = {
            b"path": path.encode("utf-8"),
            b"useHttps": b"true" if useHttps else b"false",
            b"userName": userName.encode("utf-8"),
            b"password": password.encode("utf-8"),
            b"manual": b"true",
        }

        if name in self._instances:
            self.removeInstance(name)

        self.addInstance(name, address, port, properties)
        self.instanceListChanged.emit()

    def removeManualInstance(self, name: str) -> None:
        if name in self._instances:
            self.removeInstance(name)
            self.instanceListChanged.emit()

        if name in self._manual_instances:
            self._manual_instances.pop(name, None)
            self._preferences.setValue("octoprint/manual_instances",
                                       json.dumps(self._manual_instances))

    ##  Stop looking for devices on network.
    def stop(self) -> None:
        self._keep_alive_timer.stop()

        if self._browser:
            self._browser.cancel()
        self._browser = None  # type: Optional[ServiceBrowser]

        if self._zero_conf:
            self._zero_conf.close()

    def getInstances(self) -> Dict[str, Any]:
        return self._instances

    def getInstanceById(self,
                        instance_id: str) -> Optional[OctoPrintOutputDevice]:
        instance = self._instances.get(instance_id, None)
        if instance:
            return instance
        Logger.log("w", "No instance found with id %s", instance_id)
        return None

    def reCheckConnections(self) -> None:
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if not global_container_stack:
            return

        for key in self._instances:
            if key == global_container_stack.getMetaDataEntry("octoprint_id"):
                api_key = global_container_stack.getMetaDataEntry(
                    "octoprint_api_key", "")
                self._instances[key].setApiKey(
                    self._deobfuscateString(api_key))
                self._instances[key].setShowCamera(
                    parseBool(
                        global_container_stack.getMetaDataEntry(
                            "octoprint_show_camera", "true")))
                self._instances[key].connectionStateChanged.connect(
                    self._onInstanceConnectionStateChanged)
                self._instances[key].connect()
            else:
                if self._instances[key].isConnected():
                    self._instances[key].close()

    ##  Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
    def addInstance(self, name: str, address: str, port: int,
                    properties: Dict[bytes, bytes]) -> None:
        instance = OctoPrintOutputDevice(name, address, port, properties)
        self._instances[instance.getId()] = instance
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if (global_container_stack and instance.getId()
                == global_container_stack.getMetaDataEntry("octoprint_id")):
            api_key = global_container_stack.getMetaDataEntry(
                "octoprint_api_key", "")
            instance.setApiKey(self._deobfuscateString(api_key))
            instance.setShowCamera(
                parseBool(
                    global_container_stack.getMetaDataEntry(
                        "octoprint_show_camera", "true")))
            instance.connectionStateChanged.connect(
                self._onInstanceConnectionStateChanged)
            instance.connect()

    def removeInstance(self, name: str) -> None:
        instance = self._instances.pop(name, None)
        if instance:
            if instance.isConnected():
                instance.connectionStateChanged.disconnect(
                    self._onInstanceConnectionStateChanged)
                instance.disconnect()

    ##  Utility handler to base64-decode a string (eg an obfuscated API key), if it has been encoded before
    def _deobfuscateString(self, source: str) -> str:
        try:
            return base64.b64decode(source.encode("ascii")).decode("ascii")
        except UnicodeDecodeError:
            return source

    ##  Handler for when the connection state of one of the detected instances changes
    def _onInstanceConnectionStateChanged(self, key: str) -> None:
        if key not in self._instances:
            return

        if self._instances[key].isConnected():
            self.getOutputDeviceManager().addOutputDevice(self._instances[key])
        else:
            self.getOutputDeviceManager().removeOutputDevice(key)

    ##  Handler for zeroConf detection
    def _onServiceChanged(
        self,
        zeroconf: Zeroconf,
        service_type: str,
        name: str,
        state_change: ServiceStateChange,
    ) -> None:
        if state_change == ServiceStateChange.Added:
            key = name
            result = self._name_regex.match(name)
            if result:
                if result.group(1) == "on ":
                    name = result.group(2)
                else:
                    name = result.group(1) + result.group(2)

            Logger.log("d", "Bonjour service added: %s" % name)

            # First try getting info from zeroconf cache
            info = ServiceInfo(service_type, key)
            for record in zeroconf.cache.entries_with_name(key.lower()):
                info.update_record(zeroconf, time.time(), record)

            address = ""
            for record in zeroconf.cache.entries_with_name(info.server):
                info.update_record(zeroconf, time.time(), record)
                if not isinstance(record, DNSAddress):
                    return
                ip = (
                    None
                )  # type: Optional[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]
                try:
                    ip = ipaddress.IPv4Address(record.address)  # IPv4
                except ipaddress.AddressValueError:
                    ip = ipaddress.IPv6Address(record.address)  # IPv6
                except:
                    continue

                if ip and not ip.is_link_local:  # don't accept 169.254.x.x address
                    address = str(ip) if ip.version == 4 else "[%s]" % str(ip)
                    break

            # Request more data if info is not complete
            if not address or not info.port:
                Logger.log("d", "Trying to get address of %s", name)
                requested_info = zeroconf.get_service_info(service_type, key)

                if not requested_info:
                    Logger.log("w",
                               "Could not get information about %s" % name)
                    return

                info = requested_info

            if address and info.port:
                self.addInstanceSignal.emit(name, address, info.port,
                                            info.properties)
            else:
                Logger.log(
                    "d",
                    "Discovered instance named %s but received no address",
                    name)

        elif state_change == ServiceStateChange.Removed:
            self.removeInstanceSignal.emit(str(name))
Beispiel #2
0
class OctoPrintOutputDevicePlugin(OutputDevicePlugin):
    def __init__(self):
        super().__init__()
        self._zero_conf = None
        self._browser = None
        self._instances = {}

        # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
        self.addInstanceSignal.connect(self.addInstance)
        self.removeInstanceSignal.connect(self.removeInstance)
        Application.getInstance().globalContainerStackChanged.connect(
            self.reCheckConnections)

        # Load custom instances from preferences
        self._preferences = Application.getInstance().getPreferences()
        self._preferences.addPreference("octoprint/manual_instances", "{}")

        try:
            self._manual_instances = json.loads(
                self._preferences.getValue("octoprint/manual_instances"))
        except ValueError:
            self._manual_instances = {}
        if not isinstance(self._manual_instances, dict):
            self._manual_instances = {}

        self._name_regex = re.compile(
            "OctoPrint instance (\".*\"\.|on )(.*)\.")

        self._keep_alive_timer = QTimer()
        self._keep_alive_timer.setInterval(2000)
        self._keep_alive_timer.setSingleShot(False)
        self._keep_alive_timer.timeout.connect(self._keepDiscoveryAlive)

    addInstanceSignal = Signal()
    removeInstanceSignal = Signal()
    instanceListChanged = Signal()

    ##  Start looking for devices on network.
    def start(self):
        self.startDiscovery()
        self._keep_alive_timer.start()

    def startDiscovery(self):
        if self._browser:
            self._browser.cancel()
            self._browser = None
            self._printers = {}
        self.instanceListChanged.emit()

        try:
            self._zero_conf = Zeroconf()
        except Exception:
            self._zero_conf = None
            Logger.logException(
                "e",
                "Failed to create Zeroconf instance. Auto-discovery will not work."
            )

        if self._zero_conf:
            self._browser = ServiceBrowser(self._zero_conf,
                                           u'_octoprint._tcp.local.',
                                           [self._onServiceChanged])

        # Add manual instances from preference
        for name, properties in self._manual_instances.items():
            additional_properties = {
                b"path": properties["path"].encode("utf-8"),
                b"useHttps":
                b"true" if properties.get("useHttps", False) else b"false",
                b'userName': properties.get("userName", "").encode("utf-8"),
                b'password': properties.get("password", "").encode("utf-8"),
                b"manual": b"true"
            }  # These additional properties use bytearrays to mimick the output of zeroconf
            self.addInstance(name, properties["address"], properties["port"],
                             additional_properties)

    def _keepDiscoveryAlive(self):
        if not self._browser or not self._browser.is_alive():
            Logger.log(
                "w",
                "Zeroconf discovery has died, restarting discovery of OctoPrint instances."
            )
            self.startDiscovery()

    def addManualInstance(self,
                          name,
                          address,
                          port,
                          path,
                          useHttps=False,
                          userName="",
                          password=""):
        self._manual_instances[name] = {
            "address": address,
            "port": port,
            "path": path,
            "useHttps": useHttps,
            "userName": userName,
            "password": password
        }
        self._preferences.setValue("octoprint/manual_instances",
                                   json.dumps(self._manual_instances))

        properties = {
            b"path": path.encode("utf-8"),
            b"useHttps": b"true" if useHttps else b"false",
            b'userName': userName.encode("utf-8"),
            b'password': password.encode("utf-8"),
            b"manual": b"true"
        }

        if name in self._instances:
            self.removeInstance(name)

        self.addInstance(name, address, port, properties)
        self.instanceListChanged.emit()

    def removeManualInstance(self, name):
        if name in self._instances:
            self.removeInstance(name)
            self.instanceListChanged.emit()

        if name in self._manual_instances:
            self._manual_instances.pop(name, None)
            self._preferences.setValue("octoprint/manual_instances",
                                       json.dumps(self._manual_instances))

    ##  Stop looking for devices on network.
    def stop(self):
        self._browser.cancel()
        self._browser = None
        if self._zero_conf:
            self._zero_conf.close()
        self._keep_alive_timer.start()

    def getInstances(self):
        return self._instances

    def reCheckConnections(self):
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if not global_container_stack:
            return

        for key in self._instances:
            if key == global_container_stack.getMetaDataEntry("octoprint_id"):
                api_key = global_container_stack.getMetaDataEntry(
                    "octoprint_api_key", "")
                self._instances[key].setApiKey(
                    self._deobfuscateString(api_key))
                self._instances[key].connectionStateChanged.connect(
                    self._onInstanceConnectionStateChanged)
                self._instances[key].connect()
            else:
                if self._instances[key].isConnected():
                    self._instances[key].close()

    ##  Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
    def addInstance(self, name, address, port, properties):
        instance = OctoPrintOutputDevice.OctoPrintOutputDevice(
            name, address, port, properties)
        self._instances[instance.getId()] = instance
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if global_container_stack and instance.getId(
        ) == global_container_stack.getMetaDataEntry("octoprint_id"):
            api_key = global_container_stack.getMetaDataEntry(
                "octoprint_api_key", "")
            instance.setApiKey(self._deobfuscateString(api_key))
            instance.connectionStateChanged.connect(
                self._onInstanceConnectionStateChanged)
            instance.connect()

    def removeInstance(self, name):
        instance = self._instances.pop(name, None)
        if instance:
            if instance.isConnected():
                instance.connectionStateChanged.disconnect(
                    self._onInstanceConnectionStateChanged)
                instance.disconnect()

    ##  Utility handler to base64-decode a string (eg an obfuscated API key), if it has been encoded before
    def _deobfuscateString(self, source):
        try:
            return base64.b64decode(source.encode("ascii")).decode("ascii")
        except UnicodeDecodeError:
            return source

    ##  Handler for when the connection state of one of the detected instances changes
    def _onInstanceConnectionStateChanged(self, key):
        if key not in self._instances:
            return

        if self._instances[key].isConnected():
            self.getOutputDeviceManager().addOutputDevice(self._instances[key])
        else:
            self.getOutputDeviceManager().removeOutputDevice(key)

    ##  Handler for zeroConf detection
    def _onServiceChanged(self, zeroconf, service_type, name, state_change):
        if state_change == ServiceStateChange.Added:
            key = name
            result = self._name_regex.match(name)
            if result:
                if result.group(1) == "on ":
                    name = result.group(2)
                else:
                    name = result.group(1) + result.group(2)

            Logger.log("d", "Bonjour service added: %s" % name)

            # First try getting info from zeroconf cache
            info = ServiceInfo(service_type, key, properties={})
            for record in zeroconf.cache.entries_with_name(key.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 and info.address[:2] != b'\xa9\xfe':  # don't accept 169.254.x.x address
                    break

            # Request more data if info is not complete
            if not info.address or not info.port:
                Logger.log("d", "Trying to get address of %s", name)
                info = zeroconf.get_service_info(service_type, key)

                if not info:
                    Logger.log("w",
                               "Could not get information about %s" % name)
                    return

            if info.address and info.port:
                address = '.'.join(map(lambda n: str(n), info.address))
                self.addInstanceSignal.emit(name, address, info.port,
                                            info.properties)
            else:
                Logger.log(
                    "d",
                    "Discovered instance named %s but received no address",
                    name)

        elif state_change == ServiceStateChange.Removed:
            self.removeInstanceSignal.emit(str(name))