/ octoprint_GPX / __init__.py
__init__.py
  1  # coding=utf-8
  2  from __future__ import absolute_import
  3  
  4  import os
  5  import re
  6  from collections import OrderedDict
  7  
  8  import flask
  9  from flask import request, make_response
 10  from werkzeug.exceptions import BadRequest
 11  
 12  import octoprint.plugin
 13  from octoprint.events import Events
 14  from octoprint.server import admin_permission
 15  
 16  try:
 17  	import gpx
 18  except:
 19  	pass
 20  
 21  # merges dict b into dict a, deeply
 22  def _merge_dict(a, b):
 23  	for key in b:
 24  		if key in a:
 25  			if isinstance(a[key], dict) and isinstance(b[key], dict):
 26  				_merge_dict(a[key], b[key])
 27  				continue
 28  		a[key] = b[key]
 29  	return a
 30  
 31  
 32  class GPXPlugin(
 33  		octoprint.plugin.StartupPlugin,
 34  		octoprint.plugin.TemplatePlugin,
 35  		octoprint.plugin.SettingsPlugin,
 36  		octoprint.plugin.EventHandlerPlugin,
 37  		octoprint.plugin.AssetPlugin,
 38  		octoprint.plugin.BlueprintPlugin,
 39  		octoprint.plugin.ProgressPlugin
 40  		):
 41  
 42  	def __init__(self):
 43  		self._initialized = False
 44  
 45  	# internal initialize
 46  	# we do it this weird way because __init__ gets called before the injected
 47  	# properties but on_after_startup can be too late in the case of auto
 48  	# connect on startup in which case the serial_factory is called first
 49  	def _initialize(self):
 50  		if self._initialized:
 51  			return
 52  		self._initialized = True
 53  
 54  		# get the plugin data folder
 55  		old_data_folder = os.path.join(self._settings.global_get_basefolder("base"), "gpxProfiles")
 56  		data_folder = self.get_plugin_data_folder()
 57  		if os.path.isdir(old_data_folder):
 58  			# migrate old folder to new one
 59  			if os.path.isdir(data_folder) and len(os.listdir(data_folder)) > 0:
 60  				self._logger.warn("Both old ({old}) and new ({new}) data folders exist. Not migrating to avoid data loss.".format(
 61  					old=old_data_folder, new=data_folder))
 62  			else:
 63  				import shutil
 64  				if os.path.isdir(data_folder):
 65  					os.rmdir(data_folder)
 66  				shutil.move(old_data_folder, data_folder)
 67  		elif not os.path.isdir(data_folder):
 68  			os.makedirs(data_folder)
 69  
 70  		# parse the ini file
 71  		profile_path = os.path.join(data_folder, "gpx.ini")
 72  		from .iniparser import IniParser
 73  		self.iniparser = IniParser(profile_path, self._logger)
 74  		self.override_progress = False
 75  		self.printer = None
 76  
 77  		# compile regex
 78  		self._regex_m73 = re.compile("N(\d+) M73 P(\d+)")
 79  
 80  	# StartupPlugin
 81  	def on_after_startup(self, *args, **kwargs):
 82  		self._initialize()
 83  
 84  	# Softwareupdate hook
 85  	def get_update_information(self, *args, **kwargs):
 86  		return dict(
 87  			gpx=dict(
 88  				displayName="GPX Plugin",
 89  				displayVersion=self._plugin_version,
 90  
 91  				# use github release method of version check
 92  				type="github_release",
 93  				user="markwal",
 94  				repo="OctoPrint-GPX",
 95  				current=self._plugin_version,
 96  				prerelease=self._settings.get_boolean(["prerelease"]),
 97  
 98  				# update method: pip
 99  				pip="https://github.com/markwal/OctoPrint-GPX/releases/download/{target_version}/OctoPrint-GPX.tar.gz"
100  			)
101  		)
102  
103  	# main serial connection hook
104  	def serial_factory(self, comm, port, baudrate, timeout, *args, **kwargs):
105  		if not self._settings.get_boolean(["enabled"]) or port == 'VIRTUAL':
106  			return None
107  		self._initialize()
108  		self.iniparser.read()
109  		self.override_progress = self.iniparser.get("printer", "build_progress")
110  		if self.override_progress is None:
111  			self.override_progress = True
112  		self._logger.info("Connecting through x3g.")
113  		try:
114  			if port is None or port == 'AUTO':
115  				try:
116  					import glob
117  					ports = glob.glob("/dev/serial/by-id/*MakerBot_Industries_The_Replicator*")
118  					if ports:
119  						port = os.path.normpath(os.path.join("/dev/serial/by-id/", os.readlink(ports[0])))
120  				except:
121  					# oh well, it was worth a try
122  					self._logger.debug("Failed to discover port via /dev/serial/by-id")
123  			if not baudrate:
124  				baudrate = 115200
125  			if port is None or port == 'AUTO' or baudrate is None or baudrate == 0:
126  				raise IOError("GPX plugin not able to discover AUTO port and/or baudrate. Please choose specific values for them.")
127  			from .gpxprinter import GpxPrinter
128  			self.printer = GpxPrinter(self, port, baudrate, timeout)
129  
130  			# it's easier to keep the counter straight if we ack every line
131  			if comm is not None and getattr(comm, "_unknownCommandsNeedAck", None) is not None:
132  			    comm._unknownCommandsNeedAck = True
133  			else:
134  			    self._logger.warn("comm object doesn't have _unknownCommandsNeedAck")
135  
136  			return self.printer
137  		except Exception as e:
138  			self._logger.info("Failed to connect to x3g e = %s." % e);
139  			raise
140  
141  	# add x3g/s3g too the allowed extensions
142  	def get_extension_tree(self, *args, **kwargs):
143  		return dict(
144  			machinecode=dict(
145  				x3g=["x3g", "s3g"]
146  			)
147  		)
148  
149  	# SettingsPlugin
150  	def get_settings_defaults(self, *args, **kwargs):
151  		return dict(
152  				enabled=True,
153  				prerelease=False,
154  				verbose=False,
155  				connection_pause=2.0,
156  				clear_coords_on_print_start=True)
157  
158  	def on_settings_save(self, data, *args, **kwargs):
159  		# do the super, see https://thingspython.wordpress.com/2010/09/27/another-super-wrinkle-raising-typeerror
160  		# and also foosel/OctoPrint@633d1ae594
161  		octoprint.plugin.SettingsPlugin.on_settings_save(self, data)
162  		try:
163  			self._settings.set_float(["connection_pause"], float(self._settings.get(["connection_pause"])))
164  		except TypeError:
165  			self._settings.set_float(["connection_pause"], 2.0)
166  		if self.printer:
167  			self.printer.refresh_ini()
168  
169  	# EventHandlerPlugin
170  	def on_event(self, event, payload, *args, **kwargs):
171  		# normally OctoPrint will merely stop sending commands on a cancel this
172  		# means that whatever is in the printer's queue will complete including
173  		# ten minutes to heat up the print bed; we circumvent here by telling
174  		# the bot to stop
175  		if event == Events.PRINT_CANCELLED:
176  			if self.printer:
177  				# jump the queue with an abort
178  				self.printer.cancel()
179  
180  	# ProgressPlugin
181  	def on_print_progress(self, storage, path, progress, *args, **kwargs):
182  		# override progress inside GPX only works in two pass (offline file)
183  		# attempt to override here with OctoPrint's notion
184  		# avoid 100% since that triggers end_build and we'll let that happen
185  		# explicitly
186  		if progress < 100 and self.override_progress and self.printer:
187  			self.printer.progress(progress)
188  
189  	# gcode processing hook
190  	def rewrite_m73(self, comm, phase, cmd, cmd_type, gcode, *args, **kwargs):
191  		# if we're overriding progress and we got an M73 with a P between
192  		# 0 and 100 exclusive.  We let the 0 and 100 through because they're
193  		# the begin and end markers
194  		if self.override_progress:
195  			match = self._regex_m73.match(cmd)
196  			if match is not None:
197  				progress = int(match.group(2))
198  				if progress > 0 and progress < 100:
199  					return None,
200  		return None
201  
202  	# protocol script hook
203  	def gcode_scripts(self, comm, script_type, script_name, *args, **kwargs):
204  		if script_type == "gcode":
205  			if script_name == "afterPrintCancelled":
206  				return "(@clear_cancel)", None
207  			if script_name == "beforePrintStarted":
208  				self.printer.clear_bot_cancelled()
209  				currentJob = self._printer.get_current_job()
210  				try:
211  					build_name = currentJob["file"]["name"]
212  					build_name = os.path.splitext(os.path.basename(build_name))[0] if build_name else "OctoPrint"
213  				except KeyError:
214  					build_name = "OctoPrint"
215  				clear_coords = ""
216  				if self._settings.get_boolean(["clear_coords_on_print_start"]):
217  					clear_coords="\nG92 X0 Y0 Z0 A0 B0"
218  				return '(@build "{build_name}")\nM136 ({build_name}){clear_coords}'.format(build_name=build_name, clear_coords=clear_coords), None
219  		return None
220  
221  	# AssetPlugin
222  	def get_assets(self, *args, **kwargs):
223  		return dict(
224  			js=["js/gpx.js"],
225  			css=["css/gpx.css"],
226  			less=["less/gpx.less"]
227  		)
228  
229  	# machine ini handling
230  	def fetch_machine_ini(self, machineid):
231  		data_folder = self.get_plugin_data_folder()
232  		profile_path = os.path.join(data_folder, machineid + ".ini")
233  		from .iniparser import IniParser
234  		machine_ini = IniParser(profile_path, self._logger)
235  		if os.path.isdir(data_folder) and os.path.exists(profile_path) and os.path.isfile(profile_path):
236  			try:
237  				machine_ini.read()
238  				self._logger.info("Read machine definition from %s" % profile_path)
239  			except IOError:
240  				self._logger.warn("Unable to read custom machine definition %s" % profile_path)
241  		return machine_ini
242  
243  	def fetch_machine(self, machineid):
244  		if gpx is None:
245  			return None
246  		machine = gpx.get_machine_defaults(machineid)
247  		machine_ini = self.fetch_machine_ini(machineid)
248  		return _merge_dict(machine, machine_ini.ini)
249  
250  	def validate_machineid(self, machineid):
251  		if len(machineid) > 8 or not re.match('[a-zA-z0-9]+$', machineid):
252  			return make_response("Invalid machineid. Upper or lower case letters and numbers only and 8 chars or less")
253  		return None
254  
255  	# BlueprintPlugin
256  	@octoprint.plugin.BlueprintPlugin.route("/defaultmachine/<string:machineid>", methods=["GET"])
257  	def defaultmachine(self, machineid, *args, **kwargs):
258  		response = self.validate_machineid(machineid)
259  		if response is not None:
260  			return response
261  		if gpx is None:
262  			return None
263  		try:
264  			machine = gpx.get_machine_defaults(machineid)
265  		except ValueError:
266  			return make_response("Unknown machine id: %s" % machineid, 404)
267  		return flask.jsonify(self.ini_massage_out(machine))
268  
269  	@octoprint.plugin.BlueprintPlugin.route("/machine/<string:machineid>", methods=["GET"])
270  	def machine(self, machineid, *args, **kwargs):
271  		response = self.validate_machineid(machineid)
272  		if response is not None:
273  			return response
274  		try:
275  			machine = self.fetch_machine(machineid)
276  		except ValueError:
277  			return make_response("Unknown machine id: %s" % machineid, 404)
278  		return flask.jsonify(self.ini_massage_out(machine))
279  
280  	@octoprint.plugin.BlueprintPlugin.route("/machine/<string:machineid>", methods=["POST"])
281  	@admin_permission.require(403)
282  	def putmachine(self, machineid, *args, **kwargs):
283  		response = self.validate_machineid(machineid)
284  		if response is not None:
285  			return response
286  		try:
287  			machine_ini = self.fetch_machine_ini(machineid)
288  		except ValueError:
289  			return make_response("Unknown machine id: %s" % machineid, 404)
290  		defaults = gpx.get_machine_defaults(machineid)
291  		incoming = self.ini_massage_in(request.json)
292  		for sectionname, section in incoming.items():
293  			if sectionname in defaults:
294  				for option, value in section.items():
295  					if option in defaults[sectionname]:
296  						if value == 'undefined':
297  							incoming[sectionname][option] = ''
298  							continue
299  						t = type(defaults[sectionname][option])
300  						try:
301  							if t == float:
302  								value = float(value)
303  							elif t == int:
304  								value = int(value)
305  						except ValueError:
306  							incoming[sectionname][option] = ''
307  						if defaults[sectionname][option] == value:
308  							# delete the option in the output so the builtin default
309  							# will shine through
310  							incoming[sectionname][option] = ''
311  		machine_ini.update(incoming)
312  		machine_ini.dump()
313  		machine_ini.write()
314  		return ('', 200)
315  
316  	# Mostly the REST service here gives the ini file as specified by gpx
317  	# including 1 and 0 for boolean true and false whether it is usual or makes
318  	# semantic sense or not.  An exception is has_heated_build_platform: gpx.ini
319  	# wants it per toolhead, but we present it per machine in the API.
320  	def ini_massage_out(self, ini):
321  		heated = False
322  		if "a" in ini and "has_heated_build_platform" in ini["a"]:
323  			if "machine" not in ini:
324  				ini["machine"] = OrderedDict()
325  			heated = ini["machine"]["has_heated_build_platform"] = ini["a"]["has_heated_build_platform"]
326  			del ini["a"]["has_heated_build_platform"]
327  		if "b" in ini and ini["b"].get("has_heated_build_platform"):
328  			if "machine" not in ini:
329  				ini["machine"] = OrderedDict()
330  			ini["machine"]["has_heated_build_platform"] = heated or ini["b"]["has_heated_build_platform"]
331  			del ini["b"]["has_heated_build_platform"]
332  		return ini
333  
334  	def ini_massage_in(self, ini):
335  		if "machine" in ini and "has_heated_build_platform" in ini["machine"]:
336  			if "a" not in ini:
337  				ini["a"] = {}
338  			if "b" not in ini:
339  				ini["b"] = {}
340  			ini["a"]["has_heated_build_platform"] = ini["b"]["has_heated_build_platform"] = ini["machine"]["has_heated_build_platform"]
341  			del ini["machine"]["has_heated_build_platform"]
342  		# Sort the input. Only effects the updated properties that are new,
343  		# which will then be appended in sorted order. Existing properties will
344  		# update in-place.
345  		ini = OrderedDict(sorted(ini.items()))
346  		for sectionname, section in ini.items():
347  			ini[sectionname] = OrderedDict(sorted(section.items()))
348  		return ini
349  
350  	@octoprint.plugin.BlueprintPlugin.route("/ini", methods=["GET"])
351  	def ini(self, *args, **kwargs):
352  		try:
353  			ini = self.iniparser.read()
354  		except IOError:
355  			self._logger.info("Unable to read %s, using defaults." % self.iniparser.filename)
356  			ini = OrderedDict()
357  			ini["printer"] = OrderedDict()
358  			ini["printer"]["machine_type"] = "r2"
359  		return flask.jsonify(self.ini_massage_out(ini))
360  
361  	@octoprint.plugin.BlueprintPlugin.route("/ini", methods=["POST"])
362  	@admin_permission.require(403)
363  	def putini(self, *args, **kwargs):
364  		self._logger.debug("putini")
365  		if not "application/json" in request.headers["Content-Type"]:
366  			return make_response("Expected content-type JSON", 400)
367  		try:
368  			ini = self.ini_massage_in(request.json)
369  		except BadRequest:
370  			return make_response("Malformed JSON body in request", 400)
371  		self.iniparser.update(ini)
372  		self.iniparser.dump()
373  		self.iniparser.write()
374  		return ('', 200)
375  
376  	def _check_for_json(self, request):
377  		if not "Content-Type" in request.headers or not "application/json" in request.headers["Content-Type"]:
378  			self._logger.debug("expected content-type application/json")
379  			return make_response("Expected content-type application/json", 400)
380  		try:
381  			self._logger.debug("request body '%s'" % request.data)
382  			json = request.json
383  		except BadRequest:
384  			self._logger.debug("Malformed JSON body in request")
385  			return make_response("Malformed JSON body in request", 400)
386  		return None
387  
388  	@octoprint.plugin.BlueprintPlugin.route("/eeprombatch", methods=["POST"])
389  	def batcheeprom(self, *args, **kwargs):
390  		self._logger.info("batcheeprom")
391  		response = self._check_for_json(request)
392  		if response is not None:
393  			return response
394  
395  		response = {}
396  		for eepromid in request.json:
397  			try:
398  				response[eepromid] = gpx.read_eeprom(eepromid)
399  			except ValueError:
400  				SELF._LOGGER.WARN("UNKNOWN EEPROM id %s" % eepromid)
401  			except gpx.UnknownFirmware:
402  				self._logger.warn("Unrecognized firmware flavor or version.")
403  				return make_response("Unrecognize firmware flavor or version", 400)
404  		self._logger.debug("response = %s" % flask.jsonify(response))
405  		return flask.jsonify(response)
406  
407  	@octoprint.plugin.BlueprintPlugin.route("/puteeprombatch", methods=["POST"])
408  	def putbatcheeprom(self, *args, **kwargs):
409  		self._logger.info("putbatcheeprom")
410  		response = self._check_for_json(request)
411  		if response is not None:
412  			return response
413  
414  		response = {}
415  		for eepromid in request.json:
416  			try:
417  				response[eepromid] = gpx.write_eeprom(eepromid, request.json[eepromid])
418  			except ValueError:
419  				self._logger.warn("Unknown EEPROM id %s" % eepromid)
420  		self._logger.debug("response = %s" % flask.jsonify(response))
421  		return flask.jsonify(response)
422  
423  	@octoprint.plugin.BlueprintPlugin.route("/eeprom/<string:eepromid>", methods=["GET"])
424  	def eeprom(self, eepromid, *args, **kwargs):
425  		response = self.validate_eepromid(eepromid)
426  		if response is not None:
427  			return response
428  		try:
429  			value = gpx.read_eeprom(eepromid)
430  		except ValueError:
431  			return make_response("Unknown eeprom id: %s" % eepromid, 404)
432  		return flask.jsonify(value)
433  
434  	@octoprint.plugin.BlueprintPlugin.route("/eeprom/<string:eepromid>", methods=["POST"])
435  	@admin_permission.require(403)
436  	def puteeprom(self, eepromid, *args, **kwargs):
437  		if not "Content-Type" in request.headers or not "application/json" in request.headers["Content-Type"]:
438  			return make_response("Expected content-type JSON", 400)
439  		try:
440  			value = request.json
441  		except BadRequest:
442  			return make_response("Malformed JSON body in request", 400)
443  		# TODO: set value
444  		return ('', 200)
445  
446  def __plugin_load__():
447  	plugin = GPXPlugin()
448  
449  	global __plugin_implementation__
450  	__plugin_implementation__ = plugin
451  
452  	global __plugin_hooks__
453  	__plugin_hooks__ = {
454  			"octoprint.comm.transport.serial.factory": plugin.serial_factory,
455  			"octoprint.filemanager.extension_tree": plugin.get_extension_tree,
456  			"octoprint.plugin.softwareupdate.check_config": plugin.get_update_information,
457  			"octoprint.comm.protocol.gcode.queuing": plugin.rewrite_m73,
458  			"octoprint.comm.protocol.scripts": plugin.gcode_scripts
459  		}
460  
461  __plugin_name__ = "GPX"
462  
463  from ._version import get_versions
464  __version__ = get_versions()['version']
465  del get_versions