/ 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