Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 25 additions & 63 deletions carveracontroller/Controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,85 +765,47 @@ 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

except Exception as e:
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
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/test_resume_m03_speed.py
Original file line number Diff line number Diff line change
@@ -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
Loading