diff --git a/CHANGELOG.md b/CHANGELOG.md index 603ca7b9..fd9c832e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Enhancement: Advanced TLO Calibration option. Here you can set the offset to use from tool setter, and/or the number of repeat probings to use - Enhancement: Added a warning popup if the controller version is lower than the firmware. This is not a supported config - Enhancement: Added Auto Ext. Out toggle to spindle dropdown and Config and Run screen. This can be used to automatically run a vacuum or compressor when the spindle is running +- Fixed: Resume-at-line restores spindle speed from zero-padded M03 commands - Fixed: Restore Keyboard Jogging state after Probing Popup is closed - Fixed: Repeated firmware checks now happen just once - Fixed: UI widget updates from the SerialMonitor() now dispatched via the main thread. This should reduce the number of RecycleView related crashes diff --git a/carveracontroller/Controller.py b/carveracontroller/Controller.py index cabedd3a..dd803438 100644 --- a/carveracontroller/Controller.py +++ b/carveracontroller/Controller.py @@ -765,78 +765,25 @@ def _find_m3_spindle_speed(self, lines, start_line): if start_line < 1 or start_line > len(lines): return None - # First, find the most recent M3 command and check if it has S parameter + # First, find the most recent M3 command and check if it has S parameter. + # Token matching normalizes zero-padded forms such as M03 without + # confusing other M-codes such as M30. most_recent_m3_line = None for i in range(start_line - 2, -1, -1): - original_line = lines[i] - line = original_line.strip() - - # Remove comments using string methods - if ";" in line: - line = line[: line.index(";")] - # Remove parentheses comments using string methods - while "(" in line and ")" in line: - start_paren = line.index("(") - end_paren = line.index(")", start_paren) - line = line[:start_paren] + line[end_paren + 1 :] - - line = line.strip() - if not line: - continue - - # Check if line contains M3 (not M30, M31, etc.) - line_upper = line.upper() - # Use regex to find M3 that's not part of M30, M31, etc. - # Look for M3 that's not followed by a digit (to avoid M30, M31, etc.) - # M3 can be preceded by anything (including digits from other commands like S12000). - # Yes, really! One of the example files (ACRYLIC-R2D2.nc) has "G0X0.000Y0.000S12000M3" wtf is that!!? - m3_match = re.search(r"M3(?![0-9])", line_upper) - if m3_match: + has_m3, spindle_speed = self._m3_spindle_speed_from_line(lines[i]) + if has_m3: most_recent_m3_line = i - # Extract S parameter from this line (S can appear before or after M3) - s_match = re.search(r"S(\d+)", line) - if s_match: - try: - spindle_speed = float(s_match.group(1)) - return spindle_speed - except (ValueError, TypeError): - pass + if spindle_speed is not None: + return spindle_speed # Found M3 but no S parameter, continue searching backwards break # If we found M3 but no S parameter, search backwards for previous M3 with S if most_recent_m3_line is not None: for i in range(most_recent_m3_line - 1, -1, -1): - original_line = lines[i] - line = original_line.strip() - - # Remove comments using string methods - if ";" in line: - line = line[: line.index(";")] - # Remove parentheses comments using string methods - while "(" in line and ")" in line: - start_paren = line.index("(") - end_paren = line.index(")", start_paren) - line = line[:start_paren] + line[end_paren + 1 :] - - line = line.strip() - if not line: - continue - - # Check if line contains M3 (not M30, M31, etc.) - line_upper = line.upper() - # Use regex to find M3 that's not part of M30, M31, etc. - # Look for M3 that's not followed by a digit (to avoid M30, M31, etc.) - m3_match = re.search(r"M3(?![0-9])", line_upper) - if m3_match: - # Extract S parameter from this line (S can appear before or after M3) - s_match = re.search(r"S(\d+)", line) - if s_match: - try: - spindle_speed = float(s_match.group(1)) - return spindle_speed - except (ValueError, TypeError): - continue + has_m3, spindle_speed = self._m3_spindle_speed_from_line(lines[i]) + if has_m3 and spindle_speed is not None: + return spindle_speed return None @@ -844,6 +791,21 @@ def _find_m3_spindle_speed(self, lines, start_line): logger.warning(f"Error finding M3 spindle speed before line {start_line}: {e}") return None + def _m3_spindle_speed_from_line(self, line): + tokens = self._gcode_line_to_cmd_tokens(line) + has_m3 = any(self._command_token_matches_base(token, "M3") for token in tokens) + if not has_m3: + return (False, None) + + for token in reversed(tokens): + if token[:1].upper() != "S": + continue + try: + return (True, float(token[1:])) + except (ValueError, TypeError): + continue + return (True, None) + def _find_last_feed_rate(self, lines=None, start_line=None, feed_lookup=None): """ Return the feed rate (mm/min) in effect at start_line. Uses either pre-parsed diff --git a/tests/unit/test_resume_m03_speed.py b/tests/unit/test_resume_m03_speed.py new file mode 100644 index 00000000..bc0b4857 --- /dev/null +++ b/tests/unit/test_resume_m03_speed.py @@ -0,0 +1,36 @@ +"""Tests for resume-at-line spindle-speed recovery.""" + +import pytest + +from carveracontroller.Controller import Controller + + +@pytest.mark.parametrize( + ("lines", "start_line", "expected"), + ( + (["M03 S12000\n", "G1 X1\n"], 2, 12000.0), + (["S8000M03\n", "G1 X1\n"], 2, 8000.0), + (["M03 S6000\n", "M03\n", "G1 X1\n"], 3, 6000.0), + (["M3 S7000\n", "M030 S9999\n", "G1 X1\n"], 3, 7000.0), + (["M03 S5000\n", "G1 X1 (M03 S9999)\n", "G1 X2\n"], 3, 5000.0), + ), +) +def test_recovers_speed_from_zero_padded_m03(lines, start_line, expected): + controller = Controller.__new__(Controller) + + assert controller._find_m3_spindle_speed(lines, start_line) == expected + + +def test_resume_preview_includes_m03_speed(): + controller = Controller.__new__(Controller) + controller._get_line_position_from_gcode_viewer = lambda _line: (None, None, None, None) + lines = [ + "M03 S12000\n", + "G1 X1 F100\n", + "G1 X2\n", + ] + + commands = controller.playStartLineCommand("job.nc", 3, preview=True, lines=lines) + + assert "buffer M3 S12000" in commands + assert "buffer M3" not in commands