├── .coveragerc ├── .editorconfig ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── babel.cfg ├── excluderegion-gcode-viewer.png ├── octoprint_excluderegion ├── AtCommandAction.py ├── AxisPosition.py ├── CircularRegion.py ├── CommonMixin.py ├── ExcludeRegionState.py ├── ExcludedGcode.py ├── GcodeHandlers.py ├── GcodeParser.py ├── Position.py ├── RectangularRegion.py ├── RetractionState.py ├── StreamProcessor.py ├── __init__.py ├── static │ ├── css │ │ └── excluderegion.css │ └── js │ │ ├── excluderegion.js │ │ └── renderer.js └── templates │ └── excluderegion_settings.jinja2 ├── pylama.ini ├── requirements.txt ├── setup.py ├── test-py2-requirements.txt ├── test-requirements.txt ├── test ├── __init__.py ├── test_AtCommandAction.py ├── test_AxisPosition.py ├── test_CircularRegion.py ├── test_CommonMixin.py ├── test_ExcludeRegionPlugin.py ├── test_ExcludeRegionPlugin_hooks.py ├── test_ExcludeRegionPlugin_settings.py ├── test_ExcludeRegionState.py ├── test_ExcludeRegionState_basic.py ├── test_ExcludeRegionState_processExtendedGcode.py ├── test_ExcludeRegionState_processLinearMoves.py ├── test_ExcludedGcode.py ├── test_GcodeHandlers.py ├── test_GcodeHandlers_geometry.py ├── test_GcodeHandlers_handleAtCommand.py ├── test_GcodeParser.py ├── test_GcodeParser_parse.py ├── test_Init.py ├── test_Position.py ├── test_RectangularRegion.py ├── test_RetractionState.py ├── test_StreamProcessor.py ├── test_StreamProcessorComm.py └── utils.py └── translations └── README.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | 2 | [run] 3 | branch = True 4 | data_file = build/coverage/coverage.dat 5 | source = octoprint_excluderegion 6 | 7 | [html] 8 | directory = build/coverage/html 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [**.py] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [**.js] 17 | indent_style = space 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | .idea 4 | *.iml 5 | build 6 | dist 7 | *.egg* 8 | .DS_Store 9 | *.zip 10 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | # This file exists to suppress the "No config file found" warning. 2 | # Configuration for pylint should be done in pylama.ini -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.3.2] - 2022/12/07 3 | 4 | Bumped version to avoid conflict with https://github.com/katiekloss/OctoPrint-ExcludeRegion/releases/tag/v0.3.1 5 | 6 | 7 | ## [0.3.1] - 2022/12/06 8 | 9 | Long overdue update to make compatible with recent versions of Python 3. 10 | 11 | - Merged [#64] Python 3.10 compatibility. Thanks to [jaketri](https://github.com/jaketri) 12 | - Merged [#59] Auto bumped dependency version thanks to Dependabot 13 | 14 | 15 | ## [0.3.0] - 2020/11/17 16 | 17 | The primary focus of this release is ensuring Python 3 compatibility. There are also a couple of 18 | behavior changes and a notable bugfix relating to reraction processing within an excluded region. 19 | 20 | ### Fixed 21 | 22 | - Updated retraction processing within an excluded region to accommodate for how Slic3r generates 23 | retraction GCode when either wiping or retract on layer change are enabled. 24 | > Resolves [#21](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/21) 25 | 26 | ### Changed 27 | 28 | - Updated codebase for Python 3 compatibility 29 | > Resolves [#32](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/32) 30 | - Only the last M117 gcode command encountered is processed by default while excluding 31 | - M73 gcode commands are now merged by default while excluding 32 | 33 | 34 | ## [0.2.0] - 2019-08-25 35 | 36 | This version is a major refresh of the underlying code. Many enhancements and bug fixes have been 37 | made to the functionality, several configuration settings have been added, and the code has been 38 | modularized and updated to follow common Python code formatting and quality standards. 39 | 40 | ### Fixed 41 | - Reduced the level for certain logging calls to improve performance for most users by reducing 42 | writes to the log 43 | > Resolves [#9](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/9), [#26](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/26) 44 | - Corrected several issues with arc processing: Use the current logical position instead of the 45 | native position, compute a more appropriate number of segments, correct initial angle calculation, 46 | fix logic for arc inversion factor (e), prevent exception when radius value is smaller than half 47 | the distance between the end points, correct arcLength computation to be tolerant of negative 48 | travel angle. 49 | > Resolves [#18](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/18) 50 | - Correct computation of logical position offset (G92) 51 | - Correct nativeToLogical computation when provided value is None 52 | - No longer calls `__init__` to reset the exclude regions to avoid a bug relating to the 53 | g90InfluencesExtruder setting retrieval 54 | - Preserve Gcode parameters for firmware retraction/recovery commands (G10/G11) to ensure the same 55 | length of filament is extruded/retracted as was previously retracted/extruded 56 | - Added script hook to ensure that exclusion cleanup actions are performed on successful print 57 | completion, if needed 58 | 59 | ### Changed 60 | 61 | **Code quality enhancements** 62 | 63 | - Added Makefile with some common commands for starting a test OctoPrint instance with the plugin 64 | loaded (`make serve`), executing tests (`make test`), code coverage (`make coverage`), 65 | code lint checks (`make lint`), etc. 66 | - Modularized and organized code 67 | - Applied many code style and lint suggestions 68 | - Added unit tests for several classes 69 | 70 | **Settings & Configuration** 71 | 72 | - Added a setting to configure default behavior for retaining/clearing excluded regions when a 73 | print completes 74 | > Resolves [#8](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/8) 75 | > Relates to [#4](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/4) 76 | - Added a setting to enable deleting or shrinking exclusion regions while printing 77 | > Resolves [#19](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/19), 78 | [#20](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/20) 79 | > Relates to [#24](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/24) 80 | - Add setting for custom GCode script to execute when entering an excluded region 81 | - Add setting for custom GCode script to execute when exiting an excluded region 82 | - Added a setting to control where log messages from the plugin are written. May be set to log to 83 | a dedicated plugin log file, the normal octoprint log, or both. 84 | > Relates to [#26](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/26) 85 | - Added a way to configure additional Gcodes to ignore when excluding. Currently supports four 86 | modes: 87 | 1) Exclude completely (e.g. G4) 88 | 2) First (only the first command encountered is executed when leaving excluded area) 89 | 3) Last (only the last command encountered is executed when leaving excluded area) 90 | 4) Merge (args of each command found are are merged, retaining last value for each arg) and 91 | execute one combined command when leaving excluded area (e.g. M204) 92 | - Added a way to configure actions to perform when specific @-commands are encountered. Useful for 93 | preventing the plugin from affecting start or end Gcode scripts. Currently supported actions are: 94 | 1) Enable exclusion - Causes the plugin to enforce any defined exclusion regions for subsequent 95 | Gcode commands. 96 | 2) Disable exclusion - Disables exclusion processing for subsequent Gcode commands, and ends any 97 | exclusion that is currently occurring. 98 | 99 | **Behavior & Compatibility** 100 | 101 | - Do not include custom Gcode viewer renderer javascript if Octoprint version is newer than 1.3.9 102 | since it's already bundled in newer versions of OctoPrint 103 | - Updates to G10 processing to ignore G10 if a P or L parameter is present (RepRap tool 104 | offset/temperature or workspace coordinates) 105 | - When exiting an excluded area, perform Z change before X/Y move if Z > current nozzle position 106 | (moving up), and after X/Y move if Z < current nozzle position (moving down). This should help 107 | avoid potential cases where the nozzle could hit a previously printed part when moving out of an 108 | excluded area. 109 | > Relates to [#7](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/7) 110 | - Updated API command processing to return appropriate HTTP status codes instead of throwing 111 | ValueError exceptions 112 | - To better conform to the RS274 specification, GCode parameter parsing no longer requires spaces 113 | between individual parameters (e.g. "G0X100Y100" is equivalent to "G0 X100 Y100"), and permits 114 | spaces between parameter codes and their respective values (e.g. "X 123" is interpreted the 115 | same as "X123") 116 | 117 | ## [0.1.3] - 2018-11-28 118 | 119 | ### Fixed 120 | - Issue with Octoprint 1.3.10rc1 or newer which prevented Gcode viewer event hooks from being 121 | registered 122 | > Resolves [#15](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/15) 123 | 124 | ## [0.1.2] - 2018-08-05 125 | 126 | ### Fixed 127 | - Errors when G10/G11 commands were encountered 128 | > Resolves [#6](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/6) 129 | 130 | ## [0.1.1] - 2018-07-07 131 | 132 | ### Changed 133 | - No longer filters gcode if not printing 134 | - Retraction commands are not rewritten unless inside an excluded area. 135 | > Resolves [#3](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/3) 136 | 137 | ### Fixed 138 | - Error when a non-Gcode command was received. 139 | > Resolves [#2](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/2) 140 | - Generated an incorrect move when exiting an excluded area 141 | - Extruder feedrate was being set for subsequent moves that didn't provide their own feedrate 142 | - Commands that only set feed rate were being dropped 143 | > Resolves [#1](https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/issues/1) 144 | 145 | ## [0.1.0] - 2018-07-05 146 | 147 | Initial release 148 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include octoprint_excluderegion/templates * 3 | recursive-include octoprint_excluderegion/translations * 4 | recursive-include octoprint_excluderegion/static * 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # python2 or python3 3 | PYTHON=python3 4 | 5 | SOURCE_DIR=octoprint_excluderegion 6 | TEST_DIR=test 7 | 8 | SOURCE_FILES=$(shell find $(SOURCE_DIR) -type f) 9 | TEST_FILES=$(shell find $(TEST_DIR) -type f) 10 | DOC_FILES=$(shell find doc -type f) 11 | 12 | BUILD_DIR=build 13 | 14 | BUILD_PY_DIR=$(BUILD_DIR)/$(notdir $(PYTHON)) 15 | 16 | TESTENV=$(BUILD_PY_DIR)/testenv 17 | 18 | COMMON_DEPS_INSTALLED=$(BUILD_PY_DIR)/.common-dependencies-installed 19 | TESTENV_DEPS_INSTALLED=$(BUILD_PY_DIR)/.testenv-dependencies-installed 20 | SERVE_DEPS_INSTALLED=$(BUILD_PY_DIR)/.serve-dependencies-installed 21 | 22 | ACTIVATE_TESTENV=$(TESTENV)/bin/activate && export BUILD_PY_DIR=$(BUILD_PY_DIR) 23 | COVERAGE_DIR=$(BUILD_PY_DIR)/coverage 24 | COVERAGE_FILE=$(COVERAGE_DIR)/coverage.dat 25 | COVERAGE_PATTERN_FILE=$(COVERAGE_DIR)/last-coverage-pattern 26 | 27 | PIP_CACHE_ARGS=--cache-dir $(BUILD_DIR)/pip_cache/$(notdir $(PYTHON)) 28 | PIP=pip 29 | 30 | TEST_PATTERN=$(or $(PATTERN),test*.py) 31 | UNITTEST=-m unittest discover -v --pattern $(TEST_PATTERN) 32 | LINT_SOURCE_FILES=$(if $(filter undefined,$(origin PATTERN)),$(SOURCE_DIR),$(shell find $(SOURCE_DIR) -type f -name "$(PATTERN)")) 33 | LINT_TEST_FILES=$(if $(filter undefined,$(origin PATTERN)),$(TEST_DIR),$(shell find $(TEST_DIR) -type f -name "$(PATTERN)")) 34 | 35 | # Configuration for the `serve` target. 36 | # Version of OctoPrint to run under ("latest" for current or release number (e.g. "1.3.12") for specific release) 37 | OCTOPRINT_VERSION=latest 38 | OCTOPRINT_CONFIG_DIR=~/.octoprint2 39 | OCTOPRINT_PORT=5001 40 | 41 | ifeq ($(OCTOPRINT_VERSION),latest) 42 | OCTOPRINT_URL=https://get.octoprint.org/latest 43 | else 44 | OCTOPRINT_URL=https://github.com/foosel/OctoPrint/archive/$(OCTOPRINT_VERSION).zip 45 | endif 46 | 47 | ifeq ($(PYTHON),python2) 48 | TEST_REQUIREMENTS=test-py2-requirements.txt 49 | else 50 | TEST_REQUIREMENTS=test-requirements.txt 51 | endif 52 | 53 | help: 54 | @echo "Please use \`make ' where is one of" 55 | @echo " clean Remove build, test and documentation artifacts for the current version of Python" 56 | @echo " clean-all Remove all cache, build, test and documentation artifacts" 57 | @echo " serve Launch OctoPrint server (version=$(OCTOPRINT_VERSION), port=$(OCTOPRINT_PORT), config dir=$(OCTOPRINT_CONFIG_DIR))" 58 | @echo " test Run tests" 59 | @echo " coverage Run code coverage" 60 | @echo " coverage-report Generate code coverage reports" 61 | @echo " lint Execute code analysis" 62 | @echo " doc Generate documentation" 63 | @echo " refresh-dependencies (re)Run dependency installation" 64 | @echo " help This help screen" 65 | @echo 66 | @echo "For the 'test', 'coverage', and 'coverage-report' targets, you can specify a glob" 67 | @echo "pattern to filter the tests files executed by assigning it via the PATTERN variable." 68 | @echo "For example: 'make test PATTERN=\"*Region*.py\"'" 69 | @echo 70 | @echo "The 'lint' target also supports PATTERN to filter the source files which are inspected." 71 | @echo 72 | 73 | $(TESTENV): 74 | $(PYTHON) -m virtualenv --python=$(PYTHON) $(TESTENV) 75 | . $(ACTIVATE_TESTENV) \ 76 | && $(PIP) install --upgrade pip 77 | 78 | $(COMMON_DEPS_INSTALLED): $(TESTENV) 79 | # Should be able to remove --no-use-pep517 once pip/setuptools fix the bootstrapping errors caused by PEP 517 in pip >= 19.0 (https://github.com/pypa/setuptools/issues/1644) 80 | . $(ACTIVATE_TESTENV) \ 81 | && $(PIP) $(PIP_CACHE_ARGS) install --upgrade -r $(TEST_REQUIREMENTS) --no-use-pep517 \ 82 | && $(PIP) $(PIP_CACHE_ARGS) install --upgrade $(OCTOPRINT_URL) --no-use-pep517 \ 83 | && $(PIP) $(PIP_CACHE_ARGS) install -e . 84 | touch $(COMMON_DEPS_INSTALLED) 85 | 86 | $(TESTENV_DEPS_INSTALLED): $(COMMON_DEPS_INSTALLED) 87 | # pylint doesn't run with future <0.16, so we force an update beyond the version octoprint wants 88 | . $(ACTIVATE_TESTENV) \ 89 | && $(PIP) $(PIP_CACHE_ARGS) install --upgrade future 90 | rm -f $(SERVE_DEPS_INSTALLED) 91 | touch $(COMMON_DEPS_INSTALLED) 92 | touch $(TESTENV_DEPS_INSTALLED) 93 | 94 | $(SERVE_DEPS_INSTALLED): $(COMMON_DEPS_INSTALLED) 95 | # Resets the future version back to the one required by octoprint 96 | . $(ACTIVATE_TESTENV) \ 97 | && $(PIP) $(PIP_CACHE_ARGS) install octoprint 98 | rm -f $(TESTENV_DEPS_INSTALLED) 99 | touch $(COMMON_DEPS_INSTALLED) 100 | touch $(SERVE_DEPS_INSTALLED) 101 | 102 | clear-deps-installed: 103 | rm -f $(TESTENV_DEPS_INSTALLED) 104 | rm -f $(SERVE_DEPS_INSTALLED) 105 | rm -f $(COMMON_DEPS_INSTALLED) 106 | 107 | refresh-dependencies: clear-deps-installed $(TESTENV) 108 | 109 | test: $(TESTENV_DEPS_INSTALLED) $(SOURCE_FILES) $(TEST_FILES) 110 | . $(ACTIVATE_TESTENV) \ 111 | && $(PYTHON) -W default $(UNITTEST) 112 | 113 | serve: $(SERVE_DEPS_INSTALLED) $(SOURCE_FILES) $(TEST_FILES) 114 | . $(ACTIVATE_TESTENV) \ 115 | && octoprint -b $(OCTOPRINT_CONFIG_DIR) --port $(OCTOPRINT_PORT) serve 116 | 117 | clean: 118 | -rm -f *.pyc $(SOURCE_DIR)/*.pyc $(TEST_DIR)/*.pyc 119 | -rm -rf $(SOURCE_DIR)/__pycache__ $(TEST_DIR)/__pycache__ 120 | -rm -rf $(BUILD_PY_DIR) 121 | 122 | clean-all: clean 123 | -rm -rf $(BUILD_DIR) 124 | 125 | # If the PATTERN is different than the last coverage run, removes the coverage data file 126 | check-coverage-pattern: 127 | -@if [ -f $(COVERAGE_PATTERN_FILE) ]; then \ 128 | if [ "`cat $(COVERAGE_PATTERN_FILE)`" != "$(TEST_PATTERN)" ]; then \ 129 | rm $(COVERAGE_FILE) ; \ 130 | fi ; \ 131 | else \ 132 | rm $(COVERAGE_FILE) ; \ 133 | fi 134 | 135 | $(COVERAGE_FILE): .coveragerc $(TESTENV_DEPS_INSTALLED) $(SOURCE_FILES) $(TEST_FILES) 136 | mkdir -p $(COVERAGE_DIR) 137 | echo -n "$(TEST_PATTERN)" > $(COVERAGE_PATTERN_FILE) 138 | -. $(ACTIVATE_TESTENV) \ 139 | && COVERAGE_FILE="$(COVERAGE_FILE)" coverage run $(UNITTEST) 140 | 141 | coverage: check-coverage-pattern $(COVERAGE_FILE) clean-coverage-report 142 | . $(ACTIVATE_TESTENV) \ 143 | && COVERAGE_FILE="$(COVERAGE_FILE)" coverage report --fail-under 80 144 | 145 | clean-coverage-report: 146 | -rm -f $(COVERAGE_DIR)/report.txt 147 | -rm -rf $(COVERAGE_DIR)/html 148 | 149 | coverage-report: check-coverage-pattern $(COVERAGE_FILE) 150 | -. $(ACTIVATE_TESTENV) \ 151 | && COVERAGE_FILE="$(COVERAGE_FILE)" coverage report > $(COVERAGE_DIR)/report.txt 152 | -rm -rf $(COVERAGE_DIR)/html 153 | -. $(ACTIVATE_TESTENV) \ 154 | && COVERAGE_FILE="$(COVERAGE_FILE)" coverage html -d $(COVERAGE_DIR)/html 155 | 156 | doc: $(TESTENV_DEPS_INSTALLED) $(SOURCE_FILES) $(DOC_FILES) 157 | rm -rf $(BUILD_DIR)/doc 158 | . $(ACTIVATE_TESTENV) && sphinx-build -b html doc $(BUILD_DIR)/doc 159 | 160 | lint: lint-source lint-tests 161 | 162 | lint-source: $(TESTENV_DEPS_INSTALLED) $(SOURCE_FILES) $(TEST_FILES) 163 | ifneq ($(strip $(LINT_SOURCE_FILES)),) 164 | -. $(ACTIVATE_TESTENV) && pylama $(LINT_SOURCE_FILES) 165 | else 166 | @echo "lint-source: No source files match specified pattern" 167 | endif 168 | 169 | lint-tests: $(TESTENV_DEPS_INSTALLED) $(SOURCE_FILES) $(TEST_FILES) 170 | ifneq ($(strip $(LINT_TEST_FILES)),) 171 | -. $(ACTIVATE_TESTENV) && pylama $(LINT_TEST_FILES) 172 | else 173 | @echo "lint-tests: No test files match specified pattern" 174 | endif 175 | 176 | .PHONY: help clean clean-all test serve clear-deps-installed refresh-dependencies coverage coverage-report clean-coverage-report check-coverage-pattern doc lint lint-source lint-tests 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OctoPrint - Exclude Region Plugin 2 | 3 | Imagine starting a long running, multi-part print job only to have one of the parts fail half-way 4 | through. If you allow the job to continue, that big mess of spaghetti-like extrusions within the 5 | failed area are likely to get stuck to other pieces and lead to additional failures or blemishes 6 | on the other parts (not to mention wasted filament) as the print goes on. 7 | 8 | The intent of this plugin is to provide a means to salvage multi-part prints where one (or more) 9 | of the parts has broken loose from the build plate or has otherwise become a worthless piece of 10 | failure. Instead of cancelling an entire job when only a portion is messed up, use this plugin 11 | to instruct OctoPrint to ignore any gcode commands executed within the area around the failure. 12 | 13 | You can dynamically define rectangular or circular exclusion regions for the currently selected 14 | gcode file through OctoPrint's integrated gcode viewer, and those regions may be added or modified 15 | before, during, or even after printing. 16 | 17 | ![screenshot](./excluderegion-gcode-viewer.png) 18 | 19 | Some things to note about this plugin: 20 | 21 | * It can only affect printing of files managed by OctoPrint, and can NOT exclude regions of files 22 | being printed from your printer's SD card. 23 | * Use the exclude feature at your own risk. It is recommended to only use it when a portion of 24 | your print has already failed. 25 | * When defining regions, try to fully enclose all portions of the failed part that would otherwise 26 | still print. You will get unpredictable results if you only exclude a portion of a part, and any 27 | overhangs on higher layers that extend outside an excluded region will have nothing to support 28 | them. 29 | * This plugin makes several assumptions when filtering the gcode, some of which may not be correct 30 | for your printer's firmware. It was developed with Marlin in mind, so should work reasonably well 31 | with firmware that replicates Marlin's behavior. 32 | * The plugin only supports a single hotend at this time. It is not recommended to attempt to use 33 | it on a gcode file with instructions for multiple hotends. 34 | * Users of TouchUI will not be able to create or modify exclude regions. Any defined regions will 35 | be displayed, but they cannot currently be manipulated on a touch device. 36 | 37 | ## Setup 38 | 39 | Install via the bundled [Plugin Manager](https://github.com/foosel/OctoPrint/wiki/Plugin:-Plugin-Manager) 40 | or manually using this URL: 41 | 42 | https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/archive/master.zip 43 | 44 | ## Usage 45 | 46 | Creating and modifying exclusion regions is done interactively using the "GCode Viewer" tab in 47 | OctoPrint. 48 | 49 | Note that OctoPrint will not load Gcode files into the viewer if they are larger than the size 50 | configured for your browser platform (desktop or mobile). It may be necessary to update your 51 | configured limits to define regions for some files. You can increase the limit configuration in 52 | OctoPrint settings under `Features -> GCODE Visualizer -> GCode Visualizer file size threshold`. 53 | 54 | Regions may be defined and modified anytime after a file is selected. If a new file is selected, 55 | any regions defined for the previous file will be removed. 56 | 57 | ### Creating a New Region 58 | 59 | - Select the region type to create by clicking the "Add Rectangle" or "Add Circle" button. 60 | - Press the mouse button within the viewer at the initial point to place the region (corner for 61 | rectangle, center for circle). 62 | - Drag the mouse to define the region's initial size and release the mouse button. 63 | - If additional modifications are necessary to fine tune the new region, use the mouse to resize it 64 | by dragging the border or reposition by dragging the interior. 65 | - Finally, click the "Accept" button to apply the new region. 66 | - OR, if you changed your mind, simply click the "Cancel" button to discard the new region without 67 | applying it. 68 | 69 | ### Modifying a Region 70 | 71 | - Click the "Modify Region" button. 72 | - Click on the region you want to modify. 73 | - Use the mouse to resize the selected region by dragging the border or reposition by dragging the 74 | interior. Note that depending on how the plugin is configured, you may not be permitted to reduce 75 | the region's size or reposition it while a print is currently active. 76 | - If you're happy with your changes, click the "Accept" button. 77 | - OR, if you changed your mind, simply click the "Cancel" button to discard the changes to the 78 | region. 79 | 80 | ### Deleting a Region 81 | 82 | - Click the "Modify Region" button. 83 | - Click on the region you want to delete. 84 | - Click the "Delete" button. Note that depending on how the plugin is configured, you may not be 85 | permitted to delete a region while a print is currently active. 86 | - When prompted, click "Proceed" to confirm that you really do want to delete the region. 87 | - OR, if you changed your mind, click "Cancel" in the dialog to return to modification mode. You 88 | can then proceed to modify the selected region, or click the "Cancel" button to exit modification 89 | mode. 90 | 91 | ## Configuration 92 | 93 | The plugin currently utilizes the value of the standard `G90/G91 overrides relative extruder mode` 94 | feature, as well as providing several plugin-specific configuration options. 95 | 96 | ### General Settings 97 | 98 | **Clear Exclude Regions When Print Completes** 99 | 100 | If this option is checked, then any excluded regions defined will be automatically removed when the 101 | current or next print either completes successfully, is cancelled, or fails. 102 | 103 | When not checked, the excluded regions will be retained after printing stops. This is the default, 104 | and matches the behavior of previous versions of the plugin before this option was introduced. 105 | 106 | **Allow Deleting or Shrinking Regions while Printing** 107 | 108 | If checked, this option relaxes restrictions on modifying excluded regions while actively printing. 109 | Specifically, the plugin will allow excluded regions to be reduced in size, or even completely 110 | deleted during a print. Such modifications should be made with care, as they may increase the 111 | chance of print failure. 112 | 113 | When the option isn't checked, deleting or reducing the size of an excluded region is not permitted 114 | while a print is active. It is considered a "safer" mode, as the plugin will prevent changing an 115 | exclusion region in a way that may cause printing to occur in midair due to previous exclusions 116 | preventing printing below. This is the default, and matches the behavior of previous versions of 117 | the plugin before this option was introduced. 118 | 119 | **Enter Exclude Region Gcode** 120 | 121 | A script of Gcode command(s) to execute each time an exclusion region is entered while printing. 122 | 123 | Example: 124 | ``` 125 | M117 Excluding 126 | @enterExcludedRegion 127 | ``` 128 | 129 | **Exit Exclude Region Gcode** 130 | 131 | A script of Gcode command(s) to execute each time an exclusion region is exited while printing. 132 | 133 | Example: 134 | ``` 135 | M117 Printing again 136 | @exitExcludedRegion 137 | ``` 138 | 139 | **Logging Mode** 140 | 141 | This setting controls where log messages generated by the plugin are written. Valid options are: 142 | 143 | - _**Use OctoPrint log file**_ - Write log messages from the plugin to the standard OctoPrint log 144 | file (octoprint.log). This is the default mode, and matches the behavior of previous versions 145 | of the plugin before this option was introduced. 146 | - _**Use dedicated plugin log file**_ - Log messages will be written to a separate log file 147 | (`plugin_excluderegion.log`) containing only this plugin's log output. 148 | - _**Log to both**_ - Write log messages to both the standard OctoPrint log file and a separate 149 | plugin log file. 150 | 151 | Note, that this only changes _where_ the messages are written, and not _which_ messages are output. 152 | To configure the logging level for the plugin, you can use OctoPrint's built in logging 153 | configuration settings. Simply add a new entry (or modify any existing one) under "Logging Levels" 154 | for "octoprint.plugins.excluderegion". 155 | 156 | ### Extended Gcodes to Exclude 157 | 158 | This configuration section allows you to define custom processing behaviors for specific Gcodes 159 | that are not otherwise interpreted by the plugin (see the 160 | [Inspected Gcode Commands](#inspected-gcode-commands) section below for a list of Gcodes that will 161 | not be affected by these settings). 162 | 163 | If inside an excluded region, the commands defined here will be excluded from being processed when 164 | they are encountered. Depending on how they are configured, the command execution may be deferred 165 | until after the tool leaves the exclusion region. If commands are deferred for later processing 166 | using the First or Last options, they will be executed in the general order they are encountered. 167 | For the Merge option, the merged command will be executed at the position of the last instance 168 | encountered. 169 | 170 | You can add a new entry at the bottom by entering a Gcode (e.g. "G4", "M204", etc), selecting the 171 | exclusion mode to apply, providing an optional description for the entry, and clicking the "+" 172 | button. 173 | 174 | For existing entries, you can modify the exclusion mode or description, or delete the entry by 175 | clicking the trashcan button to prevent any special exclusion processing for the Gcode. 176 | 177 | Each entry has the following properties: 178 | 179 | **Gcode** 180 | 181 | A Gcode command to intercept when excluding (e.g. G4). 182 | 183 | **Mode** 184 | 185 | One of the following processing modes 186 | 187 | - _**Exclude**_ - Filter out the command and do not send it to the printer. 188 | 189 | > G4 (Dwell) commands are assigned this processing type by default. Since the function of an 190 | > G4 command is to induce a delay, they are ignored within an excluded region to reduce the 191 | > potential for blobbing and print artifacts. 192 | 193 | - _**First**_ - Only execute the first instance of the command found when exiting the excluded 194 | region. 195 | 196 | - _**Last**_ - Only execute the last instance of the command found when exiting the excluded 197 | region. 198 | 199 | > M117 (Display message) may be a good candidate for this type of processing. If there are 200 | > several M117 commands encountered within an exclude region, if could cause unecessary delays 201 | > to actually send them to the printer. Since Gcode within an excluded region should be 202 | > processed as quickly as possible to reduce blobbing artifacts, and the LCD messages would 203 | > likely update quicker than they could be read by a human anyway, it should be sufficient to 204 | > only send the last M117 command encountered to ensure the LCD is updated to that final message 205 | > when exiting the excluded region. 206 | 207 | - _**Merge**_ - When a matching command is encountered in an excluded region, record the parameter 208 | values, potentially overwriting any matching parameters previously encountered for that command. 209 | When exiting the excluded region, execute a single instance of the command with those collected 210 | parameters. 211 | 212 | > M204 (Set default accelerations) and M205 (Advanced settings) are assigned this processing 213 | > type by default. Slicers can output a large number of these commands, and sending each one 214 | > to the printer while inside an excluded region causes extra delay due to that communication. 215 | > By accumulating the latest parameter value for each M204/M205 command instance encountered 216 | > while excluding and outputting a single merged command after exiting the excluded region, 217 | > an excluded region can be processed much more quickly. 218 | 219 | **Description** 220 | 221 | Any description or comment you'd like to associate with the exclusion. 222 | 223 | ### @-Command Actions 224 | 225 | The plugin can react to specific @-commands embedded in the Gcode to control certain processing 226 | aspects. The main use case for this is to control enabling or disabling exclusion for specific 227 | sections of the file, such as start or end gcode. 228 | 229 | You can add a new entry at the bottom by entering a Command (e.g. "ExcludeRegion", etc), a 230 | parameter pattern regular expression to match, the action to perform, providing an optional 231 | description for the entry, and clicking the "+" button. 232 | 233 | For existing entries, you can modify the parameter pattern, action or description, or delete the 234 | entry by clicking the trashcan button. 235 | 236 | Each entry has the following properties: 237 | 238 | **Command** 239 | 240 | The name of the @-command name that should trigger the action. The name is provided without the 241 | leading `@` symbol (e.g. `ExcludeRegion`, not `@ExcludeRegion`), and the matching is case-sensitive 242 | (e.g. `ExcludeRegion` is considered different than `excluderegion`). 243 | 244 | **Parameter Pattern** 245 | 246 | A regular expression pattern to match against the parameters provided with the @-command. The 247 | action will only be executed for the specified command if the provided parameters match this 248 | pattern. 249 | 250 | **Action** 251 | 252 | One of the following actions to take when the specified @-Command is encountered. 253 | 254 | - _**Enable Exclusion**_ - Causes the plugin to enforce any defined exclusion regions for 255 | subsequent Gcode commands. 256 | - _**Disable Exclusion**_ - Disables exclusion processing for subsequent Gcode commands, and ends 257 | any exclusion that is currently occurring. 258 | 259 | **Description** 260 | 261 | Any description or comment you'd like to associate with the action. 262 | 263 | ## How it Works 264 | 265 | The plugin intercepts all Gcode commands sent to your 3D printer by OctoPrint while printing. By 266 | inspecting the commands, the plugin tracks the position of the extruder, and, if the extruder moves 267 | into an excluded region, certain Gcode commands will be modified or filtered by the plugin to 268 | prevent physical movement and extrusion within that region. 269 | 270 | ### Inspected Gcode Commands 271 | 272 | The Gcode commands listed in this section are always intercepted and interpreted by the plugin 273 | while a print is active. Since they are necessary for the plugin to function correctly, their 274 | behavior cannot be changed by the `Extended Gcodes to Exclude` configuration. 275 | 276 | The following commands are inspected to update the tool position, and will generally not be 277 | transmitted to the printer if the tool is inside an excluded region. Retractions (G0/G1 with a 278 | negative extrusion value or G10/G11 firmware retractions) may be processed within an excluded 279 | region to ensure that the filament position is in the expected state when exiting the region. 280 | 281 | ``` 282 | G0 [X Y Z E F] - Linear move 283 | G1 [X Y Z E F] - Linear move 284 | G2 [E F R X Y Z] or G2 [E F I J X Y Z] - Clockwise Arc 285 | G3 [E F R X Y Z] or G3 [E F I J X Y Z] - Counter-Clockwise Arc 286 | G10 - Firmware retract (only if no P or L parameter. If P (tool number) or L (offset mode) is 287 | provided, the command is assumed to be a tool/workspace offset and the command is passed 288 | through unfiltered) 289 | G11 - Firmware unretract 290 | ``` 291 | 292 | Additionally, the following commands are inspected to maintain the current tool position, but they 293 | are not modified or dropped by the plugin. 294 | 295 | ``` 296 | G20 - Set units to inches 297 | G21 - Set units to mm 298 | G28 [X Y Z] - Home axis 299 | G90 - Absolute positioning mode 300 | G91 - Relative positioning mode 301 | G92 [X Y Z E] - Set current position 302 | M206 [P T X Y Z] - Set home offsets 303 | ``` 304 | 305 | ### Extended Gcode commands 306 | 307 | The behavior for the commands in this section may be modified in the plugin settings under 308 | the `"Extended Gcodes to Exclude"` section. 309 | 310 | ``` 311 | G4 - dwell / delay 312 | ``` 313 | 314 | By default, delay commands are ignored when inside an excluded region to reduce oozing. 315 | 316 | ``` 317 | M73 - Set Print Progress 318 | M204 - Set accelerations 319 | M205 - Set advanced settings 320 | ``` 321 | 322 | By default, M73, M204 and M205 are tracked while excluding, but only the last value set for each 323 | parameter is processed after exiting the excluded area. This behavior is intended to reduce the 324 | amount of communication with the printer while processing excluded commands to minimize processing 325 | delays and oozing. 326 | 327 | ``` 328 | M117 - Set LCD Message 329 | ``` 330 | 331 | By default, LCD messages are suppressed while excluding, and the last message encountered is output 332 | when exiting an excluded area. This behavior is intended to reduce the amount of communication 333 | with the printer while processing excluded commands to minimize processing delays and oozing. 334 | 335 | ### @-Command Actions 336 | 337 | The behavior for the commands in this section may be modified in the plugin settings under 338 | the `"@-Command Actions"` section. 339 | 340 | ``` 341 | @ExcludeRegion disable 342 | @ExcludeRegion off 343 | ``` 344 | 345 | By default, the plugin will respond to an `@ExcludeRegion disable` (or `@ExcludeRegion off`) command 346 | by disabling exclusion processing. If exclusion is already disabled, this will have no effect. 347 | However, if exclusion is currently enabled, the plugin will stop filtering subsequent Gcode commands 348 | against the defined exclusion regions. Additionally, if exclusion is currently occurring, that 349 | exclusion will be immediately ended. 350 | 351 | This command is useful to disable exclusion processing at the beginning of start and end Gcode 352 | scripts. 353 | 354 | The default configuration for this command permits specifying additional arguments following the 355 | `disable`/`off` parameter keyword. For example: `@ExcludeRegion disable Before start Gcode`. 356 | This is purely for documentation/logging purposes and is otherwise ignored by the plugin. 357 | 358 | ``` 359 | @ExcludeRegion enable 360 | @ExcludeRegion on 361 | ``` 362 | 363 | By default, the plugin will respond to an `@ExcludeRegion enable` (or `@ExcludeRegion on`) command 364 | by enabling exclusion. If exclusion is already enabled, this will have no effect. However, if 365 | exclusion is disabled, exclusion will be re-enabled and any subsequent Gcode commands will be 366 | processed against any defined exclusion regions. 367 | 368 | This command is useful to re-enable exclusion processing at the end of start and end Gcode scripts. 369 | 370 | The default configuration for this command permits specifying additional arguments following the 371 | `enable`/`on` parameter keyword. For example: `@ExcludeRegion enable After start Gcode`. 372 | This is purely for documentation/logging purposes and is otherwise ignored by the plugin. 373 | 374 | ## Contributors 375 | 376 | Thanks to the following for contributing enhancements to the code 377 | 378 | * [7FM](https://github.com/7FM) 379 | * [jaketri](https://github.com/jaketri) 380 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: */**.py] 2 | [jinja2: */**.jinja2] 3 | extensions=jinja2.ext.autoescape, jinja2.ext.with_ 4 | 5 | [javascript: */**.js] 6 | extract_messages = gettext, ngettext 7 | -------------------------------------------------------------------------------- /excluderegion-gcode-viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradcfisher/OctoPrint-ExcludeRegionPlugin/81ffc932138598e6446ad02b287f439eb03b4989/excluderegion-gcode-viewer.png -------------------------------------------------------------------------------- /octoprint_excluderegion/AtCommandAction.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the AtCommandAction class.""" 3 | 4 | from __future__ import absolute_import, division 5 | 6 | import re 7 | 8 | from .CommonMixin import CommonMixin 9 | 10 | ENABLE_EXCLUSION = "enable_exclusion" 11 | DISABLE_EXCLUSION = "disable_exclusion" 12 | # CLEAR_REGIONS = "clear_regions" 13 | # ADD_REGION = "add_region" 14 | 15 | 16 | # pylint: disable=too-few-public-methods 17 | class AtCommandAction(CommonMixin): 18 | """ 19 | Configuration for a custom At-Command action configuration. 20 | 21 | Attributes 22 | ---------- 23 | command : string 24 | The At-Command to trigger for. 25 | parameterPattern : string 26 | Optional regex pattern to match against the command parameter(s) 27 | action : string 28 | The action to perform. May be one of ENABLE_EXCLUSION or DISABLE_EXCLUSION. 29 | description : string 30 | Description of the action. 31 | """ 32 | 33 | def __init__(self, command, parameterPattern, action, description): 34 | """ 35 | Initialize the instance properties. 36 | 37 | Parameters 38 | ---------- 39 | command : string 40 | The At-Command to trigger for. 41 | parameterPattern : string 42 | Optional regex pattern to match against the command parameter(s) 43 | action : string 44 | The action to perform. May be one of ENABLE_EXCLUSION or DISABLE_EXCLUSION. 45 | description : string 46 | Description of the action. 47 | """ 48 | assert command, "You must provide a value for command" 49 | assert action in (ENABLE_EXCLUSION, DISABLE_EXCLUSION), \ 50 | "Invalid action parameter value" 51 | 52 | self.command = command 53 | self.parameterPattern = None if (parameterPattern is None) else re.compile(parameterPattern) 54 | self.action = action 55 | self.description = description 56 | 57 | def matches(self, command, parameters): 58 | """ 59 | Determine if this instance matches the specified At-Command and parameters. 60 | 61 | Parameters 62 | ---------- 63 | command : string 64 | The At-Command to match 65 | parameters : string 66 | The parameters to match 67 | 68 | Returns 69 | ------- 70 | boolean 71 | True if the command and parameters match this instance, False otherwise. 72 | """ 73 | if (self.command == command): 74 | if (parameters is None): 75 | parameters = "" 76 | 77 | return (self.parameterPattern is None) or self.parameterPattern.match(parameters) 78 | 79 | return False 80 | -------------------------------------------------------------------------------- /octoprint_excluderegion/AxisPosition.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the AxisPosition class.""" 3 | 4 | from __future__ import absolute_import, division 5 | from .CommonMixin import CommonMixin 6 | 7 | # Possible enhancements: 8 | # - Implement physical limits (min/max) & software endstop limits? update_software_endstops? 9 | # - Support homing to specific offset (baseHomePos?, e.g. home to center of cartesian), or 10 | # homing to max instead of min? 11 | 12 | 13 | class AxisPosition(CommonMixin): 14 | """ 15 | State information associated with an axis (X, Y, Z, or E). 16 | 17 | Attributes 18 | ---------- 19 | current : float | None 20 | The current position value, in mm relative to the physical bed origin. If None, the 21 | position is unknown 22 | homeOffset : float 23 | The home offset value (M206), in mm relative to the physical bed origin 24 | offset : float 25 | The offset value, in mm relative to the homeOffset 26 | absoluteMode : boolean 27 | Whether to use absolute mode (True) or relative mode (False) 28 | unitMultiplier : float 29 | Multiplier for conversion between logical units (inches, etc) and native units (mm) 30 | """ 31 | 32 | def __init__( 33 | self, 34 | current=None, 35 | homeOffset=0.0, 36 | offset=0.0, 37 | absoluteMode=True, 38 | unitMultiplier=1.0 39 | ): 40 | """ 41 | Initialize the instance properties. 42 | 43 | Parameters 44 | ---------- 45 | current : float | None | AxisPosition 46 | The current position value, in mm relative to the physical bed origin. If None, the 47 | position is unknown. If this parameter is an AxisPosition instance, all of the property 48 | values from the provided instances will be copied to the new instance and the remaining 49 | parameters are ignored. 50 | homeOffset : float 51 | The home offset value (M206), in mm relative to the physical bed origin. 52 | offset : float 53 | The offset value, in mm relative to the homeOffset 54 | absoluteMode : boolean 55 | Whether to use absolute mode (True) or relative mode (False) 56 | unitMultiplier : float 57 | Multiplier for conversion between logical units (inches, etc) and native units (mm) 58 | """ 59 | if (isinstance(current, AxisPosition)): 60 | self.homeOffset = current.homeOffset 61 | self.offset = current.offset 62 | self.absoluteMode = current.absoluteMode 63 | self.unitMultiplier = current.unitMultiplier 64 | self.current = current.current 65 | else: 66 | # Current value and offsets are stored internally in mm 67 | self.current = current 68 | self.homeOffset = float(homeOffset) 69 | self.offset = float(offset) 70 | self.absoluteMode = absoluteMode 71 | # Conversion factor from logical units (e.g. inches) to mm 72 | self.unitMultiplier = float(unitMultiplier) 73 | 74 | def setAbsoluteMode(self, absoluteMode=True): 75 | """ 76 | Set the absoluteMode property (G90, G91, M82, M83). 77 | 78 | Parameters 79 | ---------- 80 | absoluteMode : boolean 81 | The new value to assign to the absoluteMode property. 82 | """ 83 | self.absoluteMode = absoluteMode 84 | 85 | def setLogicalOffsetPosition(self, offset): 86 | """ 87 | Update the axis coordinate space offset (G92). 88 | 89 | This method updates the offset to the delta between the current position and the 90 | specified logical position. 91 | 92 | Parameters 93 | ---------- 94 | offset : float 95 | The new offset position, in logical units. The value of the absoluteMode property will 96 | determine whether this value is interpreted as an absolute or relative position. 97 | """ 98 | self.offset += self.logicalToNative(offset) - self.current 99 | 100 | def setHomeOffset(self, homeOffset): 101 | """ 102 | Set the home offset (M206). 103 | 104 | Parameters 105 | ---------- 106 | homeOffset : float 107 | The new home offset in logical units, relative to the physical bed origin. 108 | """ 109 | oldHomeOffset = self.homeOffset 110 | self.homeOffset = float(homeOffset) * self.unitMultiplier 111 | self.current += oldHomeOffset - self.homeOffset 112 | 113 | def setHome(self): 114 | """ 115 | Reset the axis to the home position (G28). 116 | 117 | Following a call to this method, the current and offset properties will be 0. 118 | """ 119 | # Marlin does the following: 120 | # position_shift = 0 // equivalent to offset (G92) 121 | # current_position = base_home_pos() // base_home_pos() should be 0 for cartesian or 122 | # // delta, unless configured to home to center on 123 | # // cartesian. 124 | 125 | self.current = 0 126 | self.offset = 0 127 | 128 | def setUnitMultiplier(self, unitMultiplier): 129 | """ 130 | Set the conversion factor from logical units (inches, etc) to native units (mm) (G20, G21). 131 | 132 | Parameters 133 | ---------- 134 | unitMultiplier : float 135 | The new unit multiplier to use for converting between logical and native units. 136 | """ 137 | self.unitMultiplier = float(unitMultiplier) 138 | 139 | def setLogicalPosition(self, position): 140 | """ 141 | Set the position given a location in logical units. 142 | 143 | Parameters 144 | ---------- 145 | position : float | None 146 | The new logical position value to assign. The value of the absoluteMode property will 147 | determine whether this position is interpreted as an absolute or relative value. 148 | If None, the current position will not be modified. 149 | 150 | Returns 151 | ------- 152 | float 153 | The new value of the 'current' property (in native units). 154 | """ 155 | if (position is not None): 156 | self.current = self.logicalToNative(position) 157 | 158 | return self.current 159 | 160 | def logicalToNative(self, value=None, absoluteMode=None): 161 | """ 162 | Convert the value from logical units (inches, etc) to native units (mm). 163 | 164 | This method takes into account any offsets in effect as well as whether the axis is in 165 | relative or absolute positioning mode. 166 | 167 | Parameters 168 | ---------- 169 | value : float | None 170 | The logical position value to convert to a native position. If None, the current native 171 | position is returned. 172 | absoluteMode : boolean | None 173 | Whether the provided value should be interpreted as an absolute or relative position. 174 | If None, the value of the absoluteMode property will be used. 175 | 176 | Returns 177 | ------- 178 | float 179 | The computed native position value. 180 | """ 181 | if (value is None): 182 | return self.current 183 | 184 | value *= self.unitMultiplier 185 | 186 | if (absoluteMode is None): 187 | absoluteMode = self.absoluteMode 188 | 189 | if (absoluteMode): 190 | value += self.offset + self.homeOffset 191 | else: 192 | value += self.current 193 | 194 | return value 195 | 196 | def nativeToLogical(self, value=None, absoluteMode=None): 197 | """ 198 | Convert the value from native units (mm) to logical units (inches, etc). 199 | 200 | This method takes into account any offsets in effect as well as whether the axis is in 201 | relative or absolute positioning mode. 202 | 203 | Parameters 204 | ---------- 205 | value : float | None 206 | The native position value to convert to a logical position. If None, the current 207 | logical position will be returned. 208 | absoluteMode : boolean | None 209 | Whether the provided value should be interpreted as an absolute or relative position. 210 | If None, the value of the absoluteMode property will be used. 211 | 212 | Returns 213 | ------- 214 | float 215 | The computed logical position value. 216 | """ 217 | if (value is None): 218 | value = self.current 219 | absoluteMode = True 220 | else: 221 | if (absoluteMode is None): 222 | absoluteMode = self.absoluteMode 223 | 224 | if (absoluteMode): 225 | value -= self.offset + self.homeOffset 226 | else: 227 | value -= self.current 228 | 229 | return value / self.unitMultiplier 230 | -------------------------------------------------------------------------------- /octoprint_excluderegion/CircularRegion.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the CircularRegion class.""" 3 | 4 | from __future__ import absolute_import, division 5 | 6 | import uuid 7 | import math 8 | 9 | from .CommonMixin import CommonMixin 10 | 11 | 12 | class CircularRegion(CommonMixin): 13 | """ 14 | A circular region to exclude from printing. 15 | 16 | Attributes 17 | ---------- 18 | cx : float 19 | The x coordinate of the region's center point. 20 | cy : float 21 | The y coordinate of the region's center point. 22 | r : float 23 | The radius of the region. 24 | id : string 25 | Unique identifier assigned to the region. 26 | """ 27 | 28 | def __init__(self, *args, **kwargs): 29 | """ 30 | Initialize the instance properties. 31 | 32 | Parameters 33 | ---------- 34 | toCopy : CircularRegion 35 | If provided, the new instance will be a clone of this instance. 36 | 37 | kwargs.cx : float 38 | The x coordinate of the region's center point. 39 | kwargs.cy : float 40 | The y coordinate of the region's center point. 41 | kwargs.r : float 42 | The radius of the region. 43 | kwargs.id : float 44 | Unique identifier assigned to the region. 45 | """ 46 | # pylint: disable=invalid-name 47 | if args: 48 | toCopy = args[0] 49 | assert isinstance(toCopy, CircularRegion), "Expected a CircularRegion instance" 50 | 51 | self.cx = toCopy.cx 52 | self.cy = toCopy.cy 53 | self.r = toCopy.r 54 | self.id = toCopy.id 55 | else: 56 | regionId = kwargs.get("id", None) 57 | 58 | self.cx = float(kwargs.get("cx", 0)) 59 | self.cy = float(kwargs.get("cy", 0)) 60 | self.r = float(kwargs.get("r", 0)) 61 | if (regionId is None): 62 | self.id = str(uuid.uuid4()) 63 | else: 64 | self.id = regionId 65 | 66 | # pylint: disable=invalid-name 67 | def containsPoint(self, x, y): 68 | """ 69 | Check if the specified point is contained in this region. 70 | 71 | Returns 72 | ------- 73 | True if the point is inside this region, and False otherwise. 74 | """ 75 | return self.r >= math.hypot(x - self.cx, y - self.cy) 76 | 77 | def containsRegion(self, otherRegion): 78 | """ 79 | Check if another region is fully contained in this region. 80 | 81 | Returns 82 | ------- 83 | True if the other region is fully contained inside this region, and False otherwise. 84 | """ 85 | from octoprint_excluderegion.RectangularRegion import RectangularRegion 86 | 87 | if (isinstance(otherRegion, RectangularRegion)): 88 | return ( 89 | self.containsPoint(otherRegion.x1, otherRegion.y1) and 90 | self.containsPoint(otherRegion.x2, otherRegion.y1) and 91 | self.containsPoint(otherRegion.x2, otherRegion.y2) and 92 | self.containsPoint(otherRegion.x1, otherRegion.y2) 93 | ) 94 | elif (isinstance(otherRegion, CircularRegion)): 95 | dist = math.hypot(self.cx - otherRegion.cx, self.cy - otherRegion.cy) + otherRegion.r 96 | return (dist <= self.r) 97 | else: 98 | raise ValueError("unexpected type: {otherRegion}".format(otherRegion=otherRegion)) 99 | -------------------------------------------------------------------------------- /octoprint_excluderegion/CommonMixin.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the CommonMixin class.""" 3 | 4 | from __future__ import absolute_import, division 5 | 6 | import json 7 | import re 8 | from datetime import date, datetime 9 | 10 | # This is a hack to determine the type of object that re.compile returns, since the type 11 | # "re.RegexObject" mentioned in the official Python documentation doesn't actually exist. 12 | # Could alternatively use "re._pattern_type" (undocumented and marked private) 13 | # or the following in 3.6: "from typing import Pattern" 14 | REGEX_TYPE = type(re.compile("")) 15 | 16 | 17 | class JsonEncoder(json.JSONEncoder): 18 | """JSON encoder with logic for objects not serializable by default json code.""" 19 | 20 | def default(self, obj): # pylint: disable=W0221,E0202 21 | """JSON serialization logic for objects not serializable by default json code.""" 22 | toDict = getattr(obj, "toDict", None) 23 | if (toDict is not None): 24 | return toDict() 25 | 26 | if isinstance(obj, (datetime, date)): 27 | return obj.isoformat() 28 | 29 | if (isinstance(obj, REGEX_TYPE)): 30 | return obj.pattern 31 | 32 | return json.JSONEncoder.default(self, obj) 33 | 34 | 35 | class CommonMixin(object): 36 | """Provides some common behavior methods and overloads.""" 37 | 38 | def toDict(self): 39 | """ 40 | Return a dictionary representation of this object. 41 | 42 | Returns 43 | ------- 44 | dict 45 | All of the standard instance properties are included in the dictionary, with an 46 | additional "type" property containing the class name. 47 | """ 48 | result = self.__dict__.copy() 49 | result['type'] = self.__class__.__name__ 50 | return result 51 | 52 | def toJson(self): 53 | """ 54 | Return a JSON string representation of this object. 55 | 56 | Returns 57 | ------- 58 | string 59 | JSON representation of the dictionary returned by toDict() 60 | """ 61 | return json.dumps(self.toDict(), cls=JsonEncoder) 62 | 63 | def __repr__(self): 64 | """ 65 | Return a string representation of this object. 66 | 67 | Returns 68 | ------- 69 | string 70 | JSON representation of the dictionary returned by toDict() 71 | """ 72 | return self.toJson() 73 | 74 | def __eq__(self, value): 75 | """ 76 | Determine whether this object is equal to another value. 77 | 78 | Parameters 79 | ---------- 80 | value : any 81 | The value to test for equality 82 | 83 | Returns 84 | ------- 85 | boolean 86 | True if the value is the same type and has the same property values as this instance, 87 | and False otherwise. 88 | """ 89 | return isinstance(value, type(self)) and (self.__dict__ == value.__dict__) 90 | 91 | def __ne__(self, value): 92 | """ 93 | Determine whether this object is not equal to another value. 94 | 95 | Parameters 96 | ---------- 97 | value : any 98 | The value to test for inequality 99 | 100 | Returns 101 | ------- 102 | boolean 103 | True if the value is not the same type or same property value differs when compared to 104 | this instance, and False otherwise. 105 | """ 106 | return not self.__eq__(value) 107 | -------------------------------------------------------------------------------- /octoprint_excluderegion/ExcludedGcode.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the ExcludedGcode class.""" 3 | 4 | from __future__ import absolute_import, division 5 | from .CommonMixin import CommonMixin 6 | 7 | # Filter out the command when in an exclude region and do not send it to the printer. 8 | EXCLUDE_ALL = "exclude" 9 | 10 | # Execute the first instance of a command from an exclude region when exiting the region. 11 | EXCLUDE_EXCEPT_FIRST = "first" 12 | 13 | # Execute the last instance of a command from an exclude region when exiting the region. 14 | EXCLUDE_EXCEPT_LAST = "last" 15 | 16 | # Execute command with last encountered value of each argument when exiting an exclude region. 17 | EXCLUDE_MERGE = "merge" 18 | 19 | 20 | # pylint: disable=too-few-public-methods 21 | class ExcludedGcode(CommonMixin): 22 | """ 23 | Configuration for a custom excluded Gcode command. 24 | 25 | Attributes 26 | ---------- 27 | gcode : string 28 | The gcode to exclude (e.g. "G4") 29 | mode : string 30 | The type of exclusion processing to perform. One of the following constant values: 31 | EXCLUDE_ALL, EXCLUDE_EXCEPT_FIRST, EXCLUDE_EXCEPT_LAST, EXCLUDE_MERGE 32 | description : string 33 | Description of the exclusion. 34 | """ 35 | 36 | def __init__(self, gcode, mode, description): 37 | """ 38 | Initialize the instance properties. 39 | 40 | Parameters 41 | ---------- 42 | gcode : string 43 | The gcode to exclude (e.g. "G4"). 44 | mode : string 45 | The type of exclusion processing to perform. One of the following constant values: 46 | EXCLUDE_ALL, EXCLUDE_EXCEPT_FIRST, EXCLUDE_EXCEPT_LAST, EXCLUDE_MERGE 47 | description : string 48 | Description of the exclusion. 49 | """ 50 | assert gcode, "You must provide a value for gcode" 51 | assert mode in (EXCLUDE_ALL, EXCLUDE_EXCEPT_FIRST, EXCLUDE_EXCEPT_LAST, EXCLUDE_MERGE), \ 52 | "Invalid mode parameter value" 53 | 54 | self.gcode = gcode 55 | self.mode = mode 56 | self.description = description 57 | -------------------------------------------------------------------------------- /octoprint_excluderegion/Position.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the Position class.""" 3 | 4 | from __future__ import absolute_import, division 5 | from .CommonMixin import CommonMixin 6 | from .AxisPosition import AxisPosition 7 | 8 | 9 | class Position(CommonMixin): 10 | """ 11 | Encapsulates the current position of all the axes. 12 | 13 | Attributes 14 | ---------- 15 | X_AXIS : AxisPosition 16 | The X axis position state. This position is initially unknown/None by default. 17 | Y_AXIS : AxisPosition 18 | The Y axis position state. This position is initially unknown/None by default. 19 | Z_AXIS : AxisPosition 20 | The Z axis position state. This position is initially unknown/None by default. 21 | E_AXIS : AxisPosition 22 | The E axis position state. The extruder position defaults to 0 23 | """ 24 | 25 | def __init__(self, position=None): 26 | """ 27 | Initialize the instance properties. 28 | 29 | Parameters 30 | ---------- 31 | position : Position | None 32 | If a Position is provided, its property values will be cloned to the new instance. 33 | """ 34 | # pylint: disable=invalid-name 35 | if (position is None): 36 | self.X_AXIS = AxisPosition() 37 | self.Y_AXIS = AxisPosition() 38 | self.Z_AXIS = AxisPosition() 39 | self.E_AXIS = AxisPosition(0) 40 | else: 41 | assert isinstance(position, Position), "position must be a Position instance" 42 | self.X_AXIS = AxisPosition(position.X_AXIS) 43 | self.Y_AXIS = AxisPosition(position.Y_AXIS) 44 | self.Z_AXIS = AxisPosition(position.Z_AXIS) 45 | self.E_AXIS = AxisPosition(position.E_AXIS) 46 | 47 | def toDict(self): 48 | """ 49 | Return a dictionary representation of this object. 50 | 51 | Returns 52 | ------- 53 | dict 54 | """ 55 | return { 56 | "type": self.__class__.__name__, 57 | "current": { 58 | "x": self.X_AXIS.current, 59 | "y": self.Y_AXIS.current, 60 | "z": self.Z_AXIS.current, 61 | "e": self.E_AXIS.current 62 | }, 63 | "homeOffset": { 64 | "x": self.X_AXIS.homeOffset, 65 | "y": self.Y_AXIS.homeOffset, 66 | "z": self.Z_AXIS.homeOffset, 67 | "e": self.E_AXIS.homeOffset 68 | }, 69 | "offset": { 70 | "x": self.X_AXIS.offset, 71 | "y": self.Y_AXIS.offset, 72 | "z": self.Z_AXIS.offset, 73 | "e": self.E_AXIS.offset 74 | }, 75 | "absoluteMode": { 76 | "x": self.X_AXIS.absoluteMode, 77 | "y": self.Y_AXIS.absoluteMode, 78 | "z": self.Z_AXIS.absoluteMode, 79 | "e": self.E_AXIS.absoluteMode 80 | }, 81 | "unitMultiplier": { 82 | "x": self.X_AXIS.unitMultiplier, 83 | "y": self.Y_AXIS.unitMultiplier, 84 | "z": self.Z_AXIS.unitMultiplier, 85 | "e": self.E_AXIS.unitMultiplier 86 | } 87 | } 88 | 89 | def setUnitMultiplier(self, unitMultiplier): 90 | """ 91 | Set the conversion factor from logical units to native units for all of the axes (G20, G21). 92 | 93 | Parameters 94 | ---------- 95 | unitMultiplier : float 96 | The new unit multiplier to use for converting between logical and native units to assign 97 | to all of the axes. 98 | """ 99 | self.X_AXIS.setUnitMultiplier(unitMultiplier) 100 | self.Y_AXIS.setUnitMultiplier(unitMultiplier) 101 | self.Z_AXIS.setUnitMultiplier(unitMultiplier) 102 | self.E_AXIS.setUnitMultiplier(unitMultiplier) 103 | 104 | def setPositionAbsoluteMode(self, absolute): 105 | """ 106 | Set the absoluteMode property for the X, Y and Z axes (G90, G91). 107 | 108 | Parameters 109 | ---------- 110 | absoluteMode : boolean 111 | The new value to assign to the absoluteMode property of the X, Y and Z axes 112 | """ 113 | self.X_AXIS.setAbsoluteMode(absolute) 114 | self.Y_AXIS.setAbsoluteMode(absolute) 115 | self.Z_AXIS.setAbsoluteMode(absolute) 116 | 117 | def setExtruderAbsoluteMode(self, absolute): 118 | """ 119 | Set the absoluteMode property for the E axis (M82, M83). 120 | 121 | Parameters 122 | ---------- 123 | absoluteMode : boolean 124 | The new value to assign to the absoluteMode property of the E axis 125 | """ 126 | self.E_AXIS.setAbsoluteMode(absolute) 127 | -------------------------------------------------------------------------------- /octoprint_excluderegion/RectangularRegion.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the RectangularRegion class.""" 3 | 4 | from __future__ import absolute_import, division 5 | 6 | import uuid 7 | 8 | from .CommonMixin import CommonMixin 9 | 10 | 11 | class RectangularRegion(CommonMixin): 12 | """ 13 | A rectangular region to exclude from printing. 14 | 15 | The region is defined by specifying the four edge coordinates. 16 | 17 | Attributes 18 | ---------- 19 | x1 : float 20 | The x coordinate of the left edge. Expected to be <= x2. 21 | y1 : float 22 | The y coordinate of the top edge. Expected to be <= y2. 23 | x2 : float 24 | The x coordinate of the right edge. Expected to be >= x1. 25 | y2 : float 26 | The y coordinate of the bottom edge. Expected to be >= y1. 27 | id : string 28 | Unique identifier assigned to the region. 29 | """ 30 | 31 | def __init__(self, *args, **kwargs): 32 | """ 33 | Initialize the instance properties. 34 | 35 | Following construction, x1 <= x2 and y1 <= y2 will hold true for the respective object 36 | properties. 37 | 38 | Parameters 39 | ---------- 40 | toCopy : RectangularRegion 41 | If provided, the new instance will be a clone of this instance. 42 | 43 | kwargs.x1 : float 44 | The x coordinate of a vertical edge. 45 | kwargs.y1 : float 46 | The x coordinate of a horizontal edge. 47 | kwargs.x2 : float 48 | The x coordinate of a vertical edge. 49 | kwargs.y2 : float 50 | The x coordinate of a horizontal edge. 51 | kwargs.id : string 52 | Unique identifier assigned to the region. 53 | """ 54 | # pylint: disable=invalid-name 55 | if args: 56 | toCopy = args[0] 57 | assert isinstance(toCopy, RectangularRegion), "Expected a RectangularRegion instance" 58 | 59 | self.x1 = toCopy.x1 60 | self.y1 = toCopy.y1 61 | self.x2 = toCopy.x2 62 | self.y2 = toCopy.y2 63 | self.id = toCopy.id 64 | else: 65 | regionId = kwargs.get("id", None) 66 | x1 = float(kwargs.get("x1", 0)) 67 | y1 = float(kwargs.get("y1", 0)) 68 | x2 = float(kwargs.get("x2", 0)) 69 | y2 = float(kwargs.get("y2", 0)) 70 | 71 | if (x2 < x1): 72 | x1, x2 = x2, x1 73 | 74 | if (y2 < y1): 75 | y1, y2 = y2, y1 76 | 77 | self.x1 = x1 78 | self.y1 = y1 79 | self.x2 = x2 80 | self.y2 = y2 81 | 82 | # pylint: disable=invalid-name 83 | if (regionId is None): 84 | self.id = str(uuid.uuid4()) 85 | else: 86 | self.id = regionId 87 | 88 | def containsPoint(self, x, y): 89 | """ 90 | Check if the specified point is contained in this region. 91 | 92 | Returns 93 | ------- 94 | True if the point is inside this region, and False otherwise. 95 | """ 96 | return (x >= self.x1) and (x <= self.x2) and (y >= self.y1) and (y <= self.y2) 97 | 98 | def containsRegion(self, otherRegion): 99 | """ 100 | Check if another region is fully contained in this region. 101 | 102 | Returns 103 | ------- 104 | True if the other region is fully contained inside this region, and False otherwise. 105 | """ 106 | from octoprint_excluderegion.CircularRegion import CircularRegion 107 | 108 | if (isinstance(otherRegion, RectangularRegion)): 109 | return ( 110 | (otherRegion.x1 >= self.x1) and 111 | (otherRegion.x2 <= self.x2) and 112 | (otherRegion.y1 >= self.y1) and 113 | (otherRegion.y2 <= self.y2) 114 | ) 115 | elif (isinstance(otherRegion, CircularRegion)): 116 | return ( 117 | (otherRegion.cx - otherRegion.r >= self.x1) and 118 | (otherRegion.cx + otherRegion.r <= self.x2) and 119 | (otherRegion.cy - otherRegion.r >= self.y1) and 120 | (otherRegion.cy + otherRegion.r <= self.y2) 121 | ) 122 | else: 123 | raise ValueError("unexpected type: {otherRegion}".format(otherRegion=otherRegion)) 124 | -------------------------------------------------------------------------------- /octoprint_excluderegion/RetractionState.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Module providing the RetractionState class.""" 3 | 4 | from __future__ import absolute_import, division 5 | import re 6 | from .CommonMixin import CommonMixin 7 | 8 | # Regular expression for extracting the parameters from a Gcode command 9 | GCODE_PARAMS_REGEX = re.compile("^[A-Za-z][0-9]+(?:\\.[0-9]+)?\\s*(.*)$") 10 | 11 | 12 | class RetractionState(CommonMixin): 13 | """ 14 | Information for a retraction that may need to be restored later. 15 | 16 | Attributes 17 | ---------- 18 | originalCommand : string 19 | Original retraction gcode 20 | firmwareRetract : boolean 21 | This was a firmware retraction (G10) 22 | extrusionAmount : float | None 23 | Amount of filament to extrude when recovering a previous retraction, in millimeters. Will 24 | be None if firmwareRetract is True and a float otherwise. 25 | feedRate : float | None 26 | Feed rate in millimeters/minute for filament recovery. Will be None if firmwareRetract 27 | is True and a float otherwise. 28 | recoverExcluded : boolean 29 | Whether the recovery for this retraction was excluded or not (default False). If it was 30 | excluded, it will need to be processed later. 31 | allowCombine : boolean 32 | Whether this retraction should be combined with subsequent retractions or not. Retractions 33 | may be combined as long as there is no extrusion/recovery executed between the two 34 | retractions. 35 | """ 36 | 37 | def __init__( 38 | self, originalCommand, firmwareRetract, extrusionAmount=None, feedRate=None 39 | ): 40 | """ 41 | Initialize the instance properties. 42 | 43 | Parameters 44 | ---------- 45 | originalCommand : string 46 | The original Gcode command for the retraction 47 | firmwareRetract : boolean 48 | Whether this was a firmware retraction (G10) or not (G0/G1 with no XYZ move) 49 | extrusionAmount : float | None 50 | Amount of filament to extrude when recovering a previous retraction, in millimeters. 51 | Must be None if firmwareRetract is True, and a float otherwise. 52 | feedRate : float | None 53 | Feed rate in millimeters/minute for filament recovery. Must be None if firmwareRetract 54 | is True, and a float otherwise. 55 | """ 56 | if (firmwareRetract): 57 | if (extrusionAmount is not None) or (feedRate is not None): 58 | raise ValueError( 59 | "You cannot provide a value for extrusionAmount or feedRate if " + 60 | "firmwareRetract is specified" 61 | ) 62 | elif ((extrusionAmount is None) or (feedRate is None)): 63 | raise ValueError( 64 | "You must provide values for both extrusionAmount and feedRate together" 65 | ) 66 | 67 | self.recoverExcluded = False 68 | self.allowCombine = True 69 | self.firmwareRetract = firmwareRetract 70 | self.extrusionAmount = extrusionAmount 71 | self.feedRate = feedRate 72 | self.originalCommand = originalCommand 73 | 74 | def combine(self, other, logger): 75 | """ 76 | Combine the retraction amount from the specified other instance with this instance. 77 | 78 | Parameters 79 | ---------- 80 | other : RetractionState 81 | The other instance to combine with this instance. 82 | 83 | Returns 84 | ------- 85 | This instance 86 | """ 87 | if (self.allowCombine): 88 | if (self.firmwareRetract == other.firmwareRetract): 89 | if (not self.firmwareRetract): 90 | self.extrusionAmount += other.extrusionAmount 91 | else: 92 | logger.warn( 93 | "Encountered mix of firmware and non-firmware retractions. " + 94 | "Extruder position may not be tracked correctly." 95 | ) 96 | else: 97 | logger.warn("Cannot combine retractions, since allowCombine = False") 98 | 99 | return self 100 | 101 | def generateRetractCommands(self, position): 102 | """ 103 | Add the necessary commands to perform a retraction for this instance. 104 | 105 | Parameters 106 | ---------- 107 | position : Position 108 | The tool position state to apply the retraction to. 109 | 110 | Returns 111 | ------- 112 | List of gcode commands 113 | A list containing the retraction command(s) to execute. 114 | """ 115 | return self._addCommands(1, position) 116 | 117 | def generateRecoverCommands(self, position): 118 | """ 119 | Add the necessary commands to perform a recovery for this instance. 120 | 121 | Parameters 122 | ---------- 123 | position : Position 124 | The tool position state to apply the retraction to. 125 | 126 | Returns 127 | ------- 128 | List of gcode commands 129 | A list containing the recovery command(s) to execute. 130 | """ 131 | return self._addCommands(-1, position) 132 | 133 | def _addCommands(self, direction, position): 134 | """ 135 | Add the necessary commands to perform a retraction or recovery for this instance. 136 | 137 | Parameters 138 | ---------- 139 | direction : { 1, -1 } 140 | For non-firmware retractions, the direction multiplier to apply to the extrusion 141 | amount. Use 1 for retract, -1 for recover. 142 | position : Position 143 | The tool position state to apply the retraction to. 144 | 145 | Returns 146 | ------- 147 | List of gcode commands 148 | A list containing the new command(s). 149 | """ 150 | returnCommands = [] 151 | 152 | if (self.firmwareRetract): 153 | cmd = "G11" if (direction == -1) else "G10" 154 | params = GCODE_PARAMS_REGEX.sub("\\1", self.originalCommand) 155 | if (params): 156 | cmd += " " + params 157 | 158 | returnCommands.append(cmd) 159 | else: 160 | amount = self.extrusionAmount * direction 161 | eAxis = position.E_AXIS 162 | eAxis.current += amount 163 | 164 | returnCommands.append( 165 | # Set logical extruder position 166 | "G92 E{e}".format(e=eAxis.nativeToLogical()) 167 | ) 168 | 169 | eAxis.current -= amount 170 | 171 | # Use "G1" over "G0", since an extrusion amount is being supplied 172 | returnCommands.append( 173 | "G1 F{f} E{e}".format( 174 | e=eAxis.nativeToLogical(), 175 | f=self.feedRate / eAxis.unitMultiplier 176 | ) 177 | ) 178 | 179 | return returnCommands 180 | -------------------------------------------------------------------------------- /octoprint_excluderegion/StreamProcessor.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Class for processing a Gcode stream to apply exclude region processing.""" 3 | 4 | from __future__ import absolute_import, division 5 | import copy 6 | 7 | from octoprint.filemanager.util import LineProcessorStream 8 | 9 | from .GcodeHandlers import GcodeHandlers 10 | 11 | 12 | class StreamProcessorComm(object): 13 | """Class implementing a sendCommand method for capturing gcode generated by @-commands.""" 14 | 15 | def __init__(self): 16 | """Initialize the object with an empty command buffer.""" 17 | self.bufferedCommands = [] 18 | 19 | def reset(self): 20 | """Clear the list of buffered commands.""" 21 | self.bufferedCommands = [] 22 | 23 | def isStreaming(self): # pylint: disable=no-self-use 24 | """Indicate this comm instance is not streaming.""" 25 | return False 26 | 27 | def sendCommand(self, command, **kwargs): # pylint: disable=unused-argument 28 | """Record the command in the buffer.""" 29 | if (command is not None): 30 | self.bufferedCommands.append(command) 31 | 32 | 33 | class StreamProcessor(LineProcessorStream): 34 | r""" 35 | StreamProcessor subclass that applies exclusion regions to the stream. 36 | 37 | Attributes 38 | ---------- 39 | input_stream : Stream 40 | The stream to process (inherited from octoprint.filemanager.util.LineProcessorStream). 41 | gcodeHandlers : GcodeHandlers 42 | The GcodeHandlers instance to use for processing the commands. 43 | commInstance : StreamProcessorComm 44 | Object that mimics the OctoPrint comm instance for capturing generated Gcode commands 45 | written to the sendCommand method. 46 | eol : string | None 47 | The eol character(s) to use when concatenating multiple lines of Gcode. This is 48 | typically auto-detected based on the first line ending encountered, and will default 49 | to "\n" if no other line ending is detected (e.g. this value is None when a line ending 50 | is needed). 51 | """ 52 | 53 | def __init__(self, inputStream, gcodeHandlers): 54 | """ 55 | Initialize the instance. 56 | 57 | Parameters 58 | ---------- 59 | inputStream : Stream 60 | The stream to process. 61 | gcodeHandlers : GcodeHandlers 62 | The GcodeHandlers instance to capture setting values from. 63 | """ 64 | assert inputStream is not None, "inputStream must be provided" 65 | assert gcodeHandlers is not None, "gcodeHandlers must be provided" 66 | 67 | super(StreamProcessor, self).__init__(inputStream) 68 | 69 | self._logger = gcodeHandlers._logger # pylint: disable=protected-access 70 | self.gcodeHandlers = GcodeHandlers( 71 | copy.deepcopy(gcodeHandlers.state), 72 | self._logger 73 | ) 74 | self._eol = None 75 | self.commInstance = StreamProcessorComm() 76 | 77 | @property 78 | def eol(self): 79 | """EOL marker to use for separating multiple lines of commands.""" 80 | if (self._eol is None): 81 | self._eol = "\n" 82 | 83 | return self._eol 84 | 85 | @eol.setter 86 | def eol(self, value): 87 | """Set the eol to use for separating multiple lines of commands.""" 88 | self._eol = value 89 | 90 | def process_line(self, line): 91 | """ 92 | Apply exclusion rules to a line of Gcode from the stream. 93 | 94 | Parameters 95 | ---------- 96 | line : string 97 | The Gcode line to process, including the terminating line ending character(s). 98 | 99 | Returns 100 | ------- 101 | string | None 102 | The (possibly altered) Gcode line, or None to omit the current line from the output. 103 | Terminating line ending character(s) will be included in this result. May return 104 | multiple lines separated by line endings. 105 | """ 106 | parsed = self.gcodeHandlers.gcodeParser.parse(line) 107 | 108 | if (parsed.eol): 109 | self.eol = parsed.eol 110 | 111 | if (parsed.type is not None): 112 | return self._handleGcode(parsed) 113 | elif (parsed.text.startswith("@")): 114 | return self._handleAtCommand(parsed) 115 | 116 | return line 117 | 118 | def _handleGcode(self, parsed): 119 | """ 120 | Process a gcode line. 121 | 122 | Parameters 123 | ---------- 124 | parsed : GcodeParser 125 | The GcodeParser instance the line was parsed into. 126 | 127 | Returns 128 | ------- 129 | string | None 130 | The (possibly altered) Gcode line, or None to omit the current line from the output. 131 | Terminating line ending character(s) will be included in this result. May return 132 | multiple lines separated by line endings. 133 | """ 134 | handlerResult = self.gcodeHandlers.handleGcode( 135 | parsed.stringify( 136 | includeLineNumber=False, 137 | includeComment=False, 138 | includeEol=False 139 | ), 140 | parsed.gcode, 141 | parsed.subCode 142 | ) 143 | 144 | if (handlerResult is not None): 145 | if (not isinstance(handlerResult, list)): 146 | handlerResult = [handlerResult] 147 | 148 | lines = [] 149 | 150 | for item in handlerResult: 151 | if (isinstance(item, tuple)): 152 | item = item[0] 153 | 154 | if (item is not None): 155 | lines.append(str(item)) 156 | 157 | if (lines): 158 | return self.eol.join(lines) + self.eol 159 | 160 | return None 161 | 162 | return parsed.source 163 | 164 | @staticmethod 165 | def _splitAtCommand(command): 166 | """ 167 | Split the provided @-command line into the command and parameter portions. 168 | 169 | This is intended to match OctoPrint's parsing of @ command lines, which simply trims leading 170 | whitespace, splits the command from the parameters on the first whitespace, and removes the 171 | leading '@' from the command. That does mean the command could be an empty string, or 172 | could potentially contain any non-whitespace character. 173 | 174 | Returns 175 | ------- 176 | tuple(command, parameters) 177 | A tuple with the parsed command as the first element (may be empty string or contain 178 | any non-whitespace character) and the parameters as the second element (may be an empty 179 | string, if no parameters were found). 180 | """ 181 | pieces = command.split(None, 1) 182 | return ( 183 | (pieces[0])[1:], 184 | "" if (len(pieces) < 2) else pieces[1] 185 | ) 186 | 187 | def _handleAtCommand(self, parsed): 188 | """ 189 | Process an @-command line. 190 | 191 | Parameters 192 | ---------- 193 | parsed : GcodeParser 194 | The GcodeParser instance the line was parsed into. 195 | 196 | Returns 197 | ------- 198 | string | None 199 | The (possibly altered) line, or None to omit the current line from the output. 200 | Terminating line ending character(s) will be included in this result. May return 201 | multiple lines separated by line endings. 202 | """ 203 | (command, parameters) = self._splitAtCommand(parsed.text) 204 | 205 | self.commInstance.reset() 206 | 207 | if (self.gcodeHandlers.handleAtCommand( 208 | self.commInstance, 209 | command, 210 | parameters 211 | )): 212 | if (self.commInstance.bufferedCommands): 213 | return self.eol.join(self.commInstance.bufferedCommands) + self.eol 214 | 215 | return None 216 | 217 | return parsed.source 218 | -------------------------------------------------------------------------------- /octoprint_excluderegion/static/css/excluderegion.css: -------------------------------------------------------------------------------- 1 | 2 | div.gcode_canvas_wrapper { 3 | position: relative; 4 | } 5 | 6 | #gcode_canvas { 7 | position: relative; 8 | } 9 | 10 | #gcode_canvas_excludeRegions_overlay { 11 | pointer-events: none; 12 | position: absolute; 13 | left: 0; 14 | top: 0; 15 | } 16 | 17 | #gcode_canvas_editRegion_overlay { 18 | pointer-events: none; 19 | position: absolute; 20 | left: 0; 21 | top: 0; 22 | } 23 | 24 | #gcode_output_overlay { 25 | pointer-events: none; 26 | position: absolute; 27 | left: 0; 28 | top: 0; 29 | display: inline-block; 30 | } 31 | 32 | #gcode_exclude_controls .message { 33 | display: none; 34 | } 35 | 36 | #gcode_exclude_controls .message .region { 37 | display: none; 38 | } 39 | 40 | #gcode_exclude_controls .message .region .text { 41 | display: none; 42 | } 43 | 44 | #gcode_exclude_controls .fa { 45 | padding-right: 5px; 46 | } 47 | 48 | #gcode_exclude_controls .message .label { 49 | margin-right: 5px; 50 | } 51 | 52 | #gcode_exclude_controls .message .action-buttons { 53 | float: right; 54 | margin-right: 20px; 55 | } 56 | 57 | #gcode_exclude_controls .message .action-buttons .btn.cancel .fa { 58 | color: red; 59 | } 60 | 61 | #gcode_exclude_controls .message .action-buttons .btn.accept .fa { 62 | color: green; 63 | } 64 | 65 | #gcode_exclude_controls .message .action-buttons .btn.delete .fa { 66 | color: red; 67 | } 68 | -------------------------------------------------------------------------------- /octoprint_excluderegion/templates/excluderegion_settings.jinja2: -------------------------------------------------------------------------------- 1 |

General Settings

2 | 3 |
4 |
5 |
6 | 10 |
11 | 12 |
13 | 17 |
18 |
19 | 20 |
21 | 22 |
23 | 24 |
25 |
26 | 27 |
28 | 29 |
30 | 31 |
32 |
33 | 34 |
35 | 36 |
37 | 42 |
43 |
44 |
45 | 46 |

Extended Gcodes to Exclude

47 | 48 |
49 |
50 |
51 |
{{ _('Gcode') }}
52 |
{{ _('Mode') }}
53 |
{{ _('Description') }}
54 |
55 |
56 |
57 |
58 |
59 | 60 | 61 | 67 | 68 | 69 | 70 | 71 | 72 |
73 |
74 |
75 |
76 | 77 | 78 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |
91 |
92 |
93 | 94 |

@-Command Actions

95 | 96 |
97 |
98 |
99 |
{{ _('Command') }}
100 |
{{ _('Parameter Pattern') }}
101 |
{{ _('Action') }}
102 |
{{ _('Description') }}
103 |
104 |
105 |
106 |
107 |
108 | 109 | 110 | 111 | 115 | 116 | 117 | 118 | 119 | 120 |
121 |
122 |
123 |
124 | 125 | 126 | 127 | 131 | 132 | 133 | 134 | 135 | 136 | 137 |
138 |
139 |
140 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | 2 | [pylama] 3 | format = pylint 4 | linters = pylint,pycodestyle,pydocstyle,mccabe 5 | 6 | ; pydocstyle exclusions. Doesn't work to specify them in the [pylama.pydocstyle] section. 7 | ; D203 1 blank line required before class docstring (found 0) 8 | ; D212 Multi-line docstring summary should start at the first line 9 | ; D413 Missing blank line after last section ('Returns') 10 | ignore = D203,D212,D413 11 | 12 | ; Use a trailing # nopep8 to ignode pycodestyle warnings for a specific line 13 | [pylama:pycodestyle] 14 | max_line_length = 100 15 | 16 | [pylama:pydocstyle] 17 | convention = pep257 18 | 19 | ; # pylint: disable= to ignore specific pylint warnings for following code block (or line if trailing comment) 20 | [pylama:pylint] 21 | single-line-if-stmt = no 22 | include-naming-hint = yes 23 | 24 | ; Threshold limit for R0913 Too many arguments 25 | max-args = 6 26 | 27 | ; PEP8 specifies snake_case 28 | ;module-naming-style = PascalCase 29 | ; PascalCase, all-lower (no underscores), "prefix_" before PascalCase, 30 | ; or "test_" before PascalCase with optional "_camelCaseSuffix" 31 | module-rgx = ^([a-z]+|([a-z][a-z0-9]+_|[A-Z])([a-zA-Z0-9]+)|test_[A-Z][a-zA-Z0-9]+(_[a-z][a-zA-Z0-9]+)?)$ 32 | 33 | ;method-naming-style = camelCase 34 | ; Camel case with optional leading underscore for normal methods, __magic__ methods and relaxed rules for "test_" methods. 35 | method-rgx = ^((_?[a-z][a-zA-Z0-9]{2,30})|(__[a-z]+__)|(_?(test|assert)_[a-zA-Z0-9_]{3,}))$ 36 | 37 | class-naming-style = PascalCase 38 | argument-naming-style = camelCase 39 | variable-naming-style = camelCase 40 | attr-naming-style = camelCase 41 | 42 | good-names = _,x,y,z,x1,y1,x2,y2 43 | 44 | ; C0111 Missing method docstring [Handled by pydocstyle] 45 | ; C0121 Comparison to None should be 'expr is None' [Handled by pycodestyle] 46 | ; C0301 Line too long [Handled by pycodestyle] 47 | ; C0303 Trailing whitespace [Handled by pycodestyle] 48 | ; C0325 Unnecessary parens after %r keyword 49 | ; C0326 No space allowed around keyword argument assignment [Handled by pycodestyle] 50 | ; W0301 Unnecessary semicolon [Handled by pycodestyle] 51 | ; W0311 Bad indentation [Handled by pycodestyle] 52 | disable = C0111,C0121,C0301,C0303,C0325,C0326,W0301,W0311 53 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ### 2 | # This file is only here to make sure that something like 3 | # 4 | # pip install -e . 5 | # 6 | # works as expected. Requirements can be found in setup.py. 7 | ### 8 | 9 | . 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | ######################################################################################################################## 4 | ### Do not forget to adjust the following variables to your own plugin. 5 | 6 | # The plugin's identifier, has to be unique 7 | plugin_identifier = "excluderegion" 8 | 9 | # The plugin's python package, should be "octoprint_", has to be unique 10 | plugin_package = "octoprint_excluderegion" 11 | 12 | # The plugin's human readable name. Can be overwritten within OctoPrint's internal data via __plugin_name__ in the 13 | # plugin module 14 | plugin_name = "OctoPrint-ExcludeRegionPlugin" 15 | 16 | # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module 17 | plugin_version = "0.3.2" 18 | 19 | # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin 20 | # module 21 | plugin_description = """Adds the ability to prevent printing within rectangular or circular regions of the currently active gcode file""" 22 | 23 | # The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module 24 | plugin_author = "Brad Fisher" 25 | 26 | # The plugin's author's mail address. 27 | plugin_author_email = "bradcfisher@hotmail.com" 28 | 29 | # The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module 30 | plugin_url = "https://github.com/bradcfisher/OctoPrint-ExcludeRegionPlugin" 31 | 32 | # The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module 33 | plugin_license = "AGPLv3" 34 | 35 | # Any additional requirements besides OctoPrint should be listed here 36 | plugin_requires = [] 37 | 38 | ### -------------------------------------------------------------------------------------------------------------------- 39 | ### More advanced options that you usually shouldn't have to touch follow after this point 40 | ### -------------------------------------------------------------------------------------------------------------------- 41 | 42 | # Additional package data to install for this plugin. The subfolders "templates", "static" and "translations" will 43 | # already be installed automatically if they exist. Note that if you add something here you'll also need to update 44 | # MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your 45 | # files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598 46 | plugin_additional_data = [] 47 | 48 | # Any additional python packages you need to install with your plugin that are not contained in .* 49 | plugin_additional_packages = [] 50 | 51 | # Any python packages within .* you do NOT want to install with your plugin 52 | plugin_ignored_packages = [] 53 | 54 | # Additional parameters for the call to setuptools.setup. If your plugin wants to register additional entry points, 55 | # define dependency links or other things like that, this is the place to go. Will be merged recursively with the 56 | # default setup parameters as provided by octoprint_setuptools.create_plugin_setup_parameters using 57 | # octoprint.util.dict_merge. 58 | # 59 | # Example: 60 | # plugin_requires = ["someDependency==dev"] 61 | # additional_setup_parameters = {"dependency_links": ["https://github.com/someUser/someRepo/archive/master.zip#egg=someDependency-dev"]} 62 | additional_setup_parameters = {} 63 | 64 | ######################################################################################################################## 65 | 66 | from setuptools import setup 67 | 68 | try: 69 | import octoprint_setuptools 70 | except: 71 | print("Could not import OctoPrint's setuptools, are you sure you are running that under " 72 | "the same python installation that OctoPrint is installed under?") 73 | import sys 74 | sys.exit(-1) 75 | 76 | setup_parameters = octoprint_setuptools.create_plugin_setup_parameters( 77 | identifier=plugin_identifier, 78 | package=plugin_package, 79 | name=plugin_name, 80 | version=plugin_version, 81 | description=plugin_description, 82 | author=plugin_author, 83 | mail=plugin_author_email, 84 | url=plugin_url, 85 | license=plugin_license, 86 | requires=plugin_requires, 87 | additional_packages=plugin_additional_packages, 88 | ignored_packages=plugin_ignored_packages, 89 | additional_data=plugin_additional_data 90 | ) 91 | 92 | if len(additional_setup_parameters): 93 | from octoprint.util import dict_merge 94 | setup_parameters = dict_merge(setup_parameters, additional_setup_parameters) 95 | 96 | setup(**setup_parameters) 97 | -------------------------------------------------------------------------------- /test-py2-requirements.txt: -------------------------------------------------------------------------------- 1 | # Packages required for running tests, coverage or linting under Python 2 2 | 3 | pip 4 | coverage==5.1 5 | mock==3.0.5 6 | callee==0.3.1 7 | sphinx==1.8.5 8 | configparser==4.0.2 9 | pylint==1.9.5 10 | pylama==7.7.1 11 | Pygments==2.7.4 12 | sphinxcontrib_websupport==1.1.2 13 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # Packages required for running tests, coverage or linting 2 | 3 | pip 4 | coverage 5 | mock 6 | callee 7 | sphinx 8 | configparser 9 | pylint 10 | pylama 11 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for ``OctoPrint-ExcludeRegionPlugin``.""" 3 | 4 | from __future__ import absolute_import 5 | -------------------------------------------------------------------------------- /test/test_AtCommandAction.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the AtCommandAction class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | import re 7 | 8 | from octoprint_excluderegion.AtCommandAction import AtCommandAction, ENABLE_EXCLUSION 9 | from .utils import TestCase 10 | 11 | 12 | class AtCommandActionTests(TestCase): 13 | """Unit tests for the AtCommandAction class.""" 14 | 15 | expectedProperties = ["command", "parameterPattern", "action", "description"] 16 | 17 | def test_constructor_noPattern(self): 18 | """Test the constructor when valid arguments are passed, but no parameter pattern.""" 19 | unit = AtCommandAction("TestCommand", None, ENABLE_EXCLUSION, "My description") 20 | 21 | self.assertIsInstance(unit, AtCommandAction) 22 | self.assertEqual(unit.command, "TestCommand", "command should be 'TestCommand'") 23 | self.assertEqual(unit.parameterPattern, None, "parameterPattern should be None") 24 | self.assertEqual( 25 | unit.action, ENABLE_EXCLUSION, 26 | "action should be '" + ENABLE_EXCLUSION + "'" 27 | ) 28 | self.assertEqual( 29 | unit.description, "My description", "description should be 'My description'" 30 | ) 31 | self.assertProperties(unit, AtCommandActionTests.expectedProperties) 32 | 33 | def test_constructor_validPattern(self): 34 | """Test the constructor when valid arguments are passed, including a parameter pattern.""" 35 | unit = AtCommandAction("TestCommand", "^abc$", ENABLE_EXCLUSION, "My description") 36 | 37 | self.assertEqual( 38 | unit.parameterPattern, re.compile("^abc$"), 39 | "parameterPattern should be a regex instance for the specified pattern" 40 | ) 41 | self.assertProperties(unit, AtCommandActionTests.expectedProperties) 42 | 43 | def test_constructor_missingCommand(self): 44 | """Test the constructor when a non-truthy command value is provided.""" 45 | with self.assertRaises(AssertionError): 46 | AtCommandAction(None, "", ENABLE_EXCLUSION, "My description") 47 | 48 | def test_constructor_invalidAction(self): 49 | """Test the constructor when an invalid action value is provided.""" 50 | with self.assertRaises(AssertionError): 51 | AtCommandAction(None, "", "invalid", "My description") 52 | 53 | def test_constructor_invalidPattern(self): 54 | """Test the constructor when an invalid parameter pattern is provided.""" 55 | with self.assertRaises(re.error): 56 | AtCommandAction("TestCommand", "(invalid", ENABLE_EXCLUSION, "My description") 57 | 58 | def test_matches_command_no_param_pattern(self): 59 | """Test the matches method when no parameter pattern is defined.""" 60 | unit = AtCommandAction("TestCommand", None, ENABLE_EXCLUSION, "My description") 61 | 62 | self.assertTrue( 63 | unit.matches("TestCommand", "test parameters"), 64 | "It should match 'TestCommand' with parameters" 65 | ) 66 | self.assertTrue( 67 | unit.matches("TestCommand", None), 68 | "It should match 'TestCommand' with no parameters" 69 | ) 70 | self.assertTrue( 71 | unit.matches("TestCommand", None), 72 | "It should match 'TestCommand' with no parameters" 73 | ) 74 | self.assertFalse( 75 | unit.matches("DifferentCommand", "test parameters"), 76 | "It should NOT match 'DifferentCommand' with parameters" 77 | ) 78 | self.assertFalse( 79 | unit.matches("DifferentCommand", None), 80 | "It should NOT match 'DifferentCommand' with no parameters" 81 | ) 82 | 83 | def test_matches_command_with_param_pattern(self): 84 | """Test the matches method when a parameter pattern is defined.""" 85 | unit = AtCommandAction( 86 | "TestCommand", "^\\s*match(\\s|$)", ENABLE_EXCLUSION, "My description" 87 | ) 88 | 89 | self.assertTrue( 90 | unit.matches("TestCommand", "match parameters"), 91 | "It should match 'TestCommand' with parameters starting with 'match'" 92 | ) 93 | self.assertFalse( 94 | unit.matches("TestCommand", "bad parameters"), 95 | "It should NOT match 'TestCommand' with parameters that don't start with 'match'" 96 | ) 97 | self.assertFalse( 98 | unit.matches("TestCommand", None), 99 | "It should NOT match 'TestCommand' with no parameters" 100 | ) 101 | 102 | self.assertFalse( 103 | unit.matches("DifferentCommand", "match parameters"), 104 | "It should NOT match 'DifferentCommand' with parameters starting with 'match'" 105 | ) 106 | self.assertFalse( 107 | unit.matches("DifferentCommand", "bad parameters"), 108 | "It should NOT match 'DifferentCommand' with parameters that don't start with 'match'" 109 | ) 110 | self.assertFalse( 111 | unit.matches("DifferentCommand", None), 112 | "It should NOT match 'DifferentCommand' with no parameters" 113 | ) 114 | -------------------------------------------------------------------------------- /test/test_AxisPosition.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | # pylint: disable=too-many-public-methods 3 | """Unit tests for the AxisPosition class.""" 4 | 5 | from __future__ import absolute_import 6 | 7 | from octoprint_excluderegion.AxisPosition import AxisPosition 8 | from .utils import TestCase 9 | 10 | 11 | class AxisPositionTests(TestCase): 12 | """Unit tests for the AxisPosition class.""" 13 | 14 | expectedProperties = ["current", "homeOffset", "offset", "absoluteMode", "unitMultiplier"] 15 | 16 | def test_default_constructor(self): 17 | """Test the constructor when passed no arguments.""" 18 | unit = AxisPosition() 19 | 20 | self.assertIsInstance(unit, AxisPosition) 21 | self.assertEqual(unit.current, None, "current should be None") 22 | self.assertEqual(unit.homeOffset, 0, "homeOffset should be 0") 23 | self.assertEqual(unit.offset, 0, "offset should be 0") 24 | self.assertEqual(unit.absoluteMode, True, "absoluteMode should be True") 25 | self.assertEqual(unit.unitMultiplier, 1, "unitMultiplier should be 1") 26 | self.assertProperties(unit, AxisPositionTests.expectedProperties) 27 | 28 | def test_constructor_args(self): 29 | """Test the constructor when the first argument passed is not an AxisPosition.""" 30 | unit = AxisPosition(1, 2, 3, False, 4) 31 | 32 | self.assertEqual(unit.current, 1, "current should be 1") 33 | self.assertEqual(unit.homeOffset, 2, "homeOffset should be 2") 34 | self.assertEqual(unit.offset, 3, "offset should be 3") 35 | self.assertEqual(unit.absoluteMode, False, "absoluteMode should be False") 36 | self.assertEqual(unit.unitMultiplier, 4, "unitMultiplier should be 4") 37 | self.assertProperties(unit, AxisPositionTests.expectedProperties) 38 | 39 | def test_copy_constructor(self): 40 | """Test the constructor when the first argument is an AxisPosition instance.""" 41 | toCopy = AxisPosition(1, 2, 3, False, 4) 42 | unit = AxisPosition(toCopy) 43 | 44 | self.assertEqual(unit.current, 1, "current should be 1") 45 | self.assertEqual(unit.homeOffset, 2, "homeOffset should be 2") 46 | self.assertEqual(unit.offset, 3, "offset should be 3") 47 | self.assertEqual(unit.absoluteMode, False, "absoluteMode should be False") 48 | self.assertEqual(unit.unitMultiplier, 4, "unitMultiplier should be 4") 49 | self.assertProperties(unit, AxisPositionTests.expectedProperties) 50 | 51 | def test_setAbsoluteMode_True(self): 52 | """Test the setAbsoluteMode method when passed True.""" 53 | unit = AxisPosition(1, 2, 3, False, 4) 54 | unit.setAbsoluteMode(True) 55 | self.assertTrue(unit.absoluteMode, "absoluteMode should be True") 56 | 57 | def test_setAbsoluteMode_False(self): 58 | """Test the setAbsoluteMode method when passed False.""" 59 | unit = AxisPosition(1, 2, 3, True, 4) 60 | unit.setAbsoluteMode(False) 61 | self.assertFalse(unit.absoluteMode, "absoluteMode should be False") 62 | 63 | def test_setLogicalOffsetPosition_Absolute(self): 64 | """Test the setLogicalOffsetPosition method when in absolute mode.""" 65 | unit = AxisPosition(100, 20, 50, True, 10) 66 | unit.setLogicalOffsetPosition(20) 67 | 68 | # 200 + 50 + 20 - 100 = 170, 50 + 170 = 220 69 | self.assertEqual(unit.offset, 220, "offset should be 220") 70 | self.assertEqual(unit.current, 100, "current should be 100") 71 | self.assertEqual(unit.homeOffset, 20, "homeOffset should be 20") 72 | 73 | def test_setLogicalOffsetPosition_Relative(self): 74 | """Test the setLogicalOffsetPosition method when in relative mode.""" 75 | unit = AxisPosition(100, 20, 50, False, 10) 76 | unit.setLogicalOffsetPosition(20) 77 | 78 | # 200 + 100 - 100 = 200, 50 + 200 = 250 79 | self.assertEqual(unit.offset, 250, "offset should be 250") 80 | self.assertEqual(unit.current, 100, "current should be 100") 81 | self.assertEqual(unit.homeOffset, 20, "homeOffset should be 20") 82 | 83 | def test_setHomeOffset(self): 84 | """Test the setHomeOffset method.""" 85 | unit = AxisPosition(0, 0, 0, True, 10) 86 | unit.setHomeOffset(20) 87 | 88 | self.assertEqual(unit.offset, 0, "offset should be 0") 89 | self.assertEqual(unit.current, -200, "current should be -200") 90 | self.assertEqual(unit.homeOffset, 200, "homeOffset should be 200") 91 | 92 | def test_setHome(self): 93 | """Test the setHome method.""" 94 | unit = AxisPosition(1, 2, 3, False, 4) 95 | unit.setHome() 96 | 97 | self.assertEqual(unit.current, 0, "current should be 0") 98 | self.assertEqual(unit.offset, 0, "offset should be 0") 99 | self.assertEqual(unit.homeOffset, 2, "homeOffset should be 2") 100 | 101 | def test_setUnitMultiplier(self): 102 | """Test the setUnitMultiplier method.""" 103 | unit = AxisPosition(1, 2, 3, False, 4) 104 | unit.setUnitMultiplier(20) 105 | self.assertEqual(unit.unitMultiplier, 20, "unitMultiplier should be 20") 106 | 107 | def test_setLogicalPosition_None(self): 108 | """Test the setLogicalPosition method when passed None.""" 109 | unit = AxisPosition(1, 2, 3, False, 4) 110 | rval = unit.setLogicalPosition(None) 111 | 112 | self.assertEqual(rval, 1, "The result should be 1") 113 | self.assertEqual(unit.current, 1, "current should be 1") 114 | 115 | def test_setLogicalPosition_Absolute(self): 116 | """Test the setLogicalPosition method in absolute mode.""" 117 | unit = AxisPosition(1, 2, 3, True, 4) 118 | unit.setLogicalPosition(20) 119 | self.assertEqual(unit.current, 85, "current should be 85") 120 | 121 | def test_setLogicalPosition_Relative(self): 122 | """Test the setLogicalPosition method in relative mode.""" 123 | unit = AxisPosition(1, 2, 3, False, 4) 124 | unit.setLogicalPosition(20) 125 | self.assertEqual(unit.current, 81, "current should be 81") 126 | 127 | def test_logicalToNative_None_Absolute(self): 128 | """Test passing None to logicalToNative method in absolute mode.""" 129 | unit = AxisPosition(1, 2, 3, True, 4) 130 | result = unit.logicalToNative(None) 131 | self.assertEqual(result, 1, "The result should be 1") 132 | 133 | def test_logicalToNative_None_Relative(self): 134 | """Test passing None to logicalToNative method in relative mode.""" 135 | unit = AxisPosition(1, 2, 3, False, 4) 136 | result = unit.logicalToNative(None) 137 | self.assertEqual(result, 1, "The result should be 1") 138 | 139 | def test_logicalToNative_None_AbsoluteParam(self): 140 | """Test passing None to logicalToNative method and overriding relative mode.""" 141 | unit = AxisPosition(1, 2, 3, False, 4) 142 | result = unit.logicalToNative(None, True) 143 | self.assertEqual(result, 1, "The result should be 1") 144 | 145 | def test_logicalToNative_None_RelativeParam(self): 146 | """Test passing None to logicalToNative method and overriding absolute mode.""" 147 | unit = AxisPosition(1, 2, 3, True, 4) 148 | result = unit.logicalToNative(None, False) 149 | self.assertEqual(result, 1, "The result should be 1") 150 | 151 | def test_logicalToNative_AbsoluteParam(self): 152 | """Test the logicalToNative method overriding relative mode.""" 153 | unit = AxisPosition(1, 2, 3, False, 4) 154 | result = unit.logicalToNative(10, True) 155 | self.assertEqual(result, 45, "The result should be 45") 156 | 157 | def test_logicalToNative_RelativeParam(self): 158 | """Test the logicalToNative method overriding absolute mode.""" 159 | unit = AxisPosition(1, 2, 3, True, 4) 160 | result = unit.logicalToNative(10, False) 161 | self.assertEqual(result, 41, "The result should be 41") 162 | 163 | def test_logicalToNative_Absolute(self): 164 | """Test the logicalToNative method when in absolute mode.""" 165 | unit = AxisPosition(1, 2, 3, True, 4) 166 | result = unit.logicalToNative(10) 167 | self.assertEqual(result, 45, "The result should be 45") 168 | 169 | def test_logicalToNative_Relative(self): 170 | """Test the logicalToNative method when in relative mode.""" 171 | unit = AxisPosition(1, 2, 3, False, 4) 172 | result = unit.logicalToNative(10) 173 | self.assertEqual(result, 41, "The result should be 41") 174 | 175 | def test_nativeToLogical_None(self): 176 | """Test passing None to the nativeToLogical method.""" 177 | unit = AxisPosition(1, 2, 3, True, 4) 178 | 179 | # 1 - (2 + 3) = -4 ; -4 / 4 = -1 180 | 181 | result = unit.nativeToLogical(None) 182 | self.assertEqual(result, -1, "The result should be -1") 183 | 184 | unit.setAbsoluteMode(False) 185 | result = unit.nativeToLogical(None) 186 | self.assertEqual(result, -1, "The result should be -1") 187 | 188 | result = unit.nativeToLogical(None, True) 189 | self.assertEqual(result, -1, "The result should be -1") 190 | 191 | result = unit.nativeToLogical(None, False) 192 | self.assertEqual(result, -1, "The result should be -1") 193 | 194 | def test_nativeToLogical_AbsoluteParam(self): 195 | """Test the nativeToLogical method overriding relative mode.""" 196 | unit = AxisPosition(1, 2, 3, False, 4) 197 | result = unit.nativeToLogical(45, True) 198 | self.assertEqual(result, 10, "The result should be 10") 199 | 200 | def test_nativeToLogical_RelativeParam(self): 201 | """Test the nativeToLogical method overriding absolute mode.""" 202 | unit = AxisPosition(1, 2, 3, True, 4) 203 | result = unit.nativeToLogical(41, False) 204 | self.assertEqual(result, 10, "The result should be 10") 205 | 206 | def test_nativeToLogical_Absolute(self): 207 | """Test the nativeToLogical method when in absolute mode.""" 208 | unit = AxisPosition(1, 2, 3, True, 4) 209 | result = unit.nativeToLogical(45) 210 | self.assertEqual(result, 10, "The result should be 10") 211 | 212 | def test_nativeToLogical_Relative(self): 213 | """Test the nativeToLogical method when in relative mode.""" 214 | unit = AxisPosition(1, 2, 3, False, 4) 215 | result = unit.nativeToLogical(41) 216 | self.assertEqual(result, 10, "The result should be 10") 217 | -------------------------------------------------------------------------------- /test/test_CircularRegion.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | """Unit tests for the CircularRegion class.""" 4 | 5 | from __future__ import absolute_import 6 | 7 | from octoprint_excluderegion.CircularRegion import CircularRegion 8 | from octoprint_excluderegion.RectangularRegion import RectangularRegion 9 | from .utils import TestCase 10 | 11 | 12 | class CircularRegionTests(TestCase): 13 | """Unit tests for the CircularRegion class.""" 14 | 15 | expectedProperties = ["cx", "cy", "r", "id"] 16 | 17 | def test_default_constructor(self): 18 | """Test the constructor when passed no arguments.""" 19 | unit = CircularRegion() 20 | 21 | self.assertIsInstance(unit, CircularRegion) 22 | self.assertEqual(unit.cx, 0, "cx should be 0") 23 | self.assertEqual(unit.cy, 0, "cy should be 0") 24 | self.assertEqual(unit.r, 0, "r should be 0") 25 | self.assertRegex(unit.id, "^[-0-9a-fA-F]{36}$", "id should be a UUID string") 26 | self.assertProperties(unit, CircularRegionTests.expectedProperties) 27 | 28 | def test_constructor_kwargs(self): 29 | """Test the constructor when passed keyword arguments.""" 30 | unit = CircularRegion(cx=1, cy=2, r=3, id="myTestId") 31 | 32 | self.assertEqual(unit.cx, 1, "cx should be 1") 33 | self.assertEqual(unit.cy, 2, "cy should be 2") 34 | self.assertEqual(unit.r, 3, "r should be 3") 35 | self.assertEqual(unit.id, "myTestId", "id should be 'myTestId'") 36 | self.assertProperties(unit, CircularRegionTests.expectedProperties) 37 | 38 | def test_copy_constructor(self): 39 | """Test the constructor when passed a CircularRegion instance.""" 40 | toCopy = CircularRegion(cx=1, cy=2, r=3, id="myTestId") 41 | 42 | unit = CircularRegion(toCopy) 43 | 44 | self.assertEqual(unit.cx, 1, "cx should be 1") 45 | self.assertEqual(unit.cy, 2, "cy should be 2") 46 | self.assertEqual(unit.r, 3, "r should be 3") 47 | self.assertEqual(unit.id, "myTestId", "id should be 'myTestId'") 48 | self.assertProperties(unit, CircularRegionTests.expectedProperties) 49 | 50 | def test_constructor_exception(self): 51 | """Test the constructor when passed a single non-CircularRegion parameter.""" 52 | with self.assertRaises(AssertionError): 53 | CircularRegion("NotACircularRegionInstance") 54 | 55 | def test_containsPoint(self): 56 | """Test the containsPoint method.""" 57 | unit = CircularRegion(cx=10, cy=10, r=3) 58 | 59 | self.assertTrue(unit.containsPoint(10, 10), "it should contain [10, 10]") 60 | self.assertTrue(unit.containsPoint(7, 10), "it should contain [7, 10]") 61 | self.assertTrue(unit.containsPoint(13, 10), "it should contain [13, 10]") 62 | self.assertTrue(unit.containsPoint(10, 7), "it should contain [10, 7]") 63 | self.assertTrue(unit.containsPoint(10, 13), "it should contain [10, 13]") 64 | self.assertTrue(unit.containsPoint(12, 12), "it should contain [12, 12]") 65 | 66 | self.assertFalse(unit.containsPoint(0, 0), "it should not contain [0, 0]") 67 | self.assertFalse(unit.containsPoint(6.9, 10), "it should not contain [6.9, 10]") 68 | 69 | def test_containsRegion_Rectangular(self): 70 | """Test the containsRegion method when passed a RectangularRegion.""" 71 | unit = CircularRegion(cx=10, cy=10, r=3) 72 | 73 | self.assertTrue( 74 | unit.containsRegion(RectangularRegion(x1=9, y1=9, x2=11, y2=11)), 75 | "it should contain Rect(9,9-11,11)" 76 | ) 77 | 78 | self.assertFalse( 79 | unit.containsRegion(RectangularRegion(x1=7.5, y1=7.5, x2=10, y2=10)), 80 | "it should not contain Rect(7.5,7.5-10,10)" 81 | ) 82 | self.assertFalse( 83 | unit.containsRegion(RectangularRegion(x1=7.5, y1=12.5, x2=10, y2=10)), 84 | "it should not contain Rect(7.5,12.5-10,10)" 85 | ) 86 | self.assertFalse( 87 | unit.containsRegion(RectangularRegion(x1=12.5, y1=7.5, x2=10, y2=10)), 88 | "it should not contain Rect(12.5,7.5-10,10)" 89 | ) 90 | self.assertFalse( 91 | unit.containsRegion(RectangularRegion(x1=7.5, y1=12.5, x2=10, y2=10)), 92 | "it should not contain Rect(7.5,12.5-10,10)" 93 | ) 94 | 95 | self.assertFalse( 96 | unit.containsRegion(RectangularRegion(x1=0, y1=0, x2=1, y2=1)), 97 | "it should not contain a RectangularRegion completely outside" 98 | ) 99 | self.assertFalse( 100 | unit.containsRegion(RectangularRegion(x1=0, y1=0, x2=20, y2=20)), 101 | "it should not contain a RectangularRegion containing this region" 102 | ) 103 | 104 | def test_containsRegion_Circular(self): 105 | """Test the containsRegion method when passed a CircularRegion.""" 106 | unit = CircularRegion(cx=10, cy=10, r=3) 107 | 108 | self.assertTrue(unit.containsRegion(unit), "it should contain itself") 109 | self.assertTrue( 110 | unit.containsRegion(CircularRegion(cx=10, cy=10, r=3)), 111 | "it should contain a CircularRegion representing the same geometric region" 112 | ) 113 | self.assertTrue( 114 | unit.containsRegion(CircularRegion(cx=8, cy=10, r=0.5)), 115 | "it should contain a CircularRegion inside" 116 | ) 117 | self.assertTrue( 118 | unit.containsRegion(CircularRegion(cx=8, cy=10, r=1)), 119 | "it should contain a CircularRegion inside, but tangent to the circle" 120 | ) 121 | 122 | self.assertFalse( 123 | unit.containsRegion(CircularRegion(cx=8, cy=10, r=1.1)), 124 | "it should not contain a CircularRegion that extends outside" 125 | ) 126 | self.assertFalse( 127 | unit.containsRegion(CircularRegion(cx=1, cy=1, r=1)), 128 | "it should not contain a CircularRegion completely outside" 129 | ) 130 | self.assertFalse( 131 | unit.containsRegion(CircularRegion(cx=10, cy=10, r=5)), 132 | "it should not contain a CircularRegion containing this region" 133 | ) 134 | 135 | def test_containsRegion_NotRegion(self): 136 | """Test the containsRegion method when passed an unsupported type.""" 137 | unit = CircularRegion(cx=10, cy=10, r=3) 138 | 139 | with self.assertRaises(ValueError): 140 | unit.containsRegion("NotARegionInstance") 141 | -------------------------------------------------------------------------------- /test/test_CommonMixin.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the CommonMixin class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | import json 7 | import re 8 | from datetime import date, datetime 9 | from octoprint_excluderegion.CommonMixin import CommonMixin, JsonEncoder 10 | from .utils import TestCase 11 | 12 | 13 | class MyUnserializableClass(object): # pylint: disable=too-few-public-methods 14 | """Class that doesn't extend CommonMixin or define a toDict method.""" 15 | 16 | def __init__(self): 17 | """No operation.""" 18 | pass 19 | 20 | 21 | class MyClass(CommonMixin): # pylint: disable=too-few-public-methods 22 | """Class that extends CommonMixin.""" 23 | 24 | def __init__(self, a, b): 25 | """Initialize the properties.""" 26 | # pylint: disable=invalid-name 27 | self.a = a 28 | self.b = b 29 | 30 | 31 | class CommonMixinTests(TestCase): # pylint: disable=too-many-instance-attributes 32 | """Unit tests for the CommonMixin class.""" 33 | 34 | def __init__(self, *args, **kwargs): 35 | """Perform property initialization.""" 36 | super(CommonMixinTests, self).__init__(*args, **kwargs) 37 | 38 | self.testDate1 = date(2010, 1, 31) 39 | self.testDate1String = "2010-01-31" 40 | 41 | self.testDate2 = date(1999, 12, 31) 42 | self.testDate2String = "1999-12-31" 43 | 44 | self.testDateTime = datetime(2000, 1, 15, 1, 2, 3) 45 | self.testDateTimeString = "2000-01-15T01:02:03" 46 | 47 | self.testDict = { 48 | "date": self.testDate2, 49 | "string": self.testDate2String 50 | } 51 | self.testDictExpected = { 52 | "date": self.testDate2String, 53 | "string": self.testDate2String 54 | } 55 | 56 | self.testRegexPatternString = "abc" 57 | self.testRegex = re.compile(self.testRegexPatternString) 58 | 59 | def test_toDict(self): 60 | """Test the toDict method.""" 61 | unit = MyClass(self.testDate1, self.testDict).toDict() 62 | 63 | self.assertIsDictionary(unit, "toDict should return a dict") 64 | self.assertProperties(unit, ["a", "b", "type"]) 65 | self.assertEqual(unit["type"], "MyClass", "type should be 'MyClass'") 66 | self.assertIsInstance(unit["a"], date, "'a' should be a date") 67 | self.assertEqual(unit["a"], self.testDate1, "'a' should be the expected date") 68 | self.assertIsDictionary(unit["b"], "'b' should be a dictionary") 69 | self.assertEqual( 70 | unit["b"], self.testDict, "'b' should have the expected properties and values" 71 | ) 72 | 73 | def test_repr(self): 74 | """Test the repr method.""" 75 | unit = MyClass(self.testDate1, self.testDict) 76 | reprStr = unit.__repr__() 77 | reprDict = json.loads(reprStr) 78 | self.assertEqual( 79 | reprDict, 80 | {"a": self.testDate1String, "b": self.testDictExpected, "type": "MyClass"} 81 | ) 82 | 83 | def test_toJson_1(self): 84 | """Test the toJson method with a nested date and dictionary.""" 85 | unit = MyClass(self.testDate1, self.testDict) 86 | jsonStr = unit.toJson() 87 | reprDict = json.loads(jsonStr) 88 | self.assertEqual( 89 | reprDict, 90 | {"a": self.testDate1String, "b": self.testDictExpected, "type": "MyClass"} 91 | ) 92 | 93 | def test_toJson_2(self): 94 | """Test the toJson method with simple type and a nested date time.""" 95 | unit = MyClass(1, self.testDateTime) 96 | jsonStr = unit.toJson() 97 | reprDict = json.loads(jsonStr) 98 | self.assertEqual( 99 | reprDict, 100 | {"a": 1, "b": self.testDateTimeString, "type": "MyClass"} 101 | ) 102 | 103 | def _assert_eq_and_ne(self, aVal, otherVal, expectedEquality, msg): 104 | """Apply assertions for testing the __eq__ and __ne__ methods.""" 105 | self.assertEqual( 106 | aVal == otherVal, expectedEquality, 107 | "it should " + ("" if expectedEquality else "not ") + "== " + msg 108 | ) 109 | self.assertEqual( 110 | aVal != otherVal, not expectedEquality, 111 | "it should " + ("not " if expectedEquality else "") + "!= " + msg 112 | ) 113 | 114 | def test_eq_and_ne(self): 115 | """Test the __eq__ and __ne__ methods.""" 116 | unit = MyClass(1, 2) 117 | 118 | self._assert_eq_and_ne(unit, unit, True, "itself") 119 | self._assert_eq_and_ne( 120 | unit, MyClass(1, 2), True, 121 | "another instance with the same property values" 122 | ) 123 | 124 | self._assert_eq_and_ne(unit, None, False, "None") 125 | self._assert_eq_and_ne( 126 | unit, MyClass(0, 2), False, 127 | "another instance with a different 'a' value" 128 | ) 129 | self._assert_eq_and_ne( 130 | unit, MyClass(1, 0), False, 131 | "another instance with a different 'b' value" 132 | ) 133 | 134 | def test_JsonEncoder_defaultTypeError(self): 135 | """Test that the JsonEncoder calls the super class to raise a TypeError.""" 136 | unit = JsonEncoder() 137 | with self.assertRaises(TypeError): 138 | unit.default(MyUnserializableClass()) 139 | 140 | def test_JsonEncoder_toDict(self): 141 | """Test that the JsonEncoder invokes the toDict method if it's defined.""" 142 | unit = JsonEncoder() 143 | self.assertEqual(unit.default(MyClass(1, 2)), {"a": 1, "b": 2, "type": "MyClass"}) 144 | 145 | def test_JsonEncoder_date(self): 146 | """Test that the JsonEncoder encodes dates as ISO date strings.""" 147 | unit = JsonEncoder() 148 | self.assertEqual(unit.default(self.testDate1), self.testDate1String) 149 | 150 | def test_JsonEncoder_datetime(self): 151 | """Test that the JsonEncoder encodes date times as ISO date time strings.""" 152 | unit = JsonEncoder() 153 | self.assertEqual(unit.default(self.testDateTime), self.testDateTimeString) 154 | 155 | def test_JsonEncoder_regex(self): 156 | """Test that the JsonEncoder encodes regular expression objects as their pattern strings.""" 157 | unit = JsonEncoder() 158 | self.assertEqual(unit.default(self.testRegex), self.testRegexPatternString) 159 | -------------------------------------------------------------------------------- /test/test_ExcludeRegionPlugin_hooks.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the hook methods in the ExcludeRegionPlugin class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | import mock 7 | 8 | from .utils import TestCase 9 | from .test_ExcludeRegionPlugin import create_plugin_instance, simulate_isActivePrintJob 10 | 11 | 12 | class ExcludeRegionPluginHookTests(TestCase): # xxpylint: disable=too-many-public-methods 13 | """Unit tests for the hook methods in the ExcludeRegionPlugin class.""" 14 | 15 | def test_getUpdateInformation(self): 16 | """Test the getUpdateInformation method.""" 17 | unit = create_plugin_instance() 18 | 19 | result = unit.getUpdateInformation() 20 | 21 | # Should have an entry for the plugin's id 22 | self.assertProperties(result, ["excluderegion"]) 23 | 24 | # Entry should have the expected properties 25 | self.assertProperties( 26 | result["excluderegion"], 27 | ["displayName", "displayVersion", "type", "user", "repo", "current", "pip"] 28 | ) 29 | 30 | def test_handleGcodeQueuing_notPrinting(self): 31 | """Test handleGcodeQueuing doesn't filter if not printing.""" 32 | unit = create_plugin_instance() 33 | with mock.patch.object(unit.gcodeHandlers, 'handleGcode') as mockHandleGcode: 34 | mockCommInstance = mock.Mock(name="commInstance") 35 | 36 | # This is to cover an otherwise missed branch 37 | unit._logger.isEnabledFor.return_value = False # pylint: disable=protected-access 38 | 39 | result = unit.handleGcodeQueuing( 40 | mockCommInstance, 41 | "queuing", 42 | "G0 X1 Y2", 43 | "cmdType", 44 | "G0", 45 | "subcode", 46 | "tags" 47 | ) 48 | 49 | mockHandleGcode.assert_not_called() 50 | self.assertIsNone(result, "The result should be None") 51 | 52 | def test_handleGcodeQueuing_falsyGcode(self): 53 | """Test handleGcodeQueuing doesn't filter if the gcode parameter is falsy.""" 54 | unit = create_plugin_instance() 55 | with mock.patch.object(unit.gcodeHandlers, 'handleGcode') as mockHandleGcode: 56 | simulate_isActivePrintJob(unit, True) 57 | mockCommInstance = mock.Mock(name="commInstance") 58 | 59 | result = unit.handleGcodeQueuing( 60 | mockCommInstance, 61 | "queuing", 62 | "not a gcode command", 63 | "cmdType", 64 | None, 65 | "subcode", 66 | "tags" 67 | ) 68 | 69 | mockHandleGcode.assert_not_called() 70 | self.assertIsNone(result, "The result should be None") 71 | 72 | def test_handleGcodeQueuing_printing(self): 73 | """Test handleGcodeQueuing does filter when printing.""" 74 | unit = create_plugin_instance() 75 | with mock.patch.object(unit.gcodeHandlers, 'handleGcode') as mockHandleGcode: 76 | simulate_isActivePrintJob(unit, True) 77 | mockHandleGcode.return_value = "ExpectedResult" 78 | mockCommInstance = mock.Mock(name="commInstance") 79 | 80 | result = unit.handleGcodeQueuing( 81 | mockCommInstance, 82 | "queuing", 83 | "G0 X1 Y2", 84 | "cmdType", 85 | "G0", 86 | "subcode", 87 | "tags" 88 | ) 89 | 90 | mockHandleGcode.assert_called_with("G0 X1 Y2", "G0", "subcode") 91 | self.assertEqual(result, "ExpectedResult", "The expected result should be returned") 92 | 93 | def test_handleAtCommandQueuing_notPrinting(self): 94 | """Test handleAtCommandQueuing won't execute the command if not printing.""" 95 | unit = create_plugin_instance() 96 | with mock.patch.object(unit.gcodeHandlers, 'handleAtCommand') as mockHandleAtCommand: 97 | simulate_isActivePrintJob(unit, False) 98 | 99 | mockCommInstance = mock.Mock(name="commInstance") 100 | 101 | result = unit.handleAtCommandQueuing( 102 | mockCommInstance, 103 | "queuing", 104 | "command", 105 | "parameters", 106 | "tags" 107 | ) 108 | 109 | mockHandleAtCommand.assert_not_called() 110 | self.assertIsNone(result, "The result should be None") 111 | 112 | def test_handleAtCommandQueuing_printing(self): 113 | """Test handleAtCommandQueuing will execute the command if printing.""" 114 | unit = create_plugin_instance() 115 | with mock.patch.object(unit.gcodeHandlers, 'handleAtCommand') as mockHandleAtCommand: 116 | simulate_isActivePrintJob(unit, True) 117 | 118 | mockCommInstance = mock.Mock(name="commInstance") 119 | 120 | result = unit.handleAtCommandQueuing( 121 | mockCommInstance, 122 | "queuing", 123 | "command", 124 | "parameters", 125 | "tags" 126 | ) 127 | 128 | mockHandleAtCommand.assert_called_with( 129 | mockCommInstance, 130 | "command", 131 | "parameters" 132 | ) 133 | self.assertIsNone(result, "The result should be None") 134 | 135 | def test_handleScriptHook_afterPrintDone_notPrinting_excluding(self): 136 | """Test handleScriptHook/afterPrintDone when not printing to ensure it does nothing.""" 137 | unit = create_plugin_instance() 138 | with mock.patch.object(unit.state, 'exitExcludedRegion') as mockExitExcludedRegion: 139 | simulate_isActivePrintJob(unit, False) 140 | unit.state.excluding = True 141 | 142 | mockCommInstance = mock.Mock(name="commInstance") 143 | 144 | result = unit.handleScriptHook( 145 | mockCommInstance, 146 | "gcode", 147 | "afterPrintDone" 148 | ) 149 | 150 | mockExitExcludedRegion.assert_not_called() 151 | self.assertIsNone(result, "The result should be None") 152 | 153 | def test_handleScriptHook_afterPrintDone_printing_notExcluding(self): 154 | """Test handleScriptHook/afterPrintDone when printing but not excluding.""" 155 | unit = create_plugin_instance() 156 | with mock.patch.object(unit.state, 'exitExcludedRegion') as mockExitExcludedRegion: 157 | simulate_isActivePrintJob(unit, True) 158 | unit.state.excluding = False 159 | 160 | mockCommInstance = mock.Mock(name="commInstance") 161 | 162 | result = unit.handleScriptHook( 163 | mockCommInstance, 164 | "gcode", 165 | "afterPrintDone" 166 | ) 167 | 168 | mockExitExcludedRegion.assert_not_called() 169 | self.assertIsNone(result, "The result should be None") 170 | 171 | def test_handleScriptHook_afterPrintDone_printing_excluding(self): 172 | """Test handleScriptHook/afterPrintDone when printing and excluding.""" 173 | unit = create_plugin_instance() 174 | with mock.patch.object(unit.state, 'exitExcludedRegion') as mockExitExcludedRegion: 175 | simulate_isActivePrintJob(unit, True) 176 | unit.state.excluding = True 177 | mockExitExcludedRegion.return_value = ["expectedResult"] 178 | 179 | mockCommInstance = mock.Mock(name="commInstance") 180 | 181 | result = unit.handleScriptHook( 182 | mockCommInstance, 183 | "gcode", 184 | "afterPrintDone" 185 | ) 186 | 187 | mockExitExcludedRegion.assert_called() 188 | self.assertEqual( 189 | result, 190 | (["expectedResult"], None), 191 | "The result should be a tuple containing the list returned by exitExcludedRegion" 192 | ) 193 | 194 | def test_handleScriptHook_scriptTypeNoMatch(self): 195 | """Test handleScriptHook when the scriptType isn't 'gcode'.""" 196 | unit = create_plugin_instance() 197 | with mock.patch.object(unit.state, 'exitExcludedRegion') as mockExitExcludedRegion: 198 | simulate_isActivePrintJob(unit, True) 199 | unit.state.excluding = True 200 | mockExitExcludedRegion.return_value = ["expectedResult"] 201 | 202 | mockCommInstance = mock.Mock(name="commInstance") 203 | 204 | result = unit.handleScriptHook( 205 | mockCommInstance, 206 | "notAMatch", 207 | "afterPrintDone" 208 | ) 209 | 210 | mockExitExcludedRegion.assert_not_called() 211 | self.assertIsNone(result, "The result should be None") 212 | 213 | def test_handleScriptHook_scriptNameNoMatch(self): 214 | """Test handleScriptHook when the scriptName isn't 'afterPrintDone'.""" 215 | unit = create_plugin_instance() 216 | with mock.patch.object(unit.state, 'exitExcludedRegion') as mockExitExcludedRegion: 217 | simulate_isActivePrintJob(unit, True) 218 | unit.state.excluding = True 219 | mockExitExcludedRegion.return_value = ["expectedResult"] 220 | 221 | mockCommInstance = mock.Mock(name="commInstance") 222 | 223 | result = unit.handleScriptHook( 224 | mockCommInstance, 225 | "gcode", 226 | "notAMatch" 227 | ) 228 | 229 | mockExitExcludedRegion.assert_not_called() 230 | self.assertIsNone(result, "The result should be None") 231 | -------------------------------------------------------------------------------- /test/test_ExcludeRegionState_processExtendedGcode.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the processExtendedGcode methods of the ExcludeRegionState class.""" 3 | 4 | from __future__ import absolute_import 5 | from collections import OrderedDict 6 | 7 | import mock 8 | from callee.operators import In as AnyIn 9 | 10 | from octoprint_excluderegion.ExcludeRegionState import ExcludeRegionState 11 | from octoprint_excluderegion.ExcludedGcode \ 12 | import EXCLUDE_ALL, EXCLUDE_EXCEPT_FIRST, EXCLUDE_EXCEPT_LAST, EXCLUDE_MERGE 13 | 14 | from .utils import TestCase 15 | 16 | 17 | class ExcludeRegionStateProcessExtendedGcodeTests( 18 | TestCase 19 | ): # xxpylint: xdisable=too-many-public-methods 20 | """Unit tests for the processExtendedGcode methods of the ExcludeRegionState class.""" 21 | 22 | def test_processExtendedGcodeEntry_EXCLUDE_ALL(self): 23 | """Test processExtendedGcodeEntry when the mode is EXCLUDE_ALL.""" 24 | mockLogger = mock.Mock() 25 | unit = ExcludeRegionState(mockLogger) 26 | unit.pendingCommands = OrderedDict() 27 | 28 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 29 | EXCLUDE_ALL, 30 | "G1 X1 Y2", "G1" 31 | ) 32 | 33 | self.assertEqual( 34 | unit.pendingCommands, OrderedDict(), 35 | "pendingCommands should not be updated." 36 | ) 37 | self.assertEqual( 38 | result, (None,), 39 | "The result should indicate to drop/ignore the command" 40 | ) 41 | 42 | def test_processExtendedGcodeEntry_EXCLUDE_MERGE_noPendingArgs_noCmdArgs(self): 43 | """Test processExtendedGcodeEntry / EXCLUDE_MERGE if no pending args and no command args.""" 44 | mockLogger = mock.Mock() 45 | unit = ExcludeRegionState(mockLogger) 46 | unit.pendingCommands = OrderedDict() 47 | 48 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 49 | EXCLUDE_MERGE, 50 | "G1", "G1" 51 | ) 52 | 53 | self.assertEqual( 54 | unit.pendingCommands, OrderedDict([("G1", {})]), 55 | "pendingCommands should be updated." 56 | ) 57 | self.assertEqual( 58 | result, (None,), 59 | "The result should indicate to drop/ignore the command" 60 | ) 61 | 62 | def test_processExtendedGcodeEntry_EXCLUDE_MERGE_noPendingArgs_cmdHasArgs(self): 63 | """Test processExtendedGcodeEntry / EXCLUDE_MERGE if no pending args, and cmd with args.""" 64 | mockLogger = mock.Mock() 65 | unit = ExcludeRegionState(mockLogger) 66 | unit.pendingCommands = OrderedDict() 67 | 68 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 69 | EXCLUDE_MERGE, 70 | "G1 X1 Y2", "G1" 71 | ) 72 | 73 | self.assertEqual( 74 | unit.pendingCommands, OrderedDict([("G1", {"X": 1, "Y": 2})]), 75 | "pendingCommands should be updated with the command arguments." 76 | ) 77 | self.assertEqual( 78 | result, (None,), 79 | "The result should indicate to drop/ignore the command" 80 | ) 81 | 82 | def test_processExtendedGcodeEntry_EXCLUDE_MERGE_hasPendingArgs_noCmdArgs(self): 83 | """Test processExtendedGcodeEntry / EXCLUDE_MERGE if pending args and no command args.""" 84 | mockLogger = mock.Mock() 85 | unit = ExcludeRegionState(mockLogger) 86 | unit.pendingCommands = OrderedDict([("G1", {"X": 10}), ("M117", "M117 Test")]) 87 | 88 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 89 | EXCLUDE_MERGE, 90 | "G1", "G1" 91 | ) 92 | 93 | # Order of elements should be updated 94 | self.assertEqual( 95 | unit.pendingCommands, 96 | OrderedDict([ 97 | ("M117", "M117 Test"), 98 | ("G1", {"X": 10}) 99 | ]), 100 | "pendingCommands should be updated with new argument values." 101 | ) 102 | self.assertEqual( 103 | result, (None,), 104 | "The result should indicate to drop/ignore the command" 105 | ) 106 | 107 | def test_processExtendedGcodeEntry_EXCLUDE_MERGE_hasPendingArgs_cmdHasArgs(self): 108 | """Test processExtendedGcodeEntry / EXCLUDE_MERGE if pending args and command with args.""" 109 | mockLogger = mock.Mock() 110 | unit = ExcludeRegionState(mockLogger) 111 | unit.pendingCommands = OrderedDict([ 112 | ("G1", {"X": 10, "Z": 20}), 113 | ("M117", "M117 Test") 114 | ]) 115 | 116 | # Use upper and lower case args to test case-sensitivity 117 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 118 | EXCLUDE_MERGE, 119 | "G1 x1 Y2", "G1" 120 | ) 121 | 122 | # Order of elements and parameter values should be updated 123 | self.assertEqual( 124 | unit.pendingCommands, 125 | OrderedDict([ 126 | ("M117", "M117 Test"), 127 | ("G1", {"X": 1, "Y": 2, "Z": 20}) 128 | ]), 129 | "pendingCommands should be updated with new argument values." 130 | ) 131 | self.assertEqual( 132 | result, (None,), 133 | "The result should indicate to drop/ignore the command" 134 | ) 135 | 136 | def test_processExtendedGcodeEntry_EXCLUDE_EXCEPT_FIRST_noYetSeen(self): 137 | """Test processExtendedGcodeEntry/EXCLUDE_EXCEPT_FIRST for a Gcode not yet seen.""" 138 | mockLogger = mock.Mock() 139 | unit = ExcludeRegionState(mockLogger) 140 | unit.pendingCommands = OrderedDict([("M117", "M117 Test")]) 141 | 142 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 143 | EXCLUDE_EXCEPT_FIRST, 144 | "G1 X1 Y2", "G1" 145 | ) 146 | 147 | # Should be appended to end 148 | self.assertEqual( 149 | unit.pendingCommands, 150 | OrderedDict([ 151 | ("M117", "M117 Test"), 152 | ("G1", "G1 X1 Y2") 153 | ]), 154 | "pendingCommands should be updated with new command string." 155 | ) 156 | self.assertEqual( 157 | result, (None,), 158 | "The result should indicate to drop/ignore the command" 159 | ) 160 | 161 | def test_processExtendedGcodeEntry_EXCLUDE_EXCEPT_FIRST_alreadySeen(self): 162 | """Test processExtendedGcodeEntry / EXCLUDE_EXCEPT_FIRST for a Gcode already seen.""" 163 | mockLogger = mock.Mock() 164 | unit = ExcludeRegionState(mockLogger) 165 | unit.pendingCommands = OrderedDict([ 166 | ("G1", "G1 E3 Z4"), 167 | ("M117", "M117 Test") 168 | ]) 169 | 170 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 171 | EXCLUDE_EXCEPT_FIRST, 172 | "G1 X1 Y2", "G1" 173 | ) 174 | 175 | # Previous command entry should not be affected 176 | self.assertEqual( 177 | unit.pendingCommands, 178 | OrderedDict([ 179 | ("G1", "G1 E3 Z4"), 180 | ("M117", "M117 Test") 181 | ]), 182 | "pendingCommands should not be updated." 183 | ) 184 | self.assertEqual( 185 | result, (None,), 186 | "The result should indicate to drop/ignore the command" 187 | ) 188 | 189 | def test_processExtendedGcodeEntry_EXCLUDE_EXCEPT_LAST_notYetSeen(self): 190 | """Test processExtendedGcodeEntry / EXCLUDE_EXCEPT_LAST for a Gcode not yet seen.""" 191 | mockLogger = mock.Mock() 192 | unit = ExcludeRegionState(mockLogger) 193 | unit.pendingCommands = OrderedDict([("M117", "M117 Test")]) 194 | 195 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 196 | EXCLUDE_EXCEPT_LAST, 197 | "G1 X1 Y2", "G1" 198 | ) 199 | 200 | # Command should be appended to end of pendingCommands 201 | self.assertEqual( 202 | unit.pendingCommands, 203 | OrderedDict([ 204 | ("M117", "M117 Test"), 205 | ("G1", "G1 X1 Y2") 206 | ]), 207 | "pendingCommands should be updated with new command string." 208 | ) 209 | self.assertEqual( 210 | result, (None,), 211 | "The result should indicate to drop/ignore the command" 212 | ) 213 | 214 | def test_processExtendedGcodeEntry_EXCLUDE_EXCEPT_LAST_alreadySeen(self): 215 | """Test processExtendedGcodeEntry / EXCLUDE_EXCEPT_LAST for a Gcode already seen.""" 216 | mockLogger = mock.Mock() 217 | unit = ExcludeRegionState(mockLogger) 218 | unit.pendingCommands = OrderedDict([ 219 | ("G1", "G1 E3 Z4"), 220 | ("M117", "M117 Test") 221 | ]) 222 | 223 | result = unit._processExtendedGcodeEntry( # pylint: disable=protected-access 224 | EXCLUDE_EXCEPT_LAST, 225 | "G1 X1 Y2", "G1" 226 | ) 227 | 228 | # Command should be updated and moved to end of pendingCommands 229 | self.assertEqual( 230 | unit.pendingCommands, 231 | OrderedDict([ 232 | ("M117", "M117 Test"), 233 | ("G1", "G1 X1 Y2") 234 | ]), 235 | "pendingCommands should be updated with new command string." 236 | ) 237 | self.assertEqual( 238 | result, (None,), 239 | "The result should indicate to drop/ignore the command" 240 | ) 241 | 242 | def test_processExtendedGcode_noGcode_excluding(self): 243 | """Test processExtendedGcode when excluding and no gcode provided.""" 244 | mockLogger = mock.Mock() 245 | unit = ExcludeRegionState(mockLogger) 246 | 247 | with mock.patch.object(unit, 'extendedExcludeGcodes') as mockExtendedExcludeGcodes: 248 | unit.excluding = True 249 | 250 | result = unit.processExtendedGcode("someCommand", None, None) 251 | 252 | mockExtendedExcludeGcodes.get.assert_not_called() 253 | self.assertIsNone(result, "The return value should be None") 254 | 255 | def test_processExtendedGcode_noGcode_notExcluding(self): 256 | """Test processExtendedGcode when not excluding and no gcode provided.""" 257 | mockLogger = mock.Mock() 258 | unit = ExcludeRegionState(mockLogger) 259 | 260 | with mock.patch.object(unit, 'extendedExcludeGcodes') as mockExtendedExcludeGcodes: 261 | unit.excluding = False 262 | 263 | result = unit.processExtendedGcode("someCommand", None, None) 264 | 265 | mockExtendedExcludeGcodes.get.assert_not_called() 266 | self.assertIsNone(result, "The return value should be None") 267 | 268 | def test_processExtendedGcode_excluding_noMatch(self): 269 | """Test processExtendedGcode when excluding and no entry matches.""" 270 | mockLogger = mock.Mock() 271 | unit = ExcludeRegionState(mockLogger) 272 | 273 | with mock.patch.multiple( 274 | unit, 275 | extendedExcludeGcodes=mock.DEFAULT, 276 | _processExtendedGcodeEntry=mock.DEFAULT 277 | ) as mocks: 278 | unit.excluding = True 279 | mocks["extendedExcludeGcodes"].get.return_value = None 280 | 281 | result = unit.processExtendedGcode("G1 X1 Y2", "G1", None) 282 | 283 | mocks["extendedExcludeGcodes"].get.assert_called_with("G1") 284 | mocks["_processExtendedGcodeEntry"].assert_not_called() 285 | self.assertIsNone(result, "The return value should be None") 286 | 287 | def test_processExtendedGcode_excluding_matchExists(self): 288 | """Test processExtendedGcode when excluding and a matching entry exists.""" 289 | mockLogger = mock.Mock() 290 | unit = ExcludeRegionState(mockLogger) 291 | 292 | with mock.patch.multiple( 293 | unit, 294 | extendedExcludeGcodes=mock.DEFAULT, 295 | _processExtendedGcodeEntry=mock.DEFAULT 296 | ) as mocks: 297 | unit.excluding = True 298 | mockEntry = mock.Mock(name="entry") 299 | mockEntry.mode = "expectedMode" 300 | mocks["extendedExcludeGcodes"].get.return_value = mockEntry 301 | mocks["_processExtendedGcodeEntry"].return_value = "expectedResult" 302 | 303 | result = unit.processExtendedGcode("G1 X1 Y2", "G1", None) 304 | 305 | mocks["extendedExcludeGcodes"].get.assert_called_with("G1") 306 | mocks["_processExtendedGcodeEntry"].assert_called_with("expectedMode", "G1 X1 Y2", "G1") 307 | self.assertEqual( 308 | result, "expectedResult", 309 | "The expected result of _processExtendedGcodeEntry should be returned" 310 | ) 311 | 312 | def test_processExtendedGcode_notExcluding_matchExists(self): 313 | """Test processExtendedGcode when not excluding and a matching entry exists.""" 314 | mockLogger = mock.Mock() 315 | mockLogger.isEnabledFor.return_value = False # For coverage of logging condition 316 | unit = ExcludeRegionState(mockLogger) 317 | 318 | with mock.patch.multiple( 319 | unit, 320 | extendedExcludeGcodes=mock.DEFAULT, 321 | _processExtendedGcodeEntry=mock.DEFAULT 322 | ) as mocks: 323 | unit.excluding = False 324 | mockEntry = mock.Mock(name="entry") 325 | mockEntry.mode = "expectedMode" 326 | mocks["extendedExcludeGcodes"].get.return_value = mockEntry 327 | 328 | result = unit.processExtendedGcode(AnyIn(["G1 X1 Y2", "G1 Y2 X1"]), "G1", None) 329 | 330 | mocks["extendedExcludeGcodes"].get.assert_not_called() 331 | mocks["_processExtendedGcodeEntry"].assert_not_called() 332 | self.assertIsNone(result, "The return value should be None") 333 | -------------------------------------------------------------------------------- /test/test_ExcludedGcode.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the ExcludedGcode class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | from octoprint_excluderegion.ExcludedGcode import ExcludedGcode, EXCLUDE_ALL 7 | from .utils import TestCase 8 | 9 | 10 | class ExcludedGcodeTests(TestCase): 11 | """Unit tests for the ExcludedGcode class.""" 12 | 13 | expectedProperties = ["gcode", "mode", "description"] 14 | 15 | def test_constructor(self): 16 | """Test the constructor when valid arguments are passed.""" 17 | unit = ExcludedGcode("G117", EXCLUDE_ALL, "My description") 18 | 19 | self.assertIsInstance(unit, ExcludedGcode) 20 | self.assertEqual(unit.gcode, "G117", "gcode should be 'G117'") 21 | self.assertEqual(unit.mode, EXCLUDE_ALL, "mode should be '" + EXCLUDE_ALL + "'") 22 | self.assertEqual( 23 | unit.description, "My description", "description should be 'My description'" 24 | ) 25 | self.assertProperties(unit, ExcludedGcodeTests.expectedProperties) 26 | 27 | def test_constructor_missingGcode(self): 28 | """Test the constructor when a non-truthy gcode value is provided.""" 29 | with self.assertRaises(AssertionError): 30 | ExcludedGcode(None, EXCLUDE_ALL, "My description") 31 | 32 | def test_constructor_invalidMode(self): 33 | """Test the constructor when an invalid mode value is provided.""" 34 | with self.assertRaises(AssertionError): 35 | ExcludedGcode("G117", "invalid", "My description") 36 | -------------------------------------------------------------------------------- /test/test_GcodeHandlers_handleAtCommand.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the handleAtCommand method of the GcodeHandlers class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | import mock 7 | 8 | from octoprint_excluderegion.GcodeHandlers import GcodeHandlers 9 | from octoprint_excluderegion.AtCommandAction import ENABLE_EXCLUSION, DISABLE_EXCLUSION 10 | 11 | from .utils import TestCase 12 | 13 | 14 | class GcodeHandlersHandleAtCommandTests(TestCase): 15 | """Unit tests for the handleAtCommand method of the GcodeHandlers class.""" 16 | 17 | def test_handleAtCommand_noHandler(self): 18 | """Test handleAtCommand when no matching command handler is defined.""" 19 | mockLogger = mock.Mock() 20 | mockState = mock.Mock() 21 | mockCommInstance = mock.Mock() 22 | mockCommInstance.isStreaming.return_value = False 23 | mockEntry = mock.Mock() 24 | 25 | mockState.atCommandActions = mock.Mock(wraps={"NoMatch": [mockEntry]}) 26 | 27 | unit = GcodeHandlers(mockState, mockLogger) 28 | 29 | result = unit.handleAtCommand(mockCommInstance, "NotDefined", "params") 30 | 31 | mockState.atCommandActions.get.assert_called_with("NotDefined") 32 | mockEntry.matches.assert_not_called() 33 | mockState.enableExclusion.assert_not_called() 34 | mockState.disableExclusion.assert_not_called() 35 | mockCommInstance.sendCommand.assert_not_called() 36 | 37 | self.assertFalse(result, "The result should be False") 38 | 39 | def test_handleAtCommand_oneHandler_noParamMatch(self): 40 | """Test handleAtCommand when one command handler is defined, but the params don't match.""" 41 | mockLogger = mock.Mock() 42 | mockState = mock.Mock() 43 | mockCommInstance = mock.Mock() 44 | mockCommInstance.isStreaming.return_value = False 45 | mockEntry = mock.Mock() 46 | mockEntry.matches.return_value = None 47 | 48 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry]}) 49 | 50 | unit = GcodeHandlers(mockState, mockLogger) 51 | 52 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 53 | 54 | mockState.atCommandActions.get.assert_called_with("DefinedCommand") 55 | mockEntry.matches.assert_called_with("DefinedCommand", "params") 56 | mockState.enableExclusion.assert_not_called() 57 | mockState.disableExclusion.assert_not_called() 58 | mockCommInstance.sendCommand.assert_not_called() 59 | 60 | self.assertFalse(result, "The result should be False") 61 | 62 | def test_handleAtCommand_multipleHandlers_noParamMatch(self): 63 | """Test handleAtCommand when multiple command handlers defined, but no param matches.""" 64 | mockLogger = mock.Mock() 65 | mockState = mock.Mock() 66 | mockCommInstance = mock.Mock() 67 | mockCommInstance.isStreaming.return_value = False 68 | 69 | mockEntry1 = mock.Mock() 70 | mockEntry1.matches.return_value = None 71 | 72 | mockEntry2 = mock.Mock() 73 | mockEntry2.matches.return_value = None 74 | 75 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry1, mockEntry2]}) 76 | 77 | unit = GcodeHandlers(mockState, mockLogger) 78 | 79 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 80 | 81 | mockState.atCommandActions.get.assert_called_with("DefinedCommand") 82 | mockEntry1.matches.assert_called_with("DefinedCommand", "params") 83 | mockEntry2.matches.assert_called_with("DefinedCommand", "params") 84 | mockState.enableExclusion.assert_not_called() 85 | mockState.disableExclusion.assert_not_called() 86 | mockCommInstance.sendCommand.assert_not_called() 87 | 88 | self.assertFalse(result, "The result should be False") 89 | 90 | def test_handleAtCommand_oneHandler_match_unsupported_action(self): 91 | """Test handleAtCommand with one matching handler that specifies an unsupported action.""" 92 | mockLogger = mock.Mock() 93 | mockState = mock.Mock() 94 | mockCommInstance = mock.Mock() 95 | mockCommInstance.isStreaming.return_value = False 96 | mockEntry = mock.Mock() 97 | mockEntry.action = "unsupported" 98 | mockEntry.matches.return_value = True 99 | 100 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry]}) 101 | 102 | unit = GcodeHandlers(mockState, mockLogger) 103 | 104 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 105 | 106 | mockState.atCommandActions.get.assert_called_with("DefinedCommand") 107 | mockEntry.matches.assert_called_with("DefinedCommand", "params") 108 | mockState.enableExclusion.assert_not_called() 109 | mockState.disableExclusion.assert_not_called() 110 | mockCommInstance.sendCommand.assert_not_called() 111 | 112 | mockLogger.warn.assert_called() 113 | 114 | self.assertTrue(result, "The result should be True") 115 | 116 | def test_handleAtCommand_oneHandler_match_ENABLE_EXCLUSION(self): 117 | """Test handleAtCommand with one matching handler that enables exclusion.""" 118 | mockLogger = mock.Mock() 119 | mockState = mock.Mock() 120 | mockCommInstance = mock.Mock() 121 | mockCommInstance.isStreaming.return_value = False 122 | mockEntry = mock.Mock() 123 | mockEntry.action = ENABLE_EXCLUSION 124 | mockEntry.matches.return_value = True 125 | 126 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry]}) 127 | 128 | unit = GcodeHandlers(mockState, mockLogger) 129 | 130 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 131 | 132 | mockState.atCommandActions.get.assert_called_with("DefinedCommand") 133 | mockEntry.matches.assert_called_with("DefinedCommand", "params") 134 | mockState.enableExclusion.assert_called_once() 135 | mockState.disableExclusion.assert_not_called() 136 | mockCommInstance.sendCommand.assert_not_called() 137 | 138 | self.assertTrue(result, "The result should be True") 139 | 140 | def test_handleAtCommand_oneHandler_match_DISABLE_EXCLUSION(self): 141 | """Test handleAtCommand with one matching handler that disables exclusion.""" 142 | mockLogger = mock.Mock() 143 | 144 | mockState = mock.Mock() 145 | mockState.disableExclusion.return_value = ["Command1", "Command2"] 146 | 147 | mockCommInstance = mock.Mock() 148 | mockCommInstance.isStreaming.return_value = False 149 | 150 | mockEntry = mock.Mock() 151 | mockEntry.action = DISABLE_EXCLUSION 152 | mockEntry.matches.return_value = True 153 | 154 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry]}) 155 | 156 | unit = GcodeHandlers(mockState, mockLogger) 157 | 158 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 159 | 160 | mockState.atCommandActions.get.assert_called_with("DefinedCommand") 161 | mockEntry.matches.assert_called_with("DefinedCommand", "params") 162 | mockState.enableExclusion.assert_not_called() 163 | mockState.disableExclusion.assert_called_once() 164 | mockCommInstance.sendCommand.assert_has_calls( 165 | [mock.call("Command1"), mock.call("Command2")] 166 | ) 167 | 168 | self.assertTrue(result, "The result should be True") 169 | 170 | def test_handleAtCommand_multipleHandlers_match(self): 171 | """Test handleAtCommand when multiple command handlers are defined and match.""" 172 | mockLogger = mock.Mock() 173 | mockState = mock.Mock() 174 | mockCommInstance = mock.Mock() 175 | mockCommInstance.isStreaming.return_value = False 176 | 177 | mockEntry1 = mock.Mock() 178 | mockEntry1.action = ENABLE_EXCLUSION 179 | mockEntry1.matches.return_value = True 180 | 181 | mockEntry2 = mock.Mock() 182 | mockEntry2.action = ENABLE_EXCLUSION 183 | mockEntry2.matches.return_value = True 184 | 185 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry1, mockEntry2]}) 186 | 187 | unit = GcodeHandlers(mockState, mockLogger) 188 | 189 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 190 | 191 | mockState.atCommandActions.get.assert_called_with("DefinedCommand") 192 | mockEntry1.matches.assert_called_with("DefinedCommand", "params") 193 | mockEntry2.matches.assert_called_with("DefinedCommand", "params") 194 | self.assertEqual( 195 | mockState.enableExclusion.call_count, 2, 196 | "enableExclusion should be called twice" 197 | ) 198 | mockState.disableExclusion.assert_not_called() 199 | mockCommInstance.sendCommand.assert_not_called() 200 | 201 | self.assertTrue(result, "The result should be True") 202 | 203 | def test_handleAtCommand_isSdStreaming(self): 204 | """Test handleAtCommand when the commInstance indicates SD streaming.""" 205 | mockLogger = mock.Mock() 206 | mockState = mock.Mock() 207 | mockCommInstance = mock.Mock() 208 | mockCommInstance.isStreaming.return_value = True 209 | 210 | # Mock a matching entry, which should not be invoked 211 | mockEntry = mock.Mock() 212 | mockEntry.action = ENABLE_EXCLUSION 213 | mockEntry.matches.return_value = True 214 | mockState.atCommandActions = mock.Mock(wraps={"DefinedCommand": [mockEntry]}) 215 | 216 | unit = GcodeHandlers(mockState, mockLogger) 217 | 218 | result = unit.handleAtCommand(mockCommInstance, "DefinedCommand", "params") 219 | 220 | mockState.atCommandActions.get.assert_not_called() 221 | mockEntry.matches.assert_not_called() 222 | mockState.enableExclusion.assert_not_called() 223 | mockState.disableExclusion.assert_not_called() 224 | mockCommInstance.sendCommand.assert_not_called() 225 | 226 | self.assertFalse(result, "The result should be False") 227 | -------------------------------------------------------------------------------- /test/test_Init.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the octoprint_excluderegion module __init__ code.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | import mock 7 | 8 | from octoprint_excluderegion import ExcludeRegionPlugin 9 | 10 | from .utils import TestCase 11 | 12 | 13 | class InitTests(TestCase): 14 | """Unit tests for the octoprint_excluderegion module __init__ code.""" 15 | 16 | def test_global_property_defaults(self): 17 | """Tests that the expected global properties are defined by default.""" 18 | import octoprint_excluderegion 19 | 20 | expectedGlobalProperties = [ 21 | "__plugin_name__", 22 | "__plugin_implementation__", 23 | "__plugin_hooks__", 24 | "__plugin_load__", 25 | "ExcludeRegionPlugin" 26 | ] 27 | 28 | moduleProperties = dir(octoprint_excluderegion) 29 | 30 | for globalProperty in expectedGlobalProperties: 31 | self.assertIn( 32 | globalProperty, moduleProperties, 33 | "The '%s' global property should be defined by the module" % globalProperty 34 | ) 35 | 36 | self.assertIsNone( 37 | octoprint_excluderegion.__plugin_implementation__, 38 | "__plugin_implementation__ should default to None" 39 | ) 40 | self.assertIsNone( 41 | octoprint_excluderegion.__plugin_hooks__, 42 | "__plugin_hooks__ should default to None" 43 | ) 44 | 45 | def test_plugin_load(self): 46 | """Tests that the expected global property values are set when __init__ is called.""" 47 | import octoprint_excluderegion 48 | 49 | octoprint_excluderegion.__plugin_load__() 50 | 51 | checkConfigHookName = "octoprint.plugin.softwareupdate.check_config" 52 | 53 | self.assertIsInstance( 54 | octoprint_excluderegion.__plugin_implementation__, 55 | ExcludeRegionPlugin, 56 | "__plugin_implementation__ should be set to an ExcludeRegionPlugin instance" 57 | ) 58 | self.assertIsDictionary( 59 | octoprint_excluderegion.__plugin_hooks__, 60 | "__plugin_hooks__ should be a dict instance" 61 | ) 62 | 63 | pluginObject = octoprint_excluderegion.__plugin_implementation__ 64 | 65 | self._assertHook( 66 | octoprint_excluderegion.__plugin_hooks__, 67 | checkConfigHookName, 68 | [], 69 | pluginObject, 70 | "getUpdateInformation", 71 | [] 72 | ) 73 | 74 | self._assertAtCommandQueuingHook(pluginObject, octoprint_excluderegion.__plugin_hooks__) 75 | self._assertGcodeQueuingHook(pluginObject, octoprint_excluderegion.__plugin_hooks__) 76 | self._assertScriptHook(pluginObject, octoprint_excluderegion.__plugin_hooks__) 77 | 78 | def _assertHook( # pylint: disable=too-many-arguments 79 | self, hooks, hookName, hookArgs, impl, implMethodName, methodArgs 80 | ): 81 | """ 82 | Ensure that a hook is defined and a named method is called when the hook is invoked. 83 | 84 | Parameters 85 | ---------- 86 | hooks : dict of hook values 87 | The defined hooks. 88 | hookName : string 89 | The name of the hook to validate. 90 | hookArgs : list 91 | List of arguments to pass to the hook function. 92 | impl : ExcludeRegionPlugin 93 | The plugin implementation instance. 94 | implMethodName : string 95 | The name of the plugin implementation method that should be invoked by the hook. 96 | methodArgs : list 97 | List of arguments expected to be passed to the module instance method. 98 | """ 99 | self.assertIn( 100 | hookName, hooks, 101 | "A '%s' hook should be defined" % hookName 102 | ) 103 | 104 | originalImplMethod = getattr(impl, implMethodName) 105 | 106 | with mock.patch.object(impl, implMethodName) as mockImplMethod: 107 | hookFunction = hooks[hookName] 108 | 109 | if (not callable(hookFunction)): 110 | self.assertEqual( 111 | len(hookFunction), 2, 112 | "The hook tuple must contain 2 values" 113 | ) 114 | 115 | hookFunction = hookFunction[0] 116 | self.assertTrue(callable(hookFunction), "The hook function must be callable") 117 | 118 | if (hookFunction != originalImplMethod): 119 | hookFunction(*hookArgs) 120 | mockImplMethod.assert_called_with(*methodArgs) 121 | 122 | def _assertAtCommandQueuingHook(self, pluginObject, hooks): 123 | """Ensure the @-command queuing hook is properly registered.""" 124 | atCommandQueuingHookName = "octoprint.comm.protocol.atcommand.queuing" 125 | 126 | commInstance = mock.Mock() 127 | phase = mock.Mock() 128 | command = mock.Mock() 129 | parameters = mock.Mock() 130 | tags = mock.Mock() 131 | 132 | self._assertHook( 133 | hooks, 134 | atCommandQueuingHookName, 135 | [commInstance, phase, command, parameters, tags], 136 | pluginObject, 137 | "handleAtCommandQueuing", 138 | [commInstance, phase, command, parameters, tags] 139 | ) 140 | 141 | def _assertGcodeQueuingHook(self, pluginObject, hooks): 142 | """Ensure the Gcode queuing hook is properly registered.""" 143 | gcodeQueuingHookName = "octoprint.comm.protocol.gcode.queuing" 144 | 145 | commInstance = mock.Mock() 146 | phase = mock.Mock() 147 | command = mock.Mock() 148 | commandType = mock.Mock() 149 | gcode = mock.Mock() 150 | subcode = mock.Mock() 151 | tags = mock.Mock() 152 | self._assertHook( 153 | hooks, 154 | gcodeQueuingHookName, 155 | [commInstance, phase, command, commandType, gcode, subcode, tags], 156 | pluginObject, 157 | "handleGcodeQueuing", 158 | [commInstance, phase, command, commandType, gcode, subcode, tags] 159 | ) 160 | 161 | def _assertScriptHook(self, pluginObject, hooks): 162 | """Ensure the script hook is properly registered.""" 163 | scriptHookName = "octoprint.comm.protocol.scripts" 164 | 165 | commInstance = mock.Mock() 166 | scriptType = mock.Mock() 167 | scriptName = mock.Mock() 168 | self._assertHook( 169 | hooks, 170 | scriptHookName, 171 | [commInstance, scriptType, scriptName], 172 | pluginObject, 173 | "handleScriptHook", 174 | [commInstance, scriptType, scriptName] 175 | ) 176 | -------------------------------------------------------------------------------- /test/test_Position.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the Position class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | from octoprint_excluderegion.Position import Position 7 | from octoprint_excluderegion.AxisPosition import AxisPosition 8 | from .utils import TestCase 9 | 10 | 11 | class PositionTests(TestCase): 12 | """Unit tests for the Position class.""" 13 | 14 | expectedProperties = ["X_AXIS", "Y_AXIS", "Z_AXIS", "E_AXIS"] 15 | 16 | def test_default_constructor(self): 17 | """Test the constructor when passed no arguments.""" 18 | unit = Position() 19 | 20 | self.assertIsInstance(unit, Position) 21 | self.assertEqual( 22 | unit.X_AXIS, AxisPosition(), "X_AXIS should be a default AxisPosition instance" 23 | ) 24 | self.assertEqual( 25 | unit.Y_AXIS, AxisPosition(), "Y_AXIS should be a default AxisPosition instance" 26 | ) 27 | self.assertEqual( 28 | unit.Z_AXIS, AxisPosition(), "Z_AXIS should be a default AxisPosition instance" 29 | ) 30 | self.assertEqual( 31 | unit.E_AXIS, AxisPosition(0), 32 | "E_AXIS should be a default AxisPosition instance with a known position of 0" 33 | ) 34 | self.assertProperties(unit, PositionTests.expectedProperties) 35 | 36 | def test_copy_constructor(self): 37 | """Test the constructor when passed a Position instance.""" 38 | toCopy = Position() 39 | toCopy.X_AXIS.current = 1 40 | toCopy.Y_AXIS.current = 2 41 | toCopy.Z_AXIS.current = 3 42 | toCopy.E_AXIS.current = 4 43 | 44 | unit = Position(toCopy) 45 | 46 | self.assertEqual(unit, toCopy, "The new instance should equal the original") 47 | self.assertIsNot( 48 | unit.X_AXIS, toCopy.X_AXIS, "The X_AXIS property should be a different instance" 49 | ) 50 | self.assertIsNot( 51 | unit.Y_AXIS, toCopy.Y_AXIS, "The Y_AXIS property should be a different instance" 52 | ) 53 | self.assertIsNot( 54 | unit.Z_AXIS, toCopy.Z_AXIS, "The Z_AXIS property should be a different instance" 55 | ) 56 | self.assertIsNot( 57 | unit.E_AXIS, toCopy.E_AXIS, "The E_AXIS property should be a different instance" 58 | ) 59 | self.assertProperties(unit, PositionTests.expectedProperties) 60 | 61 | def test_constructor_exception(self): 62 | """Test the constructor when passed a single non-Position parameter.""" 63 | with self.assertRaises(AssertionError): 64 | Position("invalid") 65 | 66 | def test_setUnitMultiplier(self): 67 | """Test the setUnitMultiplier method.""" 68 | unit = Position() 69 | 70 | unit.setUnitMultiplier(1234) 71 | 72 | self.assertEqual( 73 | unit.X_AXIS.unitMultiplier, 1234, "The X_AXIS unitMultiplier should be 1234" 74 | ) 75 | self.assertEqual( 76 | unit.Y_AXIS.unitMultiplier, 1234, "The Y_AXIS unitMultiplier should be 1234" 77 | ) 78 | self.assertEqual( 79 | unit.Z_AXIS.unitMultiplier, 1234, "The Z_AXIS unitMultiplier should be 1234" 80 | ) 81 | self.assertEqual( 82 | unit.E_AXIS.unitMultiplier, 1234, "The E_AXIS unitMultiplier should be 1234" 83 | ) 84 | 85 | def test_setPositionAbsoluteMode(self): 86 | """Test the setPositionAbsoluteMode method.""" 87 | unit = Position() 88 | 89 | extruderAbsoluteMode = unit.E_AXIS.absoluteMode 90 | 91 | unit.setPositionAbsoluteMode(False) 92 | 93 | self.assertFalse(unit.X_AXIS.absoluteMode, "The X_AXIS absoluteMode should be False") 94 | self.assertFalse(unit.Y_AXIS.absoluteMode, "The Y_AXIS absoluteMode should be False") 95 | self.assertFalse(unit.Z_AXIS.absoluteMode, "The Z_AXIS absoluteMode should be False") 96 | self.assertEqual( 97 | unit.E_AXIS.absoluteMode, extruderAbsoluteMode, 98 | "The E_AXIS absoluteMode should be unchanged" 99 | ) 100 | 101 | unit.setPositionAbsoluteMode(True) 102 | 103 | self.assertTrue(unit.X_AXIS.absoluteMode, "The X_AXIS absoluteMode should be True") 104 | self.assertTrue(unit.Y_AXIS.absoluteMode, "The Y_AXIS absoluteMode should be True") 105 | self.assertTrue(unit.Z_AXIS.absoluteMode, "The Z_AXIS absoluteMode should be True") 106 | self.assertEqual( 107 | unit.E_AXIS.absoluteMode, extruderAbsoluteMode, 108 | "The E_AXIS absoluteMode should be unchanged" 109 | ) 110 | 111 | def test_setExtruderAbsoluteMode(self): 112 | """Test the setExtruderAbsoluteMode method.""" 113 | unit = Position() 114 | 115 | unit.setExtruderAbsoluteMode(False) 116 | self.assertFalse(unit.E_AXIS.absoluteMode, "The E_AXIS absoluteMode should be False") 117 | 118 | unit.setExtruderAbsoluteMode(True) 119 | self.assertTrue(unit.E_AXIS.absoluteMode, "The E_AXIS absoluteMode should be True") 120 | -------------------------------------------------------------------------------- /test/test_RectangularRegion.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the RectangularRegion class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | from octoprint_excluderegion.CircularRegion import CircularRegion 7 | from octoprint_excluderegion.RectangularRegion import RectangularRegion 8 | from .utils import TestCase 9 | 10 | 11 | class RectangularRegionTests(TestCase): 12 | """Unit tests for the RectangularRegion class.""" 13 | 14 | expectedProperties = ["x1", "y1", "x2", "y2", "id"] 15 | 16 | def test_default_constructor(self): 17 | """Test the constructor when passed no arguments.""" 18 | unit = RectangularRegion() 19 | 20 | self.assertIsInstance(unit, RectangularRegion) 21 | self.assertEqual(unit.x1, 0, "x1 should be 0") 22 | self.assertEqual(unit.y1, 0, "y1 should be 0") 23 | self.assertEqual(unit.x2, 0, "x2 should be 0") 24 | self.assertEqual(unit.y2, 0, "y2 should be 0") 25 | self.assertRegex(unit.id, "^[-0-9a-fA-F]{36}$", "id should be a UUID string") 26 | self.assertProperties(unit, RectangularRegionTests.expectedProperties) 27 | 28 | def test_constructor_kwargs(self): 29 | """Test the constructor when passed keyword arguments.""" 30 | unit = RectangularRegion(x1=3, y1=4, x2=1, y2=2, id="myTestId") 31 | 32 | self.assertIsInstance(unit, RectangularRegion) 33 | self.assertEqual(unit.x1, 1, "x1 should be 1") 34 | self.assertEqual(unit.y1, 2, "y1 should be 2") 35 | self.assertEqual(unit.x2, 3, "x2 should be 3") 36 | self.assertEqual(unit.y2, 4, "y2 should be 2") 37 | self.assertEqual(unit.id, "myTestId", "id should be 'myTestId'") 38 | self.assertProperties(unit, RectangularRegionTests.expectedProperties) 39 | 40 | def test_copy_constructor(self): 41 | """Test the constructor when passed a RectangularRegion instance.""" 42 | toCopy = RectangularRegion(x1=1, y1=2, x2=3, y2=4, id="myTestId") 43 | 44 | unit = RectangularRegion(toCopy) 45 | 46 | self.assertEqual(unit.x1, 1, "x1 should be 1") 47 | self.assertEqual(unit.y1, 2, "y1 should be 2") 48 | self.assertEqual(unit.x2, 3, "x2 should be 3") 49 | self.assertEqual(unit.y2, 4, "y2 should be 2") 50 | self.assertEqual(unit.id, "myTestId", "id should be 'myTestId'") 51 | self.assertProperties(unit, RectangularRegionTests.expectedProperties) 52 | 53 | def test_constructor_exception(self): 54 | """Test the constructor when passed a single non-RectangularRegion parameter.""" 55 | with self.assertRaises(AssertionError): 56 | RectangularRegion("NotARectangularRegionInstance") 57 | 58 | def test_containsPoint(self): 59 | """Test the containsPoint method.""" 60 | unit = RectangularRegion(x1=0, y1=0, x2=10, y2=10) 61 | 62 | self.assertTrue(unit.containsPoint(0, 0), "it should contain [0, 0]") 63 | self.assertTrue(unit.containsPoint(10, 10), "it should contain [10, 10]") 64 | self.assertTrue(unit.containsPoint(0, 10), "it should contain [0, 10]") 65 | self.assertTrue(unit.containsPoint(10, 0), "it should contain [10, 0]") 66 | 67 | self.assertTrue(unit.containsPoint(5, 5), "it should contain [5, 5]") 68 | 69 | self.assertFalse(unit.containsPoint(-1, 5), "it should not contain [-1, 5]") 70 | self.assertFalse(unit.containsPoint(5, -1), "it should not contain [5, -1]") 71 | self.assertFalse(unit.containsPoint(5, 11), "it should not contain [5, 11]") 72 | self.assertFalse(unit.containsPoint(11, 5), "it should not contain [11, 5]") 73 | 74 | def test_containsRegion_Rectangular(self): 75 | """Test the containsRegion method when passed a RectangularRegion.""" 76 | unit = RectangularRegion(x1=0, y1=0, x2=10, y2=10) 77 | 78 | self.assertTrue(unit.containsRegion(unit), "it should contain itself") 79 | 80 | self.assertTrue( 81 | unit.containsRegion(RectangularRegion(x1=0, y1=0, x2=10, y2=10)), 82 | "it should contain a RectangularRegion representing the same geometric region" 83 | ) 84 | self.assertTrue( 85 | unit.containsRegion(RectangularRegion(x1=2, y1=2, x2=8, y2=8)), 86 | "it should contain a RectangularRegion inside" 87 | ) 88 | 89 | self.assertTrue( 90 | unit.containsRegion(RectangularRegion(x1=0, y1=4, x2=5, y2=6)), 91 | "it should contain a RectangularRegion inside, but tangent to the left edge" 92 | ) 93 | self.assertTrue( 94 | unit.containsRegion(RectangularRegion(x1=5, y1=4, x2=10, y2=6)), 95 | "it should contain a RectangularRegion inside, but tangent to the right edge" 96 | ) 97 | self.assertTrue( 98 | unit.containsRegion(RectangularRegion(x1=4, y1=0, x2=6, y2=5)), 99 | "it should contain a RectangularRegion inside, but tangent to the bottom edge" 100 | ) 101 | self.assertTrue( 102 | unit.containsRegion(RectangularRegion(x1=4, y1=5, x2=6, y2=10)), 103 | "it should contain a RectangularRegion inside, but tangent to the top edge" 104 | ) 105 | 106 | self.assertFalse( 107 | unit.containsRegion(RectangularRegion(x1=-1, y1=0, x2=5, y2=5)), 108 | "it should not contain a RectangularRegion that extends outside" 109 | ) 110 | self.assertFalse( 111 | unit.containsRegion(RectangularRegion(x1=-1, y1=0, x2=5, y2=5)), 112 | "it should not contain a RectangularRegion that extends outside" 113 | ) 114 | 115 | def test_containsRegion_Circular(self): 116 | """Test the containsRegion method when passed a CircularRegion.""" 117 | unit = RectangularRegion(x1=0, y1=0, x2=10, y2=10) 118 | 119 | self.assertTrue( 120 | unit.containsRegion(CircularRegion(cx=5, cy=5, r=1)), 121 | "it should contain a CircularRegion inside" 122 | ) 123 | self.assertTrue( 124 | unit.containsRegion(CircularRegion(cx=1, cy=5, r=1)), 125 | "it should contain a CircularRegion inside, but tangent to the left edge" 126 | ) 127 | self.assertTrue( 128 | unit.containsRegion(CircularRegion(cx=9, cy=5, r=1)), 129 | "it should contain a CircularRegion inside, but tangent to the right edge" 130 | ) 131 | self.assertTrue( 132 | unit.containsRegion(CircularRegion(cx=5, cy=1, r=1)), 133 | "it should contain a CircularRegion inside, but tangent to the bottom edge" 134 | ) 135 | self.assertTrue( 136 | unit.containsRegion(CircularRegion(cx=5, cy=9, r=1)), 137 | "it should contain a CircularRegion inside, but tangent to the top edge" 138 | ) 139 | self.assertTrue( 140 | unit.containsRegion(CircularRegion(cx=5, cy=5, r=5)), 141 | "it should contain a CircularRegion inside, but tangent to all edges" 142 | ) 143 | 144 | self.assertFalse( 145 | unit.containsRegion(CircularRegion(cx=5, cy=5, r=5.1)), 146 | "it should not contain a CircularRegion that extends outside" 147 | ) 148 | self.assertFalse( 149 | unit.containsRegion(CircularRegion(cx=5, cy=5, r=10)), 150 | "it should not contain a CircularRegion containing this region" 151 | ) 152 | self.assertFalse( 153 | unit.containsRegion(CircularRegion(cx=20, cy=20, r=1)), 154 | "it should not contain a CircularRegion completely outside" 155 | ) 156 | 157 | def test_containsRegion_NotRegion(self): 158 | """Test the containsRegion method when passed an unsupported type.""" 159 | unit = RectangularRegion(x1=0, y1=0, x2=10, y2=10) 160 | 161 | with self.assertRaises(ValueError): 162 | unit.containsRegion("NotARegionInstance") 163 | -------------------------------------------------------------------------------- /test/test_RetractionState.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the RetractionState class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | import mock 7 | 8 | from octoprint_excluderegion.RetractionState import RetractionState 9 | from octoprint_excluderegion.Position import Position 10 | from .utils import TestCase 11 | 12 | 13 | class RetractionStateTests(TestCase): 14 | """Unit tests for the RetractionState class.""" 15 | 16 | expectedProperties = [ 17 | "recoverExcluded", "allowCombine", "firmwareRetract", "extrusionAmount", 18 | "feedRate", "originalCommand" 19 | ] 20 | 21 | def test_constructor_firmwareRetraction(self): 22 | """Test the constructor when arguments are passed for a firmware retraction.""" 23 | unit = RetractionState( 24 | originalCommand="SomeCommand", 25 | firmwareRetract=True 26 | ) 27 | 28 | self.assertIsInstance(unit, RetractionState) 29 | self.assertFalse(unit.recoverExcluded, "recoverExcluded should be False") 30 | self.assertTrue(unit.allowCombine, "allowCombine should be True") 31 | self.assertTrue(unit.firmwareRetract, "firmwareRetract should be True") 32 | self.assertIsNone(unit.extrusionAmount, "extrusionAmount should be None") 33 | self.assertIsNone(unit.feedRate, "feedRate should be None") 34 | self.assertEqual( 35 | unit.originalCommand, "SomeCommand", 36 | "originalCommand should be 'SomeCommand'" 37 | ) 38 | self.assertProperties(unit, RetractionStateTests.expectedProperties) 39 | 40 | def test_constructor_extrusionAmount_feedRate(self): 41 | """Test the constructor when arguments are passed for a non-firmware retraction.""" 42 | unit = RetractionState( 43 | originalCommand="SomeCommand", 44 | firmwareRetract=False, 45 | extrusionAmount=1.0, 46 | feedRate=100.0 47 | ) 48 | 49 | self.assertIsInstance(unit, RetractionState) 50 | self.assertFalse(unit.recoverExcluded, "recoverExcluded should be False") 51 | self.assertTrue(unit.allowCombine, "allowCombine should be True") 52 | self.assertFalse(unit.firmwareRetract, "firmwareRetract should be None") 53 | self.assertEqual(unit.extrusionAmount, 1, "extrusionAmount should be 1") 54 | self.assertEqual(unit.feedRate, 100, "feedRate should be 100") 55 | self.assertEqual( 56 | unit.originalCommand, "SomeCommand", 57 | "originalCommand should be 'SomeCommand'" 58 | ) 59 | self.assertProperties(unit, RetractionStateTests.expectedProperties) 60 | 61 | def test_constructor_missing_extrusionAmount(self): 62 | """Test the constructor when feedRate is passed without extrusionAmount.""" 63 | with self.assertRaises(ValueError): 64 | RetractionState( 65 | originalCommand="SomeCommand", 66 | firmwareRetract=False, 67 | feedRate=100.0 68 | ) 69 | 70 | def test_constructor_missing_feedRate(self): 71 | """Test the constructor when extrusionAmount is passed without feedRate.""" 72 | with self.assertRaises(ValueError): 73 | RetractionState( 74 | originalCommand="SomeCommand", 75 | firmwareRetract=False, 76 | extrusionAmount=1.0 77 | ) 78 | 79 | def test_constructor_argumentConflict(self): 80 | """Test constructor when firmwareRetract is specified with extrusionAmount or feedRate.""" 81 | with self.assertRaises(ValueError): 82 | RetractionState( 83 | originalCommand="SomeCommand", 84 | firmwareRetract=True, 85 | extrusionAmount=1.0 86 | ) 87 | 88 | with self.assertRaises(ValueError): 89 | RetractionState( 90 | originalCommand="SomeCommand", 91 | firmwareRetract=True, 92 | feedRate=100.0 93 | ) 94 | 95 | with self.assertRaises(ValueError): 96 | RetractionState( 97 | originalCommand="SomeCommand", 98 | firmwareRetract=True, 99 | extrusionAmount=1.0, 100 | feedRate=100.0 101 | ) 102 | 103 | def test_generateRetractCommands_firmware_noParams(self): 104 | """Test the generateRetractCommands method on a firmware retraction instance.""" 105 | unit = RetractionState( 106 | originalCommand="G10", 107 | firmwareRetract=True 108 | ) 109 | position = Position() 110 | 111 | returnCommands = unit.generateRetractCommands(position) 112 | self.assertEqual(returnCommands, ["G10"], "The returned list should be ['G10']") 113 | self.assertEqual(position.E_AXIS.current, 0, "The extruder axis should not be modified") 114 | 115 | def test_generateRetractCommands_firmware_withParams(self): 116 | """Test generateRetractCommands method on a firmware retraction instance with parameters.""" 117 | unit = RetractionState( 118 | originalCommand="G11 S1", 119 | firmwareRetract=True 120 | ) 121 | position = Position() 122 | 123 | returnCommands = unit.generateRetractCommands(position) 124 | self.assertEqual(returnCommands, ["G10 S1"], "The returned list should be ['G10 S1']") 125 | self.assertEqual(position.E_AXIS.current, 0, "The extruder axis should not be modified") 126 | 127 | def test_generateRetractCommands_nonFirmware(self): 128 | """Test the generateRetractCommands method on a non-firmware retraction instance.""" 129 | unit = RetractionState( 130 | originalCommand="G1 F100 E1", 131 | firmwareRetract=False, 132 | extrusionAmount=1.0, 133 | feedRate=100.0 134 | ) 135 | position = Position() 136 | 137 | returnCommands = unit.generateRetractCommands(position) 138 | self.assertEqual( 139 | returnCommands, ["G92 E%s" % (1.0), "G1 F%s E%s" % (100.0, 0.0)], 140 | "The returned list should be ['G92 E1', 'G1 F100 E0']" 141 | ) 142 | self.assertEqual(position.E_AXIS.current, 0, "The extruder axis should not be modified") 143 | 144 | def test_generateRecoverCommands_firmware_noParams(self): 145 | """Test the generateRecoverCommands method on a firmware retraction instance.""" 146 | unit = RetractionState( 147 | originalCommand="G10", 148 | firmwareRetract=True 149 | ) 150 | position = Position() 151 | 152 | returnCommands = unit.generateRecoverCommands(position) 153 | self.assertEqual(returnCommands, ["G11"], "The returned list should be ['G11']") 154 | self.assertEqual(position.E_AXIS.current, 0, "The extruder axis should not be modified") 155 | 156 | def test_generateRecoverCommands_firmware_withParams(self): 157 | """Test generateRecoverCommands method on a firmware retraction instance with parameters.""" 158 | unit = RetractionState( 159 | originalCommand="G10 S1", 160 | firmwareRetract=True 161 | ) 162 | position = Position() 163 | 164 | returnCommands = unit.generateRecoverCommands(position) 165 | self.assertEqual(returnCommands, ["G11 S1"], "The returned list should be ['G11 S1']") 166 | self.assertEqual(position.E_AXIS.current, 0, "The extruder axis should not be modified") 167 | 168 | def test_generateRecoverCommands_nonFirmware(self): 169 | """Test the generateRecoverCommands method on a non-firmware retraction instance.""" 170 | unit = RetractionState( 171 | originalCommand="G1 F100 E1", 172 | firmwareRetract=False, 173 | extrusionAmount=1.0, 174 | feedRate=100.0 175 | ) 176 | position = Position() 177 | 178 | returnCommands = unit.generateRecoverCommands(position) 179 | self.assertEqual( 180 | returnCommands, ["G92 E%s" % (-1.0), "G1 F%s E%s" % (100.0, 0.0)], 181 | "The returned list should be ['G92 E-1', 'G1 F100 E0']" 182 | ) 183 | self.assertEqual(position.E_AXIS.current, 0, "The extruder axis should not be modified") 184 | 185 | def test_combine_combineAllowed_firmware(self): 186 | """Test the combine method with two firmware retractions when combine is allowed.""" 187 | mockLogger = mock.Mock() 188 | 189 | unit = RetractionState( 190 | originalCommand="G10 S1", 191 | firmwareRetract=True 192 | ) 193 | 194 | toCombine = RetractionState( 195 | originalCommand="G10 S1", 196 | firmwareRetract=True 197 | ) 198 | 199 | result = unit.combine(toCombine, mockLogger) 200 | 201 | self.assertIs(result, unit, "The return value should be the unit instance") 202 | self.assertIsNone(unit.extrusionAmount, "extrusionAmount should be None") 203 | self.assertTrue(unit.firmwareRetract, "firmwareRetract should be True") 204 | mockLogger.warn.assert_not_called() 205 | 206 | def test_combine_combineAllowed_nonFirmware(self): 207 | """Test the combine method with two non-firmware retractions when combine is allowed.""" 208 | mockLogger = mock.Mock() 209 | 210 | unit = RetractionState( 211 | originalCommand="G1 F100 E-1", 212 | firmwareRetract=False, 213 | extrusionAmount=1.0, 214 | feedRate=100.0 215 | ) 216 | 217 | toCombine = RetractionState( 218 | originalCommand="G1 F200 E-0.5", 219 | firmwareRetract=False, 220 | extrusionAmount=0.5, 221 | feedRate=200.0 222 | ) 223 | 224 | result = unit.combine(toCombine, mockLogger) 225 | 226 | self.assertIs(result, unit, "The return value should be the unit instance") 227 | self.assertEqual(unit.extrusionAmount, 1.5, "The extrusionAmount should be updated to 1.5") 228 | mockLogger.warn.assert_not_called() 229 | 230 | def test_combine_combineNotAllowed_nonFirmware(self): 231 | """Test the combine method with two non-firmware retractions when combine is not allowed.""" 232 | mockLogger = mock.Mock() 233 | 234 | unit = RetractionState( 235 | originalCommand="G1 F100 E-1", 236 | firmwareRetract=False, 237 | extrusionAmount=1.0, 238 | feedRate=100.0 239 | ) 240 | unit.allowCombine = False 241 | 242 | toCombine = RetractionState( 243 | originalCommand="G1 F200 E-0.5", 244 | firmwareRetract=False, 245 | extrusionAmount=0.5, 246 | feedRate=200.0 247 | ) 248 | 249 | result = unit.combine(toCombine, mockLogger) 250 | 251 | self.assertIs(result, unit, "The return value should be the unit instance") 252 | self.assertEqual(unit.extrusionAmount, 1, "The extrusionAmount should not be modified") 253 | mockLogger.warn.assert_called() 254 | 255 | def test_combine_combineNotAllowed_firmware(self): 256 | """Test the combine method with two firmware retractions when combine is not allowed.""" 257 | mockLogger = mock.Mock() 258 | 259 | unit = RetractionState( 260 | originalCommand="G10 S1", 261 | firmwareRetract=True 262 | ) 263 | unit.allowCombine = False 264 | 265 | toCombine = RetractionState( 266 | originalCommand="G10 S1", 267 | firmwareRetract=True 268 | ) 269 | 270 | result = unit.combine(toCombine, mockLogger) 271 | 272 | self.assertIs(result, unit, "The return value should be the unit instance") 273 | self.assertTrue(unit.firmwareRetract, "firmwareRetract should be True") 274 | self.assertIsNone(unit.extrusionAmount, "The extrusionAmount should not be modified") 275 | mockLogger.warn.assert_called() 276 | 277 | def test_combine_mixedTypes(self): 278 | """Test the combine method with a non-firmware and firmware retraction.""" 279 | mockLogger = mock.Mock() 280 | 281 | unit = RetractionState( 282 | originalCommand="G1 F100 E-1", 283 | firmwareRetract=False, 284 | extrusionAmount=1.0, 285 | feedRate=100.0 286 | ) 287 | 288 | toCombine = RetractionState( 289 | originalCommand="G10 S1", 290 | firmwareRetract=True 291 | ) 292 | 293 | result = unit.combine(toCombine, mockLogger) 294 | 295 | self.assertIs(result, unit, "The return value should be the unit instance") 296 | self.assertEqual(unit.extrusionAmount, 1, "The extrusionAmount should not be modified") 297 | mockLogger.warn.assert_called() 298 | -------------------------------------------------------------------------------- /test/test_StreamProcessorComm.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Unit tests for the StreamProcessorComm class.""" 3 | 4 | from __future__ import absolute_import 5 | 6 | from octoprint_excluderegion.StreamProcessor import StreamProcessorComm 7 | 8 | from .utils import TestCase 9 | 10 | 11 | class StreamProcessorCommTests(TestCase): 12 | """Unit tests for the StreamProcessorComm class.""" 13 | 14 | def test_initializer(self): 15 | """Test the class __init__ method.""" 16 | unit = StreamProcessorComm() 17 | self.assertEqual(unit.bufferedCommands, [], "bufferedCommands should be an empty list.") 18 | 19 | def test_reset(self): 20 | """Test the reset method.""" 21 | unit = StreamProcessorComm() 22 | unit.bufferedCommands = ["command"] 23 | 24 | unit.reset() 25 | 26 | self.assertEqual(unit.bufferedCommands, [], "bufferedCommands should be an empty list.") 27 | 28 | def test_isStreaming(self): 29 | """Test the isStreaming method.""" 30 | unit = StreamProcessorComm() 31 | self.assertFalse(unit.isStreaming(), "isStreaming() should return False.") 32 | 33 | def test_sendCommand_none(self): 34 | """Test sendCommand when passed None.""" 35 | unit = StreamProcessorComm() 36 | 37 | unit.sendCommand(None) 38 | 39 | self.assertEqual(unit.bufferedCommands, [], "bufferedCommands should be an empty list.") 40 | 41 | def test_sendCommand_notNone_emptyBuffer(self): 42 | """Test sendCommand when passed a non-None value when the buffer is empty.""" 43 | unit = StreamProcessorComm() 44 | 45 | unit.sendCommand("newCommand") 46 | 47 | self.assertEqual( 48 | unit.bufferedCommands, ["newCommand"], 49 | "bufferedCommands should have the new command appended." 50 | ) 51 | 52 | def test_sendCommand_notNone_nonEmptyBuffer(self): 53 | """Test sendCommand when passed a non-None value when the buffer is not empty.""" 54 | unit = StreamProcessorComm() 55 | unit.bufferedCommands = ["originalCommand"] 56 | 57 | unit.sendCommand("newCommand") 58 | 59 | self.assertEqual( 60 | unit.bufferedCommands, ["originalCommand", "newCommand"], 61 | "bufferedCommands should have the new command appended." 62 | ) 63 | -------------------------------------------------------------------------------- /test/utils.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Provides an enhanced TestCase class to extend when implementing unit tests.""" 3 | 4 | from __future__ import absolute_import 5 | from builtins import str 6 | 7 | import collections 8 | import unittest 9 | import warnings 10 | 11 | from callee import Matcher 12 | 13 | from octoprint_excluderegion.Position import Position 14 | 15 | 16 | class FloatAlmostEqual(Matcher): 17 | """callee.Matcher subclass to test if a mock.call argument is almost equal to a value.""" 18 | 19 | def __init__(self, value, places=None, delta=None): 20 | """Initialize object properties.""" 21 | super(FloatAlmostEqual, self).__init__() 22 | 23 | value = float(value) 24 | if (delta is None): 25 | places = 7 if (places is None) else int(places) 26 | elif (places is not None): 27 | raise TypeError("Cannot specify both places and delta") 28 | 29 | if (delta is None): 30 | value = round(value, places) 31 | self._comparison = lambda other: (round(other, places) == value) 32 | self._reprStr = "" % (value, places) 33 | else: 34 | delta = abs(float(delta)) 35 | minValue = value - delta 36 | maxValue = value + delta 37 | self._comparison = lambda other: minValue <= other <= maxValue 38 | self._reprStr = "" % (minValue, maxValue) 39 | 40 | def match(self, value): 41 | """Apply the comparison function to the provided value.""" 42 | return self._comparison(value) 43 | 44 | def __repr__(self): 45 | """Return a string representation of this object.""" 46 | return self._reprStr 47 | 48 | 49 | class TestCase(unittest.TestCase): 50 | """Enhanced untttest.TestCase subclass providing additional asserts and deprecation warnings.""" 51 | 52 | def __init__(self, *args, **kwargs): 53 | """ 54 | Initialize the instance properties. 55 | 56 | This implementation ensures that the longMessage property is set to True so the standard 57 | assertion messages are prepended to any custom messages provided. 58 | """ 59 | super(TestCase, self).__init__(*args, **kwargs) 60 | self.longMessage = True 61 | 62 | @staticmethod 63 | def _msg(value, defaultMsg, customMsg): 64 | msg = defaultMsg if (customMsg is None) else customMsg 65 | result = str(type(value)) + " " + str(value) 66 | if (msg is not None): 67 | result = msg + ": " + result 68 | return result 69 | 70 | def assertIsDictionary(self, value, msg=None): 71 | """ 72 | Ensure that the specified value is a dictionary-like collection. 73 | 74 | Parameters 75 | ---------- 76 | value : mixed 77 | The value to test 78 | msg : string | None 79 | Custom assertion error message 80 | 81 | Raises 82 | ------ 83 | AssertionError 84 | If the specified value is not a dictionary-like object. 85 | """ 86 | if (not isinstance(value, collections.Mapping)): 87 | raise AssertionError(self._msg(value, "Value is not a dictionary", msg)) 88 | 89 | def assertIsString(self, value, msg=None): 90 | """ 91 | Ensure that the specified value is a string value (str or unicode instance). 92 | 93 | Parameters 94 | ---------- 95 | value : mixed 96 | The value to test 97 | msg : string | None 98 | Custom assertion error message 99 | 100 | Raises 101 | ------ 102 | AssertionError 103 | If the specified value is not a string. 104 | """ 105 | if (not isinstance(value, str)): 106 | raise AssertionError(self._msg(value, "Value is not a string", msg)) 107 | 108 | def assertProperties(self, value, expectedProperties, required=True, exclusive=True, msg=None): 109 | """ 110 | Ensure a dictionary has specific properties defined and/or doesn't have other properties. 111 | 112 | Parameters 113 | ---------- 114 | value : mixed 115 | The dictionary to test the properties for 116 | expectedProperties : list 117 | The property names to check 118 | required : boolean 119 | Whether all of the specified properties must be present or not. If True (the default), 120 | an AssertionError will be raised if any of the expectedProperties are not found. 121 | exclusive : boolean 122 | Whether only the specified properties are permitted. If True (the default), an 123 | AssertionError will be raised if any property other than one in expectedProperties is 124 | encountered. 125 | msg : string | None 126 | Custom assertion error message 127 | 128 | Raises 129 | ------ 130 | AssertionError 131 | If the specified value is not a dictionary, or the dictionary does not contain exactly 132 | the specified property key names. 133 | """ 134 | if (not (required or exclusive)): 135 | raise ValueError("You must specify True for at least one of required or exclusive") 136 | 137 | msg = self._msg(value, "Object properties do not match expectations", msg) 138 | 139 | if (isinstance(value, collections.Mapping)): 140 | propertiesDict = value # Already a dict 141 | else: 142 | propertiesDict = vars(value) 143 | 144 | missing = [] 145 | if (required): 146 | for prop in expectedProperties: 147 | if (prop not in propertiesDict): 148 | missing.append(prop) 149 | 150 | unexpected = [] 151 | if (exclusive): 152 | for prop in iter(propertiesDict): 153 | if (prop not in expectedProperties): 154 | unexpected.append(prop) 155 | 156 | if (missing or unexpected): 157 | sep = ": " 158 | if (missing): 159 | msg = msg + sep + "Missing properties " + str(missing) 160 | sep = ", " 161 | 162 | if (unexpected): 163 | msg = msg + sep + "Unexpected properties " + str(unexpected) 164 | 165 | raise AssertionError(msg) 166 | 167 | 168 | def regex_match_to_string(match): 169 | """Represent a re.Match object as a string.""" 170 | if (match is None): 171 | return "Match" 172 | 173 | groups = [] 174 | for groupIndex in range(0, match.re.groups + 1): 175 | groups.append( 176 | "{index=%s, start=%s, end=%s, value=%s}" % ( 177 | groupIndex, 178 | match.start(groupIndex), 179 | match.end(groupIndex), 180 | repr(match.group(groupIndex)) 181 | ) 182 | ) 183 | 184 | pieces = [ 185 | "Match<" 186 | "pos=", str(match.pos), 187 | ", endpos=", str(match.endpos), 188 | ", lastindex=", str(match.lastindex), 189 | ", lastgroup=", str(match.lastgroup), 190 | ", groups=[", ",\n".join(groups), 191 | "]>" 192 | ] 193 | 194 | return "".join(pieces) 195 | 196 | 197 | def create_position(x=None, y=None, z=None, extruderPosition=0, unitMultiplier=1): 198 | """ 199 | Create a new Position object with a specified position and unit multiplier. 200 | 201 | Parameters 202 | ---------- 203 | x : float | None 204 | The x coordinate value for the position, in native units. 205 | y : float | None 206 | The x coordinate value for the position, in native units. 207 | z : float | None 208 | The x coordinate value for the position, in native units. 209 | extruderPosition : float 210 | The extruder value for the position, in native units. 211 | unitMultiplier : float 212 | The unit multiplier to apply to the position. 213 | """ 214 | position = Position() 215 | 216 | if (x is not None): 217 | position.X_AXIS.current = float(x) 218 | 219 | if (y is not None): 220 | position.Y_AXIS.current = float(y) 221 | 222 | if (z is not None): 223 | position.Z_AXIS.current = float(z) 224 | 225 | if (extruderPosition is not None): 226 | position.E_AXIS.current = float(extruderPosition) 227 | 228 | if (unitMultiplier is not None): 229 | position.setUnitMultiplier(unitMultiplier) 230 | 231 | return position 232 | 233 | 234 | # ========== 235 | # Ensure calling any of the deprecated assertion methods actually raises a deprecation warning 236 | 237 | DEPRECATED_METHODS = [ 238 | "assertEquals", "failIfEqual", "failUnless", "assert_", 239 | "failIf", "failUnlessRaises", "failUnlessAlmostEqual", "failIfAlmostEqual", 240 | "assertRegexpMatches", "assertNotRegexpMatches" 241 | ] 242 | 243 | 244 | def _apply_deprecation_closures(): 245 | # Ensures consistency between python 2 & 3 unittest by defining assertRegex and 246 | # assertNotRegex if they don't exist 247 | if (not hasattr(TestCase, "assertRegex")): 248 | setattr( 249 | TestCase, 250 | "assertRegex", 251 | getattr(TestCase, "assertRegexpMatches") 252 | ) 253 | 254 | if (not hasattr(TestCase, "assertNotRegex")): 255 | setattr( 256 | TestCase, 257 | "assertNotRegex", 258 | getattr(TestCase, "assertNotRegexpMatches") 259 | ) 260 | 261 | # Wrap methods that shouldn't be called to emit deprecation warnings 262 | def _create_deprecation_closure(deprecatedMethod, origFn): 263 | def deprecation_closure(self, *args, **kwargs): 264 | warnings.warn( 265 | "TestCase." + deprecatedMethod + " is deprecated", DeprecationWarning, stacklevel=2 266 | ) 267 | origFn(self, *args, **kwargs) 268 | 269 | deprecation_closure.__name__ = deprecatedMethod 270 | return deprecation_closure 271 | 272 | for deprecatedMethod in DEPRECATED_METHODS: 273 | if (hasattr(TestCase, deprecatedMethod)): 274 | setattr( 275 | TestCase, 276 | deprecatedMethod, 277 | _create_deprecation_closure(deprecatedMethod, getattr(TestCase, deprecatedMethod)) 278 | ) 279 | 280 | 281 | _apply_deprecation_closures() 282 | -------------------------------------------------------------------------------- /translations/README.txt: -------------------------------------------------------------------------------- 1 | Your plugin's translations will reside here. The provided setup.py supports a 2 | couple of additional commands to make managing your translations easier: 3 | 4 | babel_extract 5 | Extracts any translateable messages (marked with Jinja's `_("...")` or 6 | JavaScript's `gettext("...")`) and creates the initial `messages.pot` file. 7 | babel_refresh 8 | Reruns extraction and updates the `messages.pot` file. 9 | babel_new --locale= 10 | Creates a new translation folder for locale ``. 11 | babel_compile 12 | Compiles the translations into `mo` files, ready to be used within 13 | OctoPrint. 14 | babel_pack --locale= [ --author= ] 15 | Packs the translation for locale `` up as an installable 16 | language pack that can be manually installed by your plugin's users. This is 17 | interesting for languages you can not guarantee to keep up to date yourself 18 | with each new release of your plugin and have to depend on contributors for. 19 | 20 | If you want to bundle translations with your plugin, create a new folder 21 | `octoprint_excluderegion/translations`. When that folder exists, 22 | an additional command becomes available: 23 | 24 | babel_bundle --locale= 25 | Moves the translation for locale `` to octoprint_excluderegion/translations, 26 | effectively bundling it with your plugin. This is interesting for languages 27 | you can guarantee to keep up to date yourself with each new release of your 28 | plugin. 29 | --------------------------------------------------------------------------------