From 290fc5eebdc103a9e83e4d2e16f587d97e09a2d0 Mon Sep 17 00:00:00 2001 From: jneilliii Date: Sun, 24 Jan 2021 02:09:38 -0500 Subject: [PATCH] 1.0.0rc1 (#119) * add additional logging during check status, to aid in debugging #101 * set _autostart_file None if print canceled or completed to prevent restarting file on connect, #118 * add baudrate to printer connection command * fix invalid state in confirmation prompt, #116 * switch to using params in requests.get * move check statuses to onAllBound to avoid potential lock up of UI load, #90 * fix date format for graphing data, #92 * add css error highlighting and disable update button on graph tab when inputs are invalid * remove redundant css properties * add cost to settings and graphing, #87 * add connect event monitoring, #72 --- README.md | 1 + octoprint_tasmota/__init__.py | 135 +++++++++++++----- octoprint_tasmota/static/css/tasmota.css | 10 +- octoprint_tasmota/static/js/tasmota.js | 57 +++++++- .../templates/tasmota_navbar.jinja2 | 2 +- .../templates/tasmota_settings.jinja2 | 33 +++-- .../templates/tasmota_tab.jinja2 | 8 +- setup.py | 2 +- 8 files changed, 185 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index e450e25..618b723 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Check out my other plugins [here](https://plugins.octoprint.org/by_author/#jneil - [SimplyPrint](https://simplyprint.dk/) - [Andrew Beeman](https://github.com/Kiendeleo) - [Calanish](https://github.com/calanish) +- [Will O](https://github.com/4wrxb) ### Support My Efforts I, jneilliii, programmed this plugin for fun and do my best effort to support those that have issues with it, please return the favor and leave me a tip or become a Patron if you find this plugin helpful and want me to continue future development. diff --git a/octoprint_tasmota/__init__.py b/octoprint_tasmota/__init__.py index 0197fd4..ea48419 100644 --- a/octoprint_tasmota/__init__.py +++ b/octoprint_tasmota/__init__.py @@ -77,6 +77,7 @@ class tasmotaPlugin(octoprint.plugin.SettingsPlugin, octoprint.plugin.EventHandlerPlugin): def __init__(self): + self.print_job_power = 0.0 self._logger = logging.getLogger("octoprint.plugins.tasmota") self._tasmota_logger = logging.getLogger("octoprint.plugins.tasmota.debug") self.thermal_runaway_triggered = False @@ -92,6 +93,8 @@ def __init__(self): self.powerOffWhenIdle = False self._idleTimer = None self._autostart_file = None + self.print_job_started = False + self._storage_interface = None ##~~ StartupPlugin mixin @@ -160,13 +163,15 @@ def get_settings_defaults(self): thermal_runaway_max_extruder=300, event_on_error_monitoring=False, event_on_disconnect_monitoring=False, + event_on_connecting_monitoring=False, arrSmartplugs=[], abortTimeout=30, powerOffWhenIdle=False, idleTimeout=30, idleIgnoreCommands='M105', idleTimeoutWaitTemp=50, - event_on_upload_monitoring=False + event_on_upload_monitoring=False, + cost_rate=0 ) def on_settings_save(self, data): @@ -219,7 +224,7 @@ def on_settings_save(self, data): self.poll_status.start() def get_settings_version(self): - return 9 + return 10 def on_settings_migrate(self, target, current=None): if current is None or current < 6: @@ -248,6 +253,13 @@ def on_settings_migrate(self, target, current=None): plug["event_on_upload"] = False arrSmartplugs_new.append(plug) self._settings.set(["arrSmartplugs"], arrSmartplugs_new) + if current < 10: + # Add new fields + arrSmartplugs_new = [] + for plug in self._settings.get(['arrSmartplugs']): + plug["event_on_connecting"] = False + arrSmartplugs_new.append(plug) + self._settings.set(["arrSmartplugs"], arrSmartplugs_new) ##~~ AssetPlugin mixin @@ -290,6 +302,14 @@ def on_event(self, event, payload): if plug["event_on_error"] == True: self._tasmota_logger.debug("powering off %s:%s due to %s event." % (plug["ip"], plug["idx"], event)) self.turn_off(plug["ip"], plug["idx"]) + + if event == Events.CONNECTING and self._settings.get_boolean(["event_on_connecting_monitoring"]) and self._printer.is_closed_or_error: + self._tasmota_logger.debug("powering on due to %s event." % event) + for plug in self._settings.get(['arrSmartplugs']): + if plug["event_on_connecting"] == True: + self._tasmota_logger.debug("powering on %s:%s due to %s event." % (plug["ip"], plug["idx"], event)) + self.turn_on(plug["ip"], plug["idx"]) + # Disconnected Event if event == Events.DISCONNECTED and self._settings.get_boolean(["event_on_disconnect_monitoring"]): self._tasmota_logger.debug("powering off due to %s event." % event) @@ -297,18 +317,21 @@ def on_event(self, event, payload): if plug["event_on_disconnect"] == True: self._tasmota_logger.debug("powering off %s:%s due to %s event." % (plug["ip"], plug["idx"], event)) self.turn_off(plug["ip"], plug["idx"]) + # Client Opened Event if event == Events.CLIENT_OPENED: self._plugin_manager.send_plugin_message(self._identifier, dict(powerOffWhenIdle=self.powerOffWhenIdle, type="timeout", timeout_value=self._timeout_value)) return + # Printer Connected Event if event == Events.CONNECTED: if self._autostart_file: self._tasmota_logger.debug("printer connected starting print of %s" % self._autostart_file) self._printer.select_file(self._autostart_file, False, printAfterSelect=True) self._autostart_file = None + # File Uploaded Event if event == Events.UPLOAD and self._settings.getBoolean(["event_on_upload_monitoring"]): if payload.get("print", False): # implemented in OctoPrint version 1.4.1 @@ -329,7 +352,15 @@ def on_event(self, event, payload): self._autostart_file = payload.get("path") # Print Started Event - if event == Events.PRINT_STARTED and self.powerOffWhenIdle == True: + if event == Events.PRINT_STARTED and self._settings.getFloat(["cost_rate"]) > 0: + self.print_job_started = True + self._tasmota_logger.debug(payload.get("path", None)) + for plug in self._settings.get(["arrSmartplugs"]): + status = self.check_status(plug["ip"], plug["idx"]) + self.print_job_power -= float(self.deep_get(status, ["energy_data", "Total"], default=0)) + self._tasmota_logger.debug(self.print_job_power) + + if event == Events.PRINT_STARTED and self.powerOffWhenIdle: if self._abort_timer is not None: self._abort_timer.cancel() self._abort_timer = None @@ -337,9 +368,38 @@ def on_event(self, event, payload): if self._idleTimer is not None: self._reset_idle_timer() self._timeout_value = None - self._plugin_manager.send_plugin_message(self._identifier, - dict(powerOffWhenIdle=self.powerOffWhenIdle, type="timeout", - timeout_value=self._timeout_value)) + self._plugin_manager.send_plugin_message(self._identifier, dict(powerOffWhenIdle=self.powerOffWhenIdle, type="timeout", timeout_value=self._timeout_value)) + + # Print Cancelled/Done Events + if event == Events.PRINT_DONE and self.print_job_started: + self._tasmota_logger.debug(payload) + + for plug in self._settings.get(["arrSmartplugs"]): + status = self.check_status(plug["ip"], plug["idx"]) + self.print_job_power += float(self.deep_get(status, ["energy_data", "Total"], default=0)) + self._tasmota_logger.debug(self.print_job_power) + + hours = (payload.get("time", 0) / 60) / 60 + self._tasmota_logger.debug("hours: %s" % hours) + power_used = self.print_job_power * hours + self._tasmota_logger.debug("power used: %s" % power_used) + power_cost = power_used * self._settings.getFloat(["cost_rate"]) + self._tasmota_logger.debug("power total cost: %s" % power_cost) + + self._storage_interface = self._file_manager._storage(payload.get("origin", "local")) + self._storage_interface.set_additional_metadata(payload.get("path"), "statistics", dict( + lastPowerCost=dict(_default=float('{:.4f}'.format(power_cost)))), merge=True) + + self._autostart_file = None + self.print_job_power = 0.0 + self.print_job_started = False + + if event == Events.PRINT_CANCELLED: + self._autostart_file = None + self.print_job_power = 0.0 + self.print_job_started = False + + # Timelapse events if self.powerOffWhenIdle == True and event == Events.MOVIE_RENDERING: self._tasmota_logger.debug("Timelapse generation started: %s" % payload.get("movie_basename", "")) self._timelapse_active = True @@ -355,16 +415,12 @@ def turn_on(self, plugip, plugidx): plug = self.plug_search(self._settings.get(["arrSmartplugs"]), "ip", plugip, "idx", plugidx) try: if plug["use_backlog"] and int(plug["backlog_on_delay"]) > 0: - webresponse = requests.get( - "http://" + plug["ip"] + "/cm?user=" + plug["username"] + "&password=" + requests.utils.quote( - plug["password"]) + "&cmnd=backlog%20delay%20" + str( - int(plug["backlog_on_delay"]) * 10) + "%3BPower" + str(plug["idx"]) + "%20on%3B") + backlog_command = "backlog delay {};Power{} on;".format(int(plug["backlog_on_delay"]) * 10, plug["idx"]) + requests.get("http://{}/cm".format(plugip), params={"user": plug["username"], "password": requests.utils.quote(plug["password"]), "cmnd": backlog_command}, timeout=3) response = dict() response["POWER%s" % plug["idx"]] = "ON" else: - webresponse = requests.get( - "http://" + plug["ip"] + "/cm?user=" + plug["username"] + "&password=" + requests.utils.quote( - plug["password"]) + "&cmnd=Power" + str(plug["idx"]) + "%20on") + webresponse = requests.get("http://{}/cm".format(plugip), params={"user": plug["username"], "password": requests.utils.quote(plug["password"]), "cmnd": "Power{} on".format(plug["idx"])}, timeout=3) response = webresponse.json() chk = response["POWER%s" % plug["idx"]] except: @@ -378,7 +434,7 @@ def turn_on(self, plugip, plugidx): if plug["autoConnect"] and self._printer.is_closed_or_error(): self._logger.info(self._settings.global_get(['serial'])) c = threading.Timer(int(plug["autoConnectDelay"]), self._printer.connect, - kwargs=dict(port=self._settings.global_get(['serial', 'port']))) + kwargs=dict(port=self._settings.global_get(['serial', 'port']), baudrate=self._settings.global_get(['serial', 'baudrate']))) c.daemon = True c.start() if plug["sysCmdOn"]: @@ -399,12 +455,8 @@ def turn_off(self, plugip, plugidx): if plug["use_backlog"] and int(plug["backlog_off_delay"]) > 0: self._tasmota_logger.debug( "Using backlog commands with a delay value of %s" % str(int(plug["backlog_off_delay"]) * 10)) - backlog_url = "http://" + plug["ip"] + "/cm?user=" + plug[ - "username"] + "&password=" + requests.utils.quote( - plug["password"]) + "&cmnd=backlog%20delay%20" + str( - int(plug["backlog_off_delay"]) * 10) + "%3BPower" + str(plug["idx"]) + "%20off%3B" - self._tasmota_logger.debug("Sending command %s" % backlog_url) - webresponse = requests.get(backlog_url) + backlog_command = "backlog delay {};Power{} off;".format(int(plug["backlog_on_delay"]) * 10, plug["idx"]) + requests.get("http://{}/cm".format(plugip), params={"user": plug["username"], "password": requests.utils.quote(plug["password"]), "cmnd": backlog_command}, timeout=3) response = dict() response["POWER%s" % plug["idx"]] = "OFF" if plug["sysCmdOff"]: @@ -419,9 +471,7 @@ def turn_off(self, plugip, plugidx): time.sleep(int(plug["autoDisconnectDelay"])) if not plug["use_backlog"]: self._tasmota_logger.debug("Not using backlog commands") - webresponse = requests.get( - "http://" + plug["ip"] + "/cm?user=" + plug["username"] + "&password=" + requests.utils.quote( - plug["password"]) + "&cmnd=Power" + str(plug["idx"]) + "%20off") + webresponse = requests.get("http://{}/cm".format(plugip), params={"user": plug["username"], "password": requests.utils.quote(plug["password"]), "cmnd": "Power{} off".format(plug["idx"])}, timeout=3) response = webresponse.json() chk = response["POWER%s" % plug["idx"]] if chk.upper() == "OFF": @@ -442,9 +492,9 @@ def check_status(self, plugip, plugidx): try: plug = self.plug_search(self._settings.get(["arrSmartplugs"]), "ip", plugip, "idx", plugidx) self._tasmota_logger.debug(plug) - webresponse = requests.get( - "http://" + plugip + "/cm?user=" + plug["username"] + "&password=" + requests.utils.quote( - plug["password"]) + "&cmnd=Status%200") + webresponse = requests.get("http://{}/cm".format(plugip), params={"user": plug["username"], "password": requests.utils.quote(plug["password"]), "cmnd": "Status 0"}, timeout=3) + self._tasmota_logger.debug("check status code: {}".format(webresponse.status_code)) + self._tasmota_logger.debug("check status text: {}".format(webresponse.text)) response = webresponse.json() self._tasmota_logger.debug("%s index %s response: %s" % (plugip, plugidx, response)) # chk = response["POWER%s" % plugidx] @@ -486,6 +536,8 @@ def check_status(self, plugip, plugidx): self._tasmota_logger.error('Invalid ip or unknown error connecting to %s.' % plugip, exc_info=True) response = "unknown error with %s." % plugip chk = "UNKNOWN" + energy_data = None + sensor_data = None self._tasmota_logger.debug("%s index %s is %s" % (plugip, plugidx, chk)) if chk.upper() == "ON": @@ -500,15 +552,13 @@ def check_status(self, plugip, plugidx): return response def checkSetOption26(self, plugip, username, password): - webresponse = requests.get("http://" + plugip + "/cm?user=" + username + "&password=" + requests.utils.quote( - password) + "&cmnd=SetOption26") + webresponse = requests.get("http://{}/cm".format(plugip), params={"user": username, "password": requests.utils.quote(password), "cmnd": "SetOption26"}, timeout=3) response = webresponse.json() self._tasmota_logger.debug(response) return response def setSetOption26(self, plugip, username, password): - webresponse = requests.get("http://" + plugip + "/cm?user=" + username + "&password=" + requests.utils.quote( - password) + "&cmnd=SetOption26%20ON") + webresponse = requests.get("http://{}/cm".format(plugip), params={"user": username, "password": requests.utils.quote(password), "cmnd": "SetOption26 ON"}, timeout=3) response = webresponse.json() self._tasmota_logger.debug(response) return response @@ -557,9 +607,7 @@ def on_api_command(self, command, data): self._timeout_value = None for plug in self._settings.get(["arrSmartplugs"]): if plug["use_backlog"] and int(plug["backlog_off_delay"]) > 0: - backlog_url = "http://" + plug["ip"] + "/cm?user=" + plug[ - "username"] + "&password=" + requests.utils.quote(plug["password"]) + "&cmnd=backlog" - webresponse = requests.get(backlog_url) + webresponse = requests.get("http://{}/cm".format(plug["ip"]), params={"user": plug["username"], "password": requests.utils.quote(plug["password"]), "cmnd": "backlog"}, timeout=3) self._tasmota_logger.debug("Cleared countdown rules for %s" % plug["ip"]) self._tasmota_logger.debug(webresponse) self._tasmota_logger.debug("Power off aborted.") @@ -838,6 +886,21 @@ def plug_search(self, list, key1, value1, key2, value2): if item[key1] == value1 and item[key2] == value2: return item + def deep_get(self, d, keys, default=None): + """ + Example: + d = {'meta': {'status': 'OK', 'status_code': 200}} + deep_get(d, ['meta', 'status_code']) # => 200 + deep_get(d, ['garbage', 'status_code']) # => None + deep_get(d, ['meta', 'garbage'], default='-') # => '-' + """ + assert type(keys) is list + if d is None: + return default + if not keys: + return d + return self.deep_get(d.get(keys[0]), keys[1:], default) + ##~~ Access Permissions Hook def get_additional_permissions(self, *args, **kwargs): @@ -853,9 +916,6 @@ def get_additional_permissions(self, *args, **kwargs): ##~~ Softwareupdate hook def get_update_information(self): - # Define the configuration for your plugin to use with the Software Update - # Plugin here. See https://github.com/foosel/OctoPrint/wiki/Plugin:-Software-Update - # for details. return dict( tasmota=dict( displayName="Tasmota", @@ -883,9 +943,6 @@ def get_update_information(self): ) -# If you want your plugin to be registered within OctoPrint under a different name than what you defined in setup.py -# ("OctoPrint-PluginSkeleton"), you may define that here. Same goes for the other metadata derived from setup.py that -# can be overwritten via __plugin_xyz__ control properties. See the documentation for that. __plugin_name__ = "Tasmota" __plugin_pythoncompat__ = ">=2.7,<4" diff --git a/octoprint_tasmota/static/css/tasmota.css b/octoprint_tasmota/static/css/tasmota.css index 0ad14b0..84d991a 100644 --- a/octoprint_tasmota/static/css/tasmota.css +++ b/octoprint_tasmota/static/css/tasmota.css @@ -9,7 +9,7 @@ #navbar_plugin_tasmota > a > i.unknown::after { content: " ?"; font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; -} } +} /* TouchUI - Show Label */ #touch #navbar_plugin_tasmota > a > div.tasmota_label { @@ -27,8 +27,6 @@ } #settings_plugin_tasmota > table > thead > tr > th, #settings_plugin_tasmota > table > tbody > tr > td { - border-bottom-width: 1px; - border-top-width: 1px; border-bottom: 1px solid #ddd; border-top: 1px solid #ddd; } @@ -68,3 +66,9 @@ height: 25px; font-size: 12px; } + +input[type="datetime-local"].alert-error { + color: #b94a48; + background-color: #f2dede; + border-color: #b94a48; +} diff --git a/octoprint_tasmota/static/js/tasmota.js b/octoprint_tasmota/static/js/tasmota.js index b869cf0..eb157f5 100644 --- a/octoprint_tasmota/static/js/tasmota.js +++ b/octoprint_tasmota/static/js/tasmota.js @@ -10,6 +10,7 @@ $(function() { self.settings = parameters[0]; self.loginState = parameters[1]; + self.filesViewModel = parameters[2]; self.arrSmartplugs = ko.observableArray(); self.arrSmartplugsTooltips = ko.observableDictionary({}); @@ -23,10 +24,8 @@ $(function() { switch(self.arrSmartplugsStates.get(data.ip()+'_'+data.idx())()) { case "on": return data.on_color(); - break; case "off": return data.off_color(); - break; default: return data.unknown_color(); } @@ -47,6 +46,40 @@ $(function() { return self.automaticShutdownEnabled() ? 'Disable Automatic Power Off' : 'Enable Automatic Power Off'; }) + self.filesViewModel.getAdditionalData = function(data) { + var output = ""; + if (data["gcodeAnalysis"]) { + if (data["gcodeAnalysis"]["dimensions"]) { + var dimensions = data["gcodeAnalysis"]["dimensions"]; + output += gettext("Model size") + ": " + _.sprintf("%(width).2fmm × %(depth).2fmm × %(height).2fmm", dimensions); + output += "
"; + } + if (data["gcodeAnalysis"]["filament"] && typeof(data["gcodeAnalysis"]["filament"]) === "object") { + var filament = data["gcodeAnalysis"]["filament"]; + if (_.keys(filament).length === 1) { + output += gettext("Filament") + ": " + formatFilament(data["gcodeAnalysis"]["filament"]["tool" + 0]) + "
"; + } else if (_.keys(filament).length > 1) { + _.each(filament, function(f, k) { + if (!_.startsWith(k, "tool") || !f || !f.hasOwnProperty("length") || f["length"] <= 0) return; + output += gettext("Filament") + " (" + gettext("Tool") + " " + k.substr("tool".length) + + "): " + formatFilament(f) + "
"; + }); + } + } + output += gettext("Estimated print time") + ": " + (self.settings.appearance_fuzzyTimes() ? formatFuzzyPrintTime(data["gcodeAnalysis"]["estimatedPrintTime"]) : formatDuration(data["gcodeAnalysis"]["estimatedPrintTime"])) + "
"; + } + if (data["prints"] && data["prints"]["last"]) { + output += gettext("Last printed") + ": " + formatTimeAgo(data["prints"]["last"]["date"]) + "
"; + if (data["prints"]["last"]["printTime"]) { + output += gettext("Last print time") + ": " + formatDuration(data["prints"]["last"]["printTime"]) + "
"; + } + } + if (data["statistics"] && data["statistics"]["lastPowerCost"]) { + output += gettext("Last power cost") + ": " + data["statistics"]["lastPowerCost"]["_default"] + "
"; + } + return output; + }; + // Hack to remove automatically added Cancel button // See https://github.com/sciactive/pnotify/issues/141 PNotify.prototype.options.confirm.buttons = []; @@ -132,7 +165,7 @@ $(function() { self.arrSmartplugs(self.settings.settings.plugins.tasmota.arrSmartplugs()); } - self.onAfterBinding = function() { + self.onAllBound = function() { self.checkStatuses(); } @@ -156,8 +189,8 @@ $(function() { dataType: "json", data: JSON.stringify({ command: "getEnergyData", - start_date: self.graph_start_date(), - end_date: self.graph_end_date() + start_date: self.graph_start_date().replace('T', ' '), + end_date: self.graph_end_date().replace('T', ' ') }), contentType: "application/json; charset=UTF-8" }).done(function(data){ @@ -169,10 +202,19 @@ $(function() { for(var i=0;i +
+
+
+ +
+
+
@@ -112,6 +121,14 @@
+
+
+ +
+ +
+
+
@@ -184,25 +201,25 @@