Exemplo n.º 1
0
class FindMyMrBeamPlugin(
        octoprint.plugin.AssetPlugin,
        octoprint.plugin.StartupPlugin,
        octoprint.plugin.SettingsPlugin,
        # octoprint.plugin.SimpleApiPlugin,
        octoprint.plugin.BlueprintPlugin,
        octoprint.plugin.EventHandlerPlugin,
        octoprint.plugin.TemplatePlugin,
        octoprint.plugin.EnvironmentDetectionPlugin):
    _socket_getaddrinfo_regular = None

    def __init__(self):
        self._port = None
        self._thread = None
        self._url = None
        self._client_seen = False
        self._registered = None
        self._lastPing = 0
        self._calls = []
        self._public_ip = None
        self._public_ip6 = None
        self._uuid = None
        self._search_id = None
        self._analytics = None

        from random import choice
        import string
        chars = string.ascii_lowercase + string.ascii_uppercase + string.digits
        self._secret = "".join(choice(chars) for _ in range(32))
        self._not_so_secret = ["ping_ap_mode", "find_mrbeam_ping"]
        self._all_secrets = self._not_so_secret + [self._secret]

    def initialize(self):
        self._uuid = self._get_setting([
            ["plugins", "discovery", "upnpUuid"],
        ], ["public", "uuid"]) or self._generate_uuid()
        self._search_id = self._settings.get(["public", "search_id"
                                              ]) or self._generate_search_id()
        self._analytics = Analytics(self)
        self._url = self._settings.get(["url"])
        self._logger.info("FindMyMrBeam enabled: %s", self.is_enabled())
        self._analytics.log_enabled(self.is_enabled())
        self.update_frontend()

    def get_additional_environment(self):
        """
			Mixin: octoprint.plugin.EnvironmentDetectionPlugin
			:return: dict of environment data
			"""
        return dict(
            version=self._plugin_version,
            # uuid and search_id will be None on first boot
            uuid=self._uuid,
            search_id=self._search_id,
        )

    ##~~ data providers ##
    """
	This data is sent to the server during device registration.
	"""

    def _get_server_registry_data(self):
        return dict(
            _version=__version__,
            uuid=self._uuid,
            search_id=self._search_id,
            name=self._find_name(),
            hostname=socket.gethostname(),
            local_ips=self._get_local_ips(),
            netconnectd_state=self._get_netconnectd_state(),
            modes=self._get_internal_modes(),
            query="plugin/{}/{}".format(self._identifier, self._secret),
            plugin_version=self._get_plugin_version(),
        )

    """
	This data is sent as a response the the data JSON request coming from the find.mr-beam page in the brwoser
	This is currently not used in production.
	"""

    def _get_local_ping_data(self):
        return dict(
            _version=__version__,
            uuid=self._uuid,
            search_id=self._search_id,
            name=self._find_name(),
            hostname=socket.gethostname(),
            local_ips=self._get_local_ips(),
            netconnectd_state=self._get_netconnectd_state(),
            modes=self._get_internal_modes(),
            query="plugin/{}/{}".format(self._identifier, self._secret),
            plugin_version=self._get_plugin_version(),
        )

    ##~~ SettingsPlugin

    def get_settings_defaults(self):
        return dict(
            enabled=True,
            url="https://find.mr-beam.org/registry",
            interval_client=300.0,
            interval_noclient=60.0,
            # configured in config.yaml in appearance:{name: aBook}
            instance_with_name=u"{name}",
            instance_with_host=u"{host}",
            instance_dev_name=u"MrBeam Development on '{}'",
            disable_if_exists=[],
            public=dict(
                uuid=None,
                search_id=None,
            ),
        )

    def on_settings_load(self):
        return self.get_frontend_data()

    def on_settings_save(self, data):
        if "enabled" in data:
            enabled = bool(data["enabled"])
            self._logger.info("User changed findmymrbeam enabled to: %s",
                              enabled)
            self._settings.set_boolean(["enabled"], enabled)
            self._analytics.log_enabled(self.is_enabled())
            self.start_findmymrbeam()

    ##~~ AssetPlugin mixin

    def get_assets(self):
        # Define your plugin's asset files to automatically include in the
        # core UI here.
        assets = dict(js=["js/findmymrbeam_settings_viewmodel.js"], )
        return assets

    ##~~ StartupPlugin

    def on_startup(self, host, port):
        self._port = port

    def on_after_startup(self):
        self.start_findmymrbeam()

    ##~~ BlueprintPlugin

    def is_blueprint_protected(self):
        return False

    @octoprint.plugin.BlueprintPlugin.route("/<secret>.gif", methods=["GET"])
    def is_online_gif(self, secret):

        if secret not in self._all_secrets:
            flask.abort(404)

        if not self.is_enabled():
            flask.abort(404)

        self._track_ping()

        # send a transparent 1x1 px gif
        import base64
        response = flask.make_response(
            bytes(
                base64.b64decode(
                    "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
            ))
        response.headers["Content-Type"] = "image/gif"
        return response

    @octoprint.plugin.BlueprintPlugin.route("/<secret>.json",
                                            methods=["GET", "OPTIONS"])
    def current_status_endpoint(self, secret):

        if secret not in self._all_secrets:
            flask.abort(404)

        if not self.is_enabled():
            flask.abort(404)

        self._track_ping()

        res = dict()
        if flask.request.method == "GET":
            res = self._get_local_ping_data()
        response = flask.make_response(flask.jsonify(res), 200)
        response.headers['Access-Control-Allow-Origin'] = '*'
        return response

    @octoprint.plugin.BlueprintPlugin.route("/analytics_data",
                                            methods=["POST"])
    def route_analytics_data(self):
        json_data = None
        try:
            json_data = flask.request.json
        except flask.JSONBadRequest:
            return flask.make_response("Malformed JSON body in request", 400)

        event = json_data.get('event')
        payload = json_data.get('payload', dict())
        self._analytics.log_frontend_event(event, payload)
        return NO_CONTENT

    ##~~ EventHandlerPlugin

    def on_event(self, event, payload):
        if not event in (octoprint.events.Events.CLIENT_OPENED, ):
            return
        if not self._client_seen and self.is_enabled():
            self._logger.info(
                "Client seen, switching to slower interval for FindMyMrBeam registrations"
            )
        self._client_seen = True
        self.update_frontend()

    ##~~ TemplatePlugin mixin

    def get_template_configs(self):
        result = [
            dict(type='settings',
                 name="find.mr-beam",
                 template='findmymrbeam_settings.jinja2',
                 custom_bindings=True)
        ]
        return result

    ##~~ internal helpers

    def _generate_uuid(self):
        import uuid as u
        temp_uuid = str(u.uuid4())
        self._settings.set(["public", "uuid"], temp_uuid, force=True)
        self._settings.save()
        return temp_uuid

    def _generate_search_id(self):
        self._search_id = ''.join(
            random.choice(SEARCH_ID_CHARS) for _ in range(SEARCH_ID_LENGTH))
        self._settings.set(["public", "search_id"], self._search_id)
        self._settings.save()

    def is_registered(self):
        """
		Is device registered at find.mr-beam.org?
		:return: Bool True if registered, False if registering failed or if feature is disabled, None if not registered yet
		:rtype:
		"""
        if self.is_enabled():
            return self._registered
        else:
            return False

    def update_frontend(self):
        payload = self.get_frontend_data()
        self._plugin_manager.send_plugin_message("findmymrbeam", payload)

    def get_frontend_data(self):
        ping = self._lastPing > 0
        return dict(name=self._find_name(),
                    uuid=self._uuid,
                    enabled=self._settings.get(['enabled']),
                    registered=self.is_registered(),
                    ping=ping,
                    public_ip=self._public_ip,
                    public_ip6=self._public_ip6,
                    dev=self._settings.get(['dev']),
                    searchId=self._search_id)

    def _find_name(self):
        device_name = ""
        name = self._settings.global_get(["appearance", "name"])
        if name:
            device_name = self._settings.get(["instance_with_name"
                                              ]).format(name=name)
        else:
            device_name = self._settings.get(
                ["instance_with_host"]).format(host=socket.gethostname())

        if not device_name.lower().startswith("mrbeam"):
            device_name = self._settings.get(["instance_dev_name"
                                              ]).format(device_name)

        return device_name

    def _get_local_ips(self):
        return [
            addr for addr in octoprint.util.interface_addresses()
            if netaddr.IPAddress(addr) not in LOCALHOST
        ]

    def _get_setting(self,
                     global_paths,
                     local_path,
                     default_value=None,
                     validator=None):
        if validator is None:
            validator = lambda x: x is not None

        for global_path in global_paths:
            value = self._settings.global_get(global_path, merged=True)
            if validator(value):
                return value

        value = self._settings.get(local_path)
        if validator(value):
            return value

        return default_value

    def start_findmymrbeam(self):
        if self._url and self.is_enabled():
            self._start_update_thread()

    def _start_update_thread(self):
        if self._thread:
            self._logger.warn(
                "_start_update_thread() thread object already present. skipping"
            )
            return

        # start registration thread
        self._logger.info("Registering with FindMyMrBeam at {}".format(
            self._url))
        self._thread = octoprint.util.RepeatedTimer(
            self._get_interval,
            self._perform_update_request,
            run_first=True,
            condition=self._not_disabled,
            on_condition_false=self._on_disabled)
        self._thread.start()

    def _track_ping(self):
        my_call = dict(host=flask.request.host,
                       ref=flask.request.referrer,
                       remote_ip=flask.request.headers.get("X-Forwarded-For"))
        if not my_call in self._calls:
            self._calls.append(my_call)
            self._logger.info("First ping received from: %s", my_call)
            self._logger.info("All unique pings: %s", self._calls)
        self._lastPing = time.time()
        self._analytics.log_pinged(host=my_call['host'],
                                   remote_ip=my_call['remote_ip'],
                                   referrer=my_call['ref'])
        self.update_frontend()

    def _get_interval(self):
        if self._client_seen:
            interval = self._settings.get_float(["interval_client"])
        else:
            interval = self._settings.get_float(["interval_noclient"])
        return interval

    def is_enabled(self):
        return self._not_disabled()

    def _not_disabled(self):
        enabled = False
        try:
            if len(self._get_internal_modes()) > 0:
                enabled = True
            else:
                enabled = self._settings.get(["enabled"])
                if enabled:
                    import os
                    for path in self._settings.get(["disable_if_exists"]):
                        if os.path.exists(path):
                            enabled = False
        except:
            self._logger.exception("Exception in _not_disabled(): ")
        return enabled

    def _on_disabled(self, *args, **kwargs):
        """
		found out that this one is never called, even if _not_disabled() returned False.
		Not sure why, do not want to debug further atm
		"""
        try:
            self._logger.info("Registration with FindMyMrBeam disabled.")
        except:
            self._logger.exception("Exception in _on_disabled(): ")

    def _get_netconnectd_state(self):
        res = dict(wifi=None, ap=None, wired=None)
        try:
            pluginInfo = self._plugin_manager.get_plugin_info("netconnectd")
            if pluginInfo is not None:
                status = pluginInfo.implementation._get_status()
                if "wifi" in status["connections"]:
                    res["wifi"] = status["connections"]["wifi"]
                if "ap" in status["connections"]:
                    res["ap"] = status["connections"]["ap"]
                if "wired" in status["connections"]:
                    res["wired"] = status["connections"]["wired"]
        except Exception as e:
            self._logger.exception(
                "Exception while reading wifi/ap state from netconnectd: {}".
                format(e))
        return res

    def _get_internal_modes(self):
        internal_modes = []
        try:
            pluginInfo = self._plugin_manager.get_plugin_info("mrbeam")
            if pluginInfo is not None:
                if pluginInfo.implementation.support_mode:
                    internal_modes.append(MODE_SUPPORT)
                if pluginInfo.implementation.calibration_tool_mode:
                    internal_modes.append(MODE_CALIBRATION_TOOL)
        except Exception as e:
            self._logger.exception(
                "Exception while reading support mode state from mrbeam: {}".
                format(e))
        return internal_modes

    def _get_plugin_version(self):
        try:
            pluginInfo = self._plugin_manager.get_plugin_info("mrbeam")
            if pluginInfo is not None and pluginInfo.implementation._plugin_version:
                return pluginInfo.implementation._plugin_version
        except Exception as e:
            self._logger.exception(
                "Exception while reading version from mrbeam: {}".format(e))
        return None

    def _perform_update_request(self):
        try:
            data = self._get_server_registry_data()
            self._logger.debug('server registry data - {}'.format(data))

            headers = {
                "User-Agent":
                "OctoPrint-FindMyMrBeam/{}".format(self._plugin_version)
            }

            ip4_status_code, ip_4body, ip4_err = self._do_request_ipv4(
                data, headers)
            ip6_status_code, ip_6body, ip6_err = self._do_request_ipv6(
                data, headers)

            self._public_ip = ip_4body[
                'remote_ip'] if ip_4body is not None and 'remote_ip' in ip_4body else None
            self._public_ip6 = ip_6body[
                'remote_ip'] if ip_6body is not None and 'remote_ip' in ip_6body else None
            self._registered = (ip4_status_code == 200
                                or ip6_status_code == 200)
            self._analytics.log_registered(self._registered, ip4_status_code,
                                           ip6_status_code, ip4_err, ip6_err)
            self.update_frontend()

            if ip4_status_code == 200 or ip6_status_code == 200:
                self._logger.info(
                    "FindMyMrBeam registration: OK  - ip4_status: %s, public_ip: %s, ip6_status: %s, "
                    "public_ip6: %s, hostname: %s, local_ips: %s, netconnectd_state: %s, internal_modes: %s",
                    (ip4_status_code if ip4_status_code > 0 else ip4_err),
                    self._public_ip,
                    (ip6_status_code if ip6_status_code > 0 else ip6_err),
                    self._public_ip6, data['hostname'],
                    ", ".join(data['local_ips']), data['netconnectd_state'],
                    data['modes'])
            else:
                self._logger.info(
                    "FindMyMrBeam registration: ERR - ip4_status: %s, ip6_status: %s, hostname: %s, local_ips: %s, netconnectd_state: %s",
                    (ip4_status_code if ip4_status_code > 0 else ip4_err),
                    (ip6_status_code if ip6_status_code > 0 else ip6_err),
                    data['hostname'], ", ".join(data['local_ips']),
                    data['netconnectd_state'])
        except:
            self._logger.exception(
                "Exception in periodic call of _perform_update_request():")

    def _do_request_ipv4(self, data, headers):
        status_code = 0
        body = None
        err = None
        FindMyMrBeamPlugin._set_ipv4_only()
        try:
            status_code, body, err = self.__do_request(data, headers)
        except Exception as e:
            self._logger.exception(
                "Exception in _do_request_ipv4() while updating registration with FindMyMrBeam:"
            )
        finally:
            FindMyMrBeamPlugin._set_ip_regular()

        return status_code, body, err

    def _do_request_ipv6(self, data, headers):
        status_code = 0
        body = None
        err = None
        FindMyMrBeamPlugin._set_ipv6_only()
        try:
            status_code, body, err = self.__do_request(data, headers)
        except Exception as e:
            self._logger.exception(
                "Exception in _do_request_ipv4() while updating registration with FindMyMrBeam:"
            )
        finally:
            FindMyMrBeamPlugin._set_ip_regular()

        return status_code, body, err

    def __do_request(self, data, headers):
        status_code = 0
        body = None
        err = None
        try:
            r = requests.post(self._url, json=data, headers=headers)
            status_code = r.status_code
            self._logger.debug('request json data - {}'.format(r))
            try:
                body = r.json()
            except ValueError as e:
                self._logger.warn("Error while parsing JSON from response: %s",
                                  e)
        except requests.exceptions.RequestException as e:
            err = 'requests.{}'.format(type(e).__name__)
            status_code = -1
        except Exception as e:
            err = type(e).__name__
            status_code = -1
            self._logger.exception(
                "Exception in __do_request() while updating registration with FindMyMrBeam"
            )

        return status_code, body, err

    @staticmethod
    def _set_ipv4_only():
        if FindMyMrBeamPlugin._socket_getaddrinfo_regular is None:
            FindMyMrBeamPlugin._socket_getaddrinfo_regular = socket.getaddrinfo
        socket.getaddrinfo = FindMyMrBeamPlugin.__socket_getaddrinfo_ipv4_only

    @staticmethod
    def _set_ipv6_only():
        if FindMyMrBeamPlugin._socket_getaddrinfo_regular is None:
            FindMyMrBeamPlugin._socket_getaddrinfo_regular = socket.getaddrinfo
        socket.getaddrinfo = FindMyMrBeamPlugin.__socket_getaddrinfo_ipv6_only

    @staticmethod
    def _set_ip_regular():
        if FindMyMrBeamPlugin._socket_getaddrinfo_regular is not None:
            socket.getaddrinfo = FindMyMrBeamPlugin._socket_getaddrinfo_regular

    @staticmethod
    def __socket_getaddrinfo_ipv4_only(*args, **kwargs):
        responses = FindMyMrBeamPlugin._socket_getaddrinfo_regular(
            *args, **kwargs)
        return [
            response for response in responses if response[0] == socket.AF_INET
        ]

    @staticmethod
    def __socket_getaddrinfo_ipv6_only(*args, **kwargs):
        responses = FindMyMrBeamPlugin._socket_getaddrinfo_regular(
            *args, **kwargs)
        return [
            response for response in responses
            if response[0] == socket.AF_INET6
        ]