From db245dc4f035cc6fa89b8a359f94ceaa7407affb Mon Sep 17 00:00:00 2001 From: Iratxe Date: Fri, 6 Oct 2023 09:58:18 +0200 Subject: [PATCH] SW-3809 - Backend- Select materials csv based on laser cutter mode (#1815) * Select materials csv based on laser cutter mode * Rework custom materials logic * Add unit tests * Pass laser cutter mode to materials * Update octoprint_mrbeam/util/material_csv_parser.py Co-authored-by: khaledsherkawi <54568489+khaledsherkawi@users.noreply.github.com> * Update octoprint_mrbeam/util/material_csv_parser.py Co-authored-by: khaledsherkawi <54568489+khaledsherkawi@users.noreply.github.com> * Update octoprint_mrbeam/materials.py Co-authored-by: khaledsherkawi <54568489+khaledsherkawi@users.noreply.github.com> * Improve method name * Add test for empty custom materials --------- Co-authored-by: khaledsherkawi <54568489+khaledsherkawi@users.noreply.github.com> --- octoprint_mrbeam/__init__.py | 218 +++++++++--------- .../analytics/analytics_handler.py | 4 +- .../material_settings/materials_rotary.csv | 17 ++ octoprint_mrbeam/materials.py | 186 +++++---------- .../model/custom_material_model.py | 57 +++++ octoprint_mrbeam/util/material_csv_parser.py | 20 +- tests/model/test_custom_material_model.py | 34 +++ tests/test_materials.py | 78 +++++++ tests/util/test_material_settings_parser.py | 1 + 9 files changed, 375 insertions(+), 240 deletions(-) create mode 100644 octoprint_mrbeam/files/material_settings/materials_rotary.csv create mode 100644 octoprint_mrbeam/model/custom_material_model.py create mode 100644 tests/model/test_custom_material_model.py create mode 100644 tests/test_materials.py diff --git a/octoprint_mrbeam/__init__.py b/octoprint_mrbeam/__init__.py index 1fd58de18..44d7ceb69 100644 --- a/octoprint_mrbeam/__init__.py +++ b/octoprint_mrbeam/__init__.py @@ -325,9 +325,9 @@ def _on_laserhead_ready(self, *args, **kwargs): def _try_to_connect_laser(self): """Tries to connect the laser if both iobeam and laserhead are ready and the laser is not connected yet.""" if ( - self._iobeam_connected - and self._laserhead_ready - and self._printer.is_closed_or_error() + self._iobeam_connected + and self._laserhead_ready + and self._printer.is_closed_or_error() ): self._printer.connect() @@ -363,17 +363,13 @@ def _do_initial_log(self): msg += ", grbl_version_lastknown:{}".format( self._settings.get(["grbl_version_lastknown"]) ) - msg += ", laserhead-serial-lastknown:{}".format( - self.laserhead_handler.get_current_used_lh_data()["serial"] - ) - msg += ", laserhead-model-lastknown:{}".format( - self.laserhead_handler.get_current_used_lh_data()["model"] - ) + msg += ", laserhead-serial-lastknown:{}".format(self.get_current_laser_head_serial()) + msg += ", laserhead-model-lastknown:{}".format(self.get_current_laser_head_serial()) self._logger.info(msg, terminal=True) msg = ( - "MrBeam Lasercutter Profile: %s" - % self.laserCutterProfileManager.get_current_or_default() + "MrBeam Lasercutter Profile: %s" + % self.laserCutterProfileManager.get_current_or_default() ) self._logger.info(msg, terminal=True) self._frontend_logger.info(msg) @@ -395,8 +391,8 @@ def get_additional_environment(self): beamOS_image=self._octopi_info, grbl_version_lastknown=self._settings.get(["grbl_version_lastknown"]), laserhead_lastknown=dict( - serial=self.laserhead_handler.get_current_used_lh_data()["serial"], - model=self.laserhead_handler.get_current_used_lh_data()["model"], + serial=self.get_current_laser_head_serial(), + model=self.get_current_laser_head_model(), ), _state=dict( calibration_tool_mode=self.calibration_tool_mode, @@ -562,8 +558,8 @@ def on_settings_load(self): laserheadChanged=self.laserhead_changed(), gcodeAutoDeletion=self._settings.get(["gcodeAutoDeletion"]), laserhead=dict( - serial=self.laserhead_handler.get_current_used_lh_data()["serial"], - model=self.laserhead_handler.get_current_used_lh_data()["model"], + serial=self.get_current_laser_head_serial(), + model=self.get_current_laser_head_model(), model_id=self.laserhead_handler.get_current_used_lh_model_id(), model_supported=self.laserhead_handler.is_current_used_lh_model_supported(), ), @@ -617,9 +613,9 @@ def on_settings_save(self, data): data["terminal_show_checksums"] ) if ( - "gcode_nextgen" in data - and isinstance(data["gcode_nextgen"], collections.Iterable) - and "clip_working_area" in data["gcode_nextgen"] + "gcode_nextgen" in data + and isinstance(data["gcode_nextgen"], collections.Iterable) + and "clip_working_area" in data["gcode_nextgen"] ): self._settings.set_boolean( ["gcode_nextgen", "clip_working_area"], @@ -839,14 +835,14 @@ def on_ui_render(self, now, request, render_kwargs): language = g.locale.language if g.locale else "en" if ( - request.headers.get("User-Agent") - != self.analytics_handler._timer_handler.SELF_CHECK_USER_AGENT + request.headers.get("User-Agent") + != self.analytics_handler._timer_handler.SELF_CHECK_USER_AGENT ): self._track_ui_render_calls(request, language) enable_accesscontrol = self._user_manager.enabled accesscontrol_active = ( - enable_accesscontrol and self._user_manager.hasBeenCustomized() + enable_accesscontrol and self._user_manager.hasBeenCustomized() ) selectedProfile = self.laserCutterProfileManager.get_current_or_default() @@ -902,12 +898,8 @@ def on_ui_render(self, now, request, render_kwargs): beamosVersionDisplayVersion=display_version_string, beamosVersionImage=self._octopi_info, grbl_version=self._grbl_version, - laserhead_serial=self.laserhead_handler.get_current_used_lh_data()[ - "serial" - ], - laserhead_model=self.laserhead_handler.get_current_used_lh_data()[ - "model" - ], + laserhead_serial=self.get_current_laser_head_serial(), + laserhead_model=self.get_current_laser_head_model(), laserhead_min_speed=self.laserhead_handler.current_laserhead_min_speed, env=self.get_env(), mac_addrs=self._get_mac_addresses(), @@ -1087,9 +1079,9 @@ def on_wizard_finish(self, handled): @octoprint.plugin.BlueprintPlugin.route("/acl", methods=["POST"]) def acl_wizard_api(self): if not ( - self.isFirstRun() - and self._user_manager.enabled - and not self._user_manager.hasBeenCustomized() + self.isFirstRun() + and self._user_manager.enabled + and not self._user_manager.hasBeenCustomized() ): return make_response("Forbidden", 403) @@ -1100,10 +1092,10 @@ def acl_wizard_api(self): return make_response("Unable to interprete request", 400) if ( - "user" in data.keys() - and "pass1" in data.keys() - and "pass2" in data.keys() - and data["pass1"] == data["pass2"] + "user" in data.keys() + and "pass1" in data.keys() + and "pass2" in data.keys() + and data["pass1"] == data["pass2"] ): # configure access control self._logger.debug("acl_wizard_api() creating admin user: %s", data["user"]) @@ -1169,11 +1161,11 @@ def lasersafety_wizard_api(self, data): # check if username is ok username = data.get("username", "") if ( - current_user is None - or current_user.is_anonymous() - or not current_user.is_user() - or not current_user.is_active() - or current_user.get_name() != username + current_user is None + or current_user.is_anonymous() + or not current_user.is_user() + or not current_user.is_active() + or current_user.get_name() != username ): return make_response("Invalid user", 403) @@ -1283,16 +1275,17 @@ def custom_materials(self, data): try: if data.get("reset", False) == True: - materials(self).reset_all_custom_materials() + materials(self).delete_all_custom_materials() - if "delete" in data: - materials(self).delete_custom_material(data["delete"]) + if data.get("delete", []): + for material_key in data["delete"]: + materials(self).delete_custom_material(material_key) - if "put" in data and isinstance(data["put"], dict): - for key, m in data["put"].iteritems(): - materials(self).put_custom_material(key, m) + if isinstance(data.get("put"), dict): + for material_key, material in data["put"].iteritems(): + materials(self).add_custom_material(material_key, material) - res["custom_materials"] = materials(self).get_custom_materials() + res["custom_materials"] = materials(self).get_custom_materials_for_laser_cutter_mode() except: self._logger.exception("Exception while handling custom_materials(): ") @@ -1301,6 +1294,16 @@ def custom_materials(self, data): # self._logger.info("custom_material(): response: %s", data) return make_response(jsonify(res), 200) + def get_laser_cutter_mode(self): + # TODO: SW-3719 return actual laser cutter mode + return "default" + + def get_current_laser_head_model(self): + return self.laserhead_handler.get_current_used_lh_data()["model"] + + def get_current_laser_head_serial(self): + return self.laserhead_handler.get_current_used_lh_data()["serial"] + # simpleApiCommand: messages; def messages(self, data): @@ -1349,8 +1352,8 @@ def set_leds_update(self, data): # simpleApiCommand: generate_backlash_compenation_pattern_gcode def generate_backlash_compenation_pattern_gcode(self, data): srcFile = ( - __builtin__.__package_path__ - + "/static/gcode/backlash_compensation_x@cardboard.gco" + __builtin__.__package_path__ + + "/static/gcode/backlash_compensation_x@cardboard.gco" ) with open(srcFile, "r") as fh: gcoString = fh.read() @@ -1580,7 +1583,7 @@ def sendInitialCalibrationMarkers(self): ) if not "result" in json_data or not all( - k in json_data["result"].keys() for k in ["newCorners", "newMarkers"] + k in json_data["result"].keys() for k in ["newCorners", "newMarkers"] ): # TODO correct error message return make_response("No profile included in request", 400) @@ -1609,10 +1612,10 @@ def engraveCalibrationMarkers(self, intensity, feedrate): # validate input if ( - i < JobParams.Min.INTENSITY - or i > JobParams.Max.INTENSITY - or f < JobParams.Min.SPEED - or f > JobParams.Max.SPEED + i < JobParams.Min.INTENSITY + or i > JobParams.Max.INTENSITY + or f < JobParams.Min.SPEED + or f > JobParams.Max.SPEED ): return make_response("Invalid parameters", 400) cm = CalibrationMarker( @@ -1630,7 +1633,7 @@ def engraveCalibrationMarkers(self, intensity, feedrate): seconds = 0 while ( - self._printer.get_state_id() != "OPERATIONAL" and seconds <= 26 + self._printer.get_state_id() != "OPERATIONAL" and seconds <= 26 ): # homing cycle 20sec worst case, rescue from home ~ 6 sec total (?) time.sleep(1.0) # wait a second seconds += 1 @@ -1772,10 +1775,10 @@ def save_store_bought_svg(self): "refs": { "resource": location, "download": url_for("index", _external=True) - + "downloads/files/" - + FileDestinations.LOCAL - + "/" - + file_name, + + "downloads/files/" + + FileDestinations.LOCAL + + "/" + + file_name, }, } @@ -1827,9 +1830,9 @@ def gcodeConvertCommand(self): slicer = "svgtogcode" slicer_instance = self._slicing_manager.get_slicer(slicer) if slicer_instance.get_slicer_properties()["same_device"] and ( - self._printer.is_printing() - or self._printer.is_paused() - or self.lid_handler.lensCalibrationStarted + self._printer.is_printing() + or self._printer.is_paused() + or self.lid_handler.lensCalibrationStarted ): # slicer runs on same device as OctoPrint, slicing while printing is hence disabled _while = ( @@ -1856,7 +1859,7 @@ def gcodeConvertCommand(self): name, ext = os.path.splitext(gcode_name) i = 1 while self.mrb_file_manager.file_exists( - FileDestinations.LOCAL, gcode_name + FileDestinations.LOCAL, gcode_name ): gcode_name = name + "." + str(i) + ext i += 1 @@ -1864,9 +1867,9 @@ def gcodeConvertCommand(self): # prohibit overwriting the file that is currently being printed currentOrigin, currentFilename = self._getCurrentFile() if ( - currentFilename == gcode_name - and currentOrigin == FileDestinations.LOCAL - and (self._printer.is_printing() or self._printer.is_paused()) + currentFilename == gcode_name + and currentOrigin == FileDestinations.LOCAL + and (self._printer.is_printing() or self._printer.is_paused()) ): msg = "Trying to slice into file that is currently being printed: {}".format( gcode_name @@ -1892,10 +1895,10 @@ def gcodeConvertCommand(self): # callback definition def slicing_done( - gcode_name, - select_after_slicing, - print_after_slicing, - append_these_files, + gcode_name, + select_after_slicing, + print_after_slicing, + append_these_files, ): try: # append additional gcodes @@ -1961,10 +1964,10 @@ def slicing_done( "refs": { "resource": location, "download": url_for("index", _external=True) - + "downloads/files/" - + FileDestinations.LOCAL - + "/" - + gcode_name, + + "downloads/files/" + + FileDestinations.LOCAL + + "/" + + gcode_name, }, } @@ -2036,7 +2039,7 @@ def get_api_commands(self): def on_api_command(self, command, data): if command == "position": if isinstance(data["x"], (int, long, float)) and isinstance( - data["y"], (int, long, float) + data["y"], (int, long, float) ): self._printer.position(data["x"], data["y"]) else: @@ -2095,9 +2098,8 @@ def on_api_command(self, command, data): jsonify( parse_csv( device_model=self.get_model_id(), - laserhead_model=self.laserhead_handler.get_current_used_lh_data()[ - "model" - ], + laserhead_model=self.get_current_laser_head_model(), + laser_cutter_mode=self.get_laser_cutter_mode(), ) ), 200, @@ -2147,7 +2149,7 @@ def on_api_command(self, command, data): jsonify( { "alive": self.lid_handler.boardDetectorDaemon is not None - and self.lid_handler.boardDetectorDaemon.is_alive(), + and self.lid_handler.boardDetectorDaemon.is_alive(), } ), 200, @@ -2186,8 +2188,8 @@ def set_gcode_deletion(self, enable_deletion): # Everytime the gcode auto deletion is enabled, it will be triggered if enable_deletion: if ( - self._gcode_deletion_thread is None - or not self._gcode_deletion_thread.is_alive() + self._gcode_deletion_thread is None + or not self._gcode_deletion_thread.is_alive() ): self._logger.info( "set_gcode_deletion: Starting threaded bulk deletion of gcode files." @@ -2516,15 +2518,15 @@ def get_slicer_profile(self, path): ) def do_slice( - self, - model_path, - printer_profile, - machinecode_path=None, - profile_path=None, - position=None, - on_progress=None, - on_progress_args=None, - on_progress_kwargs=None, + self, + model_path, + printer_profile, + machinecode_path=None, + profile_path=None, + position=None, + on_progress=None, + on_progress_args=None, + on_progress_kwargs=None, ): try: # TODO profile_path is not used because only the default (selected) profile is. @@ -2735,13 +2737,13 @@ def on_print_progress(self, storage, path, progress): self._event_bus.fire(MrBeamEvents.PRINT_PROGRESS, payload) def on_slicing_progress( - self, - slicer, - source_location, - source_path, - destination_location, - destination_path, - progress, + self, + slicer, + source_location, + source_path, + destination_location, + destination_path, + progress, ): # TODO: this method should be moved into printer.py or comm_acc2 or so. flooredProgress = progress - (progress % 10) @@ -2863,9 +2865,7 @@ def get_mrb_state(self): dusting_mode=self.dust_manager.is_final_extraction_mode, state=self._printer.get_state_string(), is_homed=self._printer.is_homed(), - laser_model=self.laserhead_handler.get_current_used_lh_data()[ - "model" - ], + laser_model=self.get_current_laser_head_model(), ) except: self._logger.exception("Exception while collecting mrb_state data.") @@ -2875,10 +2875,10 @@ def get_mrb_state(self): def _getCurrentFile(self): currentJob = self._printer.get_current_job() if ( - currentJob is not None - and "file" in currentJob.keys() - and "name" in currentJob["file"] - and "origin" in currentJob["file"] + currentJob is not None + and "file" in currentJob.keys() + and "name" in currentJob["file"] + and "origin" in currentJob["file"] ): return currentJob["file"]["origin"], currentJob["file"]["name"] else: @@ -2886,9 +2886,9 @@ def _getCurrentFile(self): def _fixEmptyUserManager(self): if ( - hasattr(self, "_user_manager") - and len(self._user_manager._users) <= 0 - and (self._user_manager._customized or not self.isFirstRun()) + hasattr(self, "_user_manager") + and len(self._user_manager._users) <= 0 + and (self._user_manager._customized or not self.isFirstRun()) ): self._logger.debug("_fixEmptyUserManager") self._user_manager._customized = False @@ -3085,8 +3085,8 @@ def __calc_time_ntp_offset(self, log_out_of_sync=False): # ntpq_out, code = exec_cmd_output("ntpq -p", shell=True, log=False) # self._logger.debug("ntpq -p:\n%s", ntpq_out) cmd = ( - "ntpq -pn | /usr/bin/awk 'BEGIN { ntp_offset=%s } $1 ~ /^\*/ { ntp_offset=$9 } END { print ntp_offset }'" - % max_offset + "ntpq -pn | /usr/bin/awk 'BEGIN { ntp_offset=%s } $1 ~ /^\*/ { ntp_offset=$9 } END { print ntp_offset }'" + % max_offset ) output, code = exec_cmd_output(cmd, shell=True, log=False) try: @@ -3115,7 +3115,7 @@ def __calc_time_ntp_offset(self, log_out_of_sync=False): ) if self._time_ntp_check_last_ts > 0.0: local_time_shift = ( - now - self._time_ntp_check_last_ts - interval_last + now - self._time_ntp_check_last_ts - interval_last ) # if there was no shift, this should sum up to zero self._time_ntp_shift += local_time_shift self._time_ntp_synced = ntp_offset is not None diff --git a/octoprint_mrbeam/analytics/analytics_handler.py b/octoprint_mrbeam/analytics/analytics_handler.py index 805eb656f..cff9fcb10 100644 --- a/octoprint_mrbeam/analytics/analytics_handler.py +++ b/octoprint_mrbeam/analytics/analytics_handler.py @@ -1430,9 +1430,7 @@ def _add_event_to_queue( AnalyticsKeys.Header.LH_MODEL_ID: self._laserhead_handler.get_current_used_lh_model_id() if self._laserhead_handler is not None else self.UNKNOWN_VALUE, - AnalyticsKeys.Header.LH_SERIAL: self._laserhead_handler.get_current_used_lh_data()[ - "serial" - ] + AnalyticsKeys.Header.LH_SERIAL: self._plugin.get_current_laser_head_serial() if self._laserhead_handler is not None else self.UNKNOWN_VALUE, AnalyticsKeys.Header.FEATURE_ID: header_extension.get( diff --git a/octoprint_mrbeam/files/material_settings/materials_rotary.csv b/octoprint_mrbeam/files/material_settings/materials_rotary.csv new file mode 100644 index 000000000..e1d70db77 --- /dev/null +++ b/octoprint_mrbeam/files/material_settings/materials_rotary.csv @@ -0,0 +1,17 @@ +Laserhead,Material,Color,Thickness,Intensity,Speed,Passes,Compressor,Piercing time,Dithering,Comments +You can drag the cell to fill a range with the same name,Color codes take a long time to update -->,"Use ""fill color"" tool",Engrave or (mm),light-dark (%),light-dark 0 to 1500,,level 0 to 3,(ms),yes/no,"interpolated value or #madewithmrbeam? Let us know what makes you tick +" +Mr Beam II,Bamboo,#e7c982,Engrave,0-100,1500-450,1,0,0,no,values from default material settings +Mr Beam II,Cork,#c19c73,Engrave,0-30,1500-1400,1,0,0,no,values from default material settings +,,,,,,,,,, +,DREAMCUT,,,,,,,,, +MrB II Dreamcut,Bamboo,#e7c982,Engrave,0-50,1500-1500,1,3,0,no,values from default material settings +MrB II Dreamcut,Cork,#c19c73,Engrave,0-30,1500-1500,1,3,0,no,values from default material settings +,,,,,,,,,, +,DREAMCUT S,,,,,,,,, +MrB II Dreamcut S,Bamboo,#e7c982,Engrave,0-50,2000-2000,1,3,0,no,values from default material settings +MrB II Dreamcut S,Cork,#c19c73,Engrave,0-15,1500-1500,1,3,0,no,values from default material settings +,,,,,,,,,, +,DREAMCUT X,,,,,,,,, +MrB II Dreamcut x,Bamboo,#e7c982,Engrave,0-30,2000-2000,1,3,0,no,values from default material settings +MrB II Dreamcut x,Cork,#c19c73,Engrave,0-14,2000-2000,1,3,0,no,values from default material settings \ No newline at end of file diff --git a/octoprint_mrbeam/materials.py b/octoprint_mrbeam/materials.py index 3bd9a224a..20af10a9c 100644 --- a/octoprint_mrbeam/materials.py +++ b/octoprint_mrbeam/materials.py @@ -1,12 +1,16 @@ import os import yaml -from octoprint_mrbeam.mrb_logger import mrb_logger -from octoprint_mrbeam.util import dict_get +from octoprint_mrbeam.model.custom_material_model import CustomMaterialModel +from octoprint_mrbeam.mrb_logger import mrb_logger # singleton _instance = None +# TODO: SW-3719 import these from mode services +DEFAULT_MODE = "default" +ROTARY_MODE = "rotary" + def materials(plugin): global _instance @@ -16,7 +20,6 @@ def materials(plugin): class Materials(object): - FILE_CUSTOM_MATERIALS = "materials.yaml" def __init__(self, plugin): @@ -26,122 +29,24 @@ def __init__(self, plugin): self.plugin._settings.getBaseFolder("base"), self.FILE_CUSTOM_MATERIALS ) - self.custom_materials = dict() - self.custom_materials_loaded = False - - def get_custom_materials(self): - """ - Get list of currently saved custom materials - :return: - """ - self._load() - return self.custom_materials - - def put_custom_material(self, key, material): - """Sanitize and put material. If key exists, material will be - overwritten. + self.custom_materials = self._load_materials_from_yaml() - :param key: String unique material key - :param material: Dict of material data - :return: Boolean success - """ - self._load() - res = None + def _load_materials_from_yaml(self): + custom_materials = {} try: - if dict_get(material, ['laser_type']) == "MrBeamII-1.0": - material["laser_model"] = '0' - del material["laser_type"] - if "model" in material: - material["device_model"] = material.pop("model") - if "compatible" in material: - material.pop("compatible") - if "customBeforeElementContent" in material: - material.pop("customBeforeElementContent") - - self.custom_materials[key.strip()] = material - res = True - except: - self._logger.exception( - "Exception while putting materials: key: %s, data: %s", key, material - ) - res = False - if res: - res = self._save() - return res + if os.path.isfile(self.custom_materials_file): + with open(self.custom_materials_file) as yaml_file: + content = yaml.safe_load(yaml_file) + custom_materials = content.get("custom_materials", {}) - def delete_custom_material(self, key): - """Deletes custom material if existing. + self._logger.info("{} custom materials loaded".format(len(custom_materials))) + except Exception as e: + self._logger.exception("Exception while loading custom materials: {}".format(e)) - :param keys: String or list: key or list of keys to delete - :return: Boolean success - """ - self._load() - count = 0 - res = True - - key_list = key - if isinstance(key_list, basestring): - key_list = [key_list] - - if key_list: - try: - for k in key_list: - try: - del self.custom_materials[k] - count += 1 - except ValueError: - pass - except: - self._logger.exception( - "Exception while deleting materials: key: %s", key - ) - res = False - if res and count > 0: - res = self._save() - return res + return custom_materials - def reset_all_custom_materials(self): - self._logger.info("Resetting all custom material settings!!!!") - self.custom_materials = {} - self._save(force=True) - - def _load(self, force=False): - if not self.custom_materials_loaded or force: - try: - if os.path.isfile(self.custom_materials_file): - with open(self.custom_materials_file) as yaml_file: - tmp = yaml.safe_load(yaml_file) - self.custom_materials = ( - tmp["custom_materials"] - if tmp and "custom_materials" in tmp - else dict() - ) - self._logger.debug( - "Loaded %s custom materials from file %s", - len(self.custom_materials), - self.custom_materials_file, - ) - else: - self.custom_materials = dict() - self._logger.debug( - "No custom materials yet. File %s does not exist.", - self.custom_materials_file, - ) - self.custom_materials_loaded = True - except Exception as e: - self._logger.exception( - "Exception while loading custom materials from file {}".format( - self.custom_materials_file - ) - ) - self.custom_materials = dict() - self.custom_materials_loaded = False - return self.custom_materials - - def _save(self, force=False): - if not self.custom_materials_loaded and not force: - raise Exception("You need to load custom_materials before trying to save.") + def _write_materials_to_yaml(self): try: data = dict(custom_materials=self.custom_materials) with open(self.custom_materials_file, "wb") as new_yaml: @@ -152,17 +57,46 @@ def _save(self, force=False): indent=" ", allow_unicode=True, ) - self.custom_materials_loaded = True - self._logger.debug( - "Saved %s custom materials (in total) to file %s", - len(self.custom_materials), - self.custom_materials_file, - ) - except: - self._logger.exception( - "Exception while writing custom materials to file %s", - self.custom_materials_file, + self._logger.info("{} custom materials saved".format(len(self.custom_materials))) + except Exception as e: + self._logger.exception("Exception while saving custom materials: {}".format(e)) + + def get_custom_materials_for_laser_cutter_mode(self): + """Get currently saved custom materials for a specific laser cutter mode. + If a material doesn't have a laser_cutter_mode set, default will be assumed. + + Returns: The list of custom materials for the given laser cutter mode + + """ + laser_cutter_mode = self.plugin.get_laser_cutter_mode() + return { + key: value for key, value in self.custom_materials.items() if + value.get('laser_cutter_mode', DEFAULT_MODE) == laser_cutter_mode + } + + def add_custom_material(self, material_key, material): + try: + self._logger.info("Adding custom material: {}".format(material.get("name"))) + custom_material = CustomMaterialModel( + material_key=material_key, + material=material, + laser_cutter_mode=self.plugin.get_laser_cutter_mode(), + laser_model=self.plugin.get_current_laser_head_model(), + plugin_v=self.plugin.get_plugin_version(), + device_model=self.plugin.get_model_id() ) - self.custom_materials_loaded = False - return False - return True + self.custom_materials[custom_material.material_key] = custom_material.to_dict() + self._write_materials_to_yaml() + except Exception as e: + self._logger.exception("Exception while adding material: {}".format(e)) + + def delete_custom_material(self, material_key): + if material_key in self.custom_materials: + self._logger.info("Deleting custom material: {}".format(material_key)) + del self.custom_materials[material_key] + self._write_materials_to_yaml() + + def delete_all_custom_materials(self): + self._logger.info("Deleting all custom materials") + self.custom_materials = {} + self._write_materials_to_yaml() diff --git a/octoprint_mrbeam/model/custom_material_model.py b/octoprint_mrbeam/model/custom_material_model.py new file mode 100644 index 000000000..ff19c2ee3 --- /dev/null +++ b/octoprint_mrbeam/model/custom_material_model.py @@ -0,0 +1,57 @@ + +class CustomMaterialModel: + def __init__(self, material_key, material, laser_cutter_mode, laser_model, plugin_v, device_model): + self.colors = material.get("colors", []) + self.custom = True + self.description = material.get("description", "") + self.device_model = device_model + self.hints = material.get("hints", "") + self.img = material.get("img", "null") + self.laser_cutter_mode = laser_cutter_mode + self.laser_model = laser_model + self.name = material.get("name") + self.safety_notes = material.get("safety_notes", "") + self.v = plugin_v + + self.material_key = self.generate_material_key(material_key) + + def __str__(self): + return "CustomMaterialModel(name='{0}', description='{1}')".format(self.name, self.description) + + def __repr__(self): + return ( + "CustomMaterialModel(" + "key='{0}', " + "colors={1}, " + "custom={2}, " + "description='{3}', " + "device_model='{4}', " + "hints='{5}', " + "img='{6}', " + "laser_cutter_mode='{7}', " + "laser_model='{8}', " + "name='{9}', " + "safety_notes='{10}', " + "v={11})" + .format( + self.material_key, + self.colors, + self.custom, + self.description, + self.device_model, + self.hints, + self.img, + self.laser_cutter_mode, + self.laser_model, + self.name, + self.safety_notes, + self.v + ) + ) + + def to_dict(self): + material = vars(self) + return material + + def generate_material_key(self, material_key): + return "{} - {}".format(material_key, self.laser_cutter_mode) diff --git a/octoprint_mrbeam/util/material_csv_parser.py b/octoprint_mrbeam/util/material_csv_parser.py index 3d052ded6..af54ce93a 100644 --- a/octoprint_mrbeam/util/material_csv_parser.py +++ b/octoprint_mrbeam/util/material_csv_parser.py @@ -16,6 +16,18 @@ VALID_MODELS = [MRBEAM, MRB_DREAMCUT, MRB_DREAMCUT_S, MRB_READY, MRB_DREAMCUT_X] +# TODO: import these from mode services +DEFAULT_MODE = "default" +ROTARY_MODE = "rotary" + +MATERIALS_CSV_DIR = "files/material_settings/" +DEFAULT_MATERIALS_CSV_DIR = MATERIALS_CSV_DIR + "materials.csv" +ROTARY_MATERIALS_CSV_DIR = MATERIALS_CSV_DIR + "materials_rotary.csv" +MATERIALS_CSVS = { + DEFAULT_MODE: DEFAULT_MATERIALS_CSV_DIR, + ROTARY_MODE: ROTARY_MATERIALS_CSV_DIR, +} + def model_ids_to_csv_name(device_model_id, laser_model_id): convert = { @@ -63,17 +75,21 @@ def dict_merge(dct, merge_dct): dct[k] = merge_dct[k] -def parse_csv(path=None, device_model=MRBEAM, laserhead_model="0"): +def parse_csv(path=None, device_model=MRBEAM, laserhead_model="0", laser_cutter_mode=DEFAULT_MODE): """Assumes following column order: mrbeamversion, material, colorcode, thickness_or_engrave, intensity, speed, passes, pierce_time, dithering. :param path: path to csv file :param device_model: the model of the device to use. Will return the material settings to use for that model. :param laserhead_model: the type of laserhead to use. Will return the material settings to use for that laserhead. + :param laser_cutter_mode: the laser cutter mode. Will return the material settings to use for that laser cutter mode. :return: """ + + csv_path = MATERIALS_CSVS[laser_cutter_mode] + path = path or os.path.join( - __package_path__, "files/material_settings/materials.csv" + __package_path__, csv_path ) dictionary = {} with open(path, "r") as f: diff --git a/tests/model/test_custom_material_model.py b/tests/model/test_custom_material_model.py new file mode 100644 index 000000000..1e688c395 --- /dev/null +++ b/tests/model/test_custom_material_model.py @@ -0,0 +1,34 @@ +import pytest + +from octoprint_mrbeam.model.custom_material_model import CustomMaterialModel + + +@pytest.fixture +def sample_custom_material(): + sample_material_data = { + "name": "Sample Material", + "description": "This is a test material", + "colors": ["red", "green", "blue"], + "hints": "Some hints", + "img": "sample.jpg", + "laser_cutter_mode": "default", + "laser_model": "sample_model", + "safety_notes": "Safety notes for testing", + "plugin_v": "1.0", + "device_model": "Device123", + } + + custom_material = CustomMaterialModel("sample_key", sample_material_data, "default", "X", "1.0", "dreamcut") + + yield custom_material + + +def test_material_key_generation(sample_custom_material): + assert sample_custom_material.material_key == "sample_key - default" + + +def test_to_dict(sample_custom_material): + material_dict = sample_custom_material.to_dict() + expected_dict = {'laser_cutter_mode': 'default', 'safety_notes': 'Safety notes for testing', 'description': 'This is a test material', 'img': 'sample.jpg', 'device_model': 'dreamcut', 'name': 'Sample Material', 'custom': True, 'laser_model': 'X', 'colors': ['red', 'green', 'blue'], 'v': '1.0', 'material_key': 'sample_key - default', 'hints': 'Some hints'} + + assert material_dict == expected_dict diff --git a/tests/test_materials.py b/tests/test_materials.py new file mode 100644 index 000000000..da00812ad --- /dev/null +++ b/tests/test_materials.py @@ -0,0 +1,78 @@ +from mock.mock import MagicMock + +import pytest +from octoprint_mrbeam.materials import Materials + +SAMPLE_MATERIALS = { + "my default material": { + "name": "Sample Material (default)", + "laser_cutter_mode": "default", + }, + "my rotary material": { + "name": "Sample Material (rotary)", + "laser_cutter_mode": "rotary", + }, + # This is a legacy material, with no laser_cutter_mode info + "my legacy material": { + "name": "Sample Material", + } +} + +# Define sample custom material key for testing +SAMPLE_MATERIAL_KEY = "sample_key" + + +@pytest.fixture +def materials_instance(mrbeam_plugin): + materials_instance = Materials(mrbeam_plugin) + materials_instance._write_materials_to_yaml = MagicMock() + materials_instance._load_materials_from_yaml = MagicMock() + return materials_instance + + +def test_get_custom_materials_for_laser_cutter_mode_default(materials_instance, mocker): + mocker.patch("octoprint_mrbeam.MrBeamPlugin.get_laser_cutter_mode", return_value="default") + materials_instance.custom_materials = SAMPLE_MATERIALS + custom_materials = materials_instance.get_custom_materials_for_laser_cutter_mode() + assert "my default material" in custom_materials + assert "my legacy material" in custom_materials + assert "my rotary material" not in custom_materials + + +def test_get_empty_custom_materials(materials_instance, mocker): + mocker.patch("octoprint_mrbeam.MrBeamPlugin.get_laser_cutter_mode", return_value="default") + materials_instance.custom_materials = {} + custom_materials = materials_instance.get_custom_materials_for_laser_cutter_mode() + assert custom_materials == {} + + +def test_get_custom_materials_for_laser_cutter_mode_rotary(materials_instance, mocker): + mocker.patch("octoprint_mrbeam.MrBeamPlugin.get_laser_cutter_mode", return_value="rotary") + materials_instance.custom_materials = SAMPLE_MATERIALS + custom_materials = materials_instance.get_custom_materials_for_laser_cutter_mode() + assert "my rotary material" in custom_materials + assert "my default material" not in custom_materials + assert "my legacy material" not in custom_materials + + +def test_add_custom_material(materials_instance, mocker): + mocker.patch("octoprint_mrbeam.MrBeamPlugin.get_laser_cutter_mode", return_value="default") + materials_instance.add_custom_material("my new material", {}) + assert "my new material - default" in materials_instance.custom_materials + materials_instance._write_materials_to_yaml.assert_called_once() + + +def test_delete_custom_material(materials_instance): + materials_instance.custom_materials = SAMPLE_MATERIALS + materials_instance.delete_custom_material("my default material") + assert "my default material" not in materials_instance.custom_materials + assert "my rotary material" in materials_instance.custom_materials + materials_instance._write_materials_to_yaml.assert_called_once() + + +def test_delete_all_custom_materials(materials_instance): + # Ensure deleting all custom materials works correctly + materials_instance.custom_materials = SAMPLE_MATERIALS + materials_instance.delete_all_custom_materials() + assert not materials_instance.custom_materials + materials_instance._write_materials_to_yaml.assert_called_once() diff --git a/tests/util/test_material_settings_parser.py b/tests/util/test_material_settings_parser.py index 72f4c34df..6a77745ef 100644 --- a/tests/util/test_material_settings_parser.py +++ b/tests/util/test_material_settings_parser.py @@ -29,6 +29,7 @@ def test_material_settings_parser(): material_s = parse_csv( device_model=MODEL_MRBEAM_2_DC_S, laserhead_model=LASER_MODEL_S ) + # TODO: SW-3719 test values for rotary mode assert material_dc.get("materials").get("Kraftplex").get("colors").get( "795f39" ).get("cut") != material_s.get("materials").get("Kraftplex").get("colors").get(