From c283ebd286a0d57b49a9698cf0996f894a924dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20W=C3=BCrthner?= Date: Thu, 2 Dec 2021 16:59:56 +0100 Subject: [PATCH 1/3] Update README.md --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index 49091af..4e17711 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -⚠️⚠️⚠️⚠️ -**This plugin is not yet in the OctoPrint plugin repository and is in _beta_!** See setup below! -⚠️⚠️⚠️⚠️ # OctoApp plugin A plugin providing extra functionality to OctoApp: @@ -21,10 +18,7 @@ Get OctoApp on Google Play! 2. Open the **settings** using the 🔧 (wrench) icon in the top right 3. Select the **Plugin Manager** in the left column 4. Click **+ Get More** -5. In text field under the **... from URL** section paste -``` -https://github.com/crysxd/OctoApp-Plugin/archive/master.zip -``` +5. Search for **OctoApp** 6. Click **Install** 7. Reboot OctoPrint when promted From 22df00a7a5f00e802ef112876804029230a7f070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20W=C3=BCrthner?= Date: Sat, 11 Dec 2021 11:16:58 +0100 Subject: [PATCH 2/3] Use modulus to smartly reduce notification count --- octoprint_octoapp/__init__.py | 551 ++++++++++++++++------------------ 1 file changed, 265 insertions(+), 286 deletions(-) diff --git a/octoprint_octoapp/__init__.py b/octoprint_octoapp/__init__.py index 576a636..b8c9a1b 100755 --- a/octoprint_octoapp/__init__.py +++ b/octoprint_octoapp/__init__.py @@ -1,303 +1,282 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, unicode_literals -import octoprint.plugin -from octoprint.events import Events -import flask -import requests -import logging -import time -import json -import threading import base64 import hashlib +import json +import logging +import threading +import time +import uuid + +import flask +import octoprint.plugin +import requests from Crypto import Random from Crypto.Cipher import AES -import uuid +from octoprint.events import Events + class OctoAppPlugin( - octoprint.plugin.AssetPlugin, - octoprint.plugin.ProgressPlugin, - octoprint.plugin.StartupPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.SimpleApiPlugin, - octoprint.plugin.SettingsPlugin, - octoprint.plugin.EventHandlerPlugin, - octoprint.plugin.RestartNeedingPlugin, - ): - - - def __init__(self): - self._logger = logging.getLogger("octoprint.plugins.octoapp") - - self.default_config = dict( - maxUpdateIntervalSecs=60, - sendNotificationUrl="https://europe-west1-octoapp-4e438.cloudfunctions.net/sendNotification" - ) - self.cached_config = self.default_config - self.cached_config_at = 0 - - self.last_progress_notification_at = 0 - self.last_progress = None - self.last_print_name = None - self.firmware_info = {} - - def get_settings_defaults(self): - return dict( - registeredApps=[], - encryptionKey=None - ) - - def on_after_startup(self): - self._logger.info("OctoApp started, updating config") - self.get_config() - self.get_or_create_encryption_key() - - def get_template_configs(self): - return [ - dict(type="settings", custom_bindings=False) - ] - - def get_api_commands(self): - return dict( - registerForNotifications=[], - getPrinterFirmware=[], - ) - - def on_print_progress(self, storage, path, progress): - self.last_progress = progress - self.last_time_left = self._printer.get_current_data()["progress"]["printTimeLeft"] - - # check if we are allowed to send an update already - config = self.get_config() - earliest_update_at = self.last_progress_notification_at + config['maxUpdateIntervalSecs'] - if time.time() < earliest_update_at: - self._logger.debug("Skipping update, next update at %s" % earliest_update_at) - return - - # send update, but don't send for 100% - if (progress < 100): - self.send_notification( - dict( - type='printing', - fileName=self.last_print_name, - progress=self.last_progress, - timeLeft=self.last_time_left - ), - False - ) - - def on_event(self, event, payload): - self._logger.debug("Recevied event %s" % event) - if event == 'PrintStarted': - self.last_print_name = payload['name'] - self.last_progress_notification_at = 0 - - if event == 'PrintDone': - self.last_progress = None - self.last_print_name = None - self.last_time_left = None - self.send_notification( - dict( - type='completed', - fileName=payload['name'] - ), - True - ) - - elif event == 'PrintFailed' or event == 'PrintCancelled': - self.last_progress = None - self.last_print_name = None - self.send_notification( - dict( - type='idle', - fileName=payload['name'] - ), - False - ) - - elif event == 'FilamentChange' and self.last_progress != None: - self.send_notification( - dict( - type='filament_required', - fileName=self.last_print_name, - progress=self.last_progress, - timeLeft=self.last_time_left, - ), - True - ) - - elif event == 'PrintPaused': - self.send_notification( - dict( - type='paused', - fileName=payload['name'], - progress=self.last_progress, - timeLeft=self.last_time_left, - ), - False - ) - - def send_notification(self, data, highPriority): - self._logger.debug('send_notification') - t = threading.Thread(target=self.do_send_notification, args=[data, highPriority]) - t.daemon = True - t.start() - - def do_send_notification(self, data, highPriority): - try: - self._logger.debug('do_send_notification') - config = self.get_config() - - # encrypt message and build request body - data['serverTime'] = int(time.time()) - cipher = AESCipher(self.get_or_create_encryption_key()) - data = cipher.encrypt(json.dumps(data)) - apps = self._settings.get(['registeredApps']) - if not apps: - self._logger.debug('No apps registered, skipping notification') - return - - body=dict( - targets=apps, - highPriority=highPriority, - data=data - ) - self._logger.debug('Sending notification %s' % body) - - # make request and check 200 - r = requests.post( - config['sendNotificationUrl'], - timeout=float(15), - json=body - ) - if r.status_code != requests.codes.ok: - raise Exception('Unexpected response code %d' % r.status_code) - - # delete invalid tokens - apps = self._settings.get(['registeredApps']) - self._logger.debug("Before updating apps %s" % apps) - for fcmToken in r.json()['invalidTokens']: - apps = [app for app in apps if app['fcmToken'] != fcmToken] - self._settings.set(['registeredApps'], apps) - self._settings.save() - self._logger.debug("Updated apps %s" % apps) - except Exception as e: - self._logger.debug("Failed to send notification %s" % e) - - def on_firmware_info_received(self, comm_instance, firmware_name, firmware_data, *args, **kwargs): - self._logger.debug("Recevied firmware info") - self.firmware_info = firmware_data - - def on_api_command(self, command, data): - self._logger.debug("Recevied command %s" % command) - - if command == 'getPrinterFirmware': - return flask.jsonify(self.firmware_info) - - elif command == 'registerForNotifications': - fcmToken = data['fcmToken'] - - # load apps and filter the given FCM token out - apps = self._settings.get(['registeredApps']) - if apps: - apps = [app for app in apps if app['fcmToken'] != fcmToken] - else: - apps = [] - - # add app for new registration - apps.append( - dict( - fcmToken=fcmToken, - instanceId=data['instanceId'], - displayName=data['displayName'], - model=data['model'], - appVersion=data['appVersion'], - appBuild=data['appBuild'], - appLanguage=data['appLanguage'], - lastSeenAt=time.time() - ) - ) - - # save - self._logger.info("Registered app %s" % fcmToken) - self._logger.debug("registeredApps %s" % apps) - self._settings.set(['registeredApps'], apps) - self._settings.save() - - return flask.jsonify(dict()) - - def get_config(self): - t = threading.Thread(target=self.do_update_config) - t.daemon = True - t.start() - return self.cached_config - - def do_update_config(self): - # If we have no config cached or the cache is older than a day, request new config - cache_config_max_age = time.time() - 86400 - if (self.cached_config != None) and (self.cached_config_at > cache_config_max_age): - self._logger.debug("Reusing cached config") - return self.cached_config - - # Request config, fall back to default - try: - r = requests.get("https://www.octoapp.eu/pluginconfig.json", timeout=float(15)) - if r.status_code != requests.codes.ok: - raise Exception('Unexpected response code %d' % r.status_code) - self.cached_config = r.json() - self.cached_config_at = time.time() - self._logger.info("OctoApp loaded config: %s" % self.cached_config) - except Exception as e: - self._logger.info("Failed to fetch config using defaults for 5 minutes, recevied %s" % e) - self.cached_config = self.default_config - self._logger.info("OctoApp loaded config: %s" % self.cached_config) - self.cached_config_at = cache_config_max_age + 300 - - def get_update_information(self): - return dict( - octoapp=dict( - displayName="OctoApp", - displayVersion=self._plugin_version, - - type="github_release", - current=self._plugin_version, - - user="crysxd", - repo="OctoApp-Plugin", - pip="https://github.com/crysxd/OctoApp-Plugin/archive/{target}.zip" - ) - ) - - def get_or_create_encryption_key(self): - key = self._settings.get(['encryptionKey']) - if key == None: - key = str(uuid.uuid4()) - self._logger.info("Created new encryption key") - self._settings.set(['encryptionKey'], key) - return key + octoprint.plugin.AssetPlugin, + octoprint.plugin.ProgressPlugin, + octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.SimpleApiPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.EventHandlerPlugin, + octoprint.plugin.RestartNeedingPlugin, +): + def __init__(self): + self._logger = logging.getLogger("octoprint.plugins.octoapp") + + self.default_config = dict( + updatePercentModulus=5, + sendNotificationUrl="https://europe-west1-octoapp-4e438.cloudfunctions.net/sendNotification", + ) + self.cached_config = self.default_config + self.cached_config_at = 0 + + self.last_progress_notification_at = 0 + self.last_progress = None + self.last_print_name = None + self.firmware_info = {} + + def get_settings_defaults(self): + return dict(registeredApps=[], encryptionKey=None) + + def on_after_startup(self): + self._logger.info("OctoApp started, updating config") + self.get_config() + self.get_or_create_encryption_key() + + def get_template_configs(self): + return [dict(type="settings", custom_bindings=False)] + + def get_api_commands(self): + return dict( + registerForNotifications=[], + getPrinterFirmware=[], + ) + + def on_print_progress(self, storage, path, progress): + self.last_progress = progress + self.last_time_left = self._printer.get_current_data()["progress"][ + "printTimeLeft" + ] + + # send update, but don't send for 100% + # we send updated in "modulus" interval as well as for the first and last "modulus" percent + modulus = self.get_config()["updatePercentModulus"] + if progress < 100 and ( + (progress % modulus) == 0 + or progress <= modulus + or progress >= (100 - modulus) + ): + self.send_notification( + dict( + type="printing", + fileName=self.last_print_name, + progress=self.last_progress, + timeLeft=self.last_time_left, + ), + False, + ) + + def on_event(self, event, payload): + self._logger.debug("Recevied event %s" % event) + if event == "PrintStarted": + self.last_print_name = payload["name"] + self.last_progress_notification_at = 0 + + if event == "PrintDone": + self.last_progress = None + self.last_print_name = None + self.last_time_left = None + self.send_notification(dict(type="completed", fileName=payload["name"]), True) + + elif event == "PrintFailed" or event == "PrintCancelled": + self.last_progress = None + self.last_print_name = None + self.send_notification(dict(type="idle", fileName=payload["name"]), False) + + elif event == "FilamentChange" and self.last_progress is not None: + self.send_notification( + dict( + type="filament_required", + fileName=self.last_print_name, + progress=self.last_progress, + timeLeft=self.last_time_left, + ), + True, + ) + + elif event == "PrintPaused": + self.send_notification( + dict( + type="paused", + fileName=payload["name"], + progress=self.last_progress, + timeLeft=self.last_time_left, + ), + False, + ) + + def send_notification(self, data, highPriority): + t = threading.Thread(target=self.do_send_notification, args=[data, highPriority]) + t.daemon = True + t.start() + + def do_send_notification(self, data, highPriority): + try: + config = self.get_config() + + # encrypt message and build request body + data["serverTime"] = int(time.time()) + self._logger.warn("Sending notification %s" % data) + cipher = AESCipher(self.get_or_create_encryption_key()) + data = cipher.encrypt(json.dumps(data)) + apps = self._settings.get(["registeredApps"]) + if not apps: + self._logger.debug("No apps registered, skipping notification") + return + + body = dict(targets=apps, highPriority=highPriority, data=data) + self._logger.warn("Sending notification %s" % body) + + # make request and check 200 + r = requests.post(config["sendNotificationUrl"], timeout=float(15), json=body) + if r.status_code != requests.codes.ok: + raise Exception("Unexpected response code %d" % r.status_code) + + # delete invalid tokens + apps = self._settings.get(["registeredApps"]) + self._logger.debug("Before updating apps %s" % apps) + for fcmToken in r.json()["invalidTokens"]: + apps = [app for app in apps if app["fcmToken"] != fcmToken] + self._settings.set(["registeredApps"], apps) + self._settings.save() + self._logger.debug("Updated apps %s" % apps) + except Exception as e: + self._logger.debug("Failed to send notification %s" % e) + + def on_firmware_info_received( + self, comm_instance, firmware_name, firmware_data, *args, **kwargs + ): + self._logger.debug("Recevied firmware info") + self.firmware_info = firmware_data + + def on_api_command(self, command, data): + self._logger.debug("Recevied command %s" % command) + + if command == "getPrinterFirmware": + return flask.jsonify(self.firmware_info) + + elif command == "registerForNotifications": + fcmToken = data["fcmToken"] + + # load apps and filter the given FCM token out + apps = self._settings.get(["registeredApps"]) + if apps: + apps = [app for app in apps if app["fcmToken"] != fcmToken] + else: + apps = [] + + # add app for new registration + apps.append( + dict( + fcmToken=fcmToken, + instanceId=data["instanceId"], + displayName=data["displayName"], + model=data["model"], + appVersion=data["appVersion"], + appBuild=data["appBuild"], + appLanguage=data["appLanguage"], + lastSeenAt=time.time(), + ) + ) + + # save + self._logger.info("Registered app %s" % fcmToken) + self._logger.debug("registeredApps %s" % apps) + self._settings.set(["registeredApps"], apps) + self._settings.save() + + return flask.jsonify(dict()) + + def get_config(self): + t = threading.Thread(target=self.do_update_config) + t.daemon = True + t.start() + return self.cached_config + + def do_update_config(self): + # If we have no config cached or the cache is older than a day, request new config + cache_config_max_age = time.time() - 86400 + if (self.cached_config is not None) and ( + self.cached_config_at > cache_config_max_age + ): + return self.cached_config + + # Request config, fall back to default + try: + r = requests.get( + "https://www.octoapp.eu/pluginconfig.json", timeout=float(15) + ) + if r.status_code != requests.codes.ok: + raise Exception("Unexpected response code %d" % r.status_code) + self.cached_config = r.json() + self.cached_config_at = time.time() + self._logger.info("OctoApp loaded config: %s" % self.cached_config) + except Exception as e: + self._logger.warn( + "Failed to fetch config using defaults for 5 minutes, recevied %s" % e + ) + self.cached_config = self.default_config + self._logger.info("OctoApp loaded config: %s" % self.cached_config) + self.cached_config_at = cache_config_max_age + 300 + + def get_update_information(self): + return dict( + octoapp=dict( + displayName="OctoApp", + displayVersion=self._plugin_version, + type="github_release", + current=self._plugin_version, + user="crysxd", + repo="OctoApp-Plugin", + pip="https://github.com/crysxd/OctoApp-Plugin/archive/{target}.zip", + ) + ) + + def get_or_create_encryption_key(self): + key = self._settings.get(["encryptionKey"]) + if key is None: + key = str(uuid.uuid4()) + self._logger.info("Created new encryption key") + self._settings.set(["encryptionKey"], key) + return key + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = OctoAppPlugin() __plugin_hooks__ = { - "octoprint.plugin.softwareupdate.check_config": - __plugin_implementation__.get_update_information, - "octoprint.comm.protocol.firmware.info": - __plugin_implementation__.on_firmware_info_received + "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, + "octoprint.comm.protocol.firmware.info": __plugin_implementation__.on_firmware_info_received, } -class AESCipher(object): - - def __init__(self, key): - self.bs = AES.block_size - self.key = hashlib.sha256(key.encode()).digest() - - def encrypt(self, raw): - raw = self._pad(raw) - iv = Random.new().read(AES.block_size) - cipher = AES.new(self.key, AES.MODE_CBC, iv) - return base64.b64encode(iv + cipher.encrypt(raw.encode())).decode('utf-8') - def _pad(self, s): - return s + (self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs) +class AESCipher(object): + def __init__(self, key): + self.bs = AES.block_size + self.key = hashlib.sha256(key.encode()).digest() + + def encrypt(self, raw): + raw = self._pad(raw) + iv = Random.new().read(AES.block_size) + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return base64.b64encode(iv + cipher.encrypt(raw.encode())).decode("utf-8") + + def _pad(self, s): + return s + (self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs) From d466ada700d8637aacbcc6a249eae42a0de111e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20W=C3=BCrthner?= Date: Sat, 11 Dec 2021 11:17:16 +0100 Subject: [PATCH 3/3] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5d1ce00..2d62230 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "OctoApp" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "1.0.3" +plugin_version = "1.0.4" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module