From 0f1067a5fa8844809ea43d8b148dc011588856ed Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 16 Aug 2025 15:43:19 +0200 Subject: [PATCH 01/62] feat: add auto threading functionality and UI components --- rcp/app.py | 13 ++- .../home/automatic_threading_bar.py | 44 +++++++++ rcp/components/home/home_toolbar.py | 2 + rcp/components/home/mode_popup.py | 2 + rcp/components/setup/auto_threading_screen.kv | 63 ++++++++++++ rcp/components/setup/auto_threading_screen.py | 95 +++++++++++++++++++ rcp/components/setup/setup_screen.kv | 4 + 7 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 rcp/components/home/automatic_threading_bar.py create mode 100644 rcp/components/setup/auto_threading_screen.kv create mode 100644 rcp/components/setup/auto_threading_screen.py diff --git a/rcp/app.py b/rcp/app.py index 68d4b39..20f7c90 100644 --- a/rcp/app.py +++ b/rcp/app.py @@ -10,9 +10,11 @@ StringProperty from rcp.components.appsettings import config +from rcp.components.home.automatic_threading_bar import AutomaticThreadingBar from rcp.components.home.home_page import HomePage from rcp.components.home.coordbar import CoordBar from rcp.components.home.servobar import ServoBar +from rcp.components.setup.auto_threading_screen import AutoThreadingScreen from rcp.components.setup.servo_screen import ServoScreen from rcp.components.setup.setup_screen import SetupScreen from rcp.components.setup.network_screen import NetworkScreen @@ -56,6 +58,8 @@ class MainApp(App): servo: ServoBar = ObjectProperty() scales: List[CoordBar] = ListProperty() + automaticThreadingBar: AutomaticThreadingBar = ObjectProperty() + current_mode = ConfigParserProperty( defaultvalue=1, section="device", key="current_mode", config=config, val_type=int ) @@ -179,10 +183,10 @@ def build(self): self.servo = ServoBar( id_override="0", - ) + ) for i in range(4): self.scales.append(CoordBar(inputIndex=i, device=self.device, id_override=f"{i}")) - + self.automaticThreadingBar = AutomaticThreadingBar(id_override="0") self.task_update = Clock.schedule_interval(self.update, 1.0 / 30) Clock.schedule_interval(self.blinker, 1.0 / 4) self.beep() @@ -210,7 +214,10 @@ def build(self): # Add screen for servo setup self.manager.add_widget(ServoScreen(name="servo", servo=self.servo)) - # Add screen for servo setup + # Add screen for auto threading setup + self.manager.add_widget(AutoThreadingScreen(name="auto_threading", automaticThreadingBar=self.automaticThreadingBar, servo=self.servo, scales=self.scales)) + + # Add screen for update setup from rcp.components.setup.update_screen import UpdateScreen self.manager.add_widget(UpdateScreen(name="update")) diff --git a/rcp/components/home/automatic_threading_bar.py b/rcp/components/home/automatic_threading_bar.py new file mode 100644 index 0000000..6461e32 --- /dev/null +++ b/rcp/components/home/automatic_threading_bar.py @@ -0,0 +1,44 @@ +import collections +import time +import os + +from typing import List +from fractions import Fraction + +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.properties import ObjectProperty, ListProperty, NumericProperty, BooleanProperty + +from rcp.components.home.coordbar import CoordBar +from rcp.components.home.servobar import ServoBar +from rcp.dispatchers import SavingDispatcher +from rcp.components.keypad import Keypad +from rcp.utils.ctype_calc import uint32_subtract_to_int32 + +log = Logger.getChild(__name__) + +kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) +if os.path.exists(kv_file): + log.info(f"Loading KV file: {kv_file}") + Builder.load_file(kv_file) + + +class AutomaticThreadingBar(SavingDispatcher): + selected_cross_slide_scale_id = NumericProperty(0) + selected_saddle_scale_id = NumericProperty(1) + cross_slide_diameter_mode = BooleanProperty(True) + + reversing_speed = NumericProperty(500) + metric_mode = BooleanProperty(True) + backlash_retraction_distance = NumericProperty(10) + backlash_cusion = NumericProperty(2) + cross_slide_retraction_distance = NumericProperty(2) + + + disableControls = BooleanProperty(False) + _skip_save = [] + + def __init__(self, **kv): + from rcp.app import MainApp + self.app: MainApp = MainApp.get_running_app() + super().__init__(**kv) diff --git a/rcp/components/home/home_toolbar.py b/rcp/components/home/home_toolbar.py index 7e1f11f..632e9f2 100644 --- a/rcp/components/home/home_toolbar.py +++ b/rcp/components/home/home_toolbar.py @@ -33,6 +33,8 @@ def update_current_mode(self, instance, value): self.current_mode_desc = "ELS" if self.app.current_mode == 3: self.current_mode_desc = "JOG" + if self.app.current_mode == 4: + self.current_mode_desc = "AT" def popup_mode(self, *_): ModePopup().show_with_callback(self.app.set_mode, self.app.current_mode) diff --git a/rcp/components/home/mode_popup.py b/rcp/components/home/mode_popup.py index b327f99..73a85e6 100644 --- a/rcp/components/home/mode_popup.py +++ b/rcp/components/home/mode_popup.py @@ -25,6 +25,8 @@ def __init__(self, **kwargs): buttons.add_widget(KeypadButton(text="ELS", return_value=2, on_release=self.confirm)) buttons.add_widget(KeypadButton(text="JOG", return_value=3, on_release=self.confirm)) + if self.app.servo.elsMode: + buttons.add_widget(KeypadButton(text="AT", return_value=4, on_release=self.confirm)) self.add_widget(buttons) self.callback_fn = None diff --git a/rcp/components/setup/auto_threading_screen.kv b/rcp/components/setup/auto_threading_screen.kv new file mode 100644 index 0000000..7b30eea --- /dev/null +++ b/rcp/components/setup/auto_threading_screen.kv @@ -0,0 +1,63 @@ +: + BoxLayout: + title: "Auto Threading Settings" + orientation: "vertical" + padding: 10 + ScreenHeader: + text: "Auto Threading Settings" + + ScrollView: + do_scroll_x: False + do_scroll_y: True + GridLayout: + id: grid_layout + cols: 1 + spacing: 1 + size_hint_y: None + height: self.minimum_height + + TitleItem: + name: "Scales Settings" + DropDownItem: + id: saddle_dropdown + height: 60 + name: "Saddle Scale" + options: root.get_saddle_scale_options() + value: root.get_label_for_scale_id(root.automaticThreadingBar.selected_saddle_scale_id) if root.automaticThreadingBar else "" + on_value: root.on_saddle_scale_selected(self.value) + DropDownItem: + id: cross_slide_dropdown + height: 60 + name: "Cross Slide Scale" + options: root.get_cross_slide_scale_options() + value: root.get_label_for_scale_id(root.automaticThreadingBar.selected_cross_slide_scale_id) if root.automaticThreadingBar else "" + on_value: root.on_cross_slide_scale_selected(self.value) + + + BooleanItem: + name: "Cross Slide Diameter Mode" + value: root.automaticThreadingBar.cross_slide_diameter_mode + on_value: root.automaticThreadingBar.cross_slide_diameter_mode = self.value + + TitleItem: + name: "Speeds and Distance Settings" + NumberItem: + name: "Reversing Speed (Steps/s)" + value: root.automaticThreadingBar.reversing_speed + on_value: root.set_reversing_speed(self.value) + BooleanItem: + name: "Metric Mode" + value: root.automaticThreadingBar.metric_mode + on_value: root.automaticThreadingBar.metric_mode = self.value + NumberItem: + name: "Saddle backlash retraction distance (MM)" if root.automaticThreadingBar.metric_mode else "Saddle backlash retraction distance (IN)" + value: root.automaticThreadingBar.backlash_retraction_distance + on_value: root.automaticThreadingBar.backlash_retraction_distance = int(self.value) + NumberItem: + name: "Saddle backlash cushion (MM)" if root.automaticThreadingBar.metric_mode else "Saddle backlash cushion (IN)" + value: root.automaticThreadingBar.backlash_cusion + on_value: root.automaticThreadingBar.backlash_cusion = int(self.value) + NumberItem: + name: "Cross Slide retraction distance (MM)" if root.automaticThreadingBar.metric_mode else "Cross Slide retraction distance (IN)" + value: root.automaticThreadingBar.cross_slide_retraction_distance + on_value: root.automaticThreadingBar.cross_slide_retraction_distance = int(self.value) diff --git a/rcp/components/setup/auto_threading_screen.py b/rcp/components/setup/auto_threading_screen.py new file mode 100644 index 0000000..4f542dd --- /dev/null +++ b/rcp/components/setup/auto_threading_screen.py @@ -0,0 +1,95 @@ +import os +from typing import List + +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.properties import ObjectProperty, ListProperty, NumericProperty, BooleanProperty +from kivy.uix.screenmanager import Screen + +from rcp.components.home.automatic_threading_bar import AutomaticThreadingBar +from rcp.components.home.coordbar import CoordBar + +log = Logger.getChild(__name__) +kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) +if os.path.exists(kv_file): + log.info(f"Loading KV file: {kv_file}") + Builder.load_file(kv_file) + + +class AutoThreadingScreen(Screen): + automaticThreadingBar: AutomaticThreadingBar = ObjectProperty() + servo = ObjectProperty() + scales = ListProperty() + scales_labels = ListProperty() + scales_mapping = {} + + def __init__(self, **kv): + super().__init__(**kv) + Logger.info("AutoThreadingScreen initialized.") # Log an info message + self.update_scales_labels() + + def update_scales_labels(self): + """Update scales_labels and scales_mapping based on the current scales.""" + Logger.debug(f"Updating scales_labels with scales: {self.scales}") # Log a debug message + self.scales_labels = [ f"Scale {scale.inputIndex}: {scale.axisName}" for scale in self.scales if isinstance(scale, CoordBar) and not scale.spindleMode] + + # Update the mapping + self.scales_mapping = { + f"Scale {scale.inputIndex}: {scale.axisName}": scale.inputIndex + for scale in self.scales if isinstance(scale, CoordBar) + } + + Logger.info(f"Updated scales_labels: {self.scales_labels}") # Log an info message + + def on_saddle_scale_selected(self, selected_label): + if selected_label in self.scales_mapping: + self.automaticThreadingBar.selected_saddle_scale_id = self.scales_mapping[selected_label] + Logger.info(f"Selected saddle scale: {self.automaticThreadingBar.selected_saddle_scale_id}") + else: + Logger.warning(f"Selected label not found in mapping: {selected_label}") + + # Update the other dropdown options dynamically + cross_dropdown = self.ids.cross_slide_dropdown # make sure you give an id in KV + cross_dropdown.options = self.get_cross_slide_scale_options() + + def on_cross_slide_scale_selected(self, selected_label): + if selected_label in self.scales_mapping: + self.automaticThreadingBar.selected_cross_slide_scale_id = self.scales_mapping[selected_label] + Logger.info(f"Selected cross slide scale: {self.automaticThreadingBar.selected_cross_slide_scale_id}") + else: + Logger.warning(f"Selected label not found in mapping: {selected_label}") + + # Update the other dropdown options dynamically + saddle_dropdown = self.ids.saddle_dropdown + saddle_dropdown.options = self.get_saddle_scale_options() + + + def set_reversing_speed(self, val): + try: + self.automaticThreadingBar.reversing_speed = min(int(val), self.servo.maxSpeed) + except ValueError: + pass + + def get_label_for_scale_id(self, scale_id): + if not self.scales_mapping: + self.update_scales_labels() + for label, sid in self.scales_mapping.items(): + if sid == scale_id: + return label + return "" + + def get_saddle_scale_options(self): + """Return available options for the Saddle Scale dropdown.""" + if not self.scales_labels: + self.update_scales_labels() + cross_label = self.get_label_for_scale_id(self.automaticThreadingBar.selected_cross_slide_scale_id) + return [label for label in self.scales_labels if label != cross_label] + + def get_cross_slide_scale_options(self): + """Return available options for the Cross Slide Scale dropdown.""" + if not self.scales_labels: + self.update_scales_labels() + saddle_label = self.get_label_for_scale_id(self.automaticThreadingBar.selected_saddle_scale_id) + return [label for label in self.scales_labels if label != saddle_label] + + diff --git a/rcp/components/setup/setup_screen.kv b/rcp/components/setup/setup_screen.kv index 22f6d46..daf7fca 100644 --- a/rcp/components/setup/setup_screen.kv +++ b/rcp/components/setup/setup_screen.kv @@ -37,3 +37,7 @@ SetupButton: text: "Update" on_release: app.goto("update") + SetupButton: + text: "Auto Threading" + on_release: app.goto("auto_threading") + From 50858f78679cb0a437966b35b9071545c727dfde Mon Sep 17 00:00:00 2001 From: Pawcu Date: Wed, 20 Aug 2025 07:52:06 +0200 Subject: [PATCH 02/62] Started adding logic for automatic threading bar Added automatic threading settings popup --- .../home/automatic_threading_bar.kv | 53 ++++++++++++++++ .../home/automatic_threading_bar.py | 38 ++++++----- .../automatic_threading_settings_popup.kv | 37 +++++++++++ .../automatic_threading_settings_popup.py | 63 +++++++++++++++++++ rcp/components/home/home_page.py | 4 ++ rcp/components/home/home_toolbar.py | 3 + rcp/components/setup/auto_threading_screen.kv | 16 ++--- rcp/components/setup/auto_threading_screen.py | 5 +- uv.lock | 2 +- 9 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 rcp/components/home/automatic_threading_bar.kv create mode 100644 rcp/components/home/automatic_threading_settings_popup.kv create mode 100644 rcp/components/home/automatic_threading_settings_popup.py diff --git a/rcp/components/home/automatic_threading_bar.kv b/rcp/components/home/automatic_threading_bar.kv new file mode 100644 index 0000000..364b132 --- /dev/null +++ b/rcp/components/home/automatic_threading_bar.kv @@ -0,0 +1,53 @@ +: + orientation: "horizontal" + size_hint_y: None + height: 128 + + Button: + width: 96 + size_hint_x: None + text: "Stop" if root.is_running else "Start" + font_size: self.height / 4 + font_style: "bold" + background_color: [1, 0.2, 0.2, 1] if root.is_running else [0.3, 0.3, 0.3, 1] + on_release: root.toggle_is_running() + + BoxLayout: + orientation: "vertical" + size_hint_x: 0.8 + Label: + text: "Current" + text_size: self.size + size_hint_y: 0.15 + font_size: 16 + color: app.display_color + halign: 'center' + valign: 'top' + Button: + size_hint_y: 0.30 + font_name: "fonts/iosevka-regular.ttf" + font_size: self.height / 2 + font_style: "bold" + background_color: [0.2, 0.2, 0.2, 1] + color: app.formats.display_color + text: app.servo.formattedPosition + text_size: self.size + halign: 'center' + valign: 'middle' + disabled: app.servo.disableControls + on_release: app.servo.update_current_position() + ProgressBar: + size_hint_y: 0.20 + max: int(app.servo.maxSpeed) + value: int(abs(app.servo.speed)) + + Button: + width: self.height + size_hint_x: None + font_name: "fonts/iosevka-regular.ttf" if root.is_running else "fonts/Font Awesome 6 Free-Solid-900.otf" + text: "Next" if root.is_running else "\uf013" + font_size: self.height / 4 + halign: "center" + on_release: + root.open_settings() + # root.open_settings() if root.is_running else root.open_settings() \ No newline at end of file diff --git a/rcp/components/home/automatic_threading_bar.py b/rcp/components/home/automatic_threading_bar.py index 6461e32..7e6e298 100644 --- a/rcp/components/home/automatic_threading_bar.py +++ b/rcp/components/home/automatic_threading_bar.py @@ -1,19 +1,12 @@ -import collections -import time import os -from typing import List -from fractions import Fraction - from kivy.lang import Builder from kivy.logger import Logger -from kivy.properties import ObjectProperty, ListProperty, NumericProperty, BooleanProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.properties import NumericProperty, BooleanProperty, StringProperty -from rcp.components.home.coordbar import CoordBar -from rcp.components.home.servobar import ServoBar +from rcp.components.home.automatic_threading_settings_popup import AutomaticThreadingSettingsPopup from rcp.dispatchers import SavingDispatcher -from rcp.components.keypad import Keypad -from rcp.utils.ctype_calc import uint32_subtract_to_int32 log = Logger.getChild(__name__) @@ -23,22 +16,33 @@ Builder.load_file(kv_file) -class AutomaticThreadingBar(SavingDispatcher): +class AutomaticThreadingBar(BoxLayout, SavingDispatcher): selected_cross_slide_scale_id = NumericProperty(0) selected_saddle_scale_id = NumericProperty(1) cross_slide_diameter_mode = BooleanProperty(True) reversing_speed = NumericProperty(500) - metric_mode = BooleanProperty(True) + metric_distances = BooleanProperty(True) # This is for the UI in the setting screen backlash_retraction_distance = NumericProperty(10) backlash_cusion = NumericProperty(2) - cross_slide_retraction_distance = NumericProperty(2) - - - disableControls = BooleanProperty(False) - _skip_save = [] + + metric_mode = BooleanProperty(True) # This is for the actual threading logic + selected_pitch = StringProperty("") + thread_profile_angle = NumericProperty(60) + shaft_diameter = NumericProperty(1) + + is_running = BooleanProperty(False) + _skip_save = ["is_running"] def __init__(self, **kv): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() super().__init__(**kv) + + def toggle_is_running(self): + self.is_running = not self.is_running + self.app.servo.toggle_enable() + + def open_settings(self): + popup = AutomaticThreadingSettingsPopup(automaticThreadingBar=self) + popup.open() \ No newline at end of file diff --git a/rcp/components/home/automatic_threading_settings_popup.kv b/rcp/components/home/automatic_threading_settings_popup.kv new file mode 100644 index 0000000..9ef04cf --- /dev/null +++ b/rcp/components/home/automatic_threading_settings_popup.kv @@ -0,0 +1,37 @@ +: + title: "Automatic Threading Settings" + size_hint: 0.8, 0.6 + auto_dismiss: True + + ScrollView: + do_scroll_x: False + do_scroll_y: True + GridLayout: + id: grid_layout + cols: 1 + spacing: 1 + size_hint_y: None + height: self.minimum_height + + BooleanItem: + name: "Metric Mode" + value: root.automaticThreadingBar.metric_mode + on_value: root.on_metric_mode_changed(self.value) + + DropDownItem: + id: pitches_dropdown + height: 60 + name: "Pitch in MM" if root.automaticThreadingBar.metric_mode else "Pitch in IN" + options: root.get_pitches() + value: str(root.automaticThreadingBar.selected_pitch) if root.automaticThreadingBar.selected_pitch is not None else "" + on_value: root.on_pitch_selected(self.value) + + NumberItem: + name: "Thread Profile Angle" + value: root.automaticThreadingBar.thread_profile_angle + on_value: root.set_thread_profile_angle(self.value) + + NumberItem: + name: "Shaft Diameter in MM" if root.automaticThreadingBar.metric_mode else "Shaft Diameter in IN" + value: root.automaticThreadingBar.shaft_diameter + on_value: root.set_shaft_diameter(self.value) \ No newline at end of file diff --git a/rcp/components/home/automatic_threading_settings_popup.py b/rcp/components/home/automatic_threading_settings_popup.py new file mode 100644 index 0000000..3d53373 --- /dev/null +++ b/rcp/components/home/automatic_threading_settings_popup.py @@ -0,0 +1,63 @@ +import os + +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.uix.popup import Popup +from kivy.properties import ObjectProperty + +from rcp import feeds + +log = Logger.getChild(__name__) + +kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) +if os.path.exists(kv_file): + log.info(f"Loading KV file: {kv_file}") + Builder.load_file(kv_file) + + +class AutomaticThreadingSettingsPopup(Popup): + automaticThreadingBar = ObjectProperty(None) + + def __init__(self, **kv): + super().__init__(**kv) + + def get_pitches(self): + if not self.automaticThreadingBar: + return [] + + # Choose the correct table based on metric_mode + if self.automaticThreadingBar.metric_mode: + return [f.name for f in feeds.table["Thread MM"]] + else: + return [f.name for f in feeds.table["Thread IN"]] + + def set_thread_profile_angle(self, value): + try: + angle = float(value) + except (ValueError, TypeError): + angle = 1 + + angle = abs(angle) + if angle <= 0 or angle > 90: + angle = 90 + + self.automaticThreadingBar.thread_profile_angle = angle + + def set_shaft_diameter(self, value): + try: + diameter = float(value) + except (ValueError, TypeError): + diameter = 1 + self.automaticThreadingBar.shaft_diameter = abs(diameter) + + + def on_metric_mode_changed(self, value): + self.automaticThreadingBar.metric_mode = value + pitches_dropdown = self.ids.pitches_dropdown + pitches_dropdown.value = "" + pitches_dropdown.options = self.get_pitches() + log.info(f"Metric mode changed to: {value}") + + def on_pitch_selected(self, selected_pitch): + self.automaticThreadingBar.selected_pitch = selected_pitch + log.info(f"Selected pitch: {selected_pitch}") \ No newline at end of file diff --git a/rcp/components/home/home_page.py b/rcp/components/home/home_page.py index 25dfd04..50b7050 100644 --- a/rcp/components/home/home_page.py +++ b/rcp/components/home/home_page.py @@ -79,6 +79,10 @@ def change_mode_speed_check(self, instance): self.bars_container: BoxLayout self.bars_container.remove_widget(self.bars_container.children[0]) self.bars_container.add_widget(self.jog_bar) + if self.next_mode == 4: # AT + self.bars_container: BoxLayout + self.bars_container.remove_widget(self.bars_container.children[0]) + self.bars_container.add_widget(self.app.automaticThreadingBar) def on_touch_down(self, touch): self.app.beep() diff --git a/rcp/components/home/home_toolbar.py b/rcp/components/home/home_toolbar.py index 632e9f2..65ecda7 100644 --- a/rcp/components/home/home_toolbar.py +++ b/rcp/components/home/home_toolbar.py @@ -22,6 +22,9 @@ def __init__(self, **kv): self.app: MainApp = MainApp.get_running_app() super(HomeToolbar, self).__init__(**kv) self.app.bind(current_mode=self.update_current_mode) + + # Manually update the label on startup + self.update_current_mode(self.app, self.app.current_mode) # def popup_scene(self, *_): # ScenePopup().open() diff --git a/rcp/components/setup/auto_threading_screen.kv b/rcp/components/setup/auto_threading_screen.kv index 7b30eea..314e3e4 100644 --- a/rcp/components/setup/auto_threading_screen.kv +++ b/rcp/components/setup/auto_threading_screen.kv @@ -46,18 +46,14 @@ value: root.automaticThreadingBar.reversing_speed on_value: root.set_reversing_speed(self.value) BooleanItem: - name: "Metric Mode" - value: root.automaticThreadingBar.metric_mode - on_value: root.automaticThreadingBar.metric_mode = self.value + name: "Metric Distances" + value: root.automaticThreadingBar.metric_distances + on_value: root.automaticThreadingBar.metric_distances = self.value NumberItem: - name: "Saddle backlash retraction distance (MM)" if root.automaticThreadingBar.metric_mode else "Saddle backlash retraction distance (IN)" + name: "Saddle backlash retraction distance (MM)" if root.automaticThreadingBar.metric_distances else "Saddle backlash retraction distance (IN)" value: root.automaticThreadingBar.backlash_retraction_distance on_value: root.automaticThreadingBar.backlash_retraction_distance = int(self.value) NumberItem: - name: "Saddle backlash cushion (MM)" if root.automaticThreadingBar.metric_mode else "Saddle backlash cushion (IN)" + name: "Saddle backlash cushion (MM)" if root.automaticThreadingBar.metric_distances else "Saddle backlash cushion (IN)" value: root.automaticThreadingBar.backlash_cusion - on_value: root.automaticThreadingBar.backlash_cusion = int(self.value) - NumberItem: - name: "Cross Slide retraction distance (MM)" if root.automaticThreadingBar.metric_mode else "Cross Slide retraction distance (IN)" - value: root.automaticThreadingBar.cross_slide_retraction_distance - on_value: root.automaticThreadingBar.cross_slide_retraction_distance = int(self.value) + on_value: root.automaticThreadingBar.backlash_cusion = int(self.value) \ No newline at end of file diff --git a/rcp/components/setup/auto_threading_screen.py b/rcp/components/setup/auto_threading_screen.py index 4f542dd..6fc2466 100644 --- a/rcp/components/setup/auto_threading_screen.py +++ b/rcp/components/setup/auto_threading_screen.py @@ -1,9 +1,8 @@ import os -from typing import List from kivy.lang import Builder from kivy.logger import Logger -from kivy.properties import ObjectProperty, ListProperty, NumericProperty, BooleanProperty +from kivy.properties import ObjectProperty, ListProperty from kivy.uix.screenmanager import Screen from rcp.components.home.automatic_threading_bar import AutomaticThreadingBar @@ -49,7 +48,7 @@ def on_saddle_scale_selected(self, selected_label): Logger.warning(f"Selected label not found in mapping: {selected_label}") # Update the other dropdown options dynamically - cross_dropdown = self.ids.cross_slide_dropdown # make sure you give an id in KV + cross_dropdown = self.ids.cross_slide_dropdown cross_dropdown.options = self.get_cross_slide_scale_options() def on_cross_slide_scale_selected(self, selected_label): diff --git a/uv.lock b/uv.lock index 3d4bd90..b0e0e84 100644 --- a/uv.lock +++ b/uv.lock @@ -784,7 +784,7 @@ wheels = [ [[package]] name = "rcp" -version = "1.1.13" +version = "1.2.4" source = { editable = "." } dependencies = [ { name = "cachetools" }, From 09fa82b4484909bcffaba448792d6d159a317806 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Thu, 21 Aug 2025 08:35:16 +0200 Subject: [PATCH 03/62] Added logic for binding to the scales; Started adding wizard logic --- .../home/automatic_threading_bar.kv | 22 ++++--- .../home/automatic_threading_bar.py | 45 +++++++++++++- .../home/automatic_threading_wizard.py | 58 +++++++++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 rcp/components/home/automatic_threading_wizard.py diff --git a/rcp/components/home/automatic_threading_bar.kv b/rcp/components/home/automatic_threading_bar.kv index 364b132..8af0130 100644 --- a/rcp/components/home/automatic_threading_bar.kv +++ b/rcp/components/home/automatic_threading_bar.kv @@ -13,30 +13,38 @@ on_release: root.toggle_is_running() BoxLayout: + id: wizard_area orientation: "vertical" size_hint_x: 0.8 + + # Default display when NOT running Label: - text: "Current" + id: label_instruction + text: root.label_text text_size: self.size size_hint_y: 0.15 font_size: 16 color: app.display_color halign: 'center' valign: 'top' + Button: + id: btn_value size_hint_y: 0.30 font_name: "fonts/iosevka-regular.ttf" font_size: self.height / 2 font_style: "bold" background_color: [0.2, 0.2, 0.2, 1] color: app.formats.display_color - text: app.servo.formattedPosition + text: root.display_value text_size: self.size halign: 'center' valign: 'middle' - disabled: app.servo.disableControls - on_release: app.servo.update_current_position() + # disabled: app.servo.disableControls + # on_release: app.servo.update_current_position() + ProgressBar: + id: progress_servo size_hint_y: 0.20 max: int(app.servo.maxSpeed) value: int(abs(app.servo.speed)) @@ -45,9 +53,7 @@ width: self.height size_hint_x: None font_name: "fonts/iosevka-regular.ttf" if root.is_running else "fonts/Font Awesome 6 Free-Solid-900.otf" - text: "Next" if root.is_running else "\uf013" + text: root.next_button_text if root.is_running else "\uf013" font_size: self.height / 4 halign: "center" - on_release: - root.open_settings() - # root.open_settings() if root.is_running else root.open_settings() \ No newline at end of file + on_release: root.on_wizard_button() diff --git a/rcp/components/home/automatic_threading_bar.py b/rcp/components/home/automatic_threading_bar.py index 7e6e298..5940359 100644 --- a/rcp/components/home/automatic_threading_bar.py +++ b/rcp/components/home/automatic_threading_bar.py @@ -6,6 +6,8 @@ from kivy.properties import NumericProperty, BooleanProperty, StringProperty from rcp.components.home.automatic_threading_settings_popup import AutomaticThreadingSettingsPopup +from rcp.components.home.automatic_threading_wizard import AutomaticThreadingWizard +from rcp.components.home.coordbar import CoordBar from rcp.dispatchers import SavingDispatcher log = Logger.getChild(__name__) @@ -32,17 +34,56 @@ class AutomaticThreadingBar(BoxLayout, SavingDispatcher): shaft_diameter = NumericProperty(1) is_running = BooleanProperty(False) - _skip_save = ["is_running"] + label_text = StringProperty("") + display_value = StringProperty("") + next_button_text = StringProperty("") + start_position = NumericProperty(0) + stop_position = NumericProperty(0) + _skip_save = [ + "is_running", + "label_text", + "display_value", + "start_position" + "stop_position" + ] def __init__(self, **kv): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() super().__init__(**kv) + self.wizard = AutomaticThreadingWizard(self) def toggle_is_running(self): self.is_running = not self.is_running self.app.servo.toggle_enable() + if self.is_running: + self.wizard.start() + else: + self.wizard.reset_ui() + + def on_wizard_button(self): + """Called when the right button is pressed.""" + if self.is_running: + self.wizard.goto_next_step() + else: + self.open_settings() def open_settings(self): popup = AutomaticThreadingSettingsPopup(automaticThreadingBar=self) - popup.open() \ No newline at end of file + popup.open() + + def bind_to_scale(self, scale: CoordBar): + """Bind display_value to a scale's formattedPosition.""" + # Unbind old scale if it exists + if hasattr(self, "_bound_scale") and self._bound_scale is not None: + self._bound_scale.unbind(formattedPosition=self._update_display_value) + + # Bind new one + self._bound_scale = scale + scale.bind(formattedPosition=self._update_display_value) + + # Set immediately + self.display_value = scale.formattedPosition + + def _update_display_value(self, instance, value): + self.display_value = value \ No newline at end of file diff --git a/rcp/components/home/automatic_threading_wizard.py b/rcp/components/home/automatic_threading_wizard.py new file mode 100644 index 0000000..d1ed151 --- /dev/null +++ b/rcp/components/home/automatic_threading_wizard.py @@ -0,0 +1,58 @@ +from kivy.logger import Logger + +log = Logger.getChild(__name__) + +class AutomaticThreadingWizard: + def __init__(self, bar): + self.bar = bar + self.app = bar.app + self.current_step = 0 + self._current_callback = None + self.steps = [ + self.step_1_initial_position, + self.step_2_stop_position, + # ... same steps as before ... + ] + + def start(self): + self.goto_step(0) + + def reset_ui(self): + # Reset wizard_area to default content + self.bar.label_text = "" + self.bar.display_value = "" + + def goto_step(self, index): + self.current_step = index + if 0 <= index < len(self.steps): + self.steps[index]() + else: + log.info("Wizard finished") + self.bar.is_running = False + + def goto_next_step(self, *args): + if self._current_callback: + self._current_callback(*args) + self.goto_step(self.current_step + 1) + + def set_instruction(self, label_text, next_button_text, next_button_callback): + self.bar.label_text = label_text + self.bar.next_button_text = next_button_text + self._current_callback = next_button_callback + + + def step_1_initial_position(self): + self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) + self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) + + def step_2_stop_position(self): + self.set_instruction("Go to stop Z and press Set", "Set", None) + self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) + + def _capture_initial_position(self, *args): + self.bar.start_position = self.app.scales[self.bar.selected_saddle_scale_id].position + + def _capture_stop_position(self, *args): + self.bar.stop_position = self.app.scales[self.bar.selected_saddle_scale_id].position + + #self.bar.display_value = self.app.servo.formattedPosition \ No newline at end of file From e8b6ffe618096beb961becc7ba8d6bdaa2311833 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 23 Aug 2025 10:23:18 +0200 Subject: [PATCH 04/62] Renamed from auto threading to assisted threading; --- rcp/app.py | 12 ++-- ...ading_bar.kv => assisted_threading_bar.kv} | 2 +- ...ading_bar.py => assisted_threading_bar.py} | 10 ++-- ...v => assisted_threading_settings_popup.kv} | 16 ++--- ...y => assisted_threading_settings_popup.py} | 16 ++--- ...wizard.py => assisted_threading_wizard.py} | 10 ++-- rcp/components/home/home_page.py | 2 +- .../setup/assisted_threading_screen.kv | 59 +++++++++++++++++++ ...screen.py => assisted_threading_screen.py} | 22 +++---- rcp/components/setup/auto_threading_screen.kv | 59 ------------------- rcp/components/setup/setup_screen.kv | 4 +- 11 files changed, 107 insertions(+), 105 deletions(-) rename rcp/components/home/{automatic_threading_bar.kv => assisted_threading_bar.kv} (98%) rename rcp/components/home/{automatic_threading_bar.py => assisted_threading_bar.py} (88%) rename rcp/components/home/{automatic_threading_settings_popup.kv => assisted_threading_settings_popup.kv} (57%) rename rcp/components/home/{automatic_threading_settings_popup.py => assisted_threading_settings_popup.py} (77%) rename rcp/components/home/{automatic_threading_wizard.py => assisted_threading_wizard.py} (87%) create mode 100644 rcp/components/setup/assisted_threading_screen.kv rename rcp/components/setup/{auto_threading_screen.py => assisted_threading_screen.py} (75%) delete mode 100644 rcp/components/setup/auto_threading_screen.kv diff --git a/rcp/app.py b/rcp/app.py index 20f7c90..6d68ba4 100644 --- a/rcp/app.py +++ b/rcp/app.py @@ -10,11 +10,11 @@ StringProperty from rcp.components.appsettings import config -from rcp.components.home.automatic_threading_bar import AutomaticThreadingBar +from rcp.components.home.assisted_threading_bar import AssistedThreadingBar from rcp.components.home.home_page import HomePage from rcp.components.home.coordbar import CoordBar from rcp.components.home.servobar import ServoBar -from rcp.components.setup.auto_threading_screen import AutoThreadingScreen +from rcp.components.setup.assisted_threading_screen import AssistedThreadingScreen from rcp.components.setup.servo_screen import ServoScreen from rcp.components.setup.setup_screen import SetupScreen from rcp.components.setup.network_screen import NetworkScreen @@ -58,7 +58,7 @@ class MainApp(App): servo: ServoBar = ObjectProperty() scales: List[CoordBar] = ListProperty() - automaticThreadingBar: AutomaticThreadingBar = ObjectProperty() + assistedThreadingBar: AssistedThreadingBar = ObjectProperty() current_mode = ConfigParserProperty( defaultvalue=1, section="device", key="current_mode", config=config, val_type=int @@ -186,7 +186,7 @@ def build(self): ) for i in range(4): self.scales.append(CoordBar(inputIndex=i, device=self.device, id_override=f"{i}")) - self.automaticThreadingBar = AutomaticThreadingBar(id_override="0") + self.assistedThreadingBar = AssistedThreadingBar(id_override="0") self.task_update = Clock.schedule_interval(self.update, 1.0 / 30) Clock.schedule_interval(self.blinker, 1.0 / 4) self.beep() @@ -214,8 +214,8 @@ def build(self): # Add screen for servo setup self.manager.add_widget(ServoScreen(name="servo", servo=self.servo)) - # Add screen for auto threading setup - self.manager.add_widget(AutoThreadingScreen(name="auto_threading", automaticThreadingBar=self.automaticThreadingBar, servo=self.servo, scales=self.scales)) + # Add screen for assisted threading setup + self.manager.add_widget(AssistedThreadingScreen(name="assisted_threading", assistedThreadingBar=self.assistedThreadingBar, servo=self.servo, scales=self.scales)) # Add screen for update setup from rcp.components.setup.update_screen import UpdateScreen diff --git a/rcp/components/home/automatic_threading_bar.kv b/rcp/components/home/assisted_threading_bar.kv similarity index 98% rename from rcp/components/home/automatic_threading_bar.kv rename to rcp/components/home/assisted_threading_bar.kv index 8af0130..fd59caa 100644 --- a/rcp/components/home/automatic_threading_bar.kv +++ b/rcp/components/home/assisted_threading_bar.kv @@ -1,4 +1,4 @@ -: +: orientation: "horizontal" size_hint_y: None height: 128 diff --git a/rcp/components/home/automatic_threading_bar.py b/rcp/components/home/assisted_threading_bar.py similarity index 88% rename from rcp/components/home/automatic_threading_bar.py rename to rcp/components/home/assisted_threading_bar.py index 5940359..6640a6a 100644 --- a/rcp/components/home/automatic_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -5,8 +5,8 @@ from kivy.uix.boxlayout import BoxLayout from kivy.properties import NumericProperty, BooleanProperty, StringProperty -from rcp.components.home.automatic_threading_settings_popup import AutomaticThreadingSettingsPopup -from rcp.components.home.automatic_threading_wizard import AutomaticThreadingWizard +from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup +from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.coordbar import CoordBar from rcp.dispatchers import SavingDispatcher @@ -18,7 +18,7 @@ Builder.load_file(kv_file) -class AutomaticThreadingBar(BoxLayout, SavingDispatcher): +class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_cross_slide_scale_id = NumericProperty(0) selected_saddle_scale_id = NumericProperty(1) cross_slide_diameter_mode = BooleanProperty(True) @@ -51,7 +51,7 @@ def __init__(self, **kv): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() super().__init__(**kv) - self.wizard = AutomaticThreadingWizard(self) + self.wizard = AssistedThreadingWizard(self) def toggle_is_running(self): self.is_running = not self.is_running @@ -69,7 +69,7 @@ def on_wizard_button(self): self.open_settings() def open_settings(self): - popup = AutomaticThreadingSettingsPopup(automaticThreadingBar=self) + popup = AssistedThreadingSettingsPopup(assistedThreadingBar=self) popup.open() def bind_to_scale(self, scale: CoordBar): diff --git a/rcp/components/home/automatic_threading_settings_popup.kv b/rcp/components/home/assisted_threading_settings_popup.kv similarity index 57% rename from rcp/components/home/automatic_threading_settings_popup.kv rename to rcp/components/home/assisted_threading_settings_popup.kv index 9ef04cf..ea23feb 100644 --- a/rcp/components/home/automatic_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading_settings_popup.kv @@ -1,5 +1,5 @@ -: - title: "Automatic Threading Settings" +: + title: "Assisted Threading Settings" size_hint: 0.8, 0.6 auto_dismiss: True @@ -15,23 +15,23 @@ BooleanItem: name: "Metric Mode" - value: root.automaticThreadingBar.metric_mode + value: root.assistedThreadingBar.metric_mode on_value: root.on_metric_mode_changed(self.value) DropDownItem: id: pitches_dropdown height: 60 - name: "Pitch in MM" if root.automaticThreadingBar.metric_mode else "Pitch in IN" + name: "Pitch in MM" if root.assistedThreadingBar.metric_mode else "Pitch in IN" options: root.get_pitches() - value: str(root.automaticThreadingBar.selected_pitch) if root.automaticThreadingBar.selected_pitch is not None else "" + value: str(root.assistedThreadingBar.selected_pitch) if root.assistedThreadingBar.selected_pitch is not None else "" on_value: root.on_pitch_selected(self.value) NumberItem: name: "Thread Profile Angle" - value: root.automaticThreadingBar.thread_profile_angle + value: root.assistedThreadingBar.thread_profile_angle on_value: root.set_thread_profile_angle(self.value) NumberItem: - name: "Shaft Diameter in MM" if root.automaticThreadingBar.metric_mode else "Shaft Diameter in IN" - value: root.automaticThreadingBar.shaft_diameter + name: "Shaft Diameter in MM" if root.assistedThreadingBar.metric_mode else "Shaft Diameter in IN" + value: root.assistedThreadingBar.shaft_diameter on_value: root.set_shaft_diameter(self.value) \ No newline at end of file diff --git a/rcp/components/home/automatic_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py similarity index 77% rename from rcp/components/home/automatic_threading_settings_popup.py rename to rcp/components/home/assisted_threading_settings_popup.py index 3d53373..4ba443b 100644 --- a/rcp/components/home/automatic_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -15,18 +15,18 @@ Builder.load_file(kv_file) -class AutomaticThreadingSettingsPopup(Popup): - automaticThreadingBar = ObjectProperty(None) +class AssistedThreadingSettingsPopup(Popup): + assistedThreadingBar = ObjectProperty(None) def __init__(self, **kv): super().__init__(**kv) def get_pitches(self): - if not self.automaticThreadingBar: + if not self.assistedThreadingBar: return [] # Choose the correct table based on metric_mode - if self.automaticThreadingBar.metric_mode: + if self.assistedThreadingBar.metric_mode: return [f.name for f in feeds.table["Thread MM"]] else: return [f.name for f in feeds.table["Thread IN"]] @@ -41,23 +41,23 @@ def set_thread_profile_angle(self, value): if angle <= 0 or angle > 90: angle = 90 - self.automaticThreadingBar.thread_profile_angle = angle + self.assistedThreadingBar.thread_profile_angle = angle def set_shaft_diameter(self, value): try: diameter = float(value) except (ValueError, TypeError): diameter = 1 - self.automaticThreadingBar.shaft_diameter = abs(diameter) + self.assistedThreadingBar.shaft_diameter = abs(diameter) def on_metric_mode_changed(self, value): - self.automaticThreadingBar.metric_mode = value + self.assistedThreadingBar.metric_mode = value pitches_dropdown = self.ids.pitches_dropdown pitches_dropdown.value = "" pitches_dropdown.options = self.get_pitches() log.info(f"Metric mode changed to: {value}") def on_pitch_selected(self, selected_pitch): - self.automaticThreadingBar.selected_pitch = selected_pitch + self.assistedThreadingBar.selected_pitch = selected_pitch log.info(f"Selected pitch: {selected_pitch}") \ No newline at end of file diff --git a/rcp/components/home/automatic_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py similarity index 87% rename from rcp/components/home/automatic_threading_wizard.py rename to rcp/components/home/assisted_threading_wizard.py index d1ed151..2e0032b 100644 --- a/rcp/components/home/automatic_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -2,7 +2,7 @@ log = Logger.getChild(__name__) -class AutomaticThreadingWizard: +class AssistedThreadingWizard: def __init__(self, bar): self.bar = bar self.app = bar.app @@ -46,13 +46,15 @@ def step_1_initial_position(self): self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) def step_2_stop_position(self): - self.set_instruction("Go to stop Z and press Set", "Set", None) + self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position) self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) def _capture_initial_position(self, *args): - self.bar.start_position = self.app.scales[self.bar.selected_saddle_scale_id].position + self.bar.start_position = self.app.scales[self.bar.selected_saddle_scale_id].encoderCurrent + log.info(f"Initial position set to: {self.bar.start_position}") def _capture_stop_position(self, *args): - self.bar.stop_position = self.app.scales[self.bar.selected_saddle_scale_id].position + self.bar.stop_position = self.app.scales[self.bar.selected_saddle_scale_id].encoderCurrent + log.info(f"Stop position set to: {self.bar.stop_position}") #self.bar.display_value = self.app.servo.formattedPosition \ No newline at end of file diff --git a/rcp/components/home/home_page.py b/rcp/components/home/home_page.py index 50b7050..ed460c1 100644 --- a/rcp/components/home/home_page.py +++ b/rcp/components/home/home_page.py @@ -82,7 +82,7 @@ def change_mode_speed_check(self, instance): if self.next_mode == 4: # AT self.bars_container: BoxLayout self.bars_container.remove_widget(self.bars_container.children[0]) - self.bars_container.add_widget(self.app.automaticThreadingBar) + self.bars_container.add_widget(self.app.assistedThreadingBar) def on_touch_down(self, touch): self.app.beep() diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv new file mode 100644 index 0000000..65be6ec --- /dev/null +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -0,0 +1,59 @@ +: + BoxLayout: + title: "Assisted Threading Settings" + orientation: "vertical" + padding: 10 + ScreenHeader: + text: "Assisted Threading Settings" + + ScrollView: + do_scroll_x: False + do_scroll_y: True + GridLayout: + id: grid_layout + cols: 1 + spacing: 1 + size_hint_y: None + height: self.minimum_height + + TitleItem: + name: "Scales Settings" + DropDownItem: + id: saddle_dropdown + height: 60 + name: "Saddle Scale" + options: root.get_saddle_scale_options() + value: root.get_label_for_scale_id(root.assistedThreadingBar.selected_saddle_scale_id) if root.assistedThreadingBar else "" + on_value: root.on_saddle_scale_selected(self.value) + DropDownItem: + id: cross_slide_dropdown + height: 60 + name: "Cross Slide Scale" + options: root.get_cross_slide_scale_options() + value: root.get_label_for_scale_id(root.assistedThreadingBar.selected_cross_slide_scale_id) if root.assistedThreadingBar else "" + on_value: root.on_cross_slide_scale_selected(self.value) + + + BooleanItem: + name: "Cross Slide Diameter Mode" + value: root.assistedThreadingBar.cross_slide_diameter_mode + on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value + + TitleItem: + name: "Speeds and Distance Settings" + NumberItem: + name: "Reversing Speed (Steps/s)" + value: root.assistedThreadingBar.reversing_speed + on_value: root.set_reversing_speed(self.value) + BooleanItem: + name: "Metric Distances" + value: root.assistedThreadingBar.metric_distances + on_value: root.assistedThreadingBar.metric_distances = self.value + NumberItem: + name: "Saddle backlash retraction distance (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash retraction distance (IN)" + value: root.assistedThreadingBar.backlash_retraction_distance + on_value: root.assistedThreadingBar.backlash_retraction_distance = int(self.value) + NumberItem: + name: "Saddle backlash cushion (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash cushion (IN)" + value: root.assistedThreadingBar.backlash_cusion + on_value: root.assistedThreadingBar.backlash_cusion = int(self.value) \ No newline at end of file diff --git a/rcp/components/setup/auto_threading_screen.py b/rcp/components/setup/assisted_threading_screen.py similarity index 75% rename from rcp/components/setup/auto_threading_screen.py rename to rcp/components/setup/assisted_threading_screen.py index 6fc2466..d275bec 100644 --- a/rcp/components/setup/auto_threading_screen.py +++ b/rcp/components/setup/assisted_threading_screen.py @@ -5,7 +5,7 @@ from kivy.properties import ObjectProperty, ListProperty from kivy.uix.screenmanager import Screen -from rcp.components.home.automatic_threading_bar import AutomaticThreadingBar +from rcp.components.home.assisted_threading_bar import AssistedThreadingBar from rcp.components.home.coordbar import CoordBar log = Logger.getChild(__name__) @@ -15,8 +15,8 @@ Builder.load_file(kv_file) -class AutoThreadingScreen(Screen): - automaticThreadingBar: AutomaticThreadingBar = ObjectProperty() +class AssistedThreadingScreen(Screen): + assistedThreadingBar: AssistedThreadingBar = ObjectProperty() servo = ObjectProperty() scales = ListProperty() scales_labels = ListProperty() @@ -24,7 +24,7 @@ class AutoThreadingScreen(Screen): def __init__(self, **kv): super().__init__(**kv) - Logger.info("AutoThreadingScreen initialized.") # Log an info message + Logger.info("AssistedThreadingScreen initialized.") # Log an info message self.update_scales_labels() def update_scales_labels(self): @@ -42,8 +42,8 @@ def update_scales_labels(self): def on_saddle_scale_selected(self, selected_label): if selected_label in self.scales_mapping: - self.automaticThreadingBar.selected_saddle_scale_id = self.scales_mapping[selected_label] - Logger.info(f"Selected saddle scale: {self.automaticThreadingBar.selected_saddle_scale_id}") + self.assistedThreadingBar.selected_saddle_scale_id = self.scales_mapping[selected_label] + Logger.info(f"Selected saddle scale: {self.assistedThreadingBar.selected_saddle_scale_id}") else: Logger.warning(f"Selected label not found in mapping: {selected_label}") @@ -53,8 +53,8 @@ def on_saddle_scale_selected(self, selected_label): def on_cross_slide_scale_selected(self, selected_label): if selected_label in self.scales_mapping: - self.automaticThreadingBar.selected_cross_slide_scale_id = self.scales_mapping[selected_label] - Logger.info(f"Selected cross slide scale: {self.automaticThreadingBar.selected_cross_slide_scale_id}") + self.assistedThreadingBar.selected_cross_slide_scale_id = self.scales_mapping[selected_label] + Logger.info(f"Selected cross slide scale: {self.assistedThreadingBar.selected_cross_slide_scale_id}") else: Logger.warning(f"Selected label not found in mapping: {selected_label}") @@ -65,7 +65,7 @@ def on_cross_slide_scale_selected(self, selected_label): def set_reversing_speed(self, val): try: - self.automaticThreadingBar.reversing_speed = min(int(val), self.servo.maxSpeed) + self.assistedThreadingBar.reversing_speed = min(int(val), self.servo.maxSpeed) except ValueError: pass @@ -81,14 +81,14 @@ def get_saddle_scale_options(self): """Return available options for the Saddle Scale dropdown.""" if not self.scales_labels: self.update_scales_labels() - cross_label = self.get_label_for_scale_id(self.automaticThreadingBar.selected_cross_slide_scale_id) + cross_label = self.get_label_for_scale_id(self.assistedThreadingBar.selected_cross_slide_scale_id) return [label for label in self.scales_labels if label != cross_label] def get_cross_slide_scale_options(self): """Return available options for the Cross Slide Scale dropdown.""" if not self.scales_labels: self.update_scales_labels() - saddle_label = self.get_label_for_scale_id(self.automaticThreadingBar.selected_saddle_scale_id) + saddle_label = self.get_label_for_scale_id(self.assistedThreadingBar.selected_saddle_scale_id) return [label for label in self.scales_labels if label != saddle_label] diff --git a/rcp/components/setup/auto_threading_screen.kv b/rcp/components/setup/auto_threading_screen.kv deleted file mode 100644 index 314e3e4..0000000 --- a/rcp/components/setup/auto_threading_screen.kv +++ /dev/null @@ -1,59 +0,0 @@ -: - BoxLayout: - title: "Auto Threading Settings" - orientation: "vertical" - padding: 10 - ScreenHeader: - text: "Auto Threading Settings" - - ScrollView: - do_scroll_x: False - do_scroll_y: True - GridLayout: - id: grid_layout - cols: 1 - spacing: 1 - size_hint_y: None - height: self.minimum_height - - TitleItem: - name: "Scales Settings" - DropDownItem: - id: saddle_dropdown - height: 60 - name: "Saddle Scale" - options: root.get_saddle_scale_options() - value: root.get_label_for_scale_id(root.automaticThreadingBar.selected_saddle_scale_id) if root.automaticThreadingBar else "" - on_value: root.on_saddle_scale_selected(self.value) - DropDownItem: - id: cross_slide_dropdown - height: 60 - name: "Cross Slide Scale" - options: root.get_cross_slide_scale_options() - value: root.get_label_for_scale_id(root.automaticThreadingBar.selected_cross_slide_scale_id) if root.automaticThreadingBar else "" - on_value: root.on_cross_slide_scale_selected(self.value) - - - BooleanItem: - name: "Cross Slide Diameter Mode" - value: root.automaticThreadingBar.cross_slide_diameter_mode - on_value: root.automaticThreadingBar.cross_slide_diameter_mode = self.value - - TitleItem: - name: "Speeds and Distance Settings" - NumberItem: - name: "Reversing Speed (Steps/s)" - value: root.automaticThreadingBar.reversing_speed - on_value: root.set_reversing_speed(self.value) - BooleanItem: - name: "Metric Distances" - value: root.automaticThreadingBar.metric_distances - on_value: root.automaticThreadingBar.metric_distances = self.value - NumberItem: - name: "Saddle backlash retraction distance (MM)" if root.automaticThreadingBar.metric_distances else "Saddle backlash retraction distance (IN)" - value: root.automaticThreadingBar.backlash_retraction_distance - on_value: root.automaticThreadingBar.backlash_retraction_distance = int(self.value) - NumberItem: - name: "Saddle backlash cushion (MM)" if root.automaticThreadingBar.metric_distances else "Saddle backlash cushion (IN)" - value: root.automaticThreadingBar.backlash_cusion - on_value: root.automaticThreadingBar.backlash_cusion = int(self.value) \ No newline at end of file diff --git a/rcp/components/setup/setup_screen.kv b/rcp/components/setup/setup_screen.kv index daf7fca..c41d5c1 100644 --- a/rcp/components/setup/setup_screen.kv +++ b/rcp/components/setup/setup_screen.kv @@ -38,6 +38,6 @@ text: "Update" on_release: app.goto("update") SetupButton: - text: "Auto Threading" - on_release: app.goto("auto_threading") + text: "Assisted Threading" + on_release: app.goto("assisted_threading") From 611a7861bdc02eb879b63b61fd1d5b5575271d88 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 23 Aug 2025 14:27:41 +0200 Subject: [PATCH 05/62] Added settings for Left hand threads and internal threads; Added logic so that step 2 allows manual input; Added logic to convert manual inputs to encoder_steps --- rcp/components/home/assisted_threading_bar.py | 55 +++++++- .../home/assisted_threading_settings_popup.kv | 16 ++- .../home/assisted_threading_wizard.py | 117 +++++++++++++++--- rcp/components/home/coordbar.py | 2 +- rcp/dispatchers/formats.py | 9 +- 5 files changed, 172 insertions(+), 27 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 6640a6a..a2efc25 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -32,6 +32,8 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_pitch = StringProperty("") thread_profile_angle = NumericProperty(60) shaft_diameter = NumericProperty(1) + left_hand_thread = BooleanProperty(False) + inner_thread = BooleanProperty(False) is_running = BooleanProperty(False) label_text = StringProperty("") @@ -73,17 +75,60 @@ def open_settings(self): popup.open() def bind_to_scale(self, scale: CoordBar): - """Bind display_value to a scale's formattedPosition.""" + """Bind display_value to a scale's encoderCurrent with strict keypad override support.""" + # Unbind old scale if it exists if hasattr(self, "_bound_scale") and self._bound_scale is not None: - self._bound_scale.unbind(formattedPosition=self._update_display_value) + self._bound_scale.unbind(encoderCurrent=self._on_encoder_update) + self._bound_scale.unbind(formattedPosition=self._on_format_update) - # Bind new one + # Store the scale self._bound_scale = scale - scale.bind(formattedPosition=self._update_display_value) - # Set immediately + # --- Encoder update handler --- + def on_encoder_update(instance, value): + # Cancel manual override if the encoder moves + if self.wizard and self.wizard.manual_stop_length is not None: + log.info("Scale encoder moved — discarding manual stop length override") + self.wizard.manual_stop_length = None + # Always update display to formattedPosition (not raw encoder!) + self.display_value = instance.formattedPosition + + # --- Format update handler --- + def on_format_update(instance, value): + # Only update display if NOT in manual override + if not (self.wizard and self.wizard.manual_stop_length is not None): + self.display_value = value + + # Keep references so we can unbind later + self._on_encoder_update = on_encoder_update + self._on_format_update = on_format_update + + # Bind both + scale.bind(encoderCurrent=on_encoder_update) + scale.bind(formattedPosition=on_format_update) + + # Initial display self.display_value = scale.formattedPosition + + def bind_to_value_button(self, on_release_fn): + """Bind the value button to a function.""" + # Unbind old function if it exists + if hasattr(self, "_on_value_button_release") and self._on_value_button_release is not None: + self.ids.btn_value.unbind(on_release=self._on_value_button_release) + + # Store the binding function + self._on_value_button_release = on_release_fn + + if(on_release_fn is None): + # If None is passed, disable the button + self.ids.btn_value.disabled = True + return + + self.ids.btn_value.disabled = False + # Bind the new function + self.ids.btn_value.bind(on_release=on_release_fn) + def _update_display_value(self, instance, value): self.display_value = value \ No newline at end of file diff --git a/rcp/components/home/assisted_threading_settings_popup.kv b/rcp/components/home/assisted_threading_settings_popup.kv index ea23feb..a935a66 100644 --- a/rcp/components/home/assisted_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading_settings_popup.kv @@ -1,6 +1,8 @@ : title: "Assisted Threading Settings" - size_hint: 0.8, 0.6 + size_hint_x: 0.8 + size_hint_y: None + height: 450 auto_dismiss: True ScrollView: @@ -34,4 +36,14 @@ NumberItem: name: "Shaft Diameter in MM" if root.assistedThreadingBar.metric_mode else "Shaft Diameter in IN" value: root.assistedThreadingBar.shaft_diameter - on_value: root.set_shaft_diameter(self.value) \ No newline at end of file + on_value: root.set_shaft_diameter(self.value) + + BooleanItem: + name: "Left Hand Thread" + value: root.assistedThreadingBar.left_hand_thread + on_value: root.assistedThreadingBar.left_hand_thread = self.value + + BooleanItem: + name: "Inner Thread" + value: root.assistedThreadingBar.inner_thread + on_value: root.assistedThreadingBar.inner_thread = self.value \ No newline at end of file diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 2e0032b..f422e4a 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -1,5 +1,7 @@ from kivy.logger import Logger +from rcp.components.home.coordbar import CoordBar + log = Logger.getChild(__name__) class AssistedThreadingWizard: @@ -8,11 +10,13 @@ def __init__(self, bar): self.app = bar.app self.current_step = 0 self._current_callback = None - self.steps = [ - self.step_1_initial_position, - self.step_2_stop_position, + self.manual_stop_length = None + self._steps = [ + self._step_1_initial_position, + self._step_2_stop_position, # ... same steps as before ... ] + def start(self): self.goto_step(0) @@ -24,8 +28,8 @@ def reset_ui(self): def goto_step(self, index): self.current_step = index - if 0 <= index < len(self.steps): - self.steps[index]() + if 0 <= index < len(self._steps): + self._steps[index]() else: log.info("Wizard finished") self.bar.is_running = False @@ -35,26 +39,107 @@ def goto_next_step(self, *args): self._current_callback(*args) self.goto_step(self.current_step + 1) - def set_instruction(self, label_text, next_button_text, next_button_callback): + def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None): + #TODO add option for disabling next button self.bar.label_text = label_text self.bar.next_button_text = next_button_text self._current_callback = next_button_callback + self.bar.bind_to_value_button(value_button_fn) - - def step_1_initial_position(self): + # Instruction steps + def _step_1_initial_position(self): self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) - def step_2_stop_position(self): - self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position) + def _step_2_stop_position(self): + self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad) self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) - + + # Step callbacks + # Step 1 def _capture_initial_position(self, *args): self.bar.start_position = self.app.scales[self.bar.selected_saddle_scale_id].encoderCurrent + self._isStartPositionMetricMode = self.app.formats.current_format == "MM" + self._startScaledPosition = self.app.scales[self.bar.selected_saddle_scale_id].scaledPosition log.info(f"Initial position set to: {self.bar.start_position}") - def _capture_stop_position(self, *args): - self.bar.stop_position = self.app.scales[self.bar.selected_saddle_scale_id].encoderCurrent - log.info(f"Stop position set to: {self.bar.stop_position}") - - #self.bar.display_value = self.app.servo.formattedPosition \ No newline at end of file + #Step 2 + def _capture_stop_position(self, *args): + scale = self.app.scales[self.bar.selected_saddle_scale_id] + + if self.manual_stop_length is not None: + # convert length into encoder stop position + self.bar.stop_position = self._convert_stop_position_units_to_encoder(scale, self.manual_stop_length) + log.info(f"Stop position set manually: {self.manual_stop_length} " + f"(start={self.bar.start_position}, stop={self.bar.stop_position})") + self.manual_stop_length = None # reset for next run + else: + # default: take live encoder value + self.bar.stop_position = scale.encoderCurrent + log.info(f"Stop position set from scale: {self.bar.stop_position}" + f"(start={self.bar.start_position}, stop={self.bar.stop_position})") + + # Manual input handlers + def _open_stop_position_keypad(self, *args): + from rcp.components.keypad import Keypad + + is_metric = self.app.formats.current_format == "MM" + + keypad = Keypad(title="Enter Stop Length (" + ("mm" if is_metric else "in") + ")") + keypad.integer = False + + def on_done(value): + try: + self.manual_stop_length = float(value) + log.info(f"Manual stop length entered: {self.manual_stop_length}") + # Display this override until user moves scale again + self.bar.display_value = f"{self.manual_stop_length:.3f}" + except ValueError: + log.warning(f"Invalid stop length input: {value}") + + keypad.show_with_callback(callback_fn=on_done, + current_value=self.manual_stop_length or 0.0) + + # Utilities + def _convert_stop_position_units_to_encoder(self, scale: CoordBar, manual_position: float) -> int: + """ + Convert a user-entered stop position (MM/IN) into encoder counts. + Handles: + - unit changes (MM ↔ IN) + - offsets + - zero start positions + """ + + # Determine factors + current_factor = float(self.app.formats.factor) + factor_at_start_position = float(self.app.formats.MM_FRACTION if self._isStartPositionMetricMode else self.app.formats.INCHES_FRACTION) + + # Normalize manual input to the units used at start + manual_in_start_units = manual_position * (factor_at_start_position / current_factor) + + # Compute delta relative to start scaled position + delta_in_start_units = manual_in_start_units - self._startScaledPosition + + log.info( + f"Manual stop input: {manual_position} " + f"(converted to start units: {manual_in_start_units}, " + f"delta from start: {delta_in_start_units})" + ) + + # Compute encoder counts using inverse of CoordBar.scaledPosition + encoder_counts = ( + (delta_in_start_units / factor_at_start_position) - scale.offsets[self.app.currentOffset] + ) * (float(scale.ratioDen) / float(scale.ratioNum)) + + # Offset by the captured start position + final_encoder_position = int(round(self.bar.start_position + encoder_counts)) + + log.info( + f"Computed encoder counts: {final_encoder_position} " + f"(start_position={self.bar.start_position}, encoder delta={encoder_counts})" + ) + + return final_encoder_position + + + diff --git a/rcp/components/home/coordbar.py b/rcp/components/home/coordbar.py index cd55461..4abb059 100644 --- a/rcp/components/home/coordbar.py +++ b/rcp/components/home/coordbar.py @@ -34,6 +34,7 @@ class CoordBar(BoxLayout, SavingDispatcher): syncRatioDen = NumericProperty(100) syncEnable = BooleanProperty(False) position = NumericProperty(0) + encoderCurrent = NumericProperty(0) speed = NumericProperty(0) spindleMode = BooleanProperty(False) @@ -85,7 +86,6 @@ def __init__(self, **kv): # Private variables that don't need dispatchers etc self.encoderPrevious = 0 - self.encoderCurrent = 0 def init_connection(self, *args, **kv): """ diff --git a/rcp/dispatchers/formats.py b/rcp/dispatchers/formats.py index 14585e9..ca89693 100644 --- a/rcp/dispatchers/formats.py +++ b/rcp/dispatchers/formats.py @@ -19,6 +19,9 @@ class FormatsDispatcher(SavingDispatcher): 'color_on', 'color_off' ] + + MM_FRACTION = Fraction(1, 1) + INCHES_FRACTION = Fraction(10, 254) metric_position = StringProperty("{:+0.3f}") metric_speed = StringProperty("{:+0.3f}") @@ -34,7 +37,7 @@ class FormatsDispatcher(SavingDispatcher): current_format = StringProperty("MM") speed_format = StringProperty() position_format = StringProperty() - factor = ObjectProperty(Fraction(1, 1)) + factor = ObjectProperty(MM_FRACTION) display_color = ColorProperty("#ffcc35ff") accept_color = ColorProperty("#32ff32ff") @@ -55,11 +58,11 @@ def update_format(self, *args, **kv): if self.current_format == "MM": self.speed_format = f"{self.metric_speed} M/min" self.position_format = self.metric_position - self.factor = Fraction(1, 1) + self.factor = self.MM_FRACTION else: self.speed_format = f"{self.imperial_speed} Ft/min" self.position_format = self.imperial_position - self.factor = Fraction(10, 254) + self.factor = self.INCHES_FRACTION def toggle(self, *_): if self.current_format == "MM": From 3a7f0578ea4de2578206edadbf8892bb5c4a1df4 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 23 Aug 2025 15:15:19 +0200 Subject: [PATCH 06/62] Added logic for disabling action button Added logic to check for valid stop position --- rcp/components/home/assisted_threading_bar.kv | 4 ++- rcp/components/home/assisted_threading_bar.py | 17 ++++++++++-- .../home/assisted_threading_wizard.py | 27 ++++++++++++++++--- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.kv b/rcp/components/home/assisted_threading_bar.kv index fd59caa..b14a9c1 100644 --- a/rcp/components/home/assisted_threading_bar.kv +++ b/rcp/components/home/assisted_threading_bar.kv @@ -50,10 +50,12 @@ value: int(abs(app.servo.speed)) Button: + id: btn_action width: self.height size_hint_x: None font_name: "fonts/iosevka-regular.ttf" if root.is_running else "fonts/Font Awesome 6 Free-Solid-900.otf" text: root.next_button_text if root.is_running else "\uf013" font_size: self.height / 4 halign: "center" - on_release: root.on_wizard_button() + on_release: root.on_action_button_clicked() + disabled: not root.action_button_enabled diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index a2efc25..1c0ecc1 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -36,6 +36,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): inner_thread = BooleanProperty(False) is_running = BooleanProperty(False) + action_button_enabled = BooleanProperty(True) label_text = StringProperty("") display_value = StringProperty("") next_button_text = StringProperty("") @@ -43,6 +44,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): stop_position = NumericProperty(0) _skip_save = [ "is_running", + "action_button_enabled", "label_text", "display_value", "start_position" @@ -52,6 +54,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): def __init__(self, **kv): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() + self.action_button_condition_fn = None super().__init__(**kv) self.wizard = AssistedThreadingWizard(self) @@ -63,7 +66,7 @@ def toggle_is_running(self): else: self.wizard.reset_ui() - def on_wizard_button(self): + def on_action_button_clicked(self): """Called when the right button is pressed.""" if self.is_running: self.wizard.goto_next_step() @@ -93,6 +96,7 @@ def on_encoder_update(instance, value): self.wizard.manual_stop_length = None # Always update display to formattedPosition (not raw encoder!) self.display_value = instance.formattedPosition + self.update_action_button_state() # --- Format update handler --- def on_format_update(instance, value): @@ -130,5 +134,14 @@ def bind_to_value_button(self, on_release_fn): # Bind the new function self.ids.btn_value.bind(on_release=on_release_fn) + def update_action_button_state(self): + """Evaluate whether the action button should be enabled.""" + if self.action_button_condition_fn: + self.action_button_enabled = self.action_button_condition_fn() + else: + self.action_button_enabled = True + def _update_display_value(self, instance, value): - self.display_value = value \ No newline at end of file + self.display_value = value + + diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index f422e4a..4e1d36c 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -25,6 +25,7 @@ def reset_ui(self): # Reset wizard_area to default content self.bar.label_text = "" self.bar.display_value = "" + self.bar.action_button_enabled = True def goto_step(self, index): self.current_step = index @@ -39,12 +40,12 @@ def goto_next_step(self, *args): self._current_callback(*args) self.goto_step(self.current_step + 1) - def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None): - #TODO add option for disabling next button + def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None): self.bar.label_text = label_text self.bar.next_button_text = next_button_text self._current_callback = next_button_callback self.bar.bind_to_value_button(value_button_fn) + self.bar.action_button_condition_fn = action_button_condition_fn # Instruction steps def _step_1_initial_position(self): @@ -52,7 +53,8 @@ def _step_1_initial_position(self): self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) def _step_2_stop_position(self): - self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad) + self.bar.action_button_enabled = False # Disable until valid + self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) # Step callbacks @@ -78,6 +80,16 @@ def _capture_stop_position(self, *args): self.bar.stop_position = scale.encoderCurrent log.info(f"Stop position set from scale: {self.bar.stop_position}" f"(start={self.bar.start_position}, stop={self.bar.stop_position})") + + #Step Action button condition functions + #Step 2 + def _is_valid_stop_position(self): + """Check if the stop position is valid given the start position and thread direction. + - For right-hand threads, stop must be less than start. + - For left-hand threads, stop must be greater than start.""" + if self.bar.left_hand_thread: + return self.bar.start_position < self._get_stop_position_units(self.app.scales[self.bar.selected_saddle_scale_id]) + return self.bar.start_position > self._get_stop_position_units(self.app.scales[self.bar.selected_saddle_scale_id]) # Manual input handlers def _open_stop_position_keypad(self, *args): @@ -93,9 +105,11 @@ def on_done(value): self.manual_stop_length = float(value) log.info(f"Manual stop length entered: {self.manual_stop_length}") # Display this override until user moves scale again - self.bar.display_value = f"{self.manual_stop_length:.3f}" + self.bar.display_value = f"{self.manual_stop_length:.3f}" if is_metric else f"{self.manual_stop_length:.4f}" except ValueError: log.warning(f"Invalid stop length input: {value}") + finally: + self.bar.update_action_button_state() keypad.show_with_callback(callback_fn=on_done, current_value=self.manual_stop_length or 0.0) @@ -141,5 +155,10 @@ def _convert_stop_position_units_to_encoder(self, scale: CoordBar, manual_positi return final_encoder_position + def _get_stop_position_units(self, scale: CoordBar) -> float: + scale = self.app.scales[self.bar.selected_saddle_scale_id] + if self.manual_stop_length is not None: + return self._convert_stop_position_units_to_encoder(scale, self.manual_stop_length) + return scale.encoderCurrent From 93dfa9fb6bf0674deb5bb41c16b1c08e73c96c0f Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 23 Aug 2025 15:18:33 +0200 Subject: [PATCH 07/62] Fixed bug with the action_button condition not being reset on reset of wizard --- rcp/components/home/assisted_threading_wizard.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 4e1d36c..22e200e 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -26,6 +26,8 @@ def reset_ui(self): self.bar.label_text = "" self.bar.display_value = "" self.bar.action_button_enabled = True + self.bar.action_button_condition_fn = None + def goto_step(self, index): self.current_step = index From 855a9e4b1f372f3dc361798406fa45106f873c1c Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 24 Aug 2025 12:25:20 +0200 Subject: [PATCH 08/62] Added step 3 --- rcp/components/home/assisted_threading_bar.kv | 2 - rcp/components/home/assisted_threading_bar.py | 35 ++++++-- .../home/assisted_threading_wizard.py | 89 +++++++++++++++++-- rcp/components/home/servobar.py | 7 +- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.kv b/rcp/components/home/assisted_threading_bar.kv index b14a9c1..18304c1 100644 --- a/rcp/components/home/assisted_threading_bar.kv +++ b/rcp/components/home/assisted_threading_bar.kv @@ -40,8 +40,6 @@ text_size: self.size halign: 'center' valign: 'middle' - # disabled: app.servo.disableControls - # on_release: app.servo.update_current_position() ProgressBar: id: progress_servo diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 1c0ecc1..9bdb0d0 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -77,13 +77,11 @@ def open_settings(self): popup = AssistedThreadingSettingsPopup(assistedThreadingBar=self) popup.open() - def bind_to_scale(self, scale: CoordBar): + def bind_display_value_to_scale(self, scale: CoordBar): """Bind display_value to a scale's encoderCurrent with strict keypad override support.""" - # Unbind old scale if it exists - if hasattr(self, "_bound_scale") and self._bound_scale is not None: - self._bound_scale.unbind(encoderCurrent=self._on_encoder_update) - self._bound_scale.unbind(formattedPosition=self._on_format_update) + # Unbind any previous bindings + self._unbind_all_display_value() # Store the scale self._bound_scale = scale @@ -115,8 +113,21 @@ def on_format_update(instance, value): # Initial display self.display_value = scale.formattedPosition + def bind_display_value_to_servo_position(self): + """Bind display_value to the servo's formattedPosition.""" + # Unbind any previous bindings + self._unbind_all_display_value() + self._bound_servo = self.app.servo + + def on_servo_position_update(instance, value): + self.display_value = value + + self._on_servo_position_update = on_servo_position_update + + # Bind to servo's formattedPosition + self.app.servo.bind(formattedPosition=on_servo_position_update) - def bind_to_value_button(self, on_release_fn): + def bind_btn_value_on_release(self, on_release_fn): """Bind the value button to a function.""" # Unbind old function if it exists if hasattr(self, "_on_value_button_release") and self._on_value_button_release is not None: @@ -140,8 +151,14 @@ def update_action_button_state(self): self.action_button_enabled = self.action_button_condition_fn() else: self.action_button_enabled = True - - def _update_display_value(self, instance, value): - self.display_value = value + + def _unbind_all_display_value(self): + if hasattr(self, "_bound_scale") and self._bound_scale is not None: + self._bound_scale.unbind(encoderCurrent=self._on_encoder_update) + self._bound_scale.unbind(formattedPosition=self._on_format_update) + self._bound_scale = None + if hasattr(self, "_bound_servo") and self._bound_servo is not None: + self._bound_servo.unbind(formattedPosition=self._on_servo_position_update) + self._bound_servo = None diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 22e200e..b7ac323 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -1,3 +1,4 @@ +from fractions import Fraction from kivy.logger import Logger from rcp.components.home.coordbar import CoordBar @@ -5,15 +6,21 @@ log = Logger.getChild(__name__) class AssistedThreadingWizard: + @property + def saddle_scale(self) -> CoordBar: + return self.app.scales[self.bar.selected_saddle_scale_id] + def __init__(self, bar): self.bar = bar self.app = bar.app + self.servo = self.app.servo self.current_step = 0 self._current_callback = None self.manual_stop_length = None self._steps = [ self._step_1_initial_position, self._step_2_stop_position, + self._step_3_go_to_start, # ... same steps as before ... ] @@ -46,30 +53,34 @@ def set_instruction(self, label_text, next_button_text, next_button_callback, va self.bar.label_text = label_text self.bar.next_button_text = next_button_text self._current_callback = next_button_callback - self.bar.bind_to_value_button(value_button_fn) + self.bar.bind_btn_value_on_release(value_button_fn) self.bar.action_button_condition_fn = action_button_condition_fn # Instruction steps def _step_1_initial_position(self): self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) - self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) + self.bar.bind_display_value_to_scale(self.saddle_scale) def _step_2_stop_position(self): self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) - self.bar.bind_to_scale(self.app.scales[self.bar.selected_saddle_scale_id]) + self.bar.bind_display_value_to_scale(self.saddle_scale) + + def _step_3_go_to_start(self): + self.set_instruction("Engage half nut and press Go to return to start position", "Go", self._go_to_start) + # Step callbacks # Step 1 def _capture_initial_position(self, *args): - self.bar.start_position = self.app.scales[self.bar.selected_saddle_scale_id].encoderCurrent + self.bar.start_position = self.saddle_scale.encoderCurrent self._isStartPositionMetricMode = self.app.formats.current_format == "MM" - self._startScaledPosition = self.app.scales[self.bar.selected_saddle_scale_id].scaledPosition + self._startScaledPosition = self.saddle_scale.scaledPosition log.info(f"Initial position set to: {self.bar.start_position}") #Step 2 def _capture_stop_position(self, *args): - scale = self.app.scales[self.bar.selected_saddle_scale_id] + scale = self.saddle_scale if self.manual_stop_length is not None: # convert length into encoder stop position @@ -82,6 +93,32 @@ def _capture_stop_position(self, *args): self.bar.stop_position = scale.encoderCurrent log.info(f"Stop position set from scale: {self.bar.stop_position}" f"(start={self.bar.start_position}, stop={self.bar.stop_position})") + + #Step 3 + def _go_to_start(self, *args): + log.info(f"Moving to start position: {self.bar.start_position} + retraction") + + ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + + if self.bar.left_hand_thread: + target_scaled = self.bar.start_position - self._get_retraction_distance_encoder_steps() # subtract retraction + else: + target_scaled = self.bar.start_position + self._get_retraction_distance_encoder_steps() # add retraction + + current_scaled = self.saddle_scale.encoderCurrent + delta_steps = int((target_scaled - current_scaled) / ratio) + log.info(f"Computed move delta: {delta_steps} steps (target_scaled={target_scaled}, current_scaled={current_scaled}, ratio={ratio})") + + if delta_steps == 0: + log.info("Already at start position") + self.goto_step(self.current_step + 1) + return + + self.bar.bind_display_value_to_servo_position() # bind to servo position + self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed + self.servo.servoEnable = 1 # enable + self.app.device['servo']['direction'] = delta_steps # trigger move + self.app.bind(update_tick=self._check_servo_done) # watch until done #Step Action button condition functions #Step 2 @@ -90,8 +127,8 @@ def _is_valid_stop_position(self): - For right-hand threads, stop must be less than start. - For left-hand threads, stop must be greater than start.""" if self.bar.left_hand_thread: - return self.bar.start_position < self._get_stop_position_units(self.app.scales[self.bar.selected_saddle_scale_id]) - return self.bar.start_position > self._get_stop_position_units(self.app.scales[self.bar.selected_saddle_scale_id]) + return self.bar.start_position < self._get_stop_position_units(self.saddle_scale) + return self.bar.start_position > self._get_stop_position_units(self.saddle_scale) # Manual input handlers def _open_stop_position_keypad(self, *args): @@ -158,9 +195,43 @@ def _convert_stop_position_units_to_encoder(self, scale: CoordBar, manual_positi return final_encoder_position def _get_stop_position_units(self, scale: CoordBar) -> float: - scale = self.app.scales[self.bar.selected_saddle_scale_id] + scale = self.saddle_scale if self.manual_stop_length is not None: return self._convert_stop_position_units_to_encoder(scale, self.manual_stop_length) return scale.encoderCurrent + + def _convert_distance_units_to_encoder(self, scale: CoordBar, distance: float, is_metric: bool) -> int: + """ + Convert a pure distance (mm or inch) into encoder counts. + """ + encoder_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) + + # Compute encoder counts using inverse of CoordBar.scaledPosition + encoder_counts = ( + (distance / encoder_factor) - scale.offsets[self.app.currentOffset] + ) * (float(scale.ratioDen) / float(scale.ratioNum)) + + final_encoder_distance = int(round(encoder_counts)) + log.info( + f"Converted distance to encoder counts: {final_encoder_distance} " + f"(input distance={distance}, encoder delta={encoder_counts})" + ) + + return final_encoder_distance + + def _get_retraction_distance_encoder_steps(self) -> int: + """Get the retraction distance in encoder counts based on thread pitch and direction.""" + return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_retraction_distance, self.bar.metric_distances) + + def _check_servo_done(self, *args): + if self.app.fast_data_values['stepsToGo'] == 0: + log.info("Servo reached start position") + self.servo.servoEnable = 0 # disable + self.servo.set_max_speed(self.servo.maxSpeed) # restore speed + + # Stop watching + self.app.unbind(update_tick=self._check_servo_done) + # Advance workflow (skip callback loop!) + self.goto_step(self.current_step + 1) diff --git a/rcp/components/home/servobar.py b/rcp/components/home/servobar.py index 68d9e31..77887fc 100644 --- a/rcp/components/home/servobar.py +++ b/rcp/components/home/servobar.py @@ -119,7 +119,7 @@ def connected(self, instance, value): self.encoderPrevious = self.app.fast_data_values['servoCurrent'] self.encoderCurrent = self.app.fast_data_values['servoCurrent'] self.servoEnable = self.app.fast_data_values['servoEnable'] - self.app.device['servo']['maxSpeed'] = self.maxSpeed + self.set_max_speed(self.maxSpeed) self.app.device['servo']['acceleration'] = self.acceleration if self.servoEnable == 0: @@ -177,6 +177,9 @@ def update_scaledPosition(self, instance, value): else: self.scaledPosition = float(self.position * ratio) * self.app.formats.factor self.formattedPosition = self.app.formats.position_format.format(self.scaledPosition) + + def set_max_speed(self, value): + self.app.device['servo']['maxSpeed'] = value def on_index(self, instance, value): ratio = Fraction(self.ratioNum, self.ratioDen) @@ -206,7 +209,7 @@ def on_offset(self, instance, value): self.oldOffset = value def on_maxSpeed(self, instance, value): - self.app.device['servo']['maxSpeed'] = self.maxSpeed + self.set_max_speed(self.maxSpeed) def on_jogSpeed(self, instance, value): self.app.device['servo']['jogSpeed'] = self.jogSpeed From 4a8a56f72d6188f4493630f7ca8fa7eb10aaa59f Mon Sep 17 00:00:00 2001 From: Pawcu Date: Mon, 25 Aug 2025 07:42:34 +0200 Subject: [PATCH 09/62] Removed diameter mode settings - this shouldn;t be needed since we convert position to units based on scale ratio; Added steps for material width a doc; Refactored wizard to make some functions re-usable; --- rcp/components/home/assisted_threading_bar.py | 28 ++-- .../home/assisted_threading_wizard.py | 148 ++++++++++++++---- .../setup/assisted_threading_screen.kv | 6 - 3 files changed, 132 insertions(+), 50 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 9bdb0d0..55a1ff0 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -21,7 +21,6 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_cross_slide_scale_id = NumericProperty(0) selected_saddle_scale_id = NumericProperty(1) - cross_slide_diameter_mode = BooleanProperty(True) reversing_speed = NumericProperty(500) metric_distances = BooleanProperty(True) # This is for the UI in the setting screen @@ -42,13 +41,17 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): next_button_text = StringProperty("") start_position = NumericProperty(0) stop_position = NumericProperty(0) + material_width = NumericProperty(0) + cutting_depth = NumericProperty(0) _skip_save = [ "is_running", "action_button_enabled", "label_text", "display_value", "start_position" - "stop_position" + "stop_position", + "material_width", + "cutting_depth", ] def __init__(self, **kv): @@ -81,7 +84,7 @@ def bind_display_value_to_scale(self, scale: CoordBar): """Bind display_value to a scale's encoderCurrent with strict keypad override support.""" # Unbind any previous bindings - self._unbind_all_display_value() + self.unbind_all_display_value() # Store the scale self._bound_scale = scale @@ -116,7 +119,7 @@ def on_format_update(instance, value): def bind_display_value_to_servo_position(self): """Bind display_value to the servo's formattedPosition.""" # Unbind any previous bindings - self._unbind_all_display_value() + self.unbind_all_display_value() self._bound_servo = self.app.servo def on_servo_position_update(instance, value): @@ -144,6 +147,15 @@ def bind_btn_value_on_release(self, on_release_fn): self.ids.btn_value.disabled = False # Bind the new function self.ids.btn_value.bind(on_release=on_release_fn) + + def unbind_all_display_value(self): + if hasattr(self, "_bound_scale") and self._bound_scale is not None: + self._bound_scale.unbind(encoderCurrent=self._on_encoder_update) + self._bound_scale.unbind(formattedPosition=self._on_format_update) + self._bound_scale = None + if hasattr(self, "_bound_servo") and self._bound_servo is not None: + self._bound_servo.unbind(formattedPosition=self._on_servo_position_update) + self._bound_servo = None def update_action_button_state(self): """Evaluate whether the action button should be enabled.""" @@ -152,13 +164,5 @@ def update_action_button_state(self): else: self.action_button_enabled = True - def _unbind_all_display_value(self): - if hasattr(self, "_bound_scale") and self._bound_scale is not None: - self._bound_scale.unbind(encoderCurrent=self._on_encoder_update) - self._bound_scale.unbind(formattedPosition=self._on_format_update) - self._bound_scale = None - if hasattr(self, "_bound_servo") and self._bound_servo is not None: - self._bound_servo.unbind(formattedPosition=self._on_servo_position_update) - self._bound_servo = None diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index b7ac323..bbd9513 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -10,6 +10,10 @@ class AssistedThreadingWizard: def saddle_scale(self) -> CoordBar: return self.app.scales[self.bar.selected_saddle_scale_id] + @property + def cross_slide_scale(self) -> CoordBar: + return self.app.scales[self.bar.selected_cross_slide_scale_id] + def __init__(self, bar): self.bar = bar self.app = bar.app @@ -17,10 +21,13 @@ def __init__(self, bar): self.current_step = 0 self._current_callback = None self.manual_stop_length = None + self.manual_cutting_depth = None self._steps = [ - self._step_1_initial_position, - self._step_2_stop_position, - self._step_3_go_to_start, + self._step_set_initial_position, + self._step_set_stop_position, + self._step_set_material_width_position, + self._step_set_final_cutting_depth_position, + self._step_go_to_start, # ... same steps as before ... ] @@ -57,18 +64,33 @@ def set_instruction(self, label_text, next_button_text, next_button_callback, va self.bar.action_button_condition_fn = action_button_condition_fn # Instruction steps - def _step_1_initial_position(self): + #Step 1 + def _step_set_initial_position(self): self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) self.bar.bind_display_value_to_scale(self.saddle_scale) - def _step_2_stop_position(self): + #Step 2 + def _step_set_stop_position(self): self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) self.bar.bind_display_value_to_scale(self.saddle_scale) + + #Step 3 + def _step_set_material_width_position(self): + self.set_instruction("Set material width and press Set", "Set", self._capture_material_width_position) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) - def _step_3_go_to_start(self): + #Step 4 + def _step_set_final_cutting_depth_position(self): + self.bar.action_button_enabled = False # Disable until valid + self.set_instruction("Enter Final Cutting Depth", "Set", self._capture_final_cutting_depth_position, self._open_final_cutting_depth_position_keypad, self._is_valid_cutting_depth_position) + self.bar.unbind_all_display_value() + self.bar.display_value = "" # Clear display value since not bound to scale + + #Step 5 + def _step_go_to_start(self): self.set_instruction("Engage half nut and press Go to return to start position", "Go", self._go_to_start) - + # Step callbacks # Step 1 @@ -79,22 +101,31 @@ def _capture_initial_position(self, *args): log.info(f"Initial position set to: {self.bar.start_position}") #Step 2 - def _capture_stop_position(self, *args): - scale = self.saddle_scale - - if self.manual_stop_length is not None: - # convert length into encoder stop position - self.bar.stop_position = self._convert_stop_position_units_to_encoder(scale, self.manual_stop_length) - log.info(f"Stop position set manually: {self.manual_stop_length} " - f"(start={self.bar.start_position}, stop={self.bar.stop_position})") - self.manual_stop_length = None # reset for next run - else: - # default: take live encoder value - self.bar.stop_position = scale.encoderCurrent - log.info(f"Stop position set from scale: {self.bar.stop_position}" - f"(start={self.bar.start_position}, stop={self.bar.stop_position})") + def _capture_stop_position(self, *args): + self.bar.stop_position = self._get_stop_position_units() + self.manual_stop_length = None # reset for next run + log.info(f"Stop position set - (start={self.bar.start_position}, stop={self.bar.stop_position})") + + #Step 3 + def _capture_material_width_position(self, *args): + self.bar.material_width = self.cross_slide_scale.encoderCurrent + self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" + self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition + log.info(f"Material width set to: {self.bar.material_width}") - #Step 3 + #Step 4 + def _capture_final_cutting_depth_position(self, *args): + # convert length into encoder stop position + self.bar.cutting_depth = self._convert_position_units_to_encoder(self.cross_slide_scale, + self.manual_cutting_depth, + self._isMaterialWidthPositionMetricMode, + self._materialWidthScaledPosition, + self.bar.material_width) + + log.info(f"Cutting depth set manually: {self.manual_cutting_depth} " + f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") + + #Step 5 def _go_to_start(self, *args): log.info(f"Moving to start position: {self.bar.start_position} + retraction") @@ -127,8 +158,25 @@ def _is_valid_stop_position(self): - For right-hand threads, stop must be less than start. - For left-hand threads, stop must be greater than start.""" if self.bar.left_hand_thread: - return self.bar.start_position < self._get_stop_position_units(self.saddle_scale) - return self.bar.start_position > self._get_stop_position_units(self.saddle_scale) + return self.bar.start_position < self._get_stop_position_units() + return self.bar.start_position > self._get_stop_position_units() + + #Step 4 + def _is_valid_cutting_depth_position(self): + """Check if the cutting depth is valid given the material width position and if it's internal/external thread. + - For internal threads, cutting depth must be greater than material width. + - For external threads, cutting depth must be less than material width.""" + if self.bar.inner_thread: + return self.bar.material_width < self._convert_position_units_to_encoder(self.cross_slide_scale, + self.manual_cutting_depth, + self._isMaterialWidthPositionMetricMode, + self._materialWidthScaledPosition, + self.bar.material_width) + return self.bar.material_width > self._convert_position_units_to_encoder(self.cross_slide_scale, + self.manual_cutting_depth, + self._isMaterialWidthPositionMetricMode, + self._materialWidthScaledPosition, + self.bar.material_width) # Manual input handlers def _open_stop_position_keypad(self, *args): @@ -152,9 +200,35 @@ def on_done(value): keypad.show_with_callback(callback_fn=on_done, current_value=self.manual_stop_length or 0.0) + + def _open_final_cutting_depth_position_keypad(self, *args): + from rcp.components.keypad import Keypad + + is_metric = self.app.formats.current_format == "MM" + + keypad = Keypad(title="Enter Final Cutting Depth (" + ("mm" if is_metric else "in") + ")") + keypad.integer = False + + def on_done(value): + try: + self.manual_cutting_depth = float(value) + log.info(f"Manual cutting depth entered: {self.manual_cutting_depth}") + self.bar.display_value = f"{self.manual_cutting_depth:.3f}" if is_metric else f"{self.manual_cutting_depth:.4f}" + except ValueError: + log.warning(f"Invalid cutting depth input: {value}") + finally: + self.bar.update_action_button_state() + + keypad.show_with_callback(callback_fn=on_done, + current_value=self.manual_cutting_depth or 0.0) # Utilities - def _convert_stop_position_units_to_encoder(self, scale: CoordBar, manual_position: float) -> int: + def _convert_position_units_to_encoder(self, + scale: CoordBar, + manual_position: float, + is_original_position_metric_mode: bool, + original_scaled_position, + start_encoder_units: int) -> int: """ Convert a user-entered stop position (MM/IN) into encoder counts. Handles: @@ -165,16 +239,16 @@ def _convert_stop_position_units_to_encoder(self, scale: CoordBar, manual_positi # Determine factors current_factor = float(self.app.formats.factor) - factor_at_start_position = float(self.app.formats.MM_FRACTION if self._isStartPositionMetricMode else self.app.formats.INCHES_FRACTION) + factor_at_start_position = float(self.app.formats.MM_FRACTION if is_original_position_metric_mode else self.app.formats.INCHES_FRACTION) # Normalize manual input to the units used at start manual_in_start_units = manual_position * (factor_at_start_position / current_factor) # Compute delta relative to start scaled position - delta_in_start_units = manual_in_start_units - self._startScaledPosition + delta_in_start_units = manual_in_start_units - original_scaled_position log.info( - f"Manual stop input: {manual_position} " + f"Manual input: {manual_position} " f"(converted to start units: {manual_in_start_units}, " f"delta from start: {delta_in_start_units})" ) @@ -185,19 +259,29 @@ def _convert_stop_position_units_to_encoder(self, scale: CoordBar, manual_positi ) * (float(scale.ratioDen) / float(scale.ratioNum)) # Offset by the captured start position - final_encoder_position = int(round(self.bar.start_position + encoder_counts)) + final_encoder_position = int(round(start_encoder_units + encoder_counts)) log.info( f"Computed encoder counts: {final_encoder_position} " - f"(start_position={self.bar.start_position}, encoder delta={encoder_counts})" + f"(start_position={start_encoder_units}, encoder delta={encoder_counts})" ) return final_encoder_position - def _get_stop_position_units(self, scale: CoordBar) -> float: + def _get_stop_position_units(self) -> float: scale = self.saddle_scale if self.manual_stop_length is not None: - return self._convert_stop_position_units_to_encoder(scale, self.manual_stop_length) + log.info(f"Using manual stop length: {self.manual_stop_length}") + result = self._convert_position_units_to_encoder( + scale, + self.manual_stop_length, + self._isStartPositionMetricMode, + self._startScaledPosition, + self.bar.start_position + ) + log.info(f"Converted manual stop length to encoder units: {result}") + return result + log.info(f"Using live encoder value: {scale.encoderCurrent}") return scale.encoderCurrent def _convert_distance_units_to_encoder(self, scale: CoordBar, distance: float, is_metric: bool) -> int: diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index 65be6ec..9e84c3f 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -33,12 +33,6 @@ value: root.get_label_for_scale_id(root.assistedThreadingBar.selected_cross_slide_scale_id) if root.assistedThreadingBar else "" on_value: root.on_cross_slide_scale_selected(self.value) - - BooleanItem: - name: "Cross Slide Diameter Mode" - value: root.assistedThreadingBar.cross_slide_diameter_mode - on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value - TitleItem: name: "Speeds and Distance Settings" NumberItem: From a0e5eef1b299d27869c6dad8f155b113a4cd5bd2 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 5 Oct 2025 21:50:56 +0200 Subject: [PATCH 10/62] WIP - started adding logic for servo movement; requires testing; --- rcp/components/home/assisted_threading_bar.kv | 11 + rcp/components/home/assisted_threading_bar.py | 15 +- .../home/assisted_threading_wizard.py | 207 +++++++++++++++--- .../setup/assisted_threading_screen.kv | 8 +- rcp/utils/devices.py | 8 + 5 files changed, 212 insertions(+), 37 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.kv b/rcp/components/home/assisted_threading_bar.kv index 18304c1..7088aef 100644 --- a/rcp/components/home/assisted_threading_bar.kv +++ b/rcp/components/home/assisted_threading_bar.kv @@ -57,3 +57,14 @@ halign: "center" on_release: root.on_action_button_clicked() disabled: not root.action_button_enabled + + Button: + id: btn_retract + size_hint_x: None if root.retract_button_visible else 0 + width: self.height if root.retract_button_visible else 0 + opacity: 1 if root.retract_button_visible else 0 + text: 'Retract' + font_size: self.height / 4 + halign: "center" + on_press: root.on_retract_button_pressed() + on_release: root.on_retract_button_released() diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 55a1ff0..3ad8ab2 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -23,9 +23,10 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_saddle_scale_id = NumericProperty(1) reversing_speed = NumericProperty(500) + encoder_sync_tolerance = NumericProperty(5) metric_distances = BooleanProperty(True) # This is for the UI in the setting screen backlash_retraction_distance = NumericProperty(10) - backlash_cusion = NumericProperty(2) + backlash_cushion = NumericProperty(2) metric_mode = BooleanProperty(True) # This is for the actual threading logic selected_pitch = StringProperty("") @@ -43,6 +44,8 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): stop_position = NumericProperty(0) material_width = NumericProperty(0) cutting_depth = NumericProperty(0) + last_cutting_depth = NumericProperty(0) + retract_button_visible = BooleanProperty(False) _skip_save = [ "is_running", "action_button_enabled", @@ -52,6 +55,8 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): "stop_position", "material_width", "cutting_depth", + "last_cutting_depth", + "retract_button_visible" ] def __init__(self, **kv): @@ -69,6 +74,14 @@ def toggle_is_running(self): else: self.wizard.reset_ui() + def on_retract_button_pressed(self): + """Called when the retract button is pressed.""" + self.wizard.start_retracting() + + def on_retract_button_released(self): + """Called when the retract button is released.""" + self.wizard.stop_retracting() + def on_action_button_clicked(self): """Called when the right button is pressed.""" if self.is_running: diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index bbd9513..6329253 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -23,12 +23,14 @@ def __init__(self, bar): self.manual_stop_length = None self.manual_cutting_depth = None self._steps = [ - self._step_set_initial_position, - self._step_set_stop_position, - self._step_set_material_width_position, - self._step_set_final_cutting_depth_position, - self._step_go_to_start, - # ... same steps as before ... + self._step_set_initial_position, # Step 1 + self._step_set_stop_position, # Step 2 + self._step_set_material_width_position, # Step 3 + self._step_set_final_cutting_depth_position, # Step 4 + self._step_engage_half_nut, # Step 5 + self._step_go_to_start, # Step 6 + self._step_cut_thread, # Step 7 + self._step_depth_reached # Step 8 ] @@ -41,6 +43,7 @@ def reset_ui(self): self.bar.display_value = "" self.bar.action_button_enabled = True self.bar.action_button_condition_fn = None + self.app.device['fastData']['threadReset'] = 1 def goto_step(self, index): @@ -56,12 +59,27 @@ def goto_next_step(self, *args): self._current_callback(*args) self.goto_step(self.current_step + 1) - def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None): + def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False): self.bar.label_text = label_text self.bar.next_button_text = next_button_text self._current_callback = next_button_callback self.bar.bind_btn_value_on_release(value_button_fn) self.bar.action_button_condition_fn = action_button_condition_fn + self.bar.retract_button_visible = retract_button_visible + + def start_retracting(self): + if not self.app.connected: + return + + log.info("Retract button pressed") + #TODO implement retract logic + + def stop_retracting(self): + if not self.app.connected: + return + + log.info("Retract button released") + #TODO implement retract logic # Instruction steps #Step 1 @@ -72,12 +90,12 @@ def _step_set_initial_position(self): #Step 2 def _step_set_stop_position(self): self.bar.action_button_enabled = False # Disable until valid - self.set_instruction("Go to stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) + self.set_instruction("Go to or input stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) self.bar.bind_display_value_to_scale(self.saddle_scale) #Step 3 def _step_set_material_width_position(self): - self.set_instruction("Set material width and press Set", "Set", self._capture_material_width_position) + self.set_instruction("Go to material width and press Set", "Set", self._capture_material_width_position) self.bar.bind_display_value_to_scale(self.cross_slide_scale) #Step 4 @@ -87,10 +105,23 @@ def _step_set_final_cutting_depth_position(self): self.bar.unbind_all_display_value() self.bar.display_value = "" # Clear display value since not bound to scale - #Step 5 + #Step 5 + def _step_engage_half_nut(self): + self.set_instruction("Engage half nut and press Next", "Next", None) + self.bar.unbind_all_display_value() + self.bar.display_value = "" + + #Step 6 def _step_go_to_start(self): - self.set_instruction("Engage half nut and press Go to return to start position", "Go", self._go_to_start) + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted) + #Step 7 + def _step_cut_thread(self): + self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) + + #Step 8 + def _step_depth_reached(self): + self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", None, self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) # Step callbacks # Step 1 @@ -109,6 +140,7 @@ def _capture_stop_position(self, *args): #Step 3 def _capture_material_width_position(self, *args): self.bar.material_width = self.cross_slide_scale.encoderCurrent + self.bar.last_cutting_depth = self.bar.material_width # Initialize last_cutting_depth to material_width self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition log.info(f"Material width set to: {self.bar.material_width}") @@ -125,41 +157,91 @@ def _capture_final_cutting_depth_position(self, *args): log.info(f"Cutting depth set manually: {self.manual_cutting_depth} " f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") - #Step 5 + #Step 6 - TODO test this def _go_to_start(self, *args): + if not self.app.connected: + return + log.info(f"Moving to start position: {self.bar.start_position} + retraction") + + # --- Delta to move the servo --- + delta_steps = self._get_servo_delta_steps() - ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + _next_step: int + # Check if saddle is retracted further than or already at start position including the backlash - if so, move further than the actual retracted backlash position to take out backlash and go back + if (delta_steps >= 0 and self.bar.left_hand_thread) or (delta_steps <= 0 and not self.bar.left_hand_thread): + log.info("Saddle retracted further than or at start position") + delta_steps += (self._get_retraction_distance_encoder_steps() * (1 if self.bar.left_hand_thread else -1)) + log.info("Taking out backlash by moving further than retracted start position") + # If taking out backlash, we need to wait until the first move is done, then issue another move to go back to start position + _next_step = self.current_step # watch until done - then go to next step (which is this same step again) + else: + _next_step = self.current_step + 1 # Step 7 + # Check if at cutting depth + if (self._is_cross_slide_at_final_cutting_depth()): + _next_step += 1 # skip cutting step and go to step 8 (depth reached) + + # --- Issue servo move --- + self.bar.bind_display_value_to_servo_position() # bind to servo position + self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed + self.servo.servoEnable = 1 + self.app.device['servo']['direction'] = delta_steps # trigger move + + self.app.bind(update_tick=lambda *a: self._check_servo_done(_next_step, *a)) # watch until done - then go to next step + + #Step 7 - TODO test + def _start_threading_operation(self, *args): + if not self.app.connected: + return + #check that current position is at proper start position including the backlash retraction distance within the bar.backlash_cushion + retraction_distance = self._get_retraction_distance_encoder_steps() if self.bar.left_hand_thread: - target_scaled = self.bar.start_position - self._get_retraction_distance_encoder_steps() # subtract retraction - else: - target_scaled = self.bar.start_position + self._get_retraction_distance_encoder_steps() # add retraction - - current_scaled = self.saddle_scale.encoderCurrent - delta_steps = int((target_scaled - current_scaled) / ratio) - log.info(f"Computed move delta: {delta_steps} steps (target_scaled={target_scaled}, current_scaled={current_scaled}, ratio={ratio})") + desired_position = self.bar.start_position + retraction_distance + else: + desired_position = self.bar.start_position - retraction_distance - if delta_steps == 0: - log.info("Already at start position") - self.goto_step(self.current_step + 1) + if (abs(self.saddle_scale.encoderCurrent - desired_position) > self.bar.backlash_cushion): + log.warning("Not at valid start position including backlash cushion. Aborting threading operation.") + #TODO show error message in UI return - self.bar.bind_display_value_to_servo_position() # bind to servo position - self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed - self.servo.servoEnable = 1 # enable - self.app.device['servo']['direction'] = delta_steps # trigger move - self.app.bind(update_tick=self._check_servo_done) # watch until done + log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) + + target_servo_counts = self._get_servo_delta_steps() + + # Pick spindle index using get_spindle_scale + spindle_scale = self.app.get_spindle_scale() + spindle_index = spindle_scale.inputIndex if spindle_scale is not None else 0 + + tolerance = self.bar.encoder_sync_tolerance + + # Bind UI to servo position so the progress/pos displays servo scaledPosition + self.bar.bind_display_value_to_servo_position() + + # Write the fields into firmware via modbus/device wrapper + dev = self.app.device + dev['fastData']['threadDesiredSteps'] = target_servo_counts + dev['fastData']['threadSpindleIndex'] = spindle_index + dev['fastData']['threadTolerance'] = tolerance + + # Request latch+wait. Firmware will latch current spindle phase and wait until matched. + dev['fastData']['threadRequest'] = 1 + + self.app.bind(update_tick=lambda *a: self._check_servo_done(5, *a)) # watch until done - then go to step 6 (go to start) #Step Action button condition functions #Step 2 def _is_valid_stop_position(self): """Check if the stop position is valid given the start position and thread direction. - For right-hand threads, stop must be less than start. - - For left-hand threads, stop must be greater than start.""" + - For left-hand threads, stop must be greater than start. + - Stop position must be greater than the backlash retraction distance from start position so as to take out backlash when retracted further than start position""" + retraction_distance = self._get_retraction_distance_encoder_steps() + # Ensure stop position is beyond retraction distance from start if self.bar.left_hand_thread: - return self.bar.start_position < self._get_stop_position_units() - return self.bar.start_position > self._get_stop_position_units() + return self.bar.start_position + retraction_distance < self._get_stop_position_units() + return self.bar.start_position - retraction_distance > self._get_stop_position_units() #Step 4 def _is_valid_cutting_depth_position(self): @@ -178,6 +260,29 @@ def _is_valid_cutting_depth_position(self): self._materialWidthScaledPosition, self.bar.material_width) + #Step 6 - TODO test this + def _is_cross_slide_retracted(self): + """Check if the cross slide is retracted further than the material width if saddle is further than stop position.""" + check_saddle = False + if self.bar.left_hand_thread: + check_saddle = self.saddle_scale.encoderCurrent > self.bar.start_position + else: + check_saddle = self.saddle_scale.encoderCurrent < self.bar.start_position + + if not check_saddle: + return True # saddle is at a safe position, no need to check cross slide + + if self.bar.inner_thread: + return self.cross_slide_scale.encoderCurrent < self.bar.material_width + return self.cross_slide_scale.encoderCurrent > self.bar.material_width + + #Step 7 - TODO test this + def _is_cross_slide_at_cutting_depth(self): + """Check if the cross slide is at the cutting depth position.""" + if self.bar.inner_thread: + return self.cross_slide_scale.encoderCurrent >= self.bar.last_cutting_depth + return self.cross_slide_scale.encoderCurrent <= self.bar.last_cutting_depth + # Manual input handlers def _open_stop_position_keypad(self, *args): from rcp.components.keypad import Keypad @@ -308,9 +413,9 @@ def _get_retraction_distance_encoder_steps(self) -> int: """Get the retraction distance in encoder counts based on thread pitch and direction.""" return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_retraction_distance, self.bar.metric_distances) - def _check_servo_done(self, *args): + def _check_servo_done(self, next_step: int, *args): if self.app.fast_data_values['stepsToGo'] == 0: - log.info("Servo reached start position") + log.info("Servo reached desired position") self.servo.servoEnable = 0 # disable self.servo.set_max_speed(self.servo.maxSpeed) # restore speed @@ -318,4 +423,38 @@ def _check_servo_done(self, *args): self.app.unbind(update_tick=self._check_servo_done) # Advance workflow (skip callback loop!) - self.goto_step(self.current_step + 1) + self.goto_step(next_step) + + def _get_servo_delta_steps(self) -> int: + """Get current servo position in absolute counts.""" + # --- Convert scale encoder counts -> machine units --- + scale_ratio = Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) + current_machine_units = self.saddle_scale.encoderCurrent * scale_ratio + start_machine_units = self.bar.start_position * scale_ratio + + # --- Apply backlash retraction in machine units --- + retraction = self._get_retraction_distance_encoder_steps() * scale_ratio + if self.bar.left_hand_thread: + target_machine_units = start_machine_units - retraction + else: + target_machine_units = start_machine_units + retraction + + # --- Convert machine units -> servo steps --- + servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + target_servo_counts = int(target_machine_units / servo_ratio) + current_servo_counts = int(current_machine_units / servo_ratio) + + # --- Delta to move the servo --- + delta_steps = target_servo_counts - current_servo_counts + log.info( + f"Computed move delta: {delta_steps} steps " + f"(target={target_machine_units:.4f}, current={current_machine_units:.4f} {self.app.formats.current_format}, " + f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio})" + ) + return delta_steps + + def _is_cross_slide_at_final_cutting_depth(self): + """Check if the cross slide is at or more than the final cutting depth position.""" + if self.bar.inner_thread: + return self.cross_slide_scale.encoderCurrent >= self.bar.cutting_depth + return self.cross_slide_scale.encoderCurrent <= self.bar.cutting_depth \ No newline at end of file diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index 9e84c3f..f014aeb 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -39,6 +39,10 @@ name: "Reversing Speed (Steps/s)" value: root.assistedThreadingBar.reversing_speed on_value: root.set_reversing_speed(self.value) + NumberItem: + name: "Encoder sync tolerance (Steps)" + value: root.assistedThreadingBar.encoder_sync_tolerance + on_value: root.assistedThreadingBar.encoder_sync_tolerance = self.value BooleanItem: name: "Metric Distances" value: root.assistedThreadingBar.metric_distances @@ -49,5 +53,5 @@ on_value: root.assistedThreadingBar.backlash_retraction_distance = int(self.value) NumberItem: name: "Saddle backlash cushion (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash cushion (IN)" - value: root.assistedThreadingBar.backlash_cusion - on_value: root.assistedThreadingBar.backlash_cusion = int(self.value) \ No newline at end of file + value: root.assistedThreadingBar.backlash_cushion + on_value: root.assistedThreadingBar.backlash_cushion = int(self.value) \ No newline at end of file diff --git a/rcp/utils/devices.py b/rcp/utils/devices.py index d8e5cd3..73f7291 100644 --- a/rcp/utils/devices.py +++ b/rcp/utils/devices.py @@ -65,6 +65,14 @@ class FastData(BaseDevice): uint32_t cycles; uint32_t executionInterval; uint16_t servoEnable; + uint32_t threadDesiredSteps; + uint8_t threadRequest; + uint8_t threadReset; + uint16_t threadSpindleIndex; + uint32_t threadPhaseRef; + uint8_t threadHasPhase; + uint16_t threadEnabled; + uint16_t threadTolerance; } fastData_t; """ From 370abe9d29a01b0aa84efdc3ef4b1177d9b832da Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 12 Oct 2025 21:39:24 +0200 Subject: [PATCH 11/62] Added custom popup; Added warning popup when there is mismatch between expected saddle start position and current position; Fixed random bugs causing app to crash when not connected; Fixed wizard stop logic; Fixed goto_next_step logic to only proceed if wizard is running and IFF the callback allows it to proceed - this is required for when we are waiting for the servo to finish --- rcp/components/forms/custom_popup.kv | 21 +++++ rcp/components/forms/custom_popup.py | 40 ++++++++++ rcp/components/home/assisted_threading_bar.py | 3 +- .../home/assisted_threading_wizard.py | 80 ++++++++++++++----- 4 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 rcp/components/forms/custom_popup.kv create mode 100644 rcp/components/forms/custom_popup.py diff --git a/rcp/components/forms/custom_popup.kv b/rcp/components/forms/custom_popup.kv new file mode 100644 index 0000000..7bfc6a1 --- /dev/null +++ b/rcp/components/forms/custom_popup.kv @@ -0,0 +1,21 @@ +: + orientation: "vertical" + padding: dp(20) + spacing: dp(10) + + Label: + id: lbl_message + text: root.message + halign: "center" + valign: "middle" + text_size: self.size + color: [1, 1, 1, 1] + font_size: "18sp" + + Button: + id: btn_close + text: root.button_text + size_hint_y: None + height: dp(48) + background_color: [0.2, 0.2, 0.2, 1] + on_release: root.on_button_press() \ No newline at end of file diff --git a/rcp/components/forms/custom_popup.py b/rcp/components/forms/custom_popup.py new file mode 100644 index 0000000..1cc5d50 --- /dev/null +++ b/rcp/components/forms/custom_popup.py @@ -0,0 +1,40 @@ +import os +from kivy.logger import Logger +from kivy.properties import StringProperty, ObjectProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.popup import Popup +from kivy.lang import Builder + +log = Logger.getChild(__name__) +kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) +if os.path.exists(kv_file): + log.info(f"Loading KV file {kv_file}") + Builder.load_file(kv_file) + + +class CustomPopup(BoxLayout): + title = StringProperty("") + message = StringProperty("") + button_text = StringProperty("OK") + on_dismiss_callback = ObjectProperty(None, allownone=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._popup = Popup( + title=self.title, + content=self, + size_hint=(0.6, 0.4), + auto_dismiss=False, + ) + + def open(self): + self._popup.title = self.title + self._popup.open() + + def dismiss(self): + self._popup.dismiss() + + def on_button_press(self): + if self.on_dismiss_callback: + self.on_dismiss_callback() + self.dismiss() \ No newline at end of file diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 3ad8ab2..6e849b1 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -68,11 +68,10 @@ def __init__(self, **kv): def toggle_is_running(self): self.is_running = not self.is_running - self.app.servo.toggle_enable() if self.is_running: self.wizard.start() else: - self.wizard.reset_ui() + self.wizard.stop() def on_retract_button_pressed(self): """Called when the retract button is pressed.""" diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 6329253..d50ef01 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -1,6 +1,7 @@ from fractions import Fraction from kivy.logger import Logger +from rcp.components.forms.custom_popup import CustomPopup from rcp.components.home.coordbar import CoordBar log = Logger.getChild(__name__) @@ -20,6 +21,7 @@ def __init__(self, bar): self.servo = self.app.servo self.current_step = 0 self._current_callback = None + self._servo_watch_callback = None self.manual_stop_length = None self.manual_cutting_depth = None self._steps = [ @@ -37,13 +39,21 @@ def __init__(self, bar): def start(self): self.goto_step(0) - def reset_ui(self): + def stop(self): # Reset wizard_area to default content + log.info("Wizard finished") + self._current_callback = None self.bar.label_text = "" self.bar.display_value = "" self.bar.action_button_enabled = True self.bar.action_button_condition_fn = None - self.app.device['fastData']['threadReset'] = 1 + self.bar.is_running = False + self.bar.retract_button_visible = False + self.bar.unbind_all_display_value() + self.bar.display_value = "" + + if self.app.connected: + self.app.device['fastData']['threadReset'] = 1 def goto_step(self, index): @@ -51,13 +61,20 @@ def goto_step(self, index): if 0 <= index < len(self._steps): self._steps[index]() else: - log.info("Wizard finished") - self.bar.is_running = False + self.stop() def goto_next_step(self, *args): + # call the callback; it may return False to tell us "do not auto-advance" + result = None if self._current_callback: - self._current_callback(*args) - self.goto_step(self.current_step + 1) + result = self._current_callback(*args) + + # If callback returned exactly False => callback will handle advancement later + if result is False: + return + + if self.bar.is_running: # check to ensure still running and we didn't stop in the callback + self.goto_step(self.current_step + 1) def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False): self.bar.label_text = label_text @@ -121,7 +138,7 @@ def _step_cut_thread(self): #Step 8 def _step_depth_reached(self): - self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", None, self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) + self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) # Step callbacks # Step 1 @@ -130,12 +147,14 @@ def _capture_initial_position(self, *args): self._isStartPositionMetricMode = self.app.formats.current_format == "MM" self._startScaledPosition = self.saddle_scale.scaledPosition log.info(f"Initial position set to: {self.bar.start_position}") + return True # advance to next step #Step 2 def _capture_stop_position(self, *args): self.bar.stop_position = self._get_stop_position_units() self.manual_stop_length = None # reset for next run log.info(f"Stop position set - (start={self.bar.start_position}, stop={self.bar.stop_position})") + return True # advance to next step #Step 3 def _capture_material_width_position(self, *args): @@ -144,6 +163,7 @@ def _capture_material_width_position(self, *args): self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition log.info(f"Material width set to: {self.bar.material_width}") + return True # advance to next step #Step 4 def _capture_final_cutting_depth_position(self, *args): @@ -155,12 +175,14 @@ def _capture_final_cutting_depth_position(self, *args): self.bar.material_width) log.info(f"Cutting depth set manually: {self.manual_cutting_depth} " - f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") + f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") + return True # advance to next step #Step 6 - TODO test this def _go_to_start(self, *args): if not self.app.connected: - return + self.stop() + return False # tell goto_next_step not to advance immediately log.info(f"Moving to start position: {self.bar.start_position} + retraction") @@ -174,7 +196,7 @@ def _go_to_start(self, *args): delta_steps += (self._get_retraction_distance_encoder_steps() * (1 if self.bar.left_hand_thread else -1)) log.info("Taking out backlash by moving further than retracted start position") # If taking out backlash, we need to wait until the first move is done, then issue another move to go back to start position - _next_step = self.current_step # watch until done - then go to next step (which is this same step again) + _next_step = self.current_step # Use this same step again else: _next_step = self.current_step + 1 # Step 7 # Check if at cutting depth @@ -186,13 +208,16 @@ def _go_to_start(self, *args): self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed self.servo.servoEnable = 1 self.app.device['servo']['direction'] = delta_steps # trigger move - - self.app.bind(update_tick=lambda *a: self._check_servo_done(_next_step, *a)) # watch until done - then go to next step + + self._servo_watch_callback = lambda *a: self._check_servo_done(_next_step, *a) # watch until done - then go to next step + self.app.bind(update_tick=self._servo_watch_callback) + return False #tell goto_next_step not to advance immediately #Step 7 - TODO test def _start_threading_operation(self, *args): if not self.app.connected: - return + self.stop() + return False # tell goto_next_step not to advance immediately #check that current position is at proper start position including the backlash retraction distance within the bar.backlash_cushion retraction_distance = self._get_retraction_distance_encoder_steps() @@ -201,10 +226,22 @@ def _start_threading_operation(self, *args): else: desired_position = self.bar.start_position - retraction_distance + log.info(f"Validating start position: current={self.saddle_scale.encoderCurrent}, desired={desired_position} (start={self.bar.start_position}, retraction={retraction_distance})") if (abs(self.saddle_scale.encoderCurrent - desired_position) > self.bar.backlash_cushion): - log.warning("Not at valid start position including backlash cushion. Aborting threading operation.") - #TODO show error message in UI - return + _warning = "Not at valid start position including backlash cushion. Aborting threading operation. Go back to start position." + log.warning(_warning) + + def _acknowledge_warning(): + self.goto_step(5) # go back to step 6 - Go to start + + popup = CustomPopup( + title="Warning", + message=_warning, + button_text="Got it", + on_dismiss_callback=_acknowledge_warning + ) + popup.open() + return False # tell goto_next_step not to advance immediately log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) @@ -228,7 +265,11 @@ def _start_threading_operation(self, *args): # Request latch+wait. Firmware will latch current spindle phase and wait until matched. dev['fastData']['threadRequest'] = 1 - self.app.bind(update_tick=lambda *a: self._check_servo_done(5, *a)) # watch until done - then go to step 6 (go to start) + + self._servo_watch_callback = lambda *a: self._check_servo_done(5, *a) # watch until done - then go to step 6 (go to start) + self.app.bind(update_tick=self._servo_watch_callback) + return False #tell goto_next_step not to advance immediately + #Step Action button condition functions #Step 2 @@ -420,9 +461,10 @@ def _check_servo_done(self, next_step: int, *args): self.servo.set_max_speed(self.servo.maxSpeed) # restore speed # Stop watching - self.app.unbind(update_tick=self._check_servo_done) + if self._servo_watch_callback: + self.app.unbind(update_tick=self._servo_watch_callback) + self._servo_watch_callback = None - # Advance workflow (skip callback loop!) self.goto_step(next_step) def _get_servo_delta_steps(self) -> int: From 53548c210d171e681340f92af80cd87e5da8933a Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 19 Oct 2025 20:07:43 +0200 Subject: [PATCH 12/62] Added custom hold button so that it can keep tracking of press state even if cursor leaves button area; Added logic for retracting of saddle; Added logic to stop wizard if switching between modes; --- rcp/components/forms/hold_button.py | 30 ++++++++++++ rcp/components/home/assisted_threading_bar.kv | 3 +- rcp/components/home/assisted_threading_bar.py | 4 ++ .../home/assisted_threading_wizard.py | 48 ++++++++++++------- rcp/components/home/home_page.py | 3 ++ 5 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 rcp/components/forms/hold_button.py diff --git a/rcp/components/forms/hold_button.py b/rcp/components/forms/hold_button.py new file mode 100644 index 0000000..88dfd1c --- /dev/null +++ b/rcp/components/forms/hold_button.py @@ -0,0 +1,30 @@ +import os + +from kivy.logger import Logger +from kivy.uix.button import Button + +log = Logger.getChild(__name__) + +#Hold button created to keep tracking of press state even if cursor leaves button area +class HoldButton(Button): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._is_pressed = False + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + self._is_pressed = True + self.dispatch('on_press') + # Grab the touch to keep receiving its events even if cursor leaves + touch.grab(self) + return True + return super().on_touch_down(touch) + + def on_touch_up(self, touch): + if touch.grab_current is self: + # Ensure we get the release even if outside button + self._is_pressed = False + self.dispatch('on_release') + touch.ungrab(self) + return True + return super().on_touch_up(touch) diff --git a/rcp/components/home/assisted_threading_bar.kv b/rcp/components/home/assisted_threading_bar.kv index 7088aef..fe804d7 100644 --- a/rcp/components/home/assisted_threading_bar.kv +++ b/rcp/components/home/assisted_threading_bar.kv @@ -58,8 +58,9 @@ on_release: root.on_action_button_clicked() disabled: not root.action_button_enabled - Button: + HoldButton: id: btn_retract + font_name: "fonts/iosevka-regular.ttf" size_hint_x: None if root.retract_button_visible else 0 width: self.height if root.retract_button_visible else 0 opacity: 1 if root.retract_button_visible else 0 diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 6e849b1..8bf9ad2 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -5,6 +5,7 @@ from kivy.uix.boxlayout import BoxLayout from kivy.properties import NumericProperty, BooleanProperty, StringProperty +from rcp.components.forms.hold_button import HoldButton from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.coordbar import CoordBar @@ -71,6 +72,9 @@ def toggle_is_running(self): if self.is_running: self.wizard.start() else: + self.stop_wizard() + + def stop_wizard(self): self.wizard.stop() def on_retract_button_pressed(self): diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index d50ef01..6d61efa 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -49,8 +49,8 @@ def stop(self): self.bar.action_button_condition_fn = None self.bar.is_running = False self.bar.retract_button_visible = False - self.bar.unbind_all_display_value() - self.bar.display_value = "" + self._clear_bar_display() + self._stop_servo() if self.app.connected: self.app.device['fastData']['threadReset'] = 1 @@ -84,19 +84,26 @@ def set_instruction(self, label_text, next_button_text, next_button_callback, va self.bar.action_button_condition_fn = action_button_condition_fn self.bar.retract_button_visible = retract_button_visible - def start_retracting(self): + #TODO test this + def start_retracting(self): + log.info("Retract button pressed") + self.bar.action_button_enabled = False # disable action button while retracting + if not self.app.connected: return - - log.info("Retract button pressed") - #TODO implement retract logic + self.bar.bind_display_value_to_servo_position() # bind to servo position + self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed + self.servo.servoEnable = 2 + #TODO test this def stop_retracting(self): + log.info("Retract button released") + self.bar.action_button_enabled = True # re-enable action button + self._clear_bar_display() + if not self.app.connected: return - - log.info("Retract button released") - #TODO implement retract logic + self._stop_servo() # Instruction steps #Step 1 @@ -119,14 +126,12 @@ def _step_set_material_width_position(self): def _step_set_final_cutting_depth_position(self): self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Enter Final Cutting Depth", "Set", self._capture_final_cutting_depth_position, self._open_final_cutting_depth_position_keypad, self._is_valid_cutting_depth_position) - self.bar.unbind_all_display_value() - self.bar.display_value = "" # Clear display value since not bound to scale + self._clear_bar_display() #Step 5 def _step_engage_half_nut(self): self.set_instruction("Engage half nut and press Next", "Next", None) - self.bar.unbind_all_display_value() - self.bar.display_value = "" + self._clear_bar_display() #Step 6 def _step_go_to_start(self): @@ -213,7 +218,7 @@ def _go_to_start(self, *args): self.app.bind(update_tick=self._servo_watch_callback) return False #tell goto_next_step not to advance immediately - #Step 7 - TODO test + #Step 7 - TODO test this def _start_threading_operation(self, *args): if not self.app.connected: self.stop() @@ -457,8 +462,7 @@ def _get_retraction_distance_encoder_steps(self) -> int: def _check_servo_done(self, next_step: int, *args): if self.app.fast_data_values['stepsToGo'] == 0: log.info("Servo reached desired position") - self.servo.servoEnable = 0 # disable - self.servo.set_max_speed(self.servo.maxSpeed) # restore speed + self._stop_servo() # Stop watching if self._servo_watch_callback: @@ -499,4 +503,14 @@ def _is_cross_slide_at_final_cutting_depth(self): """Check if the cross slide is at or more than the final cutting depth position.""" if self.bar.inner_thread: return self.cross_slide_scale.encoderCurrent >= self.bar.cutting_depth - return self.cross_slide_scale.encoderCurrent <= self.bar.cutting_depth \ No newline at end of file + return self.cross_slide_scale.encoderCurrent <= self.bar.cutting_depth + + def _stop_servo(self): + if not self.app.connected: + return + self.servo.servoEnable = 0 # disable + self.servo.set_max_speed(self.servo.maxSpeed) # restore speed + + def _clear_bar_display(self): + self.bar.unbind_all_display_value() + self.bar.display_value = "" \ No newline at end of file diff --git a/rcp/components/home/home_page.py b/rcp/components/home/home_page.py index ed460c1..cd345fd 100644 --- a/rcp/components/home/home_page.py +++ b/rcp/components/home/home_page.py @@ -69,14 +69,17 @@ def change_mode_speed_check(self, instance): # Visualize things properly if self.next_mode == 1: # IDX self.bars_container: BoxLayout + self.app.assistedThreadingBar.stop_wizard() self.bars_container.remove_widget(self.bars_container.children[0]) self.bars_container.add_widget(self.app.servo) if self.next_mode == 2: # ELS self.bars_container: BoxLayout + self.app.assistedThreadingBar.stop_wizard() self.bars_container.remove_widget(self.bars_container.children[0]) self.bars_container.add_widget(self.els_bar) if self.next_mode == 3: # JOG self.bars_container: BoxLayout + self.app.assistedThreadingBar.stop_wizard() self.bars_container.remove_widget(self.bars_container.children[0]) self.bars_container.add_widget(self.jog_bar) if self.next_mode == 4: # AT From 1d02abe3e3dcff2492980c9a57fc2b3046af0a53 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Mon, 29 Dec 2025 18:48:33 +0100 Subject: [PATCH 13/62] Fixed issue with fastdata assisted threading params to use uint16_t instead of uint8_t as this was causing issues when communicating with the firmware; Started fixing bugs with scales not always having positive ratios; Fixed issue with retraction jog_speed; Fixed issue with servo not being turned off when stopping Assisted Threading; Fixed issue with go_to_start step not properly checking that cross_slide has been retracted; --- .../home/assisted_threading_wizard.py | 152 +++++++++++------- rcp/utils/devices.py | 6 +- 2 files changed, 98 insertions(+), 60 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 6d61efa..171416c 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -16,6 +16,7 @@ def cross_slide_scale(self) -> CoordBar: return self.app.scales[self.bar.selected_cross_slide_scale_id] def __init__(self, bar): + log.info("Initializing AssistedThreadingWizard") self.bar = bar self.app = bar.app self.servo = self.app.servo @@ -50,10 +51,10 @@ def stop(self): self.bar.is_running = False self.bar.retract_button_visible = False self._clear_bar_display() - self._stop_servo() if self.app.connected: self.app.device['fastData']['threadReset'] = 1 + self._stop_servo() def goto_step(self, index): @@ -84,7 +85,6 @@ def set_instruction(self, label_text, next_button_text, next_button_callback, va self.bar.action_button_condition_fn = action_button_condition_fn self.bar.retract_button_visible = retract_button_visible - #TODO test this def start_retracting(self): log.info("Retract button pressed") self.bar.action_button_enabled = False # disable action button while retracting @@ -92,10 +92,9 @@ def start_retracting(self): if not self.app.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position - self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed + self.servo.jogSpeed = self.bar.reversing_speed # set to reversing speed self.servo.servoEnable = 2 - #TODO test this def stop_retracting(self): log.info("Retract button released") self.bar.action_button_enabled = True # re-enable action button @@ -135,7 +134,10 @@ def _step_engage_half_nut(self): #Step 6 def _step_go_to_start(self): + self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_action_button_state() #Step 7 def _step_cut_thread(self): @@ -183,7 +185,7 @@ def _capture_final_cutting_depth_position(self, *args): f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") return True # advance to next step - #Step 6 - TODO test this + #Step 6 - TODO test this and fix it - no need for retraction check here def _go_to_start(self, *args): if not self.app.connected: self.stop() @@ -193,6 +195,7 @@ def _go_to_start(self, *args): # --- Delta to move the servo --- delta_steps = self._get_servo_delta_steps() + log.info(f"Calculated delta steps to start position: {delta_steps}") _next_step: int # Check if saddle is retracted further than or already at start position including the backlash - if so, move further than the actual retracted backlash position to take out backlash and go back @@ -282,45 +285,70 @@ def _is_valid_stop_position(self): """Check if the stop position is valid given the start position and thread direction. - For right-hand threads, stop must be less than start. - For left-hand threads, stop must be greater than start. - - Stop position must be greater than the backlash retraction distance from start position so as to take out backlash when retracted further than start position""" - retraction_distance = self._get_retraction_distance_encoder_steps() - # Ensure stop position is beyond retraction distance from start - if self.bar.left_hand_thread: - return self.bar.start_position + retraction_distance < self._get_stop_position_units() - return self.bar.start_position - retraction_distance > self._get_stop_position_units() + - Stop position must be greater than the backlash retraction distance from start position so as to take out backlash when retracted further than start position + - Depending on sign of the scale ratioNum/ratioDen, this will also affect the calculation""" + # Thread direction: LH → +, RH → - + thread_dir = 1 if self.bar.left_hand_thread else -1 + # Scale direction from ratio sign + scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 + + effective_dir = thread_dir * scale_dir + retraction = abs(self._get_retraction_distance_encoder_steps()) + stop = self._get_stop_position_units() + min_stop = self.bar.start_position + effective_dir * retraction + return (stop - min_stop) * effective_dir > 0 #Step 4 def _is_valid_cutting_depth_position(self): """Check if the cutting depth is valid given the material width position and if it's internal/external thread. - For internal threads, cutting depth must be greater than material width. - For external threads, cutting depth must be less than material width.""" - if self.bar.inner_thread: - return self.bar.material_width < self._convert_position_units_to_encoder(self.cross_slide_scale, - self.manual_cutting_depth, - self._isMaterialWidthPositionMetricMode, - self._materialWidthScaledPosition, - self.bar.material_width) - return self.bar.material_width > self._convert_position_units_to_encoder(self.cross_slide_scale, - self.manual_cutting_depth, - self._isMaterialWidthPositionMetricMode, - self._materialWidthScaledPosition, - self.bar.material_width) - - #Step 6 - TODO test this + # Physical cutting direction + # Internal → outward (+), External → inward (-) + thread_dir = 1 if self.bar.inner_thread else -1 + # Encoder direction + scale_dir = 1 if self.cross_slide_scale.ratioNum * self.cross_slide_scale.ratioDen > 0 else -1 + + effective_dir = thread_dir * scale_dir + target_depth = self._convert_position_units_to_encoder( + self.cross_slide_scale, + self.manual_cutting_depth, + self._isMaterialWidthPositionMetricMode, + self._materialWidthScaledPosition, + self.bar.material_width + ) + return (target_depth - self.bar.material_width) * effective_dir > 0 + + #Step 6 def _is_cross_slide_retracted(self): - """Check if the cross slide is retracted further than the material width if saddle is further than stop position.""" - check_saddle = False - if self.bar.left_hand_thread: - check_saddle = self.saddle_scale.encoderCurrent > self.bar.start_position - else: - check_saddle = self.saddle_scale.encoderCurrent < self.bar.start_position - - if not check_saddle: - return True # saddle is at a safe position, no need to check cross slide - - if self.bar.inner_thread: - return self.cross_slide_scale.encoderCurrent < self.bar.material_width - return self.cross_slide_scale.encoderCurrent > self.bar.material_width + """ + Check if the cross slide is safely retracted when the saddle has moved beyond the threading start position. + """ + log.info("Checking if cross slide is retracted for threading start...") + + # --- Saddle direction check (Z axis) --- + thread_dir_z = 1 if self.bar.left_hand_thread else -1 + scale_dir_saddle = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 + saddle_dir = thread_dir_z * scale_dir_saddle + + saddle_delta = self.saddle_scale.encoderCurrent - self.bar.start_position + saddle_beyond_start = saddle_delta * saddle_dir > 0 + + if not saddle_beyond_start: + log.info("Saddle is not beyond start position, no need to check cross slide") + return True + + log.info("Saddle is beyond start position, checking cross slide retraction") + + # --- Cross-slide retraction check (X axis) --- + # External → retract outward (+), Internal → retract inward (-) + thread_dir_x = 1 if not self.bar.inner_thread else -1 + scale_dir_cross_slide = 1 if self.cross_slide_scale.ratioNum * self.cross_slide_scale.ratioDen > 0 else -1 + retract_dir = thread_dir_x * scale_dir_cross_slide + + cross_delta = self.cross_slide_scale.encoderCurrent - self.bar.material_width + return cross_delta * retract_dir > 0 + #Step 7 - TODO test this def _is_cross_slide_at_cutting_depth(self): @@ -472,31 +500,40 @@ def _check_servo_done(self, next_step: int, *args): self.goto_step(next_step) def _get_servo_delta_steps(self) -> int: - """Get current servo position in absolute counts.""" - # --- Convert scale encoder counts -> machine units --- - scale_ratio = Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) - current_machine_units = self.saddle_scale.encoderCurrent * scale_ratio - start_machine_units = self.bar.start_position * scale_ratio + """ + Compute the servo step delta needed to move the saddle + to the retracted start position, accounting for: + - thread handedness + - encoder direction + - backlash retraction + """ - # --- Apply backlash retraction in machine units --- - retraction = self._get_retraction_distance_encoder_steps() * scale_ratio - if self.bar.left_hand_thread: - target_machine_units = start_machine_units - retraction - else: - target_machine_units = start_machine_units + retraction + # Physical Z direction: LH → +, RH → - + thread_dir = 1 if self.bar.left_hand_thread else -1 - # --- Convert machine units -> servo steps --- + # Saddle encoder direction + scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 + + effective_dir = thread_dir * scale_dir + + retraction = abs(self._get_retraction_distance_encoder_steps()) + + target_encoder = self.bar.start_position + effective_dir * retraction + current_encoder = self.saddle_scale.encoderCurrent + + # Convert encoder delta → servo steps + scale_ratio = Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) - target_servo_counts = int(target_machine_units / servo_ratio) - current_servo_counts = int(current_machine_units / servo_ratio) - # --- Delta to move the servo --- - delta_steps = target_servo_counts - current_servo_counts + delta_steps = int((target_encoder - current_encoder) * scale_ratio / servo_ratio) + log.info( - f"Computed move delta: {delta_steps} steps " - f"(target={target_machine_units:.4f}, current={current_machine_units:.4f} {self.app.formats.current_format}, " - f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio})" + f"Computed servo delta: {delta_steps} steps " + f"(target_enc={target_encoder}, current_enc={current_encoder}, " + f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " + f"dir={effective_dir})" ) + return delta_steps def _is_cross_slide_at_final_cutting_depth(self): @@ -508,8 +545,9 @@ def _is_cross_slide_at_final_cutting_depth(self): def _stop_servo(self): if not self.app.connected: return - self.servo.servoEnable = 0 # disable self.servo.set_max_speed(self.servo.maxSpeed) # restore speed + self.servo.servoEnable = 0 # disable + self.app.device['fastData']['servoEnable'] = 0 # ensure disabled in fastData def _clear_bar_display(self): self.bar.unbind_all_display_value() diff --git a/rcp/utils/devices.py b/rcp/utils/devices.py index 73f7291..b3f19af 100644 --- a/rcp/utils/devices.py +++ b/rcp/utils/devices.py @@ -66,11 +66,11 @@ class FastData(BaseDevice): uint32_t executionInterval; uint16_t servoEnable; uint32_t threadDesiredSteps; - uint8_t threadRequest; - uint8_t threadReset; + uint16_t threadRequest; + uint16_t threadReset; uint16_t threadSpindleIndex; uint32_t threadPhaseRef; - uint8_t threadHasPhase; + uint16_t threadHasPhase; uint16_t threadEnabled; uint16_t threadTolerance; } fastData_t; From 2476cf09de7adf1b4e0e1d02451ced8da6f62195 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Tue, 30 Dec 2025 18:08:17 +0100 Subject: [PATCH 14/62] Added log_caller to the communication.py for debugging purposes; WIP - further bug fixes on the assisted threading wizard; Minor code clean-up on assited threading wizard; --- .../home/assisted_threading_wizard.py | 239 ++++++++++++------ rcp/utils/communication.py | 12 + uv.lock | 3 +- 3 files changed, 175 insertions(+), 79 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 171416c..3f52441 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -92,13 +92,14 @@ def start_retracting(self): if not self.app.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position - self.servo.jogSpeed = self.bar.reversing_speed # set to reversing speed + self.servo.jogSpeed = -self.bar.reversing_speed # set to reversing speed self.servo.servoEnable = 2 def stop_retracting(self): log.info("Retract button released") self.bar.action_button_enabled = True # re-enable action button - self._clear_bar_display() + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_action_button_state() if not self.app.connected: return @@ -141,11 +142,17 @@ def _step_go_to_start(self): #Step 7 def _step_cut_thread(self): + self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_action_button_state() #Step 8 def _step_depth_reached(self): + self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_action_button_state() # Step callbacks # Step 1 @@ -185,63 +192,94 @@ def _capture_final_cutting_depth_position(self, *args): f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") return True # advance to next step - #Step 6 - TODO test this and fix it - no need for retraction check here + #Step 6 def _go_to_start(self, *args): if not self.app.connected: self.stop() return False # tell goto_next_step not to advance immediately - + log.info(f"Moving to start position: {self.bar.start_position} + retraction") # --- Delta to move the servo --- - delta_steps = self._get_servo_delta_steps() + delta_steps = self._get_start_position_servo_delta_steps() log.info(f"Calculated delta steps to start position: {delta_steps}") _next_step: int - # Check if saddle is retracted further than or already at start position including the backlash - if so, move further than the actual retracted backlash position to take out backlash and go back - if (delta_steps >= 0 and self.bar.left_hand_thread) or (delta_steps <= 0 and not self.bar.left_hand_thread): - log.info("Saddle retracted further than or at start position") - delta_steps += (self._get_retraction_distance_encoder_steps() * (1 if self.bar.left_hand_thread else -1)) - log.info("Taking out backlash by moving further than retracted start position") - # If taking out backlash, we need to wait until the first move is done, then issue another move to go back to start position + # --- Direction model (same as servo delta) --- + effective_dir = self._get_saddle_scale_effective_dir() + retraction_dir = - effective_dir # retraction is opposite to cutting direction + + retraction = abs(self._get_retraction_distance_encoder_steps()) + + # Are we already beyond the retracted start? + already_retracted = ( + (self.saddle_scale.encoderCurrent - self.bar.start_position) + * retraction_dir >= retraction + ) + log.info(f"Already retracted check: current={self.saddle_scale.encoderCurrent}, start={self.bar.start_position}, retraction={retraction}, effective_dir={effective_dir}, retraction_dir={retraction_dir} => {already_retracted}") + + if already_retracted: + log.info("Saddle already beyond retracted start; taking out backlash") + + # Move further in SAME effective direction + delta_steps += int( + effective_dir * retraction + * Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) + / Fraction(self.servo.ratioNum, self.servo.ratioDen) + ) + _next_step = self.current_step # Use this same step again else: _next_step = self.current_step + 1 # Step 7 # Check if at cutting depth - if (self._is_cross_slide_at_final_cutting_depth()): + if self._is_cross_slide_at_final_cutting_depth(): _next_step += 1 # skip cutting step and go to step 8 (depth reached) # --- Issue servo move --- - self.bar.bind_display_value_to_servo_position() # bind to servo position - self.servo.set_max_speed(self.bar.reversing_speed) # set to reversing speed + self.bar.bind_display_value_to_servo_position() + self.servo.set_max_speed(self.bar.reversing_speed) self.servo.servoEnable = 1 - self.app.device['servo']['direction'] = delta_steps # trigger move - - self._servo_watch_callback = lambda *a: self._check_servo_done(_next_step, *a) # watch until done - then go to next step - self.app.bind(update_tick=self._servo_watch_callback) - return False #tell goto_next_step not to advance immediately + self.app.device['servo']['direction'] = delta_steps + + self._servo_watch_callback = lambda *a: self._check_servo_done(_next_step, *a) + self.app.bind(update_tick=self._servo_watch_callback) + + return False # tell goto_next_step not to advance immediately #Step 7 - TODO test this def _start_threading_operation(self, *args): if not self.app.connected: self.stop() return False # tell goto_next_step not to advance immediately - - #check that current position is at proper start position including the backlash retraction distance within the bar.backlash_cushion - retraction_distance = self._get_retraction_distance_encoder_steps() - if self.bar.left_hand_thread: - desired_position = self.bar.start_position + retraction_distance - else: - desired_position = self.bar.start_position - retraction_distance - - log.info(f"Validating start position: current={self.saddle_scale.encoderCurrent}, desired={desired_position} (start={self.bar.start_position}, retraction={retraction_distance})") - if (abs(self.saddle_scale.encoderCurrent - desired_position) > self.bar.backlash_cushion): - _warning = "Not at valid start position including backlash cushion. Aborting threading operation. Go back to start position." + + # --- Determine effective direction --- + effective_dir = self._get_saddle_scale_effective_dir() + retraction_dir = - effective_dir # retraction is opposite to cutting direction + retraction_distance = abs(self._get_retraction_distance_encoder_steps()) + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) + + # Compute the desired encoder position at start including retraction + desired_position = self.bar.start_position + retraction_dir * retraction_distance + + log.info( + f"Validating start position: current={self.saddle_scale.encoderCurrent}, " + f"desired={desired_position} (start={self.bar.start_position}, retraction={retraction_distance}, " + f"backlash_cushion={backlash_cushion}, retraction_dir={retraction_dir})" + ) + + lower_bound = desired_position - backlash_cushion + upper_bound = desired_position + backlash_cushion + # Check if we are within backlash cushion + if not (lower_bound <= self.saddle_scale.encoderCurrent <= upper_bound): + _warning = ( + "Not at valid start position including backlash cushion. " + "Aborting threading operation. Go back to start position." + ) log.warning(_warning) - + def _acknowledge_warning(): - self.goto_step(5) # go back to step 6 - Go to start - + self.goto_step(5) # go back to Step 6 - Go to start + popup = CustomPopup( title="Warning", message=_warning, @@ -250,20 +288,21 @@ def _acknowledge_warning(): ) popup.open() return False # tell goto_next_step not to advance immediately - + log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) - - target_servo_counts = self._get_servo_delta_steps() - + self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position + target_servo_counts = self._get_threading_servo_delta_steps() + # Pick spindle index using get_spindle_scale spindle_scale = self.app.get_spindle_scale() spindle_index = spindle_scale.inputIndex if spindle_scale is not None else 0 - tolerance = self.bar.encoder_sync_tolerance + tolerance = self.bar.encoder_sync_tolerance - # Bind UI to servo position so the progress/pos displays servo scaledPosition + # Bind UI to servo position so progress/pos displays scaledPosition self.bar.bind_display_value_to_servo_position() + self.servo.servoEnable = 1 # Enable servo if not already # Write the fields into firmware via modbus/device wrapper dev = self.app.device dev['fastData']['threadDesiredSteps'] = target_servo_counts @@ -273,10 +312,11 @@ def _acknowledge_warning(): # Request latch+wait. Firmware will latch current spindle phase and wait until matched. dev['fastData']['threadRequest'] = 1 - - self._servo_watch_callback = lambda *a: self._check_servo_done(5, *a) # watch until done - then go to step 6 (go to start) + # Watch until done - then go back to step 6 (Go to start) + self._servo_watch_callback = lambda *a: self._check_servo_done(5, *a) self.app.bind(update_tick=self._servo_watch_callback) - return False #tell goto_next_step not to advance immediately + + return False # tell goto_next_step not to advance immediately #Step Action button condition functions @@ -287,12 +327,8 @@ def _is_valid_stop_position(self): - For left-hand threads, stop must be greater than start. - Stop position must be greater than the backlash retraction distance from start position so as to take out backlash when retracted further than start position - Depending on sign of the scale ratioNum/ratioDen, this will also affect the calculation""" - # Thread direction: LH → +, RH → - - thread_dir = 1 if self.bar.left_hand_thread else -1 - # Scale direction from ratio sign - scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 - effective_dir = thread_dir * scale_dir + effective_dir = self._get_saddle_scale_effective_dir() retraction = abs(self._get_retraction_distance_encoder_steps()) stop = self._get_stop_position_units() min_stop = self.bar.start_position + effective_dir * retraction @@ -304,12 +340,7 @@ def _is_valid_cutting_depth_position(self): - For internal threads, cutting depth must be greater than material width. - For external threads, cutting depth must be less than material width.""" # Physical cutting direction - # Internal → outward (+), External → inward (-) - thread_dir = 1 if self.bar.inner_thread else -1 - # Encoder direction - scale_dir = 1 if self.cross_slide_scale.ratioNum * self.cross_slide_scale.ratioDen > 0 else -1 - - effective_dir = thread_dir * scale_dir + effective_dir = self._get_cross_slide_scale_effective_dir() target_depth = self._convert_position_units_to_encoder( self.cross_slide_scale, self.manual_cutting_depth, @@ -327,9 +358,7 @@ def _is_cross_slide_retracted(self): log.info("Checking if cross slide is retracted for threading start...") # --- Saddle direction check (Z axis) --- - thread_dir_z = 1 if self.bar.left_hand_thread else -1 - scale_dir_saddle = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 - saddle_dir = thread_dir_z * scale_dir_saddle + saddle_dir = self._get_saddle_scale_effective_dir() saddle_delta = self.saddle_scale.encoderCurrent - self.bar.start_position saddle_beyond_start = saddle_delta * saddle_dir > 0 @@ -341,21 +370,20 @@ def _is_cross_slide_retracted(self): log.info("Saddle is beyond start position, checking cross slide retraction") # --- Cross-slide retraction check (X axis) --- - # External → retract outward (+), Internal → retract inward (-) - thread_dir_x = 1 if not self.bar.inner_thread else -1 - scale_dir_cross_slide = 1 if self.cross_slide_scale.ratioNum * self.cross_slide_scale.ratioDen > 0 else -1 - retract_dir = thread_dir_x * scale_dir_cross_slide + retract_dir = self._get_cross_slide_scale_effective_dir() cross_delta = self.cross_slide_scale.encoderCurrent - self.bar.material_width return cross_delta * retract_dir > 0 - #Step 7 - TODO test this + #Step 7 def _is_cross_slide_at_cutting_depth(self): - """Check if the cross slide is at the cutting depth position.""" - if self.bar.inner_thread: - return self.cross_slide_scale.encoderCurrent >= self.bar.last_cutting_depth - return self.cross_slide_scale.encoderCurrent <= self.bar.last_cutting_depth + """Check if the cross slide is at the cutting depth position, considering thread type and scale direction.""" + effective_dir = self._get_cross_slide_scale_effective_dir() + log.info(f"Checking if cross slide reached cutting depth: current={self.cross_slide_scale.encoderCurrent}, target={self.bar.cutting_depth}, effective_dir={effective_dir}") + + # Check: has cross slide reached or passed the target depth? + return (self.cross_slide_scale.encoderCurrent - self.bar.last_cutting_depth) * effective_dir >= 0 # Manual input handlers def _open_stop_position_keypad(self, *args): @@ -484,8 +512,12 @@ def _convert_distance_units_to_encoder(self, scale: CoordBar, distance: float, i return final_encoder_distance def _get_retraction_distance_encoder_steps(self) -> int: - """Get the retraction distance in encoder counts based on thread pitch and direction.""" + """Get the retraction distance in encoder counts.""" return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_retraction_distance, self.bar.metric_distances) + + def _get_backlash_cusion_encoder_steps(self) -> int: + """Get the backlash cushion distance in encoder counts.""" + return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_cushion, self.bar.metric_distances) def _check_servo_done(self, next_step: int, *args): if self.app.fast_data_values['stepsToGo'] == 0: @@ -499,7 +531,7 @@ def _check_servo_done(self, next_step: int, *args): self.goto_step(next_step) - def _get_servo_delta_steps(self) -> int: + def _get_start_position_servo_delta_steps(self) -> int: """ Compute the servo step delta needed to move the saddle to the retracted start position, accounting for: @@ -508,17 +540,12 @@ def _get_servo_delta_steps(self) -> int: - backlash retraction """ - # Physical Z direction: LH → +, RH → - - thread_dir = 1 if self.bar.left_hand_thread else -1 - - # Saddle encoder direction - scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 - - effective_dir = thread_dir * scale_dir + effective_dir = self._get_saddle_scale_effective_dir() + retraction_dir = - effective_dir # retraction is opposite to cutting direction retraction = abs(self._get_retraction_distance_encoder_steps()) - target_encoder = self.bar.start_position + effective_dir * retraction + target_encoder = self.bar.start_position + retraction_dir * retraction current_encoder = self.saddle_scale.encoderCurrent # Convert encoder delta → servo steps @@ -531,7 +558,42 @@ def _get_servo_delta_steps(self) -> int: f"Computed servo delta: {delta_steps} steps " f"(target_enc={target_encoder}, current_enc={current_encoder}, " f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " - f"dir={effective_dir})" + f"effective_dir={effective_dir}, retraction_dir={retraction_dir})" + ) + + return delta_steps + + def _get_threading_servo_delta_steps(self) -> int: + """ + Compute the servo step delta needed to move the saddle + from the current position to the stop position + in the cutting direction. + """ + + effective_dir = self._get_saddle_scale_effective_dir() + + current_encoder = self.saddle_scale.encoderCurrent + target_encoder = self.bar.stop_position + + delta_enc = target_encoder - current_encoder + if delta_enc * effective_dir <= 0: + log.warning( + "Threading delta is opposite to effective cutting direction " + f"(current={current_encoder}, stop={target_encoder}, " + f"effective_dir={effective_dir})" + ) + + # Convert encoder delta → servo steps + scale_ratio = Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) + servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + + delta_steps = int(delta_enc * scale_ratio / servo_ratio) + + log.info( + f"Computed threading servo delta: {delta_steps} steps " + f"(current_enc={current_encoder}, stop_enc={target_encoder}, " + f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " + f"effective_dir={effective_dir})" ) return delta_steps @@ -547,8 +609,29 @@ def _stop_servo(self): return self.servo.set_max_speed(self.servo.maxSpeed) # restore speed self.servo.servoEnable = 0 # disable - self.app.device['fastData']['servoEnable'] = 0 # ensure disabled in fastData def _clear_bar_display(self): self.bar.unbind_all_display_value() - self.bar.display_value = "" \ No newline at end of file + self.bar.display_value = "" + + def _get_cross_slide_scale_effective_dir(self) -> int: + """Get the cross slide effective direction, considering thread type (internal/external) and scale direction.""" + # Physical cutting direction: internal → outward (+), external → inward (-) + thread_dir = 1 if self.bar.inner_thread else -1 + + # Encoder direction: positive if scale ratio is positive, negative if reversed + scale_dir = 1 if self.cross_slide_scale.ratioNum * self.cross_slide_scale.ratioDen > 0 else -1 + + # Combined effective direction + return thread_dir * scale_dir + + + def _get_saddle_scale_effective_dir(self) -> int: + """Get the saddle scale effective direction, considering if it's left/right hand tread and scale direction.""" + # Thread direction: LH → +, RH → - + thread_dir = 1 if self.bar.left_hand_thread else -1 + + # Scale direction from ratio sign + scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 + + return thread_dir * scale_dir \ No newline at end of file diff --git a/rcp/utils/communication.py b/rcp/utils/communication.py index c111e3e..fcc816b 100644 --- a/rcp/utils/communication.py +++ b/rcp/utils/communication.py @@ -1,4 +1,6 @@ +from functools import wraps import logging +import traceback from typing import Optional import minimalmodbus @@ -6,6 +8,16 @@ log = logging.getLogger(__name__) +def log_caller(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Get full stack trace (excluding this wrapper) + stack_trace = "".join(traceback.format_stack()[:-1]) + log.info(f"Full stack trace for {func.__name__}:\n{stack_trace}") + + return func(*args, **kwargs) + + return wrapper class ConnectionManager: def __init__( diff --git a/uv.lock b/uv.lock index b0e0e84..103bfe9 100644 --- a/uv.lock +++ b/uv.lock @@ -380,6 +380,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/3b/a960053dccd627e4483db4765fa84318a831cbf3af648aee20297ae56815/kivy_deps.glew-0.3.1-cp312-cp312-win32.whl", hash = "sha256:b64ee4e445a04bc7c848c0261a6045fc2f0944cc05d7f953e3860b49f2703424", size = 126458, upload-time = "2023-12-06T21:24:08.893Z" }, { url = "https://files.pythonhosted.org/packages/ad/3a/37a0a051dd3c7298d9e149a489457a6196665444c1a1473ad4fa617e05af/kivy_deps.glew-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:3acbbd30da05fc10c185b5d4bb75fbbc882a6ef2192963050c1c94d60a6e795a", size = 123573, upload-time = "2023-12-06T21:25:58.922Z" }, { url = "https://files.pythonhosted.org/packages/21/99/e3478c34afed7a820b3348ce7fefc53f2034fa340348dca57162695e69d9/kivy_deps.glew-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:f4aa8322078359862ccd9e16e5cea61976d75fb43125d87922e20c916fa31a11", size = 123595, upload-time = "2024-10-07T18:46:16.273Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dc/dcb7716e511f49ab0c3b1f747a5fae2b67895359ea87994867b8ab41fe4c/kivy_deps_glew-0.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:f12bd302dc65ed683bdc03cbbb301f23c2220d8837bca444529858a8b1767acc", size = 132402, upload-time = "2025-10-09T19:36:47.871Z" }, ] [[package]] @@ -784,7 +785,7 @@ wheels = [ [[package]] name = "rcp" -version = "1.2.4" +version = "1.2.8" source = { editable = "." } dependencies = [ { name = "cachetools" }, From 0cb0096ae6142932f17977227fe418c111d5d623 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 3 Jan 2026 17:16:53 +0100 Subject: [PATCH 15/62] Setting the thread pitch in the assisted threading popup now correctly sets the sync ratios; Continued fixes to the assisted threading logic; --- rcp/components/forms/dropdown_item.py | 17 ++- .../home/assisted_threading_settings_popup.kv | 2 +- .../home/assisted_threading_settings_popup.py | 19 ++- .../home/assisted_threading_wizard.py | 112 ++++++++++++++---- rcp/utils/devices.py | 21 +++- 5 files changed, 134 insertions(+), 37 deletions(-) diff --git a/rcp/components/forms/dropdown_item.py b/rcp/components/forms/dropdown_item.py index 69ecc25..17be3ad 100644 --- a/rcp/components/forms/dropdown_item.py +++ b/rcp/components/forms/dropdown_item.py @@ -2,7 +2,7 @@ from kivy.lang import Builder from kivy.logger import Logger -from kivy.properties import StringProperty, ListProperty, ObjectProperty +from kivy.properties import StringProperty, ListProperty, ObjectProperty, NumericProperty from kivy.uix.boxlayout import BoxLayout from kivy.uix.dropdown import DropDown from kivy.uix.button import Button @@ -16,6 +16,7 @@ class DropDownItem(BoxLayout): + selected_index = NumericProperty(-1) name = StringProperty("") value = StringProperty(False) options = ListProperty([]) @@ -36,11 +37,19 @@ def on_value(self, instance, value): self.main_button.text = value def on_options(self, instance, value): - # Clean any existing self.delete_all_dropdown_options() + self._options = [] - for item in self.options: + for index, item in enumerate(self.options): btn = Button(text=item, size_hint_y=None, height=44) - btn.bind(on_release=lambda btn: self.dropdown.select(btn.text)) + btn.bind( + on_release=lambda btn, i=index: self._select(i) + ) self.dropdown.add_widget(btn) self._options.append(btn) + + def _select(self, index): + self.selected_index = index + self.value = self.options[index] + self.dropdown.dismiss() + diff --git a/rcp/components/home/assisted_threading_settings_popup.kv b/rcp/components/home/assisted_threading_settings_popup.kv index a935a66..e848986 100644 --- a/rcp/components/home/assisted_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading_settings_popup.kv @@ -26,7 +26,7 @@ name: "Pitch in MM" if root.assistedThreadingBar.metric_mode else "Pitch in IN" options: root.get_pitches() value: str(root.assistedThreadingBar.selected_pitch) if root.assistedThreadingBar.selected_pitch is not None else "" - on_value: root.on_pitch_selected(self.value) + on_value: root.on_pitch_selected(self.selected_index, self.value) NumberItem: name: "Thread Profile Angle" diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index 4ba443b..e124360 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -6,6 +6,7 @@ from kivy.properties import ObjectProperty from rcp import feeds +from rcp.components.home.coordbar import CoordBar log = Logger.getChild(__name__) @@ -27,9 +28,10 @@ def get_pitches(self): # Choose the correct table based on metric_mode if self.assistedThreadingBar.metric_mode: - return [f.name for f in feeds.table["Thread MM"]] + self.current_feeds_table = feeds.table["Thread MM"] else: - return [f.name for f in feeds.table["Thread IN"]] + self.current_feeds_table = feeds.table["Thread IN"] + return [f.name for f in self.current_feeds_table] def set_thread_profile_angle(self, value): try: @@ -58,6 +60,15 @@ def on_metric_mode_changed(self, value): pitches_dropdown.options = self.get_pitches() log.info(f"Metric mode changed to: {value}") - def on_pitch_selected(self, selected_pitch): + def on_pitch_selected(self, index, selected_pitch): self.assistedThreadingBar.selected_pitch = selected_pitch - log.info(f"Selected pitch: {selected_pitch}") \ No newline at end of file + self.update_feeds_ratio(index) + log.info(f"Selected pitch: {selected_pitch}") + + def update_feeds_ratio(self, index): + ratio = self.current_feeds_table[index].ratio + spindle_scale: CoordBar = self.assistedThreadingBar.app.get_spindle_scale() + if spindle_scale is not None: + spindle_scale.syncRatioNum = ratio.numerator + spindle_scale.syncRatioDen = ratio.denominator + log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}") \ No newline at end of file diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 3f52441..2bcd17c 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -21,6 +21,7 @@ def __init__(self, bar): self.app = bar.app self.servo = self.app.servo self.current_step = 0 + self._threading_started = False self._current_callback = None self._servo_watch_callback = None self.manual_stop_length = None @@ -38,12 +39,22 @@ def __init__(self, bar): def start(self): + dev = self.app.device + dev['assistedThreadingData']['spindlePhaseTolerance'] = self.bar.encoder_sync_tolerance + + # Pick spindle index using get_spindle_scale + spindle_scale = self.app.get_spindle_scale() + if spindle_scale is not None: + dev['assistedThreadingData']['spindleCountsPerRev'] = spindle_scale.ratioDen + dev['assistedThreadingData']['spindleScaleIndex'] = spindle_scale.inputIndex + self.goto_step(0) def stop(self): # Reset wizard_area to default content log.info("Wizard finished") self._current_callback = None + self._threading_started = False self.bar.label_text = "" self.bar.display_value = "" self.bar.action_button_enabled = True @@ -53,7 +64,7 @@ def stop(self): self._clear_bar_display() if self.app.connected: - self.app.device['fastData']['threadReset'] = 1 + self.app.device['assistedThreadingData']['threadReset'] = 1 self._stop_servo() @@ -238,10 +249,10 @@ def _go_to_start(self, *args): # --- Issue servo move --- self.bar.bind_display_value_to_servo_position() self.servo.set_max_speed(self.bar.reversing_speed) - self.servo.servoEnable = 1 self.app.device['servo']['direction'] = delta_steps + self.servo.servoEnable = 1 - self._servo_watch_callback = lambda *a: self._check_servo_done(_next_step, *a) + self._servo_watch_callback = lambda *a: self._check_servo_retract_done(_next_step, *a) self.app.bind(update_tick=self._servo_watch_callback) return False # tell goto_next_step not to advance immediately @@ -291,29 +302,27 @@ def _acknowledge_warning(): log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position - target_servo_counts = self._get_threading_servo_delta_steps() - - # Pick spindle index using get_spindle_scale - spindle_scale = self.app.get_spindle_scale() - spindle_index = spindle_scale.inputIndex if spindle_scale is not None else 0 - tolerance = self.bar.encoder_sync_tolerance - - # Bind UI to servo position so progress/pos displays scaledPosition - self.bar.bind_display_value_to_servo_position() + self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition + self.bar.action_button_enabled = False # Disable action button during threading + self.bar.retract_button_visible = False # Hide retract button during threading self.servo.servoEnable = 1 # Enable servo if not already + # Write the fields into firmware via modbus/device wrapper dev = self.app.device - dev['fastData']['threadDesiredSteps'] = target_servo_counts - dev['fastData']['threadSpindleIndex'] = spindle_index - dev['fastData']['threadTolerance'] = tolerance + dev['assistedThreadingData']['threadDesiredSteps'] = self.app.fast_data_values['servoCurrent'] + self._get_threading_servo_delta_steps() # Request latch+wait. Firmware will latch current spindle phase and wait until matched. - dev['fastData']['threadRequest'] = 1 - + if (self._threading_started is False): + # First time starting threading - latch phase and enable + self._threading_started = True + dev['assistedThreadingData']['threadRequest'] = 1 + else: + dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state + # Watch until done - then go back to step 6 (Go to start) - self._servo_watch_callback = lambda *a: self._check_servo_done(5, *a) + self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) self.app.bind(update_tick=self._servo_watch_callback) return False # tell goto_next_step not to advance immediately @@ -519,9 +528,13 @@ def _get_backlash_cusion_encoder_steps(self) -> int: """Get the backlash cushion distance in encoder counts.""" return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_cushion, self.bar.metric_distances) - def _check_servo_done(self, next_step: int, *args): + def _check_servo_retract_done(self, next_step: int, *args): + dev = self.app.device + stepsToGo = dev['servo']['direction'] + log.info(f"Checking servo retract done: stepsToGo={stepsToGo}") + if self.app.fast_data_values['stepsToGo'] == 0: - log.info("Servo reached desired position") + log.info("Servo reached desired start position") self._stop_servo() # Stop watching @@ -530,7 +543,61 @@ def _check_servo_done(self, next_step: int, *args): self._servo_watch_callback = None self.goto_step(next_step) + + def _check_servo_threading_done(self, next_step: int, *args): + #TODO remove debug logs when done testing + dev = self.app.device + dev['assistedThreadingData'].refresh() + threadRequest = dev['assistedThreadingData']['threadRequest'] + threadReset = dev['assistedThreadingData']['threadReset'] + threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] + threadEnabled = dev['assistedThreadingData']['threadEnabled'] + spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] + spindleCountsPerRev = dev['assistedThreadingData']['spindleCountsPerRev'] + spindlePhaseTolerance = dev['assistedThreadingData']['spindlePhaseTolerance'] + threadDesiredSteps = dev['assistedThreadingData']['threadDesiredSteps'] + threadPhaseRef = dev['assistedThreadingData']['threadPhaseRef'] + currentThreadPhase = dev['assistedThreadingData']['currentThreadPhase'] + desiredSteps = dev['servo']['desiredSteps'] + currentSteps = dev['servo']['currentSteps'] + stepsToGo = dev['servo']['direction'] + syncEnable = dev['scales'][spindleScaleIndex]['syncEnable'] + position = dev['scales'][spindleScaleIndex]['position'] + log.info( + f"Checking servo done: " + f"spindleScaleIndex={spindleScaleIndex}, " + f"spindleCountsPerRev={spindleCountsPerRev}, " + f"spindlePhaseTolerance={spindlePhaseTolerance}, " + + f"threadRequest={threadRequest}, " + f"threadReset={threadReset}, " + f"threadPhaseActive={threadPhaseActive}, " + f"threadEnabled={threadEnabled}, " + f"syncEnable={syncEnable}, " + + f"threadPhaseRef={threadPhaseRef}, " + f"currentThreadPhase={currentThreadPhase}, " + f"spindleEncoderposition={position}, " + + f"threadDesiredSteps={threadDesiredSteps}, " + f"desiredSteps={desiredSteps}, " + f"currentSteps={currentSteps}, " + f"stepsToGo={stepsToGo}, " + ) + + if threadEnabled == 0 and threadPhaseActive == 0: + log.info("Servo reached desired position") + self._stop_servo() + + # Stop watching + if self._servo_watch_callback: + self.app.unbind(update_tick=self._servo_watch_callback) + self._servo_watch_callback = None + + self.goto_step(next_step) + + #TODO fix using spindle encoder counts per rev def _get_start_position_servo_delta_steps(self) -> int: """ Compute the servo step delta needed to move the saddle @@ -563,6 +630,7 @@ def _get_start_position_servo_delta_steps(self) -> int: return delta_steps + #TODO fix using spindle encoder counts per rev def _get_threading_servo_delta_steps(self) -> int: """ Compute the servo step delta needed to move the saddle @@ -575,8 +643,8 @@ def _get_threading_servo_delta_steps(self) -> int: current_encoder = self.saddle_scale.encoderCurrent target_encoder = self.bar.stop_position - delta_enc = target_encoder - current_encoder - if delta_enc * effective_dir <= 0: + delta_enc = (target_encoder - current_encoder) * effective_dir + if delta_enc <= 0: log.warning( "Threading delta is opposite to effective cutting direction " f"(current={current_encoder}, stop={target_encoder}, " diff --git a/rcp/utils/devices.py b/rcp/utils/devices.py index b3f19af..ba83098 100644 --- a/rcp/utils/devices.py +++ b/rcp/utils/devices.py @@ -35,6 +35,7 @@ class Global(BaseDevice): uint32_t executionCycles; servo_t servo; input_t scales[4]; + assistedThreadingData_t assistedThreadingData; fastData_t fastData; } rampsSharedData_t; """ @@ -65,15 +66,23 @@ class FastData(BaseDevice): uint32_t cycles; uint32_t executionInterval; uint16_t servoEnable; - uint32_t threadDesiredSteps; +} fastData_t; +""" + +class AssistedThreadingData(BaseDevice): + definition = """ +typedef struct { uint16_t threadRequest; uint16_t threadReset; - uint16_t threadSpindleIndex; - uint32_t threadPhaseRef; - uint16_t threadHasPhase; + uint16_t spindleScaleIndex; + uint16_t threadPhaseActive; uint16_t threadEnabled; - uint16_t threadTolerance; -} fastData_t; + uint16_t spindlePhaseTolerance; + uint32_t threadDesiredSteps; + uint32_t spindleCountsPerRev; + int32_t threadPhaseRef; + int32_t currentThreadPhase; +} assistedThreadingData_t; """ From 2b6166d6eb698339653bf97840ca61ba78d2f931 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Thu, 22 Jan 2026 08:13:05 +0100 Subject: [PATCH 16/62] Additional bug fixes: Converted the servo threading to use thread remaining steps which is a signed Int for direction purposes; Servo will remain being enabled once threading operation starts instead of disabling it each time after each step - servo will only get disabled if the threading operation stops. This was done since for small steps, sometimes the servo is enabled too late and it doesn't turn in time; Direction for servo retract and cut should be fixed and working well depending on scale, spindle and servo directions; --- .../home/assisted_threading_wizard.py | 57 +++++++++++-------- rcp/utils/devices.py | 3 +- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 2bcd17c..3e1d169 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -22,6 +22,7 @@ def __init__(self, bar): self.servo = self.app.servo self.current_step = 0 self._threading_started = False + self._calculated_threading_delta_steps = 0 self._current_callback = None self._servo_watch_callback = None self.manual_stop_length = None @@ -62,6 +63,7 @@ def stop(self): self.bar.is_running = False self.bar.retract_button_visible = False self._clear_bar_display() + self._reset_servo_watch_callback() if self.app.connected: self.app.device['assistedThreadingData']['threadReset'] = 1 @@ -103,7 +105,8 @@ def start_retracting(self): if not self.app.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position - self.servo.jogSpeed = -self.bar.reversing_speed # set to reversing speed + servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 + self.servo.jogSpeed = - servo_direction * self.bar.reversing_speed # set to reversing speed self.servo.servoEnable = 2 def stop_retracting(self): @@ -114,7 +117,9 @@ def stop_retracting(self): if not self.app.connected: return - self._stop_servo() + self.servo.jogSpeed = 0 + self.servo.set_max_speed(self.servo.maxSpeed) + self.servo.servoEnable = 1 # back to normal servo mode # Instruction steps #Step 1 @@ -147,6 +152,7 @@ def _step_engage_half_nut(self): #Step 6 def _step_go_to_start(self): self.bar.action_button_enabled = False # Disable until valid + self.servo.servoEnable = 1 # Ensure servo enabled self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted) self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_action_button_state() @@ -250,7 +256,6 @@ def _go_to_start(self, *args): self.bar.bind_display_value_to_servo_position() self.servo.set_max_speed(self.bar.reversing_speed) self.app.device['servo']['direction'] = delta_steps - self.servo.servoEnable = 1 self._servo_watch_callback = lambda *a: self._check_servo_retract_done(_next_step, *a) self.app.bind(update_tick=self._servo_watch_callback) @@ -306,21 +311,23 @@ def _acknowledge_warning(): self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition self.bar.action_button_enabled = False # Disable action button during threading self.bar.retract_button_visible = False # Hide retract button during threading - - self.servo.servoEnable = 1 # Enable servo if not already # Write the fields into firmware via modbus/device wrapper dev = self.app.device - dev['assistedThreadingData']['threadDesiredSteps'] = self.app.fast_data_values['servoCurrent'] + self._get_threading_servo_delta_steps() - + # Request latch+wait. Firmware will latch current spindle phase and wait until matched. if (self._threading_started is False): # First time starting threading - latch phase and enable self._threading_started = True + self._calculated_threading_delta_steps = self._get_threading_servo_delta_steps() # Calculate threading delta steps - we only calculate it once including backlash + dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps dev['assistedThreadingData']['threadRequest'] = 1 else: + dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state + log.info(f"Threading requested: threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, servoCurrent={self.app.fast_data_values['servoCurrent']}, calculatedDeltaSteps={self._calculated_threading_delta_steps}") + # Watch until done - then go back to step 6 (Go to start) self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) self.app.bind(update_tick=self._servo_watch_callback) @@ -530,17 +537,18 @@ def _get_backlash_cusion_encoder_steps(self) -> int: def _check_servo_retract_done(self, next_step: int, *args): dev = self.app.device + desiredSteps = dev['servo']['desiredSteps'] + currentSteps = dev['servo']['currentSteps'] + servoCurrent = self.app.fast_data_values['servoCurrent'] stepsToGo = dev['servo']['direction'] - log.info(f"Checking servo retract done: stepsToGo={stepsToGo}") + log.info(f"Checking servo retract done: stepsToGo={stepsToGo}, desiredSteps={desiredSteps}, currentSteps={currentSteps}, servoCurrent={servoCurrent}") if self.app.fast_data_values['stepsToGo'] == 0: log.info("Servo reached desired start position") - self._stop_servo() + self.servo.set_max_speed(self.servo.maxSpeed) # Stop watching - if self._servo_watch_callback: - self.app.unbind(update_tick=self._servo_watch_callback) - self._servo_watch_callback = None + self._reset_servo_watch_callback() self.goto_step(next_step) @@ -555,7 +563,8 @@ def _check_servo_threading_done(self, next_step: int, *args): spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] spindleCountsPerRev = dev['assistedThreadingData']['spindleCountsPerRev'] spindlePhaseTolerance = dev['assistedThreadingData']['spindlePhaseTolerance'] - threadDesiredSteps = dev['assistedThreadingData']['threadDesiredSteps'] + threadRemainingSteps = dev['assistedThreadingData']['threadRemainingSteps'] + threadStartSteps = dev['assistedThreadingData']['threadStartSteps'] threadPhaseRef = dev['assistedThreadingData']['threadPhaseRef'] currentThreadPhase = dev['assistedThreadingData']['currentThreadPhase'] desiredSteps = dev['servo']['desiredSteps'] @@ -580,7 +589,8 @@ def _check_servo_threading_done(self, next_step: int, *args): f"currentThreadPhase={currentThreadPhase}, " f"spindleEncoderposition={position}, " - f"threadDesiredSteps={threadDesiredSteps}, " + f"threadRemainingSteps={threadRemainingSteps}, " + f"threadStartSteps={threadStartSteps}, " f"desiredSteps={desiredSteps}, " f"currentSteps={currentSteps}, " f"stepsToGo={stepsToGo}, " @@ -588,16 +598,12 @@ def _check_servo_threading_done(self, next_step: int, *args): if threadEnabled == 0 and threadPhaseActive == 0: log.info("Servo reached desired position") - self._stop_servo() # Stop watching - if self._servo_watch_callback: - self.app.unbind(update_tick=self._servo_watch_callback) - self._servo_watch_callback = None + self._reset_servo_watch_callback() self.goto_step(next_step) - #TODO fix using spindle encoder counts per rev def _get_start_position_servo_delta_steps(self) -> int: """ Compute the servo step delta needed to move the saddle @@ -616,10 +622,10 @@ def _get_start_position_servo_delta_steps(self) -> int: current_encoder = self.saddle_scale.encoderCurrent # Convert encoder delta → servo steps - scale_ratio = Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) + scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) - delta_steps = int((target_encoder - current_encoder) * scale_ratio / servo_ratio) + delta_steps = int((target_encoder - current_encoder) * effective_dir * scale_ratio / servo_ratio) log.info( f"Computed servo delta: {delta_steps} steps " @@ -630,7 +636,6 @@ def _get_start_position_servo_delta_steps(self) -> int: return delta_steps - #TODO fix using spindle encoder counts per rev def _get_threading_servo_delta_steps(self) -> int: """ Compute the servo step delta needed to move the saddle @@ -652,7 +657,7 @@ def _get_threading_servo_delta_steps(self) -> int: ) # Convert encoder delta → servo steps - scale_ratio = Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) + scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) delta_steps = int(delta_enc * scale_ratio / servo_ratio) @@ -660,6 +665,7 @@ def _get_threading_servo_delta_steps(self) -> int: log.info( f"Computed threading servo delta: {delta_steps} steps " f"(current_enc={current_encoder}, stop_enc={target_encoder}, " + f"delta_enc={delta_enc}, " f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " f"effective_dir={effective_dir})" ) @@ -677,6 +683,11 @@ def _stop_servo(self): return self.servo.set_max_speed(self.servo.maxSpeed) # restore speed self.servo.servoEnable = 0 # disable + + def _reset_servo_watch_callback(self): + if self._servo_watch_callback: + self.app.unbind(update_tick=self._servo_watch_callback) + self._servo_watch_callback = None def _clear_bar_display(self): self.bar.unbind_all_display_value() diff --git a/rcp/utils/devices.py b/rcp/utils/devices.py index ba83098..a46cf1d 100644 --- a/rcp/utils/devices.py +++ b/rcp/utils/devices.py @@ -78,7 +78,8 @@ class AssistedThreadingData(BaseDevice): uint16_t threadPhaseActive; uint16_t threadEnabled; uint16_t spindlePhaseTolerance; - uint32_t threadDesiredSteps; + int32_t threadRemainingSteps; + uint32_t threadStartSteps; uint32_t spindleCountsPerRev; int32_t threadPhaseRef; int32_t currentThreadPhase; From c8b958d58554284422ce5c95717cddb3b99e8b69 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 24 Jan 2026 11:58:47 +0100 Subject: [PATCH 17/62] Clicking retract goes back to the go to start step; --- rcp/components/home/assisted_threading_wizard.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 3e1d169..8d6d182 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -120,6 +120,8 @@ def stop_retracting(self): self.servo.jogSpeed = 0 self.servo.set_max_speed(self.servo.maxSpeed) self.servo.servoEnable = 1 # back to normal servo mode + + self.goto_step(5) # Go back to step 6 - Go to start position # Instruction steps #Step 1 @@ -153,7 +155,7 @@ def _step_engage_half_nut(self): def _step_go_to_start(self): self.bar.action_button_enabled = False # Disable until valid self.servo.servoEnable = 1 # Ensure servo enabled - self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted) + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True) self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_action_button_state() From baa4f30efc31345c261a9e606c3051008cc2031f Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 25 Jan 2026 11:46:31 +0100 Subject: [PATCH 18/62] Added logic for proper backlash preload; --- rcp/components/home/assisted_threading_bar.py | 2 +- .../home/assisted_threading_wizard.py | 199 ++++++++++-------- .../setup/assisted_threading_screen.kv | 6 +- 3 files changed, 113 insertions(+), 94 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 8bf9ad2..c56a944 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -26,7 +26,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): reversing_speed = NumericProperty(500) encoder_sync_tolerance = NumericProperty(5) metric_distances = BooleanProperty(True) # This is for the UI in the setting screen - backlash_retraction_distance = NumericProperty(10) + saddle_backlash_distance = NumericProperty(10) backlash_cushion = NumericProperty(2) metric_mode = BooleanProperty(True) # This is for the actual threading logic diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 8d6d182..78eb3e1 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -27,6 +27,7 @@ def __init__(self, bar): self._servo_watch_callback = None self.manual_stop_length = None self.manual_cutting_depth = None + self._start_position_preloaded = False self._steps = [ self._step_set_initial_position, # Step 1 self._step_set_stop_position, # Step 2 @@ -215,78 +216,48 @@ def _capture_final_cutting_depth_position(self, *args): def _go_to_start(self, *args): if not self.app.connected: self.stop() - return False # tell goto_next_step not to advance immediately + return False - log.info(f"Moving to start position: {self.bar.start_position} + retraction") + self._start_position_preloaded = False + self._goto_start_phase = GoToStartPhase.RETRACT - # --- Delta to move the servo --- - delta_steps = self._get_start_position_servo_delta_steps() - log.info(f"Calculated delta steps to start position: {delta_steps}") - - _next_step: int - # --- Direction model (same as servo delta) --- effective_dir = self._get_saddle_scale_effective_dir() - retraction_dir = - effective_dir # retraction is opposite to cutting direction - retraction = abs(self._get_retraction_distance_encoder_steps()) + retraction = abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.5 # retract 1.5x backlash distance + retraction_dir = -effective_dir # retract opposite to cutting direction - # Are we already beyond the retracted start? - already_retracted = ( - (self.saddle_scale.encoderCurrent - self.bar.start_position) - * retraction_dir >= retraction - ) - log.info(f"Already retracted check: current={self.saddle_scale.encoderCurrent}, start={self.bar.start_position}, retraction={retraction}, effective_dir={effective_dir}, retraction_dir={retraction_dir} => {already_retracted}") + retract_target = self.bar.start_position + retraction_dir * retraction - if already_retracted: - log.info("Saddle already beyond retracted start; taking out backlash") - - # Move further in SAME effective direction - delta_steps += int( - effective_dir * retraction - * Fraction(self.saddle_scale.ratioNum, self.saddle_scale.ratioDen) - / Fraction(self.servo.ratioNum, self.servo.ratioDen) - ) - - _next_step = self.current_step # Use this same step again - else: - _next_step = self.current_step + 1 # Step 7 - # Check if at cutting depth - if self._is_cross_slide_at_final_cutting_depth(): - _next_step += 1 # skip cutting step and go to step 8 (depth reached) - - # --- Issue servo move --- - self.bar.bind_display_value_to_servo_position() - self.servo.set_max_speed(self.bar.reversing_speed) - self.app.device['servo']['direction'] = delta_steps + self._command_move_to_encoder(retract_target, speed=self.bar.reversing_speed) - self._servo_watch_callback = lambda *a: self._check_servo_retract_done(_next_step, *a) + self._servo_watch_callback = self._watch_go_to_start self.app.bind(update_tick=self._servo_watch_callback) - return False # tell goto_next_step not to advance immediately + return False - #Step 7 - TODO test this + #Step 7 def _start_threading_operation(self, *args): if not self.app.connected: self.stop() return False # tell goto_next_step not to advance immediately + + if not self._start_position_preloaded: + log.warning("Threading requested without start preload") + self.goto_step(5) + return False - # --- Determine effective direction --- - effective_dir = self._get_saddle_scale_effective_dir() - retraction_dir = - effective_dir # retraction is opposite to cutting direction - retraction_distance = abs(self._get_retraction_distance_encoder_steps()) + # Below is a sanity check to make sure that we are at the correct start position including backlash cushion + # if for some reason the start_position_preloaded flag was bypassed or the saddle moved after preload. backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) - # Compute the desired encoder position at start including retraction - desired_position = self.bar.start_position + retraction_dir * retraction_distance - log.info( f"Validating start position: current={self.saddle_scale.encoderCurrent}, " - f"desired={desired_position} (start={self.bar.start_position}, retraction={retraction_distance}, " - f"backlash_cushion={backlash_cushion}, retraction_dir={retraction_dir})" + f"start={self.bar.start_position}, " + f"backlash_cushion={backlash_cushion}" ) - lower_bound = desired_position - backlash_cushion - upper_bound = desired_position + backlash_cushion + lower_bound = self.bar.start_position - backlash_cushion + upper_bound = self.bar.start_position + backlash_cushion # Check if we are within backlash cushion if not (lower_bound <= self.saddle_scale.encoderCurrent <= upper_bound): _warning = ( @@ -343,13 +314,13 @@ def _is_valid_stop_position(self): """Check if the stop position is valid given the start position and thread direction. - For right-hand threads, stop must be less than start. - For left-hand threads, stop must be greater than start. - - Stop position must be greater than the backlash retraction distance from start position so as to take out backlash when retracted further than start position + - Stop position must be greater than the backlash cushion distance from start position - if stop is too small, the saddle may not have enough room to cut properly. - Depending on sign of the scale ratioNum/ratioDen, this will also affect the calculation""" effective_dir = self._get_saddle_scale_effective_dir() - retraction = abs(self._get_retraction_distance_encoder_steps()) + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) stop = self._get_stop_position_units() - min_stop = self.bar.start_position + effective_dir * retraction + min_stop = self.bar.start_position + effective_dir * backlash_cushion return (stop - min_stop) * effective_dir > 0 #Step 4 @@ -388,7 +359,7 @@ def _is_cross_slide_retracted(self): log.info("Saddle is beyond start position, checking cross slide retraction") # --- Cross-slide retraction check (X axis) --- - retract_dir = self._get_cross_slide_scale_effective_dir() + retract_dir = -self._get_cross_slide_scale_effective_dir() #TODO test this cross_delta = self.cross_slide_scale.encoderCurrent - self.bar.material_width return cross_delta * retract_dir > 0 @@ -529,9 +500,9 @@ def _convert_distance_units_to_encoder(self, scale: CoordBar, distance: float, i return final_encoder_distance - def _get_retraction_distance_encoder_steps(self) -> int: + def _get_saddle_backlash_distance_encoder_steps(self) -> int: """Get the retraction distance in encoder counts.""" - return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_retraction_distance, self.bar.metric_distances) + return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.saddle_backlash_distance, self.bar.metric_distances) def _get_backlash_cusion_encoder_steps(self) -> int: """Get the backlash cushion distance in encoder counts.""" @@ -606,38 +577,6 @@ def _check_servo_threading_done(self, next_step: int, *args): self.goto_step(next_step) - def _get_start_position_servo_delta_steps(self) -> int: - """ - Compute the servo step delta needed to move the saddle - to the retracted start position, accounting for: - - thread handedness - - encoder direction - - backlash retraction - """ - - effective_dir = self._get_saddle_scale_effective_dir() - retraction_dir = - effective_dir # retraction is opposite to cutting direction - - retraction = abs(self._get_retraction_distance_encoder_steps()) - - target_encoder = self.bar.start_position + retraction_dir * retraction - current_encoder = self.saddle_scale.encoderCurrent - - # Convert encoder delta → servo steps - scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) - servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) - - delta_steps = int((target_encoder - current_encoder) * effective_dir * scale_ratio / servo_ratio) - - log.info( - f"Computed servo delta: {delta_steps} steps " - f"(target_enc={target_encoder}, current_enc={current_encoder}, " - f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " - f"effective_dir={effective_dir}, retraction_dir={retraction_dir})" - ) - - return delta_steps - def _get_threading_servo_delta_steps(self) -> int: """ Compute the servo step delta needed to move the saddle @@ -715,4 +654,84 @@ def _get_saddle_scale_effective_dir(self) -> int: # Scale direction from ratio sign scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 - return thread_dir * scale_dir \ No newline at end of file + return thread_dir * scale_dir + + def _command_move_to_encoder(self, target_encoder, speed): + effective_dir = self._get_saddle_scale_effective_dir() + current_enc = self.saddle_scale.encoderCurrent + + scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) + servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + + delta = int((target_encoder - current_enc) * effective_dir * scale_ratio / servo_ratio) + + log.info( + f"Move to encoder: current={current_enc}, " + f"target={target_encoder}, delta={delta}" + ) + + self.bar.bind_display_value_to_servo_position() + self.servo.set_max_speed(speed) + self.app.device['servo']['direction'] = delta + + def _watch_go_to_start(self, *_): + if self.app.fast_data_values['stepsToGo'] != 0: + return + + if self._goto_start_phase == GoToStartPhase.RETRACT: + self._start_preload_move() + + elif self._goto_start_phase == GoToStartPhase.PRELOAD: + self._start_adjust_move() + + elif self._goto_start_phase == GoToStartPhase.ADJUST: + self._finish_go_to_start() + + def _start_preload_move(self): + self._reset_servo_watch_callback() + self._goto_start_phase = GoToStartPhase.PRELOAD + + log.info("Retract complete, starting preload move") + backlash_preload_steps = abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25 # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion + preload_target = self.saddle_scale.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps + + self._command_move_to_encoder( + preload_target, + speed=self.servo.maxSpeed + ) + + self._servo_watch_callback = self._watch_go_to_start + self.app.bind(update_tick=self._servo_watch_callback) + + def _start_adjust_move(self): + self._reset_servo_watch_callback() + self._goto_start_phase = GoToStartPhase.ADJUST + + log.info("Preload move complete, starting final adjust move") + + self._command_move_to_encoder( + self.bar.start_position, + speed=self.servo.maxSpeed + ) + + self._servo_watch_callback = self._watch_go_to_start + self.app.bind(update_tick=self._servo_watch_callback) + + def _finish_go_to_start(self): + self._reset_servo_watch_callback() + + log.info("Start position reached with backlash preloaded") + + self._start_position_preloaded = True + + next_step = self.current_step + 1 + if self._is_cross_slide_at_final_cutting_depth(): + next_step += 1 + + self.goto_step(next_step) + +class GoToStartPhase: + IDLE = 0 + RETRACT = 1 + PRELOAD = 2 + ADJUST = 3 \ No newline at end of file diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index f014aeb..cf052cc 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -48,9 +48,9 @@ value: root.assistedThreadingBar.metric_distances on_value: root.assistedThreadingBar.metric_distances = self.value NumberItem: - name: "Saddle backlash retraction distance (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash retraction distance (IN)" - value: root.assistedThreadingBar.backlash_retraction_distance - on_value: root.assistedThreadingBar.backlash_retraction_distance = int(self.value) + name: "Saddle backlash distance (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash distance (IN)" + value: root.assistedThreadingBar.saddle_backlash_distance + on_value: root.assistedThreadingBar.saddle_backlash_distance = int(self.value) NumberItem: name: "Saddle backlash cushion (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash cushion (IN)" value: root.assistedThreadingBar.backlash_cushion From cd6091858707249b43846403a678af930fa39974 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Wed, 11 Mar 2026 11:35:27 +0100 Subject: [PATCH 19/62] WIP - requires testing. Added logic for better preload and adjust steps by waiting for the scale encoder to stop changing --- rcp/components/home/assisted_threading_bar.py | 5 +- .../home/assisted_threading_settings_popup.kv | 5 -- .../home/assisted_threading_settings_popup.py | 10 +--- .../home/assisted_threading_wizard.py | 46 ++++++++++++++++--- .../setup/assisted_threading_screen.kv | 18 ++++++-- .../setup/assisted_threading_screen.py | 6 +++ 6 files changed, 66 insertions(+), 24 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index c56a944..3eca806 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -24,10 +24,13 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_saddle_scale_id = NumericProperty(1) reversing_speed = NumericProperty(500) - encoder_sync_tolerance = NumericProperty(5) + preload_adjust_speed = NumericProperty(500) + rotary_encoder_sync_tolerance = NumericProperty(5) metric_distances = BooleanProperty(True) # This is for the UI in the setting screen saddle_backlash_distance = NumericProperty(10) backlash_cushion = NumericProperty(2) + saddle_encoder_stability_tolerance = NumericProperty(1) + saddle_encoder_stability_samples = NumericProperty(3) metric_mode = BooleanProperty(True) # This is for the actual threading logic selected_pitch = StringProperty("") diff --git a/rcp/components/home/assisted_threading_settings_popup.kv b/rcp/components/home/assisted_threading_settings_popup.kv index e848986..fc88079 100644 --- a/rcp/components/home/assisted_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading_settings_popup.kv @@ -33,11 +33,6 @@ value: root.assistedThreadingBar.thread_profile_angle on_value: root.set_thread_profile_angle(self.value) - NumberItem: - name: "Shaft Diameter in MM" if root.assistedThreadingBar.metric_mode else "Shaft Diameter in IN" - value: root.assistedThreadingBar.shaft_diameter - on_value: root.set_shaft_diameter(self.value) - BooleanItem: name: "Left Hand Thread" value: root.assistedThreadingBar.left_hand_thread diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index e124360..0eb58a3 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -43,15 +43,7 @@ def set_thread_profile_angle(self, value): if angle <= 0 or angle > 90: angle = 90 - self.assistedThreadingBar.thread_profile_angle = angle - - def set_shaft_diameter(self, value): - try: - diameter = float(value) - except (ValueError, TypeError): - diameter = 1 - self.assistedThreadingBar.shaft_diameter = abs(diameter) - + self.assistedThreadingBar.thread_profile_angle = angle def on_metric_mode_changed(self, value): self.assistedThreadingBar.metric_mode = value diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 78eb3e1..13b6fe0 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -27,6 +27,7 @@ def __init__(self, bar): self._servo_watch_callback = None self.manual_stop_length = None self.manual_cutting_depth = None + self._last_saddle_encoder_value = None self._start_position_preloaded = False self._steps = [ self._step_set_initial_position, # Step 1 @@ -42,7 +43,7 @@ def __init__(self, bar): def start(self): dev = self.app.device - dev['assistedThreadingData']['spindlePhaseTolerance'] = self.bar.encoder_sync_tolerance + dev['assistedThreadingData']['spindlePhaseTolerance'] = self.bar.rotary_encoder_sync_tolerance # Pick spindle index using get_spindle_scale spindle_scale = self.app.get_spindle_scale() @@ -65,6 +66,7 @@ def stop(self): self.bar.retract_button_visible = False self._clear_bar_display() self._reset_servo_watch_callback() + self._reset_encoder_stability_check() if self.app.connected: self.app.device['assistedThreadingData']['threadReset'] = 1 @@ -223,7 +225,7 @@ def _go_to_start(self, *args): effective_dir = self._get_saddle_scale_effective_dir() - retraction = abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.5 # retract 1.5x backlash distance + retraction = abs(self._get_saddle_backlash_distance_encoder_steps() * 1.5) # retract 1.5x backlash distance retraction_dir = -effective_dir # retract opposite to cutting direction retract_target = self.bar.start_position + retraction_dir * retraction @@ -657,6 +659,8 @@ def _get_saddle_scale_effective_dir(self) -> int: return thread_dir * scale_dir def _command_move_to_encoder(self, target_encoder, speed): + self._reset_encoder_stability_check() + effective_dir = self._get_saddle_scale_effective_dir() current_enc = self.saddle_scale.encoderCurrent @@ -675,7 +679,7 @@ def _command_move_to_encoder(self, target_encoder, speed): self.app.device['servo']['direction'] = delta def _watch_go_to_start(self, *_): - if self.app.fast_data_values['stepsToGo'] != 0: + if not self._motion_complete(): return if self._goto_start_phase == GoToStartPhase.RETRACT: @@ -686,18 +690,48 @@ def _watch_go_to_start(self, *_): elif self._goto_start_phase == GoToStartPhase.ADJUST: self._finish_go_to_start() + + def _reset_encoder_stability_check(self): + self._last_saddle_encoder_value = None + self._stable_count = 0 + + def _encoder_is_stable(self, tolerance, samples): + current = self.saddle_scale.encoderCurrent + + if self._last_saddle_encoder_value is None: + self._last_saddle_encoder_value = current + self._stable_count = 0 + return False + + if abs(current - self._last_saddle_encoder_value) <= tolerance: + self._stable_count += 1 + else: + self._stable_count = 0 + + self._last_saddle_encoder_value = current + + return self._stable_count >= samples + + def _motion_complete(self): + if self.app.fast_data_values['stepsToGo'] != 0: + return False + + if not self._encoder_is_stable(self.bar.saddle_encoder_stability_tolerance, self.bar.saddle_encoder_stability_samples): + return False + + return True def _start_preload_move(self): self._reset_servo_watch_callback() self._goto_start_phase = GoToStartPhase.PRELOAD log.info("Retract complete, starting preload move") - backlash_preload_steps = abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25 # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion + backlash_preload_steps = int(abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25) # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion preload_target = self.saddle_scale.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps self._command_move_to_encoder( preload_target, - speed=self.servo.maxSpeed + speed=self.bar.preload_adjust_speed ) self._servo_watch_callback = self._watch_go_to_start @@ -711,7 +745,7 @@ def _start_adjust_move(self): self._command_move_to_encoder( self.bar.start_position, - speed=self.servo.maxSpeed + speed=self.bar.preload_adjust_speed ) self._servo_watch_callback = self._watch_go_to_start diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index cf052cc..2002a08 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -40,9 +40,21 @@ value: root.assistedThreadingBar.reversing_speed on_value: root.set_reversing_speed(self.value) NumberItem: - name: "Encoder sync tolerance (Steps)" - value: root.assistedThreadingBar.encoder_sync_tolerance - on_value: root.assistedThreadingBar.encoder_sync_tolerance = self.value + name: "Preload/Adjust Speed (Steps/s)" + value: root.assistedThreadingBar.preload_adjust_speed + on_value: root.set_preload_adjust_speed(self.value) + NumberItem: + name: "Rotary Encoder sync tolerance (Steps)" + value: root.assistedThreadingBar.rotary_encoder_sync_tolerance + on_value: root.assistedThreadingBar.rotary_encoder_sync_tolerance = self.value + NumberItem: + name: "Saddle Encoder stability tolerance (Steps)" + value: root.assistedThreadingBar.saddle_encoder_stability_tolerance + on_value: root.assistedThreadingBar.saddle_encoder_stability_tolerance = int(self.value) + NumberItem: + name: "Saddle Encoder stability samples" + value: root.assistedThreadingBar.saddle_encoder_stability_samples + on_value: root.assistedThreadingBar.saddle_encoder_stability_samples = int(self.value) BooleanItem: name: "Metric Distances" value: root.assistedThreadingBar.metric_distances diff --git a/rcp/components/setup/assisted_threading_screen.py b/rcp/components/setup/assisted_threading_screen.py index d275bec..136f88f 100644 --- a/rcp/components/setup/assisted_threading_screen.py +++ b/rcp/components/setup/assisted_threading_screen.py @@ -69,6 +69,12 @@ def set_reversing_speed(self, val): except ValueError: pass + def set_preload_adjust_speed(self, val): + try: + self.assistedThreadingBar.preload_adjust_speed = min(int(val), self.servo.maxSpeed) + except ValueError: + pass + def get_label_for_scale_id(self, scale_id): if not self.scales_mapping: self.update_scales_labels() From dc2c1cb58893fd8aee6894bc0e166e5baeff4a9d Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 14 Mar 2026 15:13:02 +0100 Subject: [PATCH 20/62] WIP - added better handling for different thread pitches and depth calculation --- rcp/components/home/assisted_threading_bar.py | 25 +++- .../home/assisted_threading_settings_popup.kv | 16 ++- .../home/assisted_threading_settings_popup.py | 39 ++++-- .../home/assisted_threading_wizard.py | 114 +++++++++++++++++- 4 files changed, 170 insertions(+), 24 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 3eca806..a300cdf 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -19,6 +19,15 @@ Builder.load_file(kv_file) +from enum import StrEnum + +class ThreadType(StrEnum): + """Thread profile types with their calculation formulas.""" + ISO_METRIC = "ISO Metric" + UNIFIED = "Unified" + WHITWORTH = "Whitworth" + ACME = "ACME" + class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_cross_slide_scale_id = NumericProperty(0) selected_saddle_scale_id = NumericProperty(1) @@ -34,7 +43,8 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): metric_mode = BooleanProperty(True) # This is for the actual threading logic selected_pitch = StringProperty("") - thread_profile_angle = NumericProperty(60) + thread_profile_type = StringProperty(None) + cross_slide_diameter_mode = BooleanProperty(False) shaft_diameter = NumericProperty(1) left_hand_thread = BooleanProperty(False) inner_thread = BooleanProperty(False) @@ -55,19 +65,23 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): "action_button_enabled", "label_text", "display_value", - "start_position" + "start_position", "stop_position", "material_width", "cutting_depth", "last_cutting_depth", "retract_button_visible" - ] + ] def __init__(self, **kv): from rcp.app import MainApp + from rcp.components.home.assisted_threading_wizard import ThreadType self.app: MainApp = MainApp.get_running_app() self.action_button_condition_fn = None super().__init__(**kv) + # Initialize with default thread type if not set + if not self.thread_profile_type: + self.thread_profile_type = ThreadType.ISO_METRIC self.wizard = AssistedThreadingWizard(self) def toggle_is_running(self): @@ -175,6 +189,11 @@ def unbind_all_display_value(self): if hasattr(self, "_bound_servo") and self._bound_servo is not None: self._bound_servo.unbind(formattedPosition=self._on_servo_position_update) self._bound_servo = None + # Unbind threading progress display if it was bound + if hasattr(self.wizard, "_progress_display_scale") and self.wizard._progress_display_scale is not None: + if hasattr(self.wizard, "_on_threading_progress_update"): + self.wizard._progress_display_scale.unbind(encoderCurrent=self.wizard._on_threading_progress_update) + self.wizard._progress_display_scale = None def update_action_button_state(self): """Evaluate whether the action button should be enabled.""" diff --git a/rcp/components/home/assisted_threading_settings_popup.kv b/rcp/components/home/assisted_threading_settings_popup.kv index fc88079..8823dd3 100644 --- a/rcp/components/home/assisted_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading_settings_popup.kv @@ -28,10 +28,18 @@ value: str(root.assistedThreadingBar.selected_pitch) if root.assistedThreadingBar.selected_pitch is not None else "" on_value: root.on_pitch_selected(self.selected_index, self.value) - NumberItem: - name: "Thread Profile Angle" - value: root.assistedThreadingBar.thread_profile_angle - on_value: root.set_thread_profile_angle(self.value) + DropDownItem: + id: thread_type_dropdown + height: 60 + name: "Thread Type" + options: root.get_thread_types() + value: root.assistedThreadingBar.thread_profile_type + on_value: root.on_thread_type_selected(self.value) + + BooleanItem: + name: "Cross Slide Diameter Mode" + value: root.assistedThreadingBar.cross_slide_diameter_mode + on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value BooleanItem: name: "Left Hand Thread" diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index 0eb58a3..92ee369 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -7,6 +7,7 @@ from rcp import feeds from rcp.components.home.coordbar import CoordBar +from rcp.components.home.assisted_threading_bar import ThreadType log = Logger.getChild(__name__) @@ -33,29 +34,43 @@ def get_pitches(self): self.current_feeds_table = feeds.table["Thread IN"] return [f.name for f in self.current_feeds_table] - def set_thread_profile_angle(self, value): - try: - angle = float(value) - except (ValueError, TypeError): - angle = 1 - - angle = abs(angle) - if angle <= 0 or angle > 90: - angle = 90 - - self.assistedThreadingBar.thread_profile_angle = angle - + def get_thread_types(self): + """Get available thread types based on metric mode.""" + if self.assistedThreadingBar.metric_mode: + return [ThreadType.ISO_METRIC.value, ThreadType.ACME.value] + else: + return [ThreadType.UNIFIED.value, ThreadType.WHITWORTH.value, ThreadType.ACME.value] + def on_metric_mode_changed(self, value): self.assistedThreadingBar.metric_mode = value pitches_dropdown = self.ids.pitches_dropdown pitches_dropdown.value = "" pitches_dropdown.options = self.get_pitches() + + # Update thread type options based on metric mode + thread_type_dropdown = self.ids.thread_type_dropdown + thread_type_dropdown.options = self.get_thread_types() + # Reset to first available type + first_type = self.get_thread_types()[0] if self.get_thread_types() else ThreadType.ISO_METRIC.value + thread_type_dropdown.value = first_type + self.assistedThreadingBar.thread_profile_type = ThreadType(first_type) + log.info(f"Metric mode changed to: {value}") def on_pitch_selected(self, index, selected_pitch): self.assistedThreadingBar.selected_pitch = selected_pitch self.update_feeds_ratio(index) log.info(f"Selected pitch: {selected_pitch}") + + def on_thread_type_selected(self, value): + """Handle thread type selection.""" + try: + # Convert string value back to ThreadType enum + thread_type = ThreadType(value) + self.assistedThreadingBar.thread_profile_type = thread_type + log.info(f"Selected thread type: {thread_type}") + except ValueError: + log.warning(f"Invalid thread type value: {value}") def update_feeds_ratio(self, index): ratio = self.current_feeds_table[index].ratio diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 13b6fe0..dbfc369 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -5,7 +5,6 @@ from rcp.components.home.coordbar import CoordBar log = Logger.getChild(__name__) - class AssistedThreadingWizard: @property def saddle_scale(self) -> CoordBar: @@ -100,7 +99,7 @@ def set_instruction(self, label_text, next_button_text, next_button_callback, va self.bar.bind_btn_value_on_release(value_button_fn) self.bar.action_button_condition_fn = action_button_condition_fn self.bar.retract_button_visible = retract_button_visible - + def start_retracting(self): log.info("Retract button pressed") self.bar.action_button_enabled = False # disable action button while retracting @@ -283,7 +282,7 @@ def _acknowledge_warning(): log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position - self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition + self._bind_threading_progress_display() # Bind to progress display instead of servo position self.bar.action_button_enabled = False # Disable action button during threading self.bar.retract_button_visible = False # Hide retract button during threading @@ -404,7 +403,12 @@ def _open_final_cutting_depth_position_keypad(self, *args): is_metric = self.app.formats.current_format == "MM" - keypad = Keypad(title="Enter Final Cutting Depth (" + ("mm" if is_metric else "in") + ")") + # Calculate default depth + calculated_depth = self._calculate_thread_depth() + default_value = calculated_depth if calculated_depth is not None else 0.0 + + depth_unit = "mm" if is_metric else "in" + keypad = Keypad(title=f"Enter Final Cutting Depth ({depth_unit})") keypad.integer = False def on_done(value): @@ -417,8 +421,9 @@ def on_done(value): finally: self.bar.update_action_button_state() + log.info(f"Opening cutting depth keypad with calculated default: {default_value:.4f}") keypad.show_with_callback(callback_fn=on_done, - current_value=self.manual_cutting_depth or 0.0) + current_value=self.manual_cutting_depth or default_value) # Utilities def _convert_position_units_to_encoder(self, @@ -615,6 +620,71 @@ def _get_threading_servo_delta_steps(self) -> int: return delta_steps + def _calculate_thread_depth(self): + """ + Calculate thread depth based on selected pitch and thread profile type. + + Uses metric_mode to determine if selected_pitch is in mm or TPI. + Formulas provided are for radial depth; multiply by 2 if diameter mode is enabled. + + Returns: + Thread depth in the selected units (mm or inches), or None if invalid + """ + if not self.bar.selected_pitch: + log.warning("No pitch selected for depth calculation") + return None + + # Determine effective pitch based on metric_mode + try: + if self.bar.metric_mode: + # In metric mode, selected_pitch is the pitch in mm + pitch = float(self.bar.selected_pitch) + else: + # In imperial mode, selected_pitch is TPI (threads per inch) + # Convert TPI to pitch in inches + tpi = float(self.bar.selected_pitch) + pitch = 25.4 / tpi + except (ValueError, TypeError): + log.warning(f"Could not parse pitch from: {self.bar.selected_pitch}") + return None + + if pitch <= 0: + log.warning(f"Invalid pitch value: {pitch}") + return None + + # Determine thread profile and calculate radial depth + thread_type = self.bar.thread_profile_type + + if thread_type == ThreadType.ISO_METRIC: + depth = 0.61343 * pitch + elif thread_type == ThreadType.UNIFIED: + depth = 0.64952 * pitch + elif thread_type == ThreadType.WHITWORTH: + depth = 0.6403 * pitch + elif thread_type == ThreadType.ACME: + depth = 0.5 * pitch + else: + log.warning(f"Unknown thread profile: {thread_type}") + return None + + # Account for cross-slide diameter mode + # Formulas are for radial depth; in diameter mode multiply by 2 + if self.bar.cross_slide_diameter_mode: + depth = depth * 2 + + # Convert depth to match current display format if needed + is_current_format_metric = self.app.formats.current_format == "MM" + if self.bar.metric_mode and not is_current_format_metric: + # Calculated in mm but displaying in inches + depth = depth / 25.4 + elif not self.bar.metric_mode and is_current_format_metric: + # Calculated in inches but displaying in mm + depth = depth * 25.4 + + log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.bar.cross_slide_diameter_mode})") + return depth + + def _is_cross_slide_at_final_cutting_depth(self): """Check if the cross slide is at or more than the final cutting depth position.""" if self.bar.inner_thread: @@ -635,6 +705,40 @@ def _reset_servo_watch_callback(self): def _clear_bar_display(self): self.bar.unbind_all_display_value() self.bar.display_value = "" + + def _bind_threading_progress_display(self): + """ + Bind display to show threading progress: "Last: | Rem: " + where: + - Last = incremental cut since last_cutting_depth + - Rem = remaining distance until final thread depth + """ + self.bar.unbind_all_display_value() + self._progress_display_scale = self.cross_slide_scale + def on_cross_slide_update(instance, value): + try: + is_metric = self.app.formats.current_format == "MM" + effective_dir = self._get_cross_slide_scale_effective_dir() + current_encoder = self.cross_slide_scale.encoderCurrent + last_cutting_depth_encoder = self.bar.last_cutting_depth + # Calculate incremental cut depth in encoder units + incremental_cut_encoder = abs(current_encoder - last_cutting_depth_encoder) + factor = float(self.app.formats.factor) + incremental_cut_display = incremental_cut_encoder / factor if factor != 0 else 0 + # Calculate remaining depth + final_depth_encoder = abs(self.bar.cutting_depth - current_encoder) + remaining_display = final_depth_encoder / factor if factor != 0 else 0 + + if is_metric: + self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" + else: + self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" + log.info(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") + except Exception as e: + log.error(f"Error updating threading progress display: {e}") + self._on_threading_progress_update = on_cross_slide_update + self.cross_slide_scale.bind(encoderCurrent=on_cross_slide_update) + on_cross_slide_update(self.cross_slide_scale, self.cross_slide_scale.encoderCurrent) def _get_cross_slide_scale_effective_dir(self) -> int: """Get the cross slide effective direction, considering thread type (internal/external) and scale direction.""" From 3d01a3e885b78cb8271ad77ede026ee280432ad1 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 14 Mar 2026 16:36:16 +0100 Subject: [PATCH 21/62] Moved Cross Slide Diameter setting to the AT settings; Minor fixes + improvements --- rcp/components/home/assisted_threading_bar.py | 14 ++------------ .../home/assisted_threading_settings_popup.kv | 5 ----- .../home/assisted_threading_settings_popup.py | 2 +- rcp/components/home/assisted_threading_wizard.py | 9 +++++++++ rcp/components/home/thread_type.py | 9 +++++++++ rcp/components/setup/assisted_threading_screen.kv | 5 +++++ 6 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 rcp/components/home/thread_type.py diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index a300cdf..b878f2c 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -6,9 +6,9 @@ from kivy.properties import NumericProperty, BooleanProperty, StringProperty from rcp.components.forms.hold_button import HoldButton -from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.coordbar import CoordBar +from rcp.components.home.thread_type import ThreadType from rcp.dispatchers import SavingDispatcher log = Logger.getChild(__name__) @@ -18,16 +18,6 @@ log.info(f"Loading KV file: {kv_file}") Builder.load_file(kv_file) - -from enum import StrEnum - -class ThreadType(StrEnum): - """Thread profile types with their calculation formulas.""" - ISO_METRIC = "ISO Metric" - UNIFIED = "Unified" - WHITWORTH = "Whitworth" - ACME = "ACME" - class AssistedThreadingBar(BoxLayout, SavingDispatcher): selected_cross_slide_scale_id = NumericProperty(0) selected_saddle_scale_id = NumericProperty(1) @@ -75,7 +65,6 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): def __init__(self, **kv): from rcp.app import MainApp - from rcp.components.home.assisted_threading_wizard import ThreadType self.app: MainApp = MainApp.get_running_app() self.action_button_condition_fn = None super().__init__(**kv) @@ -110,6 +99,7 @@ def on_action_button_clicked(self): self.open_settings() def open_settings(self): + from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup popup = AssistedThreadingSettingsPopup(assistedThreadingBar=self) popup.open() diff --git a/rcp/components/home/assisted_threading_settings_popup.kv b/rcp/components/home/assisted_threading_settings_popup.kv index 8823dd3..6ab1166 100644 --- a/rcp/components/home/assisted_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading_settings_popup.kv @@ -36,11 +36,6 @@ value: root.assistedThreadingBar.thread_profile_type on_value: root.on_thread_type_selected(self.value) - BooleanItem: - name: "Cross Slide Diameter Mode" - value: root.assistedThreadingBar.cross_slide_diameter_mode - on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value - BooleanItem: name: "Left Hand Thread" value: root.assistedThreadingBar.left_hand_thread diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index 92ee369..eba83b5 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -7,7 +7,7 @@ from rcp import feeds from rcp.components.home.coordbar import CoordBar -from rcp.components.home.assisted_threading_bar import ThreadType +from rcp.components.home.thread_type import ThreadType log = Logger.getChild(__name__) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index dbfc369..c97748f 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -3,6 +3,7 @@ from rcp.components.forms.custom_popup import CustomPopup from rcp.components.home.coordbar import CoordBar +from rcp.components.home.thread_type import ThreadType log = Logger.getChild(__name__) class AssistedThreadingWizard: @@ -28,6 +29,7 @@ def __init__(self, bar): self.manual_cutting_depth = None self._last_saddle_encoder_value = None self._start_position_preloaded = False + self._retracting = False self._steps = [ self._step_set_initial_position, # Step 1 self._step_set_stop_position, # Step 2 @@ -63,6 +65,7 @@ def stop(self): self.bar.action_button_condition_fn = None self.bar.is_running = False self.bar.retract_button_visible = False + self._retracting = False self._clear_bar_display() self._reset_servo_watch_callback() self._reset_encoder_stability_check() @@ -104,6 +107,11 @@ def start_retracting(self): log.info("Retract button pressed") self.bar.action_button_enabled = False # disable action button while retracting + if self._retracting: + log.info("Already retracting, ignoring additional press") + return + + self._retracting = True if not self.app.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position @@ -116,6 +124,7 @@ def stop_retracting(self): self.bar.action_button_enabled = True # re-enable action button self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_action_button_state() + self._retracting = False if not self.app.connected: return diff --git a/rcp/components/home/thread_type.py b/rcp/components/home/thread_type.py new file mode 100644 index 0000000..690f764 --- /dev/null +++ b/rcp/components/home/thread_type.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class ThreadType(StrEnum): + """Thread profile types with their calculation formulas.""" + ISO_METRIC = "ISO Metric" + UNIFIED = "Unified" + WHITWORTH = "Whitworth" + ACME = "ACME" diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index 2002a08..4d65b50 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -33,6 +33,11 @@ value: root.get_label_for_scale_id(root.assistedThreadingBar.selected_cross_slide_scale_id) if root.assistedThreadingBar else "" on_value: root.on_cross_slide_scale_selected(self.value) + BooleanItem: + name: "Cross Slide Diameter Mode" + value: root.assistedThreadingBar.cross_slide_diameter_mode + on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value + TitleItem: name: "Speeds and Distance Settings" NumberItem: From 0604701a2699f87b5db39cf91146695639fb7c8d Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 14 Mar 2026 16:58:11 +0100 Subject: [PATCH 22/62] Fixed bug with settings not being persisted because of a type mismatch inthread_profile_type (strEnum and StringProperty) --- rcp/components/home/assisted_threading_bar.py | 4 ++-- rcp/components/home/assisted_threading_settings_popup.py | 4 ++-- rcp/components/home/assisted_threading_wizard.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index b878f2c..f862407 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -33,7 +33,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): metric_mode = BooleanProperty(True) # This is for the actual threading logic selected_pitch = StringProperty("") - thread_profile_type = StringProperty(None) + thread_profile_type = StringProperty("ISO_METRIC") cross_slide_diameter_mode = BooleanProperty(False) shaft_diameter = NumericProperty(1) left_hand_thread = BooleanProperty(False) @@ -70,7 +70,7 @@ def __init__(self, **kv): super().__init__(**kv) # Initialize with default thread type if not set if not self.thread_profile_type: - self.thread_profile_type = ThreadType.ISO_METRIC + self.thread_profile_type = ThreadType.ISO_METRIC.value self.wizard = AssistedThreadingWizard(self) def toggle_is_running(self): diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index eba83b5..595a534 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -53,7 +53,7 @@ def on_metric_mode_changed(self, value): # Reset to first available type first_type = self.get_thread_types()[0] if self.get_thread_types() else ThreadType.ISO_METRIC.value thread_type_dropdown.value = first_type - self.assistedThreadingBar.thread_profile_type = ThreadType(first_type) + self.assistedThreadingBar.thread_profile_type = ThreadType(first_type).value log.info(f"Metric mode changed to: {value}") @@ -67,7 +67,7 @@ def on_thread_type_selected(self, value): try: # Convert string value back to ThreadType enum thread_type = ThreadType(value) - self.assistedThreadingBar.thread_profile_type = thread_type + self.assistedThreadingBar.thread_profile_type = thread_type.value log.info(f"Selected thread type: {thread_type}") except ValueError: log.warning(f"Invalid thread type value: {value}") diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index c97748f..6b37205 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -662,8 +662,8 @@ def _calculate_thread_depth(self): return None # Determine thread profile and calculate radial depth - thread_type = self.bar.thread_profile_type - + thread_type = ThreadType(self.bar.thread_profile_type) + if thread_type == ThreadType.ISO_METRIC: depth = 0.61343 * pitch elif thread_type == ThreadType.UNIFIED: From 24d1668c865976943391f41daadf03c65ebf5cc0 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 14 Mar 2026 17:23:09 +0100 Subject: [PATCH 23/62] Removed check for valid cutting depth since we are now using absolute values; Removed logic for getting cutting depth from scale position; --- .../home/assisted_threading_wizard.py | 59 ++++++++----------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 6b37205..578d4b2 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -154,7 +154,21 @@ def _step_set_material_width_position(self): #Step 4 def _step_set_final_cutting_depth_position(self): self.bar.action_button_enabled = False # Disable until valid - self.set_instruction("Enter Final Cutting Depth", "Set", self._capture_final_cutting_depth_position, self._open_final_cutting_depth_position_keypad, self._is_valid_cutting_depth_position) + # Calculate thread depth and show immediately + calculated_depth = self._calculate_thread_depth() + self.manual_cutting_depth = None # Reset manual override + if calculated_depth is not None: + is_metric = self.app.formats.current_format == "MM" + self.bar.display_value = f"{calculated_depth:.3f}" if is_metric else f"{calculated_depth:.4f}" + else: + self.bar.display_value = "" + self.set_instruction( + "Enter Final Cutting Depth (auto-calculated shown, tap to override)", + "Set", + self._capture_final_cutting_depth_position, + self._open_final_cutting_depth_position_keypad + ) + # No scale binding, just show calculated value self._clear_bar_display() #Step 5 @@ -210,17 +224,14 @@ def _capture_material_width_position(self, *args): return True # advance to next step #Step 4 - def _capture_final_cutting_depth_position(self, *args): - # convert length into encoder stop position - self.bar.cutting_depth = self._convert_position_units_to_encoder(self.cross_slide_scale, - self.manual_cutting_depth, - self._isMaterialWidthPositionMetricMode, - self._materialWidthScaledPosition, - self.bar.material_width) - - log.info(f"Cutting depth set manually: {self.manual_cutting_depth} " - f"(start={self.bar.material_width}, stop={self.bar.cutting_depth})") - return True # advance to next step + def _capture_final_cutting_depth_position(self, *args): + # Use manual override if set, otherwise use calculated depth + depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() + self.bar.cutting_depth = depth + is_metric = self.app.formats.current_format == "MM" + log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") + self.bar.display_value = f"{depth:.3f}" if is_metric else f"{depth:.4f}" + return True # advance to next step #Step 6 def _go_to_start(self, *args): @@ -333,21 +344,6 @@ def _is_valid_stop_position(self): min_stop = self.bar.start_position + effective_dir * backlash_cushion return (stop - min_stop) * effective_dir > 0 - #Step 4 - def _is_valid_cutting_depth_position(self): - """Check if the cutting depth is valid given the material width position and if it's internal/external thread. - - For internal threads, cutting depth must be greater than material width. - - For external threads, cutting depth must be less than material width.""" - # Physical cutting direction - effective_dir = self._get_cross_slide_scale_effective_dir() - target_depth = self._convert_position_units_to_encoder( - self.cross_slide_scale, - self.manual_cutting_depth, - self._isMaterialWidthPositionMetricMode, - self._materialWidthScaledPosition, - self.bar.material_width - ) - return (target_depth - self.bar.material_width) * effective_dir > 0 #Step 6 def _is_cross_slide_retracted(self): @@ -409,17 +405,13 @@ def on_done(value): def _open_final_cutting_depth_position_keypad(self, *args): from rcp.components.keypad import Keypad - is_metric = self.app.formats.current_format == "MM" - - # Calculate default depth + # Always use calculated depth as default calculated_depth = self._calculate_thread_depth() default_value = calculated_depth if calculated_depth is not None else 0.0 - depth_unit = "mm" if is_metric else "in" keypad = Keypad(title=f"Enter Final Cutting Depth ({depth_unit})") keypad.integer = False - def on_done(value): try: self.manual_cutting_depth = float(value) @@ -429,10 +421,9 @@ def on_done(value): log.warning(f"Invalid cutting depth input: {value}") finally: self.bar.update_action_button_state() - log.info(f"Opening cutting depth keypad with calculated default: {default_value:.4f}") keypad.show_with_callback(callback_fn=on_done, - current_value=self.manual_cutting_depth or default_value) + current_value=self.manual_cutting_depth if self.manual_cutting_depth is not None else default_value) # Utilities def _convert_position_units_to_encoder(self, From b6aec4063ac2055318bc824cf7d53c7963aeb48b Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 15 Mar 2026 18:26:04 +0100 Subject: [PATCH 24/62] Added different accellerations for threading and reversing Added threading max speed; Bug fixes; --- rcp/components/home/assisted_threading_bar.py | 3 + .../home/assisted_threading_wizard.py | 93 ++++++++++--------- .../setup/assisted_threading_screen.kv | 17 +++- .../setup/assisted_threading_screen.py | 24 +++++ 4 files changed, 92 insertions(+), 45 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index f862407..cef3fc5 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -24,6 +24,9 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): reversing_speed = NumericProperty(500) preload_adjust_speed = NumericProperty(500) + threading_max_speed = NumericProperty(2000) + reversing_adjusting_acceleration = NumericProperty(1000) + threading_acceleration = NumericProperty(1000) rotary_encoder_sync_tolerance = NumericProperty(5) metric_distances = BooleanProperty(True) # This is for the UI in the setting screen saddle_backlash_distance = NumericProperty(10) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 578d4b2..10e6b61 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -29,7 +29,6 @@ def __init__(self, bar): self.manual_cutting_depth = None self._last_saddle_encoder_value = None self._start_position_preloaded = False - self._retracting = False self._steps = [ self._step_set_initial_position, # Step 1 self._step_set_stop_position, # Step 2 @@ -65,14 +64,13 @@ def stop(self): self.bar.action_button_condition_fn = None self.bar.is_running = False self.bar.retract_button_visible = False - self._retracting = False self._clear_bar_display() self._reset_servo_watch_callback() self._reset_encoder_stability_check() if self.app.connected: self.app.device['assistedThreadingData']['threadReset'] = 1 - self._stop_servo() + self._stop_servo() def goto_step(self, index): @@ -107,14 +105,10 @@ def start_retracting(self): log.info("Retract button pressed") self.bar.action_button_enabled = False # disable action button while retracting - if self._retracting: - log.info("Already retracting, ignoring additional press") - return - - self._retracting = True if not self.app.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position + self._apply_reversing_adjusting_acceleration() servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 self.servo.jogSpeed = - servo_direction * self.bar.reversing_speed # set to reversing speed self.servo.servoEnable = 2 @@ -124,7 +118,6 @@ def stop_retracting(self): self.bar.action_button_enabled = True # re-enable action button self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_action_button_state() - self._retracting = False if not self.app.connected: return @@ -153,7 +146,8 @@ def _step_set_material_width_position(self): #Step 4 def _step_set_final_cutting_depth_position(self): - self.bar.action_button_enabled = False # Disable until valid + self._clear_bar_display() + # Calculate thread depth and show immediately calculated_depth = self._calculate_thread_depth() self.manual_cutting_depth = None # Reset manual override @@ -162,14 +156,13 @@ def _step_set_final_cutting_depth_position(self): self.bar.display_value = f"{calculated_depth:.3f}" if is_metric else f"{calculated_depth:.4f}" else: self.bar.display_value = "" + self.set_instruction( "Enter Final Cutting Depth (auto-calculated shown, tap to override)", "Set", self._capture_final_cutting_depth_position, self._open_final_cutting_depth_position_keypad ) - # No scale binding, just show calculated value - self._clear_bar_display() #Step 5 def _step_engage_half_nut(self): @@ -180,7 +173,7 @@ def _step_engage_half_nut(self): def _step_go_to_start(self): self.bar.action_button_enabled = False # Disable until valid self.servo.servoEnable = 1 # Ensure servo enabled - self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True) + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted) self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_action_button_state() @@ -188,14 +181,14 @@ def _step_go_to_start(self): def _step_cut_thread(self): self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) - self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self._bind_threading_progress_display() # Bind to progress display self.bar.update_action_button_state() #Step 8 def _step_depth_reached(self): self.bar.action_button_enabled = False # Disable until valid self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) - self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self._bind_threading_progress_display() # Bind to progress display self.bar.update_action_button_state() # Step callbacks @@ -239,6 +232,7 @@ def _go_to_start(self, *args): self.stop() return False + self._apply_reversing_adjusting_acceleration() self._start_position_preloaded = False self._goto_start_phase = GoToStartPhase.RETRACT @@ -277,10 +271,9 @@ def _start_threading_operation(self, *args): f"backlash_cushion={backlash_cushion}" ) - lower_bound = self.bar.start_position - backlash_cushion - upper_bound = self.bar.start_position + backlash_cushion - # Check if we are within backlash cushion - if not (lower_bound <= self.saddle_scale.encoderCurrent <= upper_bound): + delta = abs(self.saddle_scale.encoderCurrent - self.bar.start_position) + # Check if we are within backlash cushion distance from the start position. If not, warn the user and go back to step 6 to return to start position. + if delta > backlash_cushion: _warning = ( "Not at valid start position including backlash cushion. " "Aborting threading operation. Go back to start position." @@ -302,7 +295,9 @@ def _acknowledge_warning(): log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position - self._bind_threading_progress_display() # Bind to progress display instead of servo position + self._apply_threading_acceleration() + self._apply_threading_max_speed() + self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition self.bar.action_button_enabled = False # Disable action button during threading self.bar.retract_button_visible = False # Hide retract button during threading @@ -378,7 +373,7 @@ def _is_cross_slide_at_cutting_depth(self): log.info(f"Checking if cross slide reached cutting depth: current={self.cross_slide_scale.encoderCurrent}, target={self.bar.cutting_depth}, effective_dir={effective_dir}") # Check: has cross slide reached or passed the target depth? - return (self.cross_slide_scale.encoderCurrent - self.bar.last_cutting_depth) * effective_dir >= 0 + return (self.cross_slide_scale.encoderCurrent - self.bar.cutting_depth) * effective_dir >= 0 # Manual input handlers def _open_stop_position_keypad(self, *args): @@ -414,13 +409,14 @@ def _open_final_cutting_depth_position_keypad(self, *args): keypad.integer = False def on_done(value): try: - self.manual_cutting_depth = float(value) + self.manual_cutting_depth = abs(float(value)) log.info(f"Manual cutting depth entered: {self.manual_cutting_depth}") self.bar.display_value = f"{self.manual_cutting_depth:.3f}" if is_metric else f"{self.manual_cutting_depth:.4f}" + self.bar.action_button_enabled = True except ValueError: - log.warning(f"Invalid cutting depth input: {value}") - finally: - self.bar.update_action_button_state() + log.warning(f"Invalid cutting depth input: {value}") + self.bar.action_button_enabled = False + log.info(f"Opening cutting depth keypad with calculated default: {default_value:.4f}") keypad.show_with_callback(callback_fn=on_done, current_value=self.manual_cutting_depth if self.manual_cutting_depth is not None else default_value) @@ -515,23 +511,6 @@ def _get_backlash_cusion_encoder_steps(self) -> int: """Get the backlash cushion distance in encoder counts.""" return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_cushion, self.bar.metric_distances) - def _check_servo_retract_done(self, next_step: int, *args): - dev = self.app.device - desiredSteps = dev['servo']['desiredSteps'] - currentSteps = dev['servo']['currentSteps'] - servoCurrent = self.app.fast_data_values['servoCurrent'] - stepsToGo = dev['servo']['direction'] - log.info(f"Checking servo retract done: stepsToGo={stepsToGo}, desiredSteps={desiredSteps}, currentSteps={currentSteps}, servoCurrent={servoCurrent}") - - if self.app.fast_data_values['stepsToGo'] == 0: - log.info("Servo reached desired start position") - self.servo.set_max_speed(self.servo.maxSpeed) - - # Stop watching - self._reset_servo_watch_callback() - - self.goto_step(next_step) - def _check_servo_threading_done(self, next_step: int, *args): #TODO remove debug logs when done testing dev = self.app.device @@ -696,6 +675,7 @@ def _stop_servo(self): return self.servo.set_max_speed(self.servo.maxSpeed) # restore speed self.servo.servoEnable = 0 # disable + self._apply_original_servo_acceleration() # restore original acceleration if it was changed def _reset_servo_watch_callback(self): if self._servo_watch_callback: @@ -705,7 +685,31 @@ def _reset_servo_watch_callback(self): def _clear_bar_display(self): self.bar.unbind_all_display_value() self.bar.display_value = "" - + + def _apply_original_servo_acceleration(self): + self.app.device['servo']['acceleration'] = self.servo.acceleration + + def _apply_reversing_adjusting_acceleration(self): + rate = self.bar.reversing_adjusting_acceleration + if rate and rate > 0: + self.app.device['servo']['acceleration'] = rate + else: + self._apply_original_servo_acceleration() + + def _apply_threading_acceleration(self): + rate = self.bar.threading_acceleration + if rate and rate > 0: + self.app.device['servo']['acceleration'] = rate + else: + self._apply_original_servo_acceleration() + + def _apply_threading_max_speed(self): + target_speed = self.bar.threading_max_speed + if target_speed and target_speed > 0: + self.servo.set_max_speed(target_speed) + else: + self.servo.set_max_speed(self.servo.maxSpeed) + def _bind_threading_progress_display(self): """ Bind display to show threading progress: "Last: | Rem: " @@ -718,7 +722,6 @@ def _bind_threading_progress_display(self): def on_cross_slide_update(instance, value): try: is_metric = self.app.formats.current_format == "MM" - effective_dir = self._get_cross_slide_scale_effective_dir() current_encoder = self.cross_slide_scale.encoderCurrent last_cutting_depth_encoder = self.bar.last_cutting_depth # Calculate incremental cut depth in encoder units @@ -833,6 +836,7 @@ def _start_preload_move(self): backlash_preload_steps = int(abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25) # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion preload_target = self.saddle_scale.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps + self._apply_reversing_adjusting_acceleration() self._command_move_to_encoder( preload_target, speed=self.bar.preload_adjust_speed @@ -847,6 +851,7 @@ def _start_adjust_move(self): log.info("Preload move complete, starting final adjust move") + self._apply_reversing_adjusting_acceleration() self._command_move_to_encoder( self.bar.start_position, speed=self.bar.preload_adjust_speed diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index 4d65b50..04c3793 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -39,7 +39,7 @@ on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value TitleItem: - name: "Speeds and Distance Settings" + name: "Speed Settings" NumberItem: name: "Reversing Speed (Steps/s)" value: root.assistedThreadingBar.reversing_speed @@ -48,6 +48,21 @@ name: "Preload/Adjust Speed (Steps/s)" value: root.assistedThreadingBar.preload_adjust_speed on_value: root.set_preload_adjust_speed(self.value) + NumberItem: + name: "Threading Max Speed (Steps/s)" + value: root.assistedThreadingBar.threading_max_speed + on_value: root.set_threading_max_speed(self.value) + NumberItem: + name: "Reversing/Adjusting Acceleration (Steps/s^2)" + value: root.assistedThreadingBar.reversing_adjusting_acceleration + on_value: root.set_reversing_adjusting_acceleration(self.value) + NumberItem: + name: "Threading Acceleration (Steps/s^2)" + value: root.assistedThreadingBar.threading_acceleration + on_value: root.set_threading_acceleration(self.value) + + TitleItem: + name: "Distance and Tolerance Settings" NumberItem: name: "Rotary Encoder sync tolerance (Steps)" value: root.assistedThreadingBar.rotary_encoder_sync_tolerance diff --git a/rcp/components/setup/assisted_threading_screen.py b/rcp/components/setup/assisted_threading_screen.py index 136f88f..0e8b373 100644 --- a/rcp/components/setup/assisted_threading_screen.py +++ b/rcp/components/setup/assisted_threading_screen.py @@ -74,6 +74,30 @@ def set_preload_adjust_speed(self, val): self.assistedThreadingBar.preload_adjust_speed = min(int(val), self.servo.maxSpeed) except ValueError: pass + + def set_threading_max_speed(self, val): + try: + speed = min(int(val), self.servo.maxSpeed) + if speed > 0: + self.assistedThreadingBar.threading_max_speed = speed + except (ValueError, TypeError): + pass + + def set_reversing_adjusting_acceleration(self, val): + try: + acc = int(val) + if acc > 0: + self.assistedThreadingBar.reversing_adjusting_acceleration = acc + except ValueError: + pass + + def set_threading_acceleration(self, val): + try: + acc = int(val) + if acc > 0: + self.assistedThreadingBar.threading_acceleration = acc + except ValueError: + pass def get_label_for_scale_id(self, scale_id): if not self.scales_mapping: From fe3af00f979eab3f8a07647cb2fc86431fac8d1a Mon Sep 17 00:00:00 2001 From: Pawcu Date: Mon, 16 Mar 2026 08:11:28 +0100 Subject: [PATCH 25/62] Bug fixes for showing remaining depths - still need to fix issue with remaining depth --- .../home/assisted_threading_wizard.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 10e6b61..6dddb78 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -173,7 +173,7 @@ def _step_engage_half_nut(self): def _step_go_to_start(self): self.bar.action_button_enabled = False # Disable until valid self.servo.servoEnable = 1 # Ensure servo enabled - self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted) + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True) self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_action_button_state() @@ -219,9 +219,13 @@ def _capture_material_width_position(self, *args): #Step 4 def _capture_final_cutting_depth_position(self, *args): # Use manual override if set, otherwise use calculated depth - depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() - self.bar.cutting_depth = depth is_metric = self.app.formats.current_format == "MM" + depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() + encoder_cutting_depth = self._convert_distance_units_to_encoder(self.cross_slide_scale, depth, is_metric) + + self.bar.cutting_depth = self.cross_slide_scale.encoderCurrent + (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) + + log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") self.bar.display_value = f"{depth:.3f}" if is_metric else f"{depth:.4f}" return True # advance to next step @@ -724,13 +728,17 @@ def on_cross_slide_update(instance, value): is_metric = self.app.formats.current_format == "MM" current_encoder = self.cross_slide_scale.encoderCurrent last_cutting_depth_encoder = self.bar.last_cutting_depth + factor = float(self.app.formats.factor) + + scale_ratio = Fraction(self.cross_slide_scale.ratioNum, self.cross_slide_scale.ratioDen) * factor + # Calculate incremental cut depth in encoder units incremental_cut_encoder = abs(current_encoder - last_cutting_depth_encoder) - factor = float(self.app.formats.factor) - incremental_cut_display = incremental_cut_encoder / factor if factor != 0 else 0 + + incremental_cut_display = incremental_cut_encoder * scale_ratio # Calculate remaining depth final_depth_encoder = abs(self.bar.cutting_depth - current_encoder) - remaining_display = final_depth_encoder / factor if factor != 0 else 0 + remaining_display = final_depth_encoder * scale_ratio if is_metric: self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" From f65d5eb8824fe590e33b80984b8fa59a9fb2a75f Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 21 Mar 2026 09:40:01 +0100 Subject: [PATCH 26/62] Fixed cutting depth scale display; Fixed issue with the backlash cusion not being displayed properly because of int conversion --- .../home/assisted_threading_wizard.py | 29 +++++++------------ .../setup/assisted_threading_screen.kv | 4 +-- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 6dddb78..d6523c9 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -180,14 +180,14 @@ def _step_go_to_start(self): #Step 7 def _step_cut_thread(self): self.bar.action_button_enabled = False # Disable until valid - self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) + self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, None, True) self._bind_threading_progress_display() # Bind to progress display self.bar.update_action_button_state() #Step 8 def _step_depth_reached(self): self.bar.action_button_enabled = False # Disable until valid - self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, self._is_cross_slide_at_cutting_depth, True) + self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, None, True) self._bind_threading_progress_display() # Bind to progress display self.bar.update_action_button_state() @@ -223,7 +223,7 @@ def _capture_final_cutting_depth_position(self, *args): depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() encoder_cutting_depth = self._convert_distance_units_to_encoder(self.cross_slide_scale, depth, is_metric) - self.bar.cutting_depth = self.cross_slide_scale.encoderCurrent + (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) + self.bar.cutting_depth = self.cross_slide_scale.encoderCurrent - (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") @@ -369,16 +369,6 @@ def _is_cross_slide_retracted(self): cross_delta = self.cross_slide_scale.encoderCurrent - self.bar.material_width return cross_delta * retract_dir > 0 - - #Step 7 - def _is_cross_slide_at_cutting_depth(self): - """Check if the cross slide is at the cutting depth position, considering thread type and scale direction.""" - effective_dir = self._get_cross_slide_scale_effective_dir() - log.info(f"Checking if cross slide reached cutting depth: current={self.cross_slide_scale.encoderCurrent}, target={self.bar.cutting_depth}, effective_dir={effective_dir}") - - # Check: has cross slide reached or passed the target depth? - return (self.cross_slide_scale.encoderCurrent - self.bar.cutting_depth) * effective_dir >= 0 - # Manual input handlers def _open_stop_position_keypad(self, *args): from rcp.components.keypad import Keypad @@ -669,10 +659,11 @@ def _calculate_thread_depth(self): def _is_cross_slide_at_final_cutting_depth(self): + #TODO check if this is working correctly with reversed scales and inner vs outer threads """Check if the cross slide is at or more than the final cutting depth position.""" - if self.bar.inner_thread: - return self.cross_slide_scale.encoderCurrent >= self.bar.cutting_depth - return self.cross_slide_scale.encoderCurrent <= self.bar.cutting_depth + effective_dir = self._get_cross_slide_scale_effective_dir() + current = self.cross_slide_scale.encoderCurrent + return (current - self.bar.cutting_depth) * effective_dir >= 0 def _stop_servo(self): if not self.app.connected: @@ -730,14 +721,14 @@ def on_cross_slide_update(instance, value): last_cutting_depth_encoder = self.bar.last_cutting_depth factor = float(self.app.formats.factor) - scale_ratio = Fraction(self.cross_slide_scale.ratioNum, self.cross_slide_scale.ratioDen) * factor + scale_ratio = abs(Fraction(self.cross_slide_scale.ratioNum, self.cross_slide_scale.ratioDen) * factor) # Calculate incremental cut depth in encoder units - incremental_cut_encoder = abs(current_encoder - last_cutting_depth_encoder) + incremental_cut_encoder = last_cutting_depth_encoder - current_encoder if self.bar.inner_thread else current_encoder - last_cutting_depth_encoder incremental_cut_display = incremental_cut_encoder * scale_ratio # Calculate remaining depth - final_depth_encoder = abs(self.bar.cutting_depth - current_encoder) + final_depth_encoder = current_encoder - self.bar.cutting_depth if self.bar.inner_thread else self.bar.cutting_depth - current_encoder remaining_display = final_depth_encoder * scale_ratio if is_metric: diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv index 04c3793..66e059a 100644 --- a/rcp/components/setup/assisted_threading_screen.kv +++ b/rcp/components/setup/assisted_threading_screen.kv @@ -82,8 +82,8 @@ NumberItem: name: "Saddle backlash distance (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash distance (IN)" value: root.assistedThreadingBar.saddle_backlash_distance - on_value: root.assistedThreadingBar.saddle_backlash_distance = int(self.value) + on_value: root.assistedThreadingBar.saddle_backlash_distance = self.value NumberItem: name: "Saddle backlash cushion (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash cushion (IN)" value: root.assistedThreadingBar.backlash_cushion - on_value: root.assistedThreadingBar.backlash_cushion = int(self.value) \ No newline at end of file + on_value: root.assistedThreadingBar.backlash_cushion = self.value \ No newline at end of file From 365278b2bf782f6baca288f16f946fca83d68317 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 21 Mar 2026 10:52:30 +0100 Subject: [PATCH 27/62] Added logic to properly sync spindle ratio num and den values based on wether ELS or AT is selected; Commented debug logs when waiting for threading done --- rcp/components/home/assisted_threading_bar.py | 27 +++++++ .../home/assisted_threading_settings_popup.py | 20 +---- .../home/assisted_threading_wizard.py | 76 +++++++++---------- rcp/components/home/elsbar.py | 9 +++ 4 files changed, 78 insertions(+), 54 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index cef3fc5..57044a7 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -5,6 +5,7 @@ from kivy.uix.boxlayout import BoxLayout from kivy.properties import NumericProperty, BooleanProperty, StringProperty +from rcp import feeds from rcp.components.forms.hold_button import HoldButton from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.coordbar import CoordBar @@ -36,6 +37,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): metric_mode = BooleanProperty(True) # This is for the actual threading logic selected_pitch = StringProperty("") + current_feeds_index = NumericProperty(0) thread_profile_type = StringProperty("ISO_METRIC") cross_slide_diameter_mode = BooleanProperty(False) shaft_diameter = NumericProperty(1) @@ -71,10 +73,20 @@ def __init__(self, **kv): self.app: MainApp = MainApp.get_running_app() self.action_button_condition_fn = None super().__init__(**kv) + + if self.metric_mode: + self.current_feeds_table = feeds.table["Thread MM"] + else: + self.current_feeds_table = feeds.table["Thread IN"] + + self.update_feeds_ratio(self, None) + # Initialize with default thread type if not set if not self.thread_profile_type: self.thread_profile_type = ThreadType.ISO_METRIC.value self.wizard = AssistedThreadingWizard(self) + + self.app.bind(current_mode=self.on_mode_change) def toggle_is_running(self): self.is_running = not self.is_running @@ -85,6 +97,10 @@ def toggle_is_running(self): def stop_wizard(self): self.wizard.stop() + + def on_mode_change(self, instance, mode): + if mode == 4: # AT mode + self.update_feeds_ratio(None, None) def on_retract_button_pressed(self): """Called when the retract button is pressed.""" @@ -100,6 +116,17 @@ def on_action_button_clicked(self): self.wizard.goto_next_step() else: self.open_settings() + + def update_feeds_ratio(self, instance, value): + if self.app.current_mode != 4: + return # only sync in AT mode + + ratio = self.current_feeds_table[self.current_feeds_index].ratio + spindle_scale: CoordBar = self.app.get_spindle_scale() + if spindle_scale is not None: + spindle_scale.syncRatioNum = ratio.numerator + spindle_scale.syncRatioDen = ratio.denominator + log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}") def open_settings(self): from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index 595a534..88808d3 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -27,12 +27,7 @@ def get_pitches(self): if not self.assistedThreadingBar: return [] - # Choose the correct table based on metric_mode - if self.assistedThreadingBar.metric_mode: - self.current_feeds_table = feeds.table["Thread MM"] - else: - self.current_feeds_table = feeds.table["Thread IN"] - return [f.name for f in self.current_feeds_table] + return [f.name for f in self.assistedThreadingBar.current_feeds_table] def get_thread_types(self): """Get available thread types based on metric mode.""" @@ -59,7 +54,8 @@ def on_metric_mode_changed(self, value): def on_pitch_selected(self, index, selected_pitch): self.assistedThreadingBar.selected_pitch = selected_pitch - self.update_feeds_ratio(index) + self.assistedThreadingBar.current_feeds_index = index + self.assistedThreadingBar.update_feeds_ratio(None,None) log.info(f"Selected pitch: {selected_pitch}") def on_thread_type_selected(self, value): @@ -70,12 +66,4 @@ def on_thread_type_selected(self, value): self.assistedThreadingBar.thread_profile_type = thread_type.value log.info(f"Selected thread type: {thread_type}") except ValueError: - log.warning(f"Invalid thread type value: {value}") - - def update_feeds_ratio(self, index): - ratio = self.current_feeds_table[index].ratio - spindle_scale: CoordBar = self.assistedThreadingBar.app.get_spindle_scale() - if spindle_scale is not None: - spindle_scale.syncRatioNum = ratio.numerator - spindle_scale.syncRatioDen = ratio.denominator - log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}") \ No newline at end of file + log.warning(f"Invalid thread type value: {value}") \ No newline at end of file diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index d6523c9..4433245 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -506,48 +506,48 @@ def _get_backlash_cusion_encoder_steps(self) -> int: return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_cushion, self.bar.metric_distances) def _check_servo_threading_done(self, next_step: int, *args): - #TODO remove debug logs when done testing - dev = self.app.device - dev['assistedThreadingData'].refresh() - threadRequest = dev['assistedThreadingData']['threadRequest'] - threadReset = dev['assistedThreadingData']['threadReset'] + dev = self.app.device threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] - threadEnabled = dev['assistedThreadingData']['threadEnabled'] - spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] - spindleCountsPerRev = dev['assistedThreadingData']['spindleCountsPerRev'] - spindlePhaseTolerance = dev['assistedThreadingData']['spindlePhaseTolerance'] - threadRemainingSteps = dev['assistedThreadingData']['threadRemainingSteps'] - threadStartSteps = dev['assistedThreadingData']['threadStartSteps'] - threadPhaseRef = dev['assistedThreadingData']['threadPhaseRef'] - currentThreadPhase = dev['assistedThreadingData']['currentThreadPhase'] - desiredSteps = dev['servo']['desiredSteps'] - currentSteps = dev['servo']['currentSteps'] - stepsToGo = dev['servo']['direction'] - syncEnable = dev['scales'][spindleScaleIndex]['syncEnable'] - position = dev['scales'][spindleScaleIndex]['position'] - - log.info( - f"Checking servo done: " - f"spindleScaleIndex={spindleScaleIndex}, " - f"spindleCountsPerRev={spindleCountsPerRev}, " - f"spindlePhaseTolerance={spindlePhaseTolerance}, " + threadEnabled = dev['assistedThreadingData']['threadEnabled'] + + # dev['assistedThreadingData'].refresh() + # threadRequest = dev['assistedThreadingData']['threadRequest'] + # threadReset = dev['assistedThreadingData']['threadReset'] + # spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] + # spindleCountsPerRev = dev['assistedThreadingData']['spindleCountsPerRev'] + # spindlePhaseTolerance = dev['assistedThreadingData']['spindlePhaseTolerance'] + # threadRemainingSteps = dev['assistedThreadingData']['threadRemainingSteps'] + # threadStartSteps = dev['assistedThreadingData']['threadStartSteps'] + # threadPhaseRef = dev['assistedThreadingData']['threadPhaseRef'] + # currentThreadPhase = dev['assistedThreadingData']['currentThreadPhase'] + # desiredSteps = dev['servo']['desiredSteps'] + # currentSteps = dev['servo']['currentSteps'] + # stepsToGo = dev['servo']['direction'] + # syncEnable = dev['scales'][spindleScaleIndex]['syncEnable'] + # position = dev['scales'][spindleScaleIndex]['position'] + + # log.info( + # f"Checking servo done: " + # f"spindleScaleIndex={spindleScaleIndex}, " + # f"spindleCountsPerRev={spindleCountsPerRev}, " + # f"spindlePhaseTolerance={spindlePhaseTolerance}, " - f"threadRequest={threadRequest}, " - f"threadReset={threadReset}, " - f"threadPhaseActive={threadPhaseActive}, " - f"threadEnabled={threadEnabled}, " - f"syncEnable={syncEnable}, " + # f"threadRequest={threadRequest}, " + # f"threadReset={threadReset}, " + # f"threadPhaseActive={threadPhaseActive}, " + # f"threadEnabled={threadEnabled}, " + # f"syncEnable={syncEnable}, " - f"threadPhaseRef={threadPhaseRef}, " - f"currentThreadPhase={currentThreadPhase}, " - f"spindleEncoderposition={position}, " + # f"threadPhaseRef={threadPhaseRef}, " + # f"currentThreadPhase={currentThreadPhase}, " + # f"spindleEncoderposition={position}, " - f"threadRemainingSteps={threadRemainingSteps}, " - f"threadStartSteps={threadStartSteps}, " - f"desiredSteps={desiredSteps}, " - f"currentSteps={currentSteps}, " - f"stepsToGo={stepsToGo}, " - ) + # f"threadRemainingSteps={threadRemainingSteps}, " + # f"threadStartSteps={threadStartSteps}, " + # f"desiredSteps={desiredSteps}, " + # f"currentSteps={currentSteps}, " + # f"stepsToGo={stepsToGo}, " + # ) if threadEnabled == 0 and threadPhaseActive == 0: log.info("Servo reached desired position") diff --git a/rcp/components/home/elsbar.py b/rcp/components/home/elsbar.py index 7af01ad..eb245ba 100644 --- a/rcp/components/home/elsbar.py +++ b/rcp/components/home/elsbar.py @@ -48,7 +48,13 @@ def __init__(self, **kwargs): self.mode_name = next(iter(feeds.table.keys())) self.current_feeds_table = feeds.table[self.mode_name] self.update_feeds_ratio(self, None) + + self.app.bind(current_mode=self.on_mode_change) self.bind(current_feeds_index=self.update_feeds_ratio) + + def on_mode_change(self, instance, mode): + if mode == 2: + self.update_feeds_ratio(None, None) def update_current_position(self): Factory.Keypad().show_with_callback(self.servo.set_current_position, self.servo.scaledPosition) @@ -60,6 +66,9 @@ def set_feed_ratio(self, table_name, index): self.current_feeds_index = index def update_feeds_ratio(self, instance, value): + if self.app.current_mode != 2: + return # only sync in ELS mode + ratio = self.current_feeds_table[self.current_feeds_index].ratio spindle_scale: CoordBar = self.app.get_spindle_scale() if spindle_scale is not None: From ccf7811cce0f81db8ae17d4735c70ddc607dc973 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 21 Mar 2026 13:46:04 +0100 Subject: [PATCH 28/62] Minor fixes for ui; Added decelaration on retract; --- rcp/components/home/assisted_threading_bar.kv | 1 + rcp/components/home/assisted_threading_bar.py | 20 ++++++-- .../home/assisted_threading_wizard.py | 51 ++++++++++++++----- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.kv b/rcp/components/home/assisted_threading_bar.kv index fe804d7..27acad0 100644 --- a/rcp/components/home/assisted_threading_bar.kv +++ b/rcp/components/home/assisted_threading_bar.kv @@ -64,6 +64,7 @@ size_hint_x: None if root.retract_button_visible else 0 width: self.height if root.retract_button_visible else 0 opacity: 1 if root.retract_button_visible else 0 + disabled: not root.retract_button_enabled text: 'Retract' font_size: self.height / 4 halign: "center" diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 57044a7..a328c96 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -55,6 +55,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): cutting_depth = NumericProperty(0) last_cutting_depth = NumericProperty(0) retract_button_visible = BooleanProperty(False) + retract_button_enabled = BooleanProperty(True) _skip_save = [ "is_running", "action_button_enabled", @@ -65,13 +66,15 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): "material_width", "cutting_depth", "last_cutting_depth", - "retract_button_visible" + "retract_button_visible", + "retract_button_enabled" ] def __init__(self, **kv): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() self.action_button_condition_fn = None + self.retract_button_condition_fn = None super().__init__(**kv) if self.metric_mode: @@ -104,10 +107,14 @@ def on_mode_change(self, instance, mode): def on_retract_button_pressed(self): """Called when the retract button is pressed.""" + if not self.retract_button_enabled: + return self.wizard.start_retracting() def on_retract_button_released(self): """Called when the retract button is released.""" + if not self.retract_button_enabled: + return self.wizard.stop_retracting() def on_action_button_clicked(self): @@ -150,7 +157,7 @@ def on_encoder_update(instance, value): self.wizard.manual_stop_length = None # Always update display to formattedPosition (not raw encoder!) self.display_value = instance.formattedPosition - self.update_action_button_state() + self.update_buttons_state() # --- Format update handler --- def on_format_update(instance, value): @@ -215,12 +222,17 @@ def unbind_all_display_value(self): self.wizard._progress_display_scale.unbind(encoderCurrent=self.wizard._on_threading_progress_update) self.wizard._progress_display_scale = None - def update_action_button_state(self): - """Evaluate whether the action button should be enabled.""" + def update_buttons_state(self): + """Evaluate whether the action/retract buttons should be enabled.""" if self.action_button_condition_fn: self.action_button_enabled = self.action_button_condition_fn() else: self.action_button_enabled = True + + if self.retract_button_condition_fn: + self.retract_button_enabled = self.retract_button_condition_fn() + else: + self.retract_button_enabled = True diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 4433245..9c1f976 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -93,13 +93,14 @@ def goto_next_step(self, *args): if self.bar.is_running: # check to ensure still running and we didn't stop in the callback self.goto_step(self.current_step + 1) - def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False): + def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False, retract_button_condition_fn=None): self.bar.label_text = label_text self.bar.next_button_text = next_button_text self._current_callback = next_button_callback self.bar.bind_btn_value_on_release(value_button_fn) self.bar.action_button_condition_fn = action_button_condition_fn self.bar.retract_button_visible = retract_button_visible + self.bar.retract_button_condition_fn = retract_button_condition_fn def start_retracting(self): log.info("Retract button pressed") @@ -108,24 +109,27 @@ def start_retracting(self): if not self.app.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position - self._apply_reversing_adjusting_acceleration() servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 self.servo.jogSpeed = - servo_direction * self.bar.reversing_speed # set to reversing speed + self._apply_reversing_adjusting_acceleration() self.servo.servoEnable = 2 def stop_retracting(self): log.info("Retract button released") self.bar.action_button_enabled = True # re-enable action button self.bar.bind_display_value_to_scale(self.cross_slide_scale) - self.bar.update_action_button_state() + self.bar.update_buttons_state() if not self.app.connected: return self.servo.jogSpeed = 0 - self.servo.set_max_speed(self.servo.maxSpeed) - self.servo.servoEnable = 1 # back to normal servo mode - self.goto_step(5) # Go back to step 6 - Go to start position + self._servo_watch_callback = self._watch_retracting_stopped + self.app.bind(update_tick=self._servo_watch_callback) + # self.servo.set_max_speed(self.servo.maxSpeed) + # self.servo.servoEnable = 1 # back to normal servo mode + + # self.goto_step(5) # Go back to step 6 - Go to start position # Instruction steps #Step 1 @@ -172,24 +176,27 @@ def _step_engage_half_nut(self): #Step 6 def _step_go_to_start(self): self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid self.servo.servoEnable = 1 # Ensure servo enabled - self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True) + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True, self._is_cross_slide_retracted) self.bar.bind_display_value_to_scale(self.cross_slide_scale) - self.bar.update_action_button_state() + self.bar.update_buttons_state() #Step 7 - def _step_cut_thread(self): + def _step_cut_thread(self): self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, None, True) self._bind_threading_progress_display() # Bind to progress display - self.bar.update_action_button_state() + self.bar.update_buttons_state() #Step 8 def _step_depth_reached(self): self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, None, True) self._bind_threading_progress_display() # Bind to progress display - self.bar.update_action_button_state() + self.bar.update_buttons_state() # Step callbacks # Step 1 @@ -235,6 +242,9 @@ def _go_to_start(self, *args): if not self.app.connected: self.stop() return False + + self.bar.retract_button_enabled = False # Disable retract button during move to start + self.bar.action_button_enabled = False # Disable action button during move to start self._apply_reversing_adjusting_acceleration() self._start_position_preloaded = False @@ -387,7 +397,7 @@ def on_done(value): except ValueError: log.warning(f"Invalid stop length input: {value}") finally: - self.bar.update_action_button_state() + self.bar.update_buttons_state() keypad.show_with_callback(callback_fn=on_done, current_value=self.manual_stop_length or 0.0) @@ -506,7 +516,8 @@ def _get_backlash_cusion_encoder_steps(self) -> int: return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_cushion, self.bar.metric_distances) def _check_servo_threading_done(self, next_step: int, *args): - dev = self.app.device + dev = self.app.device + dev['assistedThreadingData'].refresh() threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] threadEnabled = dev['assistedThreadingData']['threadEnabled'] @@ -663,7 +674,8 @@ def _is_cross_slide_at_final_cutting_depth(self): """Check if the cross slide is at or more than the final cutting depth position.""" effective_dir = self._get_cross_slide_scale_effective_dir() current = self.cross_slide_scale.encoderCurrent - return (current - self.bar.cutting_depth) * effective_dir >= 0 + log.info(f"Checking if at cutting depth: last_cutting_depth={self.bar.last_cutting_depth}, cutting_depth={self.bar.cutting_depth}, effective_dir={effective_dir}") + return (self.bar.last_cutting_depth - self.bar.cutting_depth) * effective_dir >= 0 def _stop_servo(self): if not self.app.connected: @@ -783,6 +795,17 @@ def _command_move_to_encoder(self, target_encoder, speed): self.bar.bind_display_value_to_servo_position() self.servo.set_max_speed(speed) self.app.device['servo']['direction'] = delta + + def _watch_retracting_stopped(self, *_): + if not self._encoder_is_stable(self.bar.saddle_encoder_stability_tolerance, self.bar.saddle_encoder_stability_samples): + return + + self._reset_servo_watch_callback() + self.servo.set_max_speed(self.servo.maxSpeed) + self.servo.servoEnable = 1 # back to normal servo mode + + self.goto_step(5) # Go back to step 6 - Go to start position + def _watch_go_to_start(self, *_): if not self._motion_complete(): From f2a45aca6a2c7adb258b3547b1d6fd75c3e545d9 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 21 Mar 2026 14:10:37 +0100 Subject: [PATCH 29/62] Fix for sporadic issue where the watch for servo reached position was firing before the firmware gets updated with the new state --- rcp/components/home/assisted_threading_wizard.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 9c1f976..b88ae22 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -22,6 +22,7 @@ def __init__(self, bar): self.servo = self.app.servo self.current_step = 0 self._threading_started = False + self._threading_active_confirmed = False self._calculated_threading_delta_steps = 0 self._current_callback = None self._servo_watch_callback = None @@ -58,6 +59,7 @@ def stop(self): log.info("Wizard finished") self._current_callback = None self._threading_started = False + self._threading_active_confirmed = False self.bar.label_text = "" self.bar.display_value = "" self.bar.action_button_enabled = True @@ -322,6 +324,7 @@ def _acknowledge_warning(): if (self._threading_started is False): # First time starting threading - latch phase and enable self._threading_started = True + self._threading_active_confirmed = False self._calculated_threading_delta_steps = self._get_threading_servo_delta_steps() # Calculate threading delta steps - we only calculate it once including backlash dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps dev['assistedThreadingData']['threadRequest'] = 1 @@ -560,7 +563,10 @@ def _check_servo_threading_done(self, next_step: int, *args): # f"stepsToGo={stepsToGo}, " # ) - if threadEnabled == 0 and threadPhaseActive == 0: + if threadEnabled == 1 or threadPhaseActive == 1: + self._threading_active_confirmed = True + + if self._threading_active_confirmed and threadEnabled == 0 and threadPhaseActive == 0: log.info("Servo reached desired position") # Stop watching From 8ce54bb1f0d6aff4bdb1e27d7068e7c363f9c53f Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 21 Mar 2026 14:27:51 +0100 Subject: [PATCH 30/62] Further fix for the watch servo issue --- rcp/components/home/assisted_threading_wizard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index b88ae22..e9138eb 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -329,6 +329,7 @@ def _acknowledge_warning(): dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps dev['assistedThreadingData']['threadRequest'] = 1 else: + self._threading_active_confirmed = False dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state From 48ba5b388c6fba82af2c57e5bf8718ded1c3c762 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 21 Mar 2026 14:50:29 +0100 Subject: [PATCH 31/62] Fixed bug with servo max speed not being reset when retracting --- rcp/components/home/assisted_threading_wizard.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index e9138eb..5bb5f59 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -114,6 +114,7 @@ def start_retracting(self): servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 self.servo.jogSpeed = - servo_direction * self.bar.reversing_speed # set to reversing speed self._apply_reversing_adjusting_acceleration() + self.servo.set_max_speed(self.bar.reversing_speed) # ensure step rate supports jog speed self.servo.servoEnable = 2 def stop_retracting(self): @@ -128,10 +129,6 @@ def stop_retracting(self): self._servo_watch_callback = self._watch_retracting_stopped self.app.bind(update_tick=self._servo_watch_callback) - # self.servo.set_max_speed(self.servo.maxSpeed) - # self.servo.servoEnable = 1 # back to normal servo mode - - # self.goto_step(5) # Go back to step 6 - Go to start position # Instruction steps #Step 1 From 7a0c8f21f9b90430be9822f674938169c6929feb Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 23 Mar 2026 00:05:15 +0000 Subject: [PATCH 32/62] 1.3.0 Automatically generated by python-semantic-release --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 664a6fa..45ec815 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rcp" -version = "1.3.0-rc.17" +version = "1.3.0" description = "Rotary Controller Python" authors = [ { name = "Stefano Bertelli", email = "stefano@provvedo.com" } From 838a756b9f00a02b3fb2fadb08749686e6ccb38d Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 23 Mar 2026 07:06:12 +0100 Subject: [PATCH 33/62] Added logic to check that spindle is turning in the CCW direction --- .../home/assisted_threading_wizard.py | 98 +++++++++++++------ 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 5bb5f59..2886e0d 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -4,6 +4,7 @@ from rcp.components.forms.custom_popup import CustomPopup from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType +from rcp.utils.devices import SCALES_COUNT log = Logger.getChild(__name__) class AssistedThreadingWizard: @@ -268,42 +269,17 @@ def _start_threading_operation(self, *args): if not self.app.connected: self.stop() return False # tell goto_next_step not to advance immediately - + if not self._start_position_preloaded: log.warning("Threading requested without start preload") self.goto_step(5) return False - # Below is a sanity check to make sure that we are at the correct start position including backlash cushion - # if for some reason the start_position_preloaded flag was bypassed or the saddle moved after preload. - backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) - - log.info( - f"Validating start position: current={self.saddle_scale.encoderCurrent}, " - f"start={self.bar.start_position}, " - f"backlash_cushion={backlash_cushion}" - ) - - delta = abs(self.saddle_scale.encoderCurrent - self.bar.start_position) - # Check if we are within backlash cushion distance from the start position. If not, warn the user and go back to step 6 to return to start position. - if delta > backlash_cushion: - _warning = ( - "Not at valid start position including backlash cushion. " - "Aborting threading operation. Go back to start position." - ) - log.warning(_warning) - - def _acknowledge_warning(): - self.goto_step(5) # go back to Step 6 - Go to start + if not self._check_valid_start_position(): + return False - popup = CustomPopup( - title="Warning", - message=_warning, - button_text="Got it", - on_dismiss_callback=_acknowledge_warning - ) - popup.open() - return False # tell goto_next_step not to advance immediately + if not self._check_spindle_turning_forward(): + return False log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position @@ -337,8 +313,8 @@ def _acknowledge_warning(): self.app.bind(update_tick=self._servo_watch_callback) return False # tell goto_next_step not to advance immediately - - + + #Step Action button condition functions #Step 2 def _is_valid_stop_position(self): @@ -898,6 +874,64 @@ def _finish_go_to_start(self): next_step += 1 self.goto_step(next_step) + + def _check_valid_start_position(self) -> bool: + """Return True if the saddle is within the backlash cushion of the start position. + Shows a warning popup and redirects to step 6 if not. Sanity check in case the + start_position_preloaded flag was bypassed or the saddle moved after preload.""" + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) + log.info( + f"Validating start position: current={self.saddle_scale.encoderCurrent}, " + f"start={self.bar.start_position}, " + f"backlash_cushion={backlash_cushion}" + ) + delta = abs(self.saddle_scale.encoderCurrent - self.bar.start_position) + if delta > backlash_cushion: + message = ( + "Not at valid start position including backlash cushion. " + "Aborting threading operation. Go back to start position." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + + def _check_spindle_turning_forward(self) -> bool: + """Return True if the spindle scale exists and is turning in the right/positive/CCW direction. + Shows a warning popup and redirects to step 6 if not.""" + spindle_scale = self.app.get_spindle_scale() + if spindle_scale is None: + log.warning("No spindle scale configured — cannot verify spindle direction") + CustomPopup( + title="Warning", + message="No spindle scale configured. Cannot verify spindle is turning.", + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + + spindle_speed = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_scale.inputIndex] + log.info(f"Validating spindle direction: scaleSpeed[{spindle_scale.inputIndex}]={spindle_speed}") + + if spindle_speed <= 0: + message = ( + "Spindle is not turning in the right/positive/CCW direction. " + "Ensure the spindle is running forward before starting the threading operation." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True class GoToStartPhase: IDLE = 0 From 79e7cfc191e73d2db732905b1c66a97afe809ffa Mon Sep 17 00:00:00 2001 From: Pawcu Date: Mon, 23 Mar 2026 07:17:01 +0100 Subject: [PATCH 34/62] Minor UI improvements --- rcp/components/forms/custom_popup.py | 2 +- rcp/components/setup/setup_screen.kv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rcp/components/forms/custom_popup.py b/rcp/components/forms/custom_popup.py index 1cc5d50..88b03bb 100644 --- a/rcp/components/forms/custom_popup.py +++ b/rcp/components/forms/custom_popup.py @@ -23,7 +23,7 @@ def __init__(self, **kwargs): self._popup = Popup( title=self.title, content=self, - size_hint=(0.6, 0.4), + size_hint=(0.6, 0.5), auto_dismiss=False, ) diff --git a/rcp/components/setup/setup_screen.kv b/rcp/components/setup/setup_screen.kv index c41d5c1..1e3bb7e 100644 --- a/rcp/components/setup/setup_screen.kv +++ b/rcp/components/setup/setup_screen.kv @@ -38,6 +38,6 @@ text: "Update" on_release: app.goto("update") SetupButton: - text: "Assisted Threading" + text: "Assisted\nThreading" on_release: app.goto("assisted_threading") From 49aab909b78aa7bdc394438825dca3856aa18314 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 23 Mar 2026 07:24:17 +0100 Subject: [PATCH 35/62] Added extra check to make sure that the spindle RPM is not too high for the selected threading pitch and max threading speed --- .../home/assisted_threading_wizard.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 2886e0d..2ec4d80 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -281,6 +281,9 @@ def _start_threading_operation(self, *args): if not self._check_spindle_turning_forward(): return False + if not self._check_spindle_speed_for_pitch(): + return False + log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position @@ -933,6 +936,64 @@ def _check_spindle_turning_forward(self) -> bool: return False return True + def _check_spindle_speed_for_pitch(self) -> bool: + """Return True if the current spindle RPM is within the servo's speed limit + for the selected pitch. Shows a warning popup and redirects to step 6 if not.""" + spindle_scale = self.app.get_spindle_scale() + if spindle_scale is None: + return True # already caught by _check_spindle_turning_forward + + spindle_steps_per_sec = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_scale.inputIndex] + + try: + pitch_str = self.bar.selected_pitch.strip() + if not pitch_str: + return True # no pitch selected yet — skip + pitch_val = float(pitch_str) + except ValueError: + log.warning(f"Cannot parse selected_pitch={self.bar.selected_pitch!r} — skipping speed check") + return True + + if self.bar.metric_mode: + pitch_mm = pitch_val + else: + if pitch_val == 0: + return True + pitch_mm = 25.4 / pitch_val # TPI → mm/rev + + spindle_rev_per_sec = spindle_steps_per_sec / spindle_scale.ratioDen + feed_mm_per_sec = spindle_rev_per_sec * pitch_mm + encoder_steps_per_sec = feed_mm_per_sec * self.saddle_scale.stepsPerMM + + scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) + servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) + + log.info( + f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " + f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " + f"required_servo={required:.1f} steps/s, max={self.bar.threading_max_speed}" + ) + + if required > self.bar.threading_max_speed: + spindle_rpm = spindle_rev_per_sec * 60 + pitch_label = f"{pitch_mm:.3g} mm" if self.bar.metric_mode else f"{self.bar.selected_pitch} TPI" + message = ( + f"Spindle speed ({spindle_rpm:.0f} RPM) is too fast for {pitch_label} pitch. " + f"Required servo speed ({required:.0f} steps/s) exceeds the threading limit " + f"({self.bar.threading_max_speed} steps/s). " + "Reduce spindle speed or increase the threading max speed limit." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + class GoToStartPhase: IDLE = 0 RETRACT = 1 From 7c6edb8ae58a582a153f261664fc39fce33ed300 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Thu, 26 Mar 2026 07:08:57 +0100 Subject: [PATCH 36/62] Fixed issue with spindle speed check --- rcp/components/home/assisted_threading_wizard.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 2ec4d80..1b7b254 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -966,13 +966,14 @@ def _check_spindle_speed_for_pitch(self) -> bool: encoder_steps_per_sec = feed_mm_per_sec * self.saddle_scale.stepsPerMM scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) - servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) log.info( f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " - f"required_servo={required:.1f} steps/s, max={self.bar.threading_max_speed}" + f"required_servo={required:.1f} steps/s, max={self.bar.threading_max_speed}, " + f"greater={required > self.bar.threading_max_speed}" ) if required > self.bar.threading_max_speed: From f7b67dcc8bbd00d8dc1a1514ec89078a00eb6e44 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 28 Mar 2026 09:37:00 +0100 Subject: [PATCH 37/62] Fixing direction for when cutting LHT --- rcp/components/home/assisted_threading_wizard.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 1b7b254..f22fa3b 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -761,14 +761,13 @@ def _get_saddle_scale_effective_dir(self) -> int: def _command_move_to_encoder(self, target_encoder, speed): self._reset_encoder_stability_check() - - effective_dir = self._get_saddle_scale_effective_dir() + current_enc = self.saddle_scale.encoderCurrent scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) - servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) - delta = int((target_encoder - current_enc) * effective_dir * scale_ratio / servo_ratio) + delta = int((target_encoder - current_enc) * scale_ratio / servo_ratio) log.info( f"Move to encoder: current={current_enc}, " From cdde8065349c55c957ae7d67e98029043dd7a8dd Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sat, 28 Mar 2026 09:37:48 +0100 Subject: [PATCH 38/62] Added logging --- rcp/components/home/assisted_threading_wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 1b7b254..06a0bff 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -254,7 +254,7 @@ def _go_to_start(self, *args): retraction = abs(self._get_saddle_backlash_distance_encoder_steps() * 1.5) # retract 1.5x backlash distance retraction_dir = -effective_dir # retract opposite to cutting direction - + log.info(f"Starting retract to go to start: effective_dir={effective_dir}, retraction={retraction}, retraction_dir={retraction_dir}") retract_target = self.bar.start_position + retraction_dir * retraction self._command_move_to_encoder(retract_target, speed=self.bar.reversing_speed) From 3a5fb38d225978e6fae8b7024fa7ef2e2a4c3acb Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 28 Mar 2026 09:57:05 +0100 Subject: [PATCH 39/62] Fixed issue with cutting LHT --- rcp/components/home/assisted_threading_wizard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 53d58e9..7eaa364 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -563,8 +563,8 @@ def _get_threading_servo_delta_steps(self) -> int: current_encoder = self.saddle_scale.encoderCurrent target_encoder = self.bar.stop_position - delta_enc = (target_encoder - current_encoder) * effective_dir - if delta_enc <= 0: + delta_enc = target_encoder - current_encoder + if delta_enc * effective_dir <= 0: log.warning( "Threading delta is opposite to effective cutting direction " f"(current={current_encoder}, stop={target_encoder}, " @@ -573,7 +573,7 @@ def _get_threading_servo_delta_steps(self) -> int: # Convert encoder delta → servo steps scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) - servo_ratio = Fraction(self.servo.ratioNum, self.servo.ratioDen) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) delta_steps = int(delta_enc * scale_ratio / servo_ratio) From 886274919d1147ab6d0a5adf8f49d2d79266861e Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 28 Mar 2026 10:24:58 +0100 Subject: [PATCH 40/62] Added fix to update spindle sync ratio based on whether its LH or RH thread --- rcp/components/home/assisted_threading_bar.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index a328c96..5a613c6 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -83,13 +83,14 @@ def __init__(self, **kv): self.current_feeds_table = feeds.table["Thread IN"] self.update_feeds_ratio(self, None) - + # Initialize with default thread type if not set if not self.thread_profile_type: self.thread_profile_type = ThreadType.ISO_METRIC.value self.wizard = AssistedThreadingWizard(self) - + self.app.bind(current_mode=self.on_mode_change) + self.bind(left_hand_thread=self.update_feeds_ratio) def toggle_is_running(self): self.is_running = not self.is_running @@ -131,9 +132,10 @@ def update_feeds_ratio(self, instance, value): ratio = self.current_feeds_table[self.current_feeds_index].ratio spindle_scale: CoordBar = self.app.get_spindle_scale() if spindle_scale is not None: - spindle_scale.syncRatioNum = ratio.numerator + direction = -1 if self.left_hand_thread else 1 + spindle_scale.syncRatioNum = ratio.numerator * direction spindle_scale.syncRatioDen = ratio.denominator - log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}") + log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}, left_hand_thread={self.left_hand_thread}") def open_settings(self): from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup @@ -232,7 +234,4 @@ def update_buttons_state(self): if self.retract_button_condition_fn: self.retract_button_enabled = self.retract_button_condition_fn() else: - self.retract_button_enabled = True - - - + self.retract_button_enabled = True \ No newline at end of file From 10dc4a86ff3bb57aeee9e2df7eb9f25d3928846d Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 28 Mar 2026 14:53:05 +0100 Subject: [PATCH 41/62] Updated the _check_spindle_speed_for_pitch to also show the max RPM for the current selected pitch --- rcp/components/home/assisted_threading_wizard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 7eaa364..5af41a2 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -968,11 +968,14 @@ def _check_spindle_speed_for_pitch(self) -> bool: servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) + steps_per_mm_per_rev = pitch_mm * self.saddle_scale.stepsPerMM * float(scale_ratio / servo_ratio) + max_rpm = (self.bar.threading_max_speed / steps_per_mm_per_rev) * 60 if steps_per_mm_per_rev > 0 else 0 + log.info( f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " f"required_servo={required:.1f} steps/s, max={self.bar.threading_max_speed}, " - f"greater={required > self.bar.threading_max_speed}" + f"max_rpm={max_rpm:.1f}, greater={required > self.bar.threading_max_speed}" ) if required > self.bar.threading_max_speed: @@ -982,6 +985,7 @@ def _check_spindle_speed_for_pitch(self) -> bool: f"Spindle speed ({spindle_rpm:.0f} RPM) is too fast for {pitch_label} pitch. " f"Required servo speed ({required:.0f} steps/s) exceeds the threading limit " f"({self.bar.threading_max_speed} steps/s). " + f"Max allowed spindle speed for this pitch is {max_rpm:.0f} RPM. " "Reduce spindle speed or increase the threading max speed limit." ) log.warning(message) From ac874ee8e2d9e9a2ee2325c0d348cc270aa72cf4 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 28 Mar 2026 15:04:56 +0100 Subject: [PATCH 42/62] fix: prevent RecursionError in CoordBar.update_scaledPosition on spindle wrap The method was bound to self.position but also wrote back to self.position when the spindle position wrapped past ratioNum/0. Kivy's synchronous property dispatch caused immediate re-entry, hitting Python's recursion limit. Added a reentrancy guard (_updating_scaled_position flag) and extracted the body to _do_update_scaledPosition to prevent re-entrant calls from looping. --- rcp/components/home/coordbar.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rcp/components/home/coordbar.py b/rcp/components/home/coordbar.py index 036a5c3..6177fac 100644 --- a/rcp/components/home/coordbar.py +++ b/rcp/components/home/coordbar.py @@ -70,6 +70,7 @@ def __init__(self, **kv): self.speed_history = collections.deque(maxlen=25) self.previous_position = 0 self.motion_detected = True + self._updating_scaled_position = False self.previous_axis_time: float = 0 self.previous_axis_pos: Decimal = Decimal(0) self.app.bind(currentOffset=self.update_scaledPosition) @@ -150,6 +151,15 @@ def on_syncRatioDen(self, instance, value): self.set_sync_ratio() def update_scaledPosition(self, instance=None, value=None): + if self._updating_scaled_position: + return + self._updating_scaled_position = True + try: + self._do_update_scaledPosition() + finally: + self._updating_scaled_position = False + + def _do_update_scaledPosition(self): if self.spindleMode: # When working in spindle mode we report the position in degrees self.scaledPosition = float( From 380eb905b93747a3e031f55c511817603c99f688 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sat, 28 Mar 2026 15:10:28 +0100 Subject: [PATCH 43/62] Add more metric thread pitches --- rcp/feeds.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rcp/feeds.py b/rcp/feeds.py index 3bac117..75ea6e5 100644 --- a/rcp/feeds.py +++ b/rcp/feeds.py @@ -9,11 +9,16 @@ class FeedConfiguration(BaseModel): ratio: Optional[Fraction] = None THREAD_MM = [ + FeedConfiguration(name="0.20", ratio=Fraction("0.2"), mode=1), + FeedConfiguration(name="0.25", ratio=Fraction("0.25"), mode=1), + FeedConfiguration(name="0.30", ratio=Fraction("0.3"), mode=1), FeedConfiguration(name="0.35", ratio=Fraction("0.35"), mode=1), FeedConfiguration(name="0.40", ratio=Fraction("0.4"), mode=1), FeedConfiguration(name="0.45", ratio=Fraction("0.45"), mode=1), FeedConfiguration(name="0.50", ratio=Fraction("0.5"), mode=1), + FeedConfiguration(name="0.60", ratio=Fraction("0.6"), mode=1), FeedConfiguration(name="0.70", ratio=Fraction("0.7"), mode=1), + FeedConfiguration(name="0.75", ratio=Fraction("0.75"), mode=1), FeedConfiguration(name="0.80", ratio=Fraction("0.8"), mode=1), FeedConfiguration(name="1.00", ratio=Fraction("1"), mode=1), FeedConfiguration(name="1.25", ratio=Fraction("1.25"), mode=1), @@ -24,6 +29,10 @@ class FeedConfiguration(BaseModel): FeedConfiguration(name="3.00", ratio=Fraction("3"), mode=1), FeedConfiguration(name="3.50", ratio=Fraction("3.5"), mode=1), FeedConfiguration(name="4.00", ratio=Fraction("4"), mode=1), + FeedConfiguration(name="4.50", ratio=Fraction("4.5"), mode=1), + FeedConfiguration(name="5.00", ratio=Fraction("5"), mode=1), + FeedConfiguration(name="5.50", ratio=Fraction("5.5"), mode=1), + FeedConfiguration(name="6.00", ratio=Fraction("6"), mode=1), ] THREAD_IN = [ From 00897216838256ee0da7533a92316380c5830627 Mon Sep 17 00:00:00 2001 From: funkenjaeger <> Date: Sat, 28 Mar 2026 16:26:46 -0500 Subject: [PATCH 44/62] Enable unicode character support for Axis names (parse escape sequences) --- rcp/components/widgets/string_item.kv | 2 +- rcp/components/widgets/string_item.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/rcp/components/widgets/string_item.kv b/rcp/components/widgets/string_item.kv index 7a3912f..afcaf68 100644 --- a/rcp/components/widgets/string_item.kv +++ b/rcp/components/widgets/string_item.kv @@ -24,4 +24,4 @@ valign: "center" halign: "center" disabled: root.disabled - on_text_validate: root.value = self.text \ No newline at end of file + on_text_validate: root.set_value_from_text(self.text) \ No newline at end of file diff --git a/rcp/components/widgets/string_item.py b/rcp/components/widgets/string_item.py index 29d2cdc..e7b3181 100644 --- a/rcp/components/widgets/string_item.py +++ b/rcp/components/widgets/string_item.py @@ -14,3 +14,9 @@ class StringItem(BoxLayout): value = StringProperty("") disabled = BooleanProperty(False) help_file = StringProperty("") + + def set_value_from_text(self, text: str): + try: + self.value = text.encode("raw_unicode_escape").decode("unicode_escape") + except (UnicodeDecodeError, UnicodeEncodeError): + self.value = text From 4404f17adde9bfc8970ffbcf0be54180d74d5f3b Mon Sep 17 00:00:00 2001 From: funkenjaeger <> Date: Sat, 28 Mar 2026 12:54:35 -0500 Subject: [PATCH 45/62] Made axis row height logic consistent across modes, and made max row height a format setting --- rcp/components/home/dro_mode_layout.py | 23 ++++++++++++++++++++++- rcp/components/home/els_mode_layout.py | 5 ++--- rcp/components/home/index_mode_layout.py | 22 ++++++++++++++++++++++ rcp/components/home/jog_mode_layout.py | 21 +++++++++++++++++++++ rcp/components/screens/formats_screen.kv | 4 ++++ rcp/dispatchers/formats.py | 2 ++ 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/rcp/components/home/dro_mode_layout.py b/rcp/components/home/dro_mode_layout.py index 5255994..aecdf31 100644 --- a/rcp/components/home/dro_mode_layout.py +++ b/rcp/components/home/dro_mode_layout.py @@ -1,13 +1,34 @@ from rcp.components.home.dro_coordbar import DroCoordBar from rcp.components.home.mode_layout import ModeLayout +from kivy.uix.widget import Widget class DroModeLayout(ModeLayout): - """DRO mode: simplified DroCoordBars filling all space, no bottom bar.""" + """DRO mode: simplified DroCoordBars, no bottom bar.""" def __init__(self, **kwargs): super().__init__(**kwargs) + self.build_axis_bars() + self.spacer = Widget() + self.add_widget(self.spacer) + + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + + def _update_row_heights(self, *args): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + + row_height = min(self.height / num_rows, self.app.formats.max_row_height) + + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + + # spacer absorbs remaining space (size_hint_y defaults to 1) def build_axis_bars(self): for axis_disp in self.app.axes: diff --git a/rcp/components/home/els_mode_layout.py b/rcp/components/home/els_mode_layout.py index a27504e..4eff8b1 100644 --- a/rcp/components/home/els_mode_layout.py +++ b/rcp/components/home/els_mode_layout.py @@ -15,10 +15,8 @@ ICON_CCW = "\uf0e2" # rotate-left ICON_STOP = "\uf04d" # stop -MAX_ROW_HEIGHT = 150 LONG_PRESS_THRESHOLD = 1.0 - class ElsSpindleInfo(BoxLayout): """Displays spindle speed with direction icon and absolute position with zero button.""" spindle_rpm = StringProperty("--") @@ -99,6 +97,7 @@ def __init__(self, els_bar: ElsBar, **kwargs): x_axis_index=lambda *a: self.rebuild_axes(), ) + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) self.bind(height=self._update_row_heights) self._update_row_heights() @@ -108,7 +107,7 @@ def _update_row_heights(self, *args): return available = self.height - self.els_bar.height - row_height = min(available / num_rows, MAX_ROW_HEIGHT) + row_height = min(available / num_rows, self.app.formats.max_row_height) self.spindle_info.size_hint_y = None self.spindle_info.height = row_height diff --git a/rcp/components/home/index_mode_layout.py b/rcp/components/home/index_mode_layout.py index 6d37fb6..599135f 100644 --- a/rcp/components/home/index_mode_layout.py +++ b/rcp/components/home/index_mode_layout.py @@ -1,6 +1,7 @@ from rcp.components.home.coordbar import CoordBar from rcp.components.home.mode_layout import ModeLayout from rcp.components.home.servobar import ServoBar +from kivy.uix.widget import Widget class IndexModeLayout(ModeLayout): @@ -9,9 +10,16 @@ class IndexModeLayout(ModeLayout): def __init__(self, **kwargs): super().__init__(**kwargs) self.servo_bar = ServoBar() + self.spacer = Widget() + self.build_axis_bars() + self.add_widget(self.spacer) self.add_widget(self.servo_bar) + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + def build_axis_bars(self): for axis_disp in self.app.axes: cb = CoordBar(axis=axis_disp) @@ -22,3 +30,17 @@ def rebuild_axes(self): self.remove_widget(self.servo_bar) super().rebuild_axes() self.add_widget(self.servo_bar) + + def _update_row_heights(self, *args): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + + available = self.height - self.servo_bar.height + row_height = min(available / num_rows, self.app.formats.max_row_height) + + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + + # spacer absorbs remaining space (size_hint_y defaults to 1) diff --git a/rcp/components/home/jog_mode_layout.py b/rcp/components/home/jog_mode_layout.py index 389ba1f..2b3784d 100644 --- a/rcp/components/home/jog_mode_layout.py +++ b/rcp/components/home/jog_mode_layout.py @@ -1,6 +1,7 @@ from rcp.components.home.coordbar import CoordBar from rcp.components.home.jogbar import JogBar from rcp.components.home.mode_layout import ModeLayout +from kivy.uix.widget import Widget class JogModeLayout(ModeLayout): @@ -10,8 +11,14 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.jog_bar = JogBar() self.build_axis_bars() + self.spacer = Widget() + self.add_widget(self.spacer) self.add_widget(self.jog_bar) + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + def build_axis_bars(self): for axis_disp in self.app.axes: cb = CoordBar(axis=axis_disp) @@ -22,3 +29,17 @@ def rebuild_axes(self): self.remove_widget(self.jog_bar) super().rebuild_axes() self.add_widget(self.jog_bar) + + def _update_row_heights(self, *args): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + + available = self.height - self.jog_bar.height + row_height = min(available / num_rows, self.app.formats.max_row_height) + + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + + # spacer absorbs remaining space (size_hint_y defaults to 1) diff --git a/rcp/components/screens/formats_screen.kv b/rcp/components/screens/formats_screen.kv index 6b2d4f1..54ba47b 100644 --- a/rcp/components/screens/formats_screen.kv +++ b/rcp/components/screens/formats_screen.kv @@ -87,6 +87,10 @@ value: root.formats.hide_mouse_cursor help_file: "hide_mouse_cursor.md" on_value: root.formats.hide_mouse_cursor = self.value + NumberItem: + name: "Max axis row height (px)" + value: root.formats.max_row_height + on_value: root.formats.max_row_height = self.value TitleItem: name: "Sound Settings" diff --git a/rcp/dispatchers/formats.py b/rcp/dispatchers/formats.py index 4399ce1..7a54e34 100644 --- a/rcp/dispatchers/formats.py +++ b/rcp/dispatchers/formats.py @@ -51,6 +51,8 @@ class FormatsDispatcher(SavingDispatcher): hide_mouse_cursor = BooleanProperty(False) + max_row_height = NumericProperty(150) + def __init__(self, **kv): super().__init__(**kv) self.angle_speed_format = self.angle_speed_format.replace("RPM", "").replace(" ", "") From 32d5de121a9f19c6aa2e6303e3901646e539fbd5 Mon Sep 17 00:00:00 2001 From: Pawcu Date: Sun, 29 Mar 2026 11:26:14 +0200 Subject: [PATCH 46/62] WIP - minor fixes to get app to compile and run --- rcp/components/home/assisted_threading_bar.py | 2 +- rcp/components/home/assisted_threading_wizard.py | 2 +- rcp/components/widgets/dropdown_item.py | 2 +- uv.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 5a613c6..bc434c6 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -6,7 +6,7 @@ from kivy.properties import NumericProperty, BooleanProperty, StringProperty from rcp import feeds -from rcp.components.forms.hold_button import HoldButton +from rcp.components.widgets.hold_button import HoldButton from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 5af41a2..dd3bd68 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -1,7 +1,7 @@ from fractions import Fraction from kivy.logger import Logger -from rcp.components.forms.custom_popup import CustomPopup +from rcp.components.widgets.custom_popup import CustomPopup from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType from rcp.utils.devices import SCALES_COUNT diff --git a/rcp/components/widgets/dropdown_item.py b/rcp/components/widgets/dropdown_item.py index 537921e..4e855ef 100644 --- a/rcp/components/widgets/dropdown_item.py +++ b/rcp/components/widgets/dropdown_item.py @@ -54,7 +54,7 @@ def on_options(self, instance, value): app = MainApp.get_running_app() font_size = app.formats.font_size if app else 24 - for item in self.options: + for index, item in enumerate(self.options): btn = Button( text=item, size_hint_y=None, height=60, font_size=font_size, background_color=DROPDOWN_OPTION_COLOR, diff --git a/uv.lock b/uv.lock index 4933c65..3fff04c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <4.0" [[package]] @@ -1490,7 +1490,7 @@ wheels = [ [[package]] name = "rcp" -version = "1.3.0rc16" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 6d37ea6f93ba7ebf058d04883d4a466ee843dfba Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sun, 29 Mar 2026 12:08:04 +0200 Subject: [PATCH 47/62] WIP - added logic for AT layout and screen --- rcp/components/home/assisted_threading_bar.py | 2 +- rcp/components/home/at_mode_layout.py | 28 +++++++++++++++++++ rcp/components/screens/home_screen.py | 10 +++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 rcp/components/home/at_mode_layout.py diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index bc434c6..a775e4f 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -103,7 +103,7 @@ def stop_wizard(self): self.wizard.stop() def on_mode_change(self, instance, mode): - if mode == 4: # AT mode + if mode == 5: # AT mode self.update_feeds_ratio(None, None) def on_retract_button_pressed(self): diff --git a/rcp/components/home/at_mode_layout.py b/rcp/components/home/at_mode_layout.py new file mode 100644 index 0000000..cfdc231 --- /dev/null +++ b/rcp/components/home/at_mode_layout.py @@ -0,0 +1,28 @@ +from rcp.components.home.assisted_threading_bar import AssistedThreadingBar +from rcp.components.home.coordbar import CoordBar +from rcp.components.home.dro_coordbar import DroCoordBar +from rcp.components.home.mode_layout import ModeLayout + + +class AtModeLayout(ModeLayout): + """AT mode: spindle axis uses CoordBar (Num/Den visible), all others use DroCoordBar + AssistedThreadingBar.""" + + def __init__(self, at_bar: AssistedThreadingBar, **kwargs): + super().__init__(**kwargs) + self.at_bar = at_bar + self.build_axis_bars() + self.add_widget(self.at_bar) + + def build_axis_bars(self): + for axis in self.app.axes: + if axis.spindleMode: + cb = CoordBar(axis=axis) + else: + cb = DroCoordBar(axis=axis) + self.axis_bars.append(cb) + self.add_widget(cb) + + def rebuild_axes(self): + self.remove_widget(self.at_bar) + super().rebuild_axes() + self.add_widget(self.at_bar) diff --git a/rcp/components/screens/home_screen.py b/rcp/components/screens/home_screen.py index 0020145..a275e79 100644 --- a/rcp/components/screens/home_screen.py +++ b/rcp/components/screens/home_screen.py @@ -5,6 +5,8 @@ from kivy.clock import Clock from kivy.uix.screenmanager import Screen +from rcp.components.home.assisted_threading_bar import AssistedThreadingBar +from rcp.components.home.at_mode_layout import AtModeLayout from rcp.components.home.dro_mode_layout import DroModeLayout from rcp.components.home.els_mode_layout import ElsModeLayout from rcp.components.home.elsbar import ElsBar @@ -33,12 +35,16 @@ def __init__(self, **kv): # Create shared ElsBar (has SavingDispatcher state) self.els_bar = ElsBar(id_override="0") + # Create shared AssistedThreadingBar (has SavingDispatcher state) + self.at_bar = AssistedThreadingBar(id_override="0") + # Create all mode layouts once self.mode_layouts = { 1: IndexModeLayout(), 2: ElsModeLayout(els_bar=self.els_bar), 3: JogModeLayout(), 4: DroModeLayout(), + 5: AtModeLayout(at_bar=self.at_bar), } # Configure initial mode, disable Indexing if servo is in ELS mode @@ -74,6 +80,10 @@ def change_mode_speed_check(self, instance): jog_layout.jog_bar.enable_jog = False self.app.servo.servoEnable = 0 + # Stop AT wizard if leaving AT mode + if self.current_layout is self.mode_layouts[5]: + self.at_bar.stop_wizard() + # Swap the entire mode layout if self.current_layout is not None: self.bars_container.remove_widget(self.current_layout) From 1d46c9319c9a2d5efb28067adcf4764479ef92a1 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sun, 29 Mar 2026 12:17:31 +0200 Subject: [PATCH 48/62] Fixed Dispatcher in assisted_threading_bar --- rcp/components/home/assisted_threading_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index a775e4f..5f08776 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -10,7 +10,7 @@ from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType -from rcp.dispatchers import SavingDispatcher +from rcp.dispatchers.saving_dispatcher import SavingDispatcher log = Logger.getChild(__name__) From 421c12ba41af05de5cc595edbb6dcba15196ad1b Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sun, 29 Mar 2026 17:32:32 +0200 Subject: [PATCH 49/62] WIP - more fixes following merge from upstream main --- rcp/components/home/assisted_threading_bar.py | 73 +++--- .../home/assisted_threading_settings_popup.py | 7 +- .../home/assisted_threading_wizard.py | 208 ++++++++++-------- rcp/components/screens/els_setup_screen.kv | 57 +++++ rcp/components/screens/els_setup_screen.py | 36 +++ rcp/components/screens/setup_screen.kv | 3 - .../setup/assisted_threading_screen.kv | 89 -------- .../setup/assisted_threading_screen.py | 124 ----------- rcp/dispatchers/els.py | 23 +- rcp/dispatchers/input.py | 3 +- 10 files changed, 265 insertions(+), 358 deletions(-) delete mode 100644 rcp/components/setup/assisted_threading_screen.kv delete mode 100644 rcp/components/setup/assisted_threading_screen.py diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 5f08776..7e001cb 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -8,7 +8,6 @@ from rcp import feeds from rcp.components.widgets.hold_button import HoldButton from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard -from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType from rcp.dispatchers.saving_dispatcher import SavingDispatcher @@ -19,27 +18,12 @@ log.info(f"Loading KV file: {kv_file}") Builder.load_file(kv_file) -class AssistedThreadingBar(BoxLayout, SavingDispatcher): - selected_cross_slide_scale_id = NumericProperty(0) - selected_saddle_scale_id = NumericProperty(1) - - reversing_speed = NumericProperty(500) - preload_adjust_speed = NumericProperty(500) - threading_max_speed = NumericProperty(2000) - reversing_adjusting_acceleration = NumericProperty(1000) - threading_acceleration = NumericProperty(1000) - rotary_encoder_sync_tolerance = NumericProperty(5) - metric_distances = BooleanProperty(True) # This is for the UI in the setting screen - saddle_backlash_distance = NumericProperty(10) - backlash_cushion = NumericProperty(2) - saddle_encoder_stability_tolerance = NumericProperty(1) - saddle_encoder_stability_samples = NumericProperty(3) - - metric_mode = BooleanProperty(True) # This is for the actual threading logic +class AssistedThreadingBar(BoxLayout, SavingDispatcher): + # ── Per-job thread settings (saved on the bar) ──────────────────── + metric_mode = BooleanProperty(True) # This is for the actual threading logic selected_pitch = StringProperty("") current_feeds_index = NumericProperty(0) thread_profile_type = StringProperty("ISO_METRIC") - cross_slide_diameter_mode = BooleanProperty(False) shaft_diameter = NumericProperty(1) left_hand_thread = BooleanProperty(False) inner_thread = BooleanProperty(False) @@ -77,11 +61,7 @@ def __init__(self, **kv): self.retract_button_condition_fn = None super().__init__(**kv) - if self.metric_mode: - self.current_feeds_table = feeds.table["Thread MM"] - else: - self.current_feeds_table = feeds.table["Thread IN"] - + self.current_feeds_table = feeds.table["Thread MM"] if self.metric_mode else feeds.table["Thread IN"] self.update_feeds_ratio(self, None) # Initialize with default thread type if not set @@ -102,6 +82,9 @@ def toggle_is_running(self): def stop_wizard(self): self.wizard.stop() + def on_metric_mode(self, instance, value): + self.current_feeds_table = feeds.table["Thread MM"] if value else feeds.table["Thread IN"] + def on_mode_change(self, instance, mode): if mode == 5: # AT mode self.update_feeds_ratio(None, None) @@ -126,15 +109,15 @@ def on_action_button_clicked(self): self.open_settings() def update_feeds_ratio(self, instance, value): - if self.app.current_mode != 4: + if self.app.current_mode != 5: return # only sync in AT mode - + ratio = self.current_feeds_table[self.current_feeds_index].ratio - spindle_scale: CoordBar = self.app.get_spindle_scale() - if spindle_scale is not None: + spindle_axis = self.app.els.get_spindle_axis() + if spindle_axis is not None: direction = -1 if self.left_hand_thread else 1 - spindle_scale.syncRatioNum = ratio.numerator * direction - spindle_scale.syncRatioDen = ratio.denominator + spindle_axis.syncRatioNum = ratio.numerator * direction + spindle_axis.syncRatioDen = ratio.denominator log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}, left_hand_thread={self.left_hand_thread}") def open_settings(self): @@ -142,23 +125,24 @@ def open_settings(self): popup = AssistedThreadingSettingsPopup(assistedThreadingBar=self) popup.open() - def bind_display_value_to_scale(self, scale: CoordBar): - """Bind display_value to a scale's encoderCurrent with strict keypad override support.""" + def bind_display_value_to_scale(self, axis): + """Bind display_value to an AxisDispatcher's formattedPosition with strict keypad override support.""" # Unbind any previous bindings self.unbind_all_display_value() - # Store the scale - self._bound_scale = scale + # Store the axis (AxisDispatcher) for later unbind + self._bound_scale = axis + inp = axis._primary_input() if axis is not None else None - # --- Encoder update handler --- - def on_encoder_update(instance, value): + # --- Encoder update handler (fires on raw encoder tick) --- + def on_encoder_update(*_): # Cancel manual override if the encoder moves if self.wizard and self.wizard.manual_stop_length is not None: log.info("Scale encoder moved — discarding manual stop length override") self.wizard.manual_stop_length = None - # Always update display to formattedPosition (not raw encoder!) - self.display_value = instance.formattedPosition + # Display the axis formatted position (not raw encoder ticks) + self.display_value = axis.formattedPosition self.update_buttons_state() # --- Format update handler --- @@ -171,12 +155,13 @@ def on_format_update(instance, value): self._on_encoder_update = on_encoder_update self._on_format_update = on_format_update - # Bind both - scale.bind(encoderCurrent=on_encoder_update) - scale.bind(formattedPosition=on_format_update) + # encoderCurrent lives on InputDispatcher; formattedPosition on AxisDispatcher + if inp is not None: + inp.bind(encoderCurrent=on_encoder_update) + axis.bind(formattedPosition=on_format_update) # Initial display - self.display_value = scale.formattedPosition + self.display_value = axis.formattedPosition def bind_display_value_to_servo_position(self): """Bind display_value to the servo's formattedPosition.""" @@ -212,7 +197,9 @@ def bind_btn_value_on_release(self, on_release_fn): def unbind_all_display_value(self): if hasattr(self, "_bound_scale") and self._bound_scale is not None: - self._bound_scale.unbind(encoderCurrent=self._on_encoder_update) + inp = self._bound_scale._primary_input() + if inp is not None: + inp.unbind(encoderCurrent=self._on_encoder_update) self._bound_scale.unbind(formattedPosition=self._on_format_update) self._bound_scale = None if hasattr(self, "_bound_servo") and self._bound_servo is not None: diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index 88808d3..a73d151 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -39,8 +39,11 @@ def get_thread_types(self): def on_metric_mode_changed(self, value): self.assistedThreadingBar.metric_mode = value pitches_dropdown = self.ids.pitches_dropdown - pitches_dropdown.value = "" - pitches_dropdown.options = self.get_pitches() + pitches = self.get_pitches() + pitches_dropdown.options = pitches + first_pitch = pitches[0] if pitches else "" + pitches_dropdown.value = first_pitch + self.on_pitch_selected(0, first_pitch) # Update thread type options based on metric mode thread_type_dropdown = self.ids.thread_type_dropdown diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index dd3bd68..b7ab443 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -2,24 +2,38 @@ from kivy.logger import Logger from rcp.components.widgets.custom_popup import CustomPopup -from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType from rcp.utils.devices import SCALES_COUNT log = Logger.getChild(__name__) class AssistedThreadingWizard: @property - def saddle_scale(self) -> CoordBar: - return self.app.scales[self.bar.selected_saddle_scale_id] - + def saddle_scale(self): + """Returns the AxisDispatcher for the saddle (Z) axis.""" + return self.app.els.get_z_axis() + @property - def cross_slide_scale(self) -> CoordBar: - return self.app.scales[self.bar.selected_cross_slide_scale_id] - + def cross_slide_scale(self): + """Returns the AxisDispatcher for the cross-slide (X) axis.""" + return self.app.els.get_x_axis() + + @property + def saddle_input(self): + """Returns the InputDispatcher (raw encoder) for the saddle axis.""" + axis = self.saddle_scale + return axis._primary_input() if axis is not None else None + + @property + def cross_slide_input(self): + """Returns the InputDispatcher (raw encoder) for the cross-slide axis.""" + axis = self.cross_slide_scale + return axis._primary_input() if axis is not None else None + def __init__(self, bar): + from rcp.app import MainApp log.info("Initializing AssistedThreadingWizard") self.bar = bar - self.app = bar.app + self.app: MainApp = MainApp.get_running_app() self.servo = self.app.servo self.current_step = 0 self._threading_started = False @@ -44,14 +58,15 @@ def __init__(self, bar): def start(self): - dev = self.app.device - dev['assistedThreadingData']['spindlePhaseTolerance'] = self.bar.rotary_encoder_sync_tolerance + dev = self.app.board.device + dev['assistedThreadingData']['spindlePhaseTolerance'] = self.app.els.at_rotary_encoder_sync_tolerance - # Pick spindle index using get_spindle_scale - spindle_scale = self.app.get_spindle_scale() - if spindle_scale is not None: - dev['assistedThreadingData']['spindleCountsPerRev'] = spindle_scale.ratioDen - dev['assistedThreadingData']['spindleScaleIndex'] = spindle_scale.inputIndex + spindle_axis = self.app.els.get_spindle_axis() + if spindle_axis is not None: + inp = spindle_axis._primary_input() + if inp is not None: + dev['assistedThreadingData']['spindleCountsPerRev'] = inp.ratioDen + dev['assistedThreadingData']['spindleScaleIndex'] = inp.inputIndex self.goto_step(0) @@ -71,8 +86,8 @@ def stop(self): self._reset_servo_watch_callback() self._reset_encoder_stability_check() - if self.app.connected: - self.app.device['assistedThreadingData']['threadReset'] = 1 + if self.app.board.connected: + self.app.board.device['assistedThreadingData']['threadReset'] = 1 self._stop_servo() @@ -109,13 +124,13 @@ def start_retracting(self): log.info("Retract button pressed") self.bar.action_button_enabled = False # disable action button while retracting - if not self.app.connected: + if not self.app.board.connected: return self.bar.bind_display_value_to_servo_position() # bind to servo position servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 - self.servo.jogSpeed = - servo_direction * self.bar.reversing_speed # set to reversing speed + self.servo.jogSpeed = - servo_direction * self.app.els.at_reversing_speed # set to reversing speed self._apply_reversing_adjusting_acceleration() - self.servo.set_max_speed(self.bar.reversing_speed) # ensure step rate supports jog speed + self.servo.set_max_speed(self.app.els.at_reversing_speed) # ensure step rate supports jog speed self.servo.servoEnable = 2 def stop_retracting(self): @@ -124,7 +139,7 @@ def stop_retracting(self): self.bar.bind_display_value_to_scale(self.cross_slide_scale) self.bar.update_buttons_state() - if not self.app.connected: + if not self.app.board.connected: return self.servo.jogSpeed = 0 @@ -201,7 +216,7 @@ def _step_depth_reached(self): # Step callbacks # Step 1 def _capture_initial_position(self, *args): - self.bar.start_position = self.saddle_scale.encoderCurrent + self.bar.start_position = self.saddle_input.encoderCurrent self._isStartPositionMetricMode = self.app.formats.current_format == "MM" self._startScaledPosition = self.saddle_scale.scaledPosition log.info(f"Initial position set to: {self.bar.start_position}") @@ -216,7 +231,7 @@ def _capture_stop_position(self, *args): #Step 3 def _capture_material_width_position(self, *args): - self.bar.material_width = self.cross_slide_scale.encoderCurrent + self.bar.material_width = self.cross_slide_input.encoderCurrent self.bar.last_cutting_depth = self.bar.material_width # Initialize last_cutting_depth to material_width self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition @@ -230,7 +245,7 @@ def _capture_final_cutting_depth_position(self, *args): depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() encoder_cutting_depth = self._convert_distance_units_to_encoder(self.cross_slide_scale, depth, is_metric) - self.bar.cutting_depth = self.cross_slide_scale.encoderCurrent - (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) + self.bar.cutting_depth = self.cross_slide_input.encoderCurrent - (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") @@ -239,7 +254,7 @@ def _capture_final_cutting_depth_position(self, *args): #Step 6 def _go_to_start(self, *args): - if not self.app.connected: + if not self.app.board.connected: self.stop() return False @@ -257,7 +272,7 @@ def _go_to_start(self, *args): log.info(f"Starting retract to go to start: effective_dir={effective_dir}, retraction={retraction}, retraction_dir={retraction_dir}") retract_target = self.bar.start_position + retraction_dir * retraction - self._command_move_to_encoder(retract_target, speed=self.bar.reversing_speed) + self._command_move_to_encoder(retract_target, speed=self.app.els.at_reversing_speed) self._servo_watch_callback = self._watch_go_to_start self.app.bind(update_tick=self._servo_watch_callback) @@ -266,7 +281,7 @@ def _go_to_start(self, *args): #Step 7 def _start_threading_operation(self, *args): - if not self.app.connected: + if not self.app.board.connected: self.stop() return False # tell goto_next_step not to advance immediately @@ -285,7 +300,7 @@ def _start_threading_operation(self, *args): return False log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) - self.bar.last_cutting_depth = self.cross_slide_scale.encoderCurrent # Update last cutting depth to current position + self.bar.last_cutting_depth = self.cross_slide_input.encoderCurrent # Update last cutting depth to current position self._apply_threading_acceleration() self._apply_threading_max_speed() @@ -294,7 +309,7 @@ def _start_threading_operation(self, *args): self.bar.retract_button_visible = False # Hide retract button during threading # Write the fields into firmware via modbus/device wrapper - dev = self.app.device + dev = self.app.board.device # Request latch+wait. Firmware will latch current spindle phase and wait until matched. if (self._threading_started is False): @@ -344,7 +359,7 @@ def _is_cross_slide_retracted(self): # --- Saddle direction check (Z axis) --- saddle_dir = self._get_saddle_scale_effective_dir() - saddle_delta = self.saddle_scale.encoderCurrent - self.bar.start_position + saddle_delta = self.saddle_input.encoderCurrent - self.bar.start_position saddle_beyond_start = saddle_delta * saddle_dir > 0 if not saddle_beyond_start: @@ -356,12 +371,12 @@ def _is_cross_slide_retracted(self): # --- Cross-slide retraction check (X axis) --- retract_dir = -self._get_cross_slide_scale_effective_dir() #TODO test this - cross_delta = self.cross_slide_scale.encoderCurrent - self.bar.material_width + cross_delta = self.cross_slide_input.encoderCurrent - self.bar.material_width return cross_delta * retract_dir > 0 # Manual input handlers def _open_stop_position_keypad(self, *args): - from rcp.components.keypad import Keypad + from rcp.components.popups.keypad import Keypad is_metric = self.app.formats.current_format == "MM" @@ -383,7 +398,7 @@ def on_done(value): current_value=self.manual_stop_length or 0.0) def _open_final_cutting_depth_position_keypad(self, *args): - from rcp.components.keypad import Keypad + from rcp.components.popups.keypad import Keypad is_metric = self.app.formats.current_format == "MM" # Always use calculated depth as default calculated_depth = self._calculate_thread_depth() @@ -406,11 +421,11 @@ def on_done(value): current_value=self.manual_cutting_depth if self.manual_cutting_depth is not None else default_value) # Utilities - def _convert_position_units_to_encoder(self, - scale: CoordBar, - manual_position: float, + def _convert_position_units_to_encoder(self, + scale, + manual_position: float, is_original_position_metric_mode: bool, - original_scaled_position, + original_scaled_position, start_encoder_units: int) -> int: """ Convert a user-entered stop position (MM/IN) into encoder counts. @@ -436,10 +451,11 @@ def _convert_position_units_to_encoder(self, f"delta from start: {delta_in_start_units})" ) - # Compute encoder counts using inverse of CoordBar.scaledPosition + # Compute encoder counts using inverse of AxisDispatcher.scaledPosition + inp = scale._primary_input() encoder_counts = ( (delta_in_start_units / factor_at_start_position) - scale.offsets[self.app.currentOffset] - ) * (float(scale.ratioDen) / float(scale.ratioNum)) + ) * (float(inp.ratioDen) / float(inp.ratioNum)) # Offset by the captured start position final_encoder_position = int(round(start_encoder_units + encoder_counts)) @@ -464,19 +480,21 @@ def _get_stop_position_units(self) -> float: ) log.info(f"Converted manual stop length to encoder units: {result}") return result - log.info(f"Using live encoder value: {scale.encoderCurrent}") - return scale.encoderCurrent - - def _convert_distance_units_to_encoder(self, scale: CoordBar, distance: float, is_metric: bool) -> int: + log.info(f"Using live encoder value: {self.saddle_input.encoderCurrent}") + return self.saddle_input.encoderCurrent + + def _convert_distance_units_to_encoder(self, scale, distance: float, is_metric: bool) -> int: """ Convert a pure distance (mm or inch) into encoder counts. + scale: AxisDispatcher """ + inp = scale._primary_input() encoder_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) - # Compute encoder counts using inverse of CoordBar.scaledPosition + # Compute encoder counts using inverse of AxisDispatcher.scaledPosition encoder_counts = ( (distance / encoder_factor) - scale.offsets[self.app.currentOffset] - ) * (float(scale.ratioDen) / float(scale.ratioNum)) + ) * (float(inp.ratioDen) / float(inp.ratioNum)) final_encoder_distance = int(round(encoder_counts)) @@ -489,14 +507,14 @@ def _convert_distance_units_to_encoder(self, scale: CoordBar, distance: float, i def _get_saddle_backlash_distance_encoder_steps(self) -> int: """Get the retraction distance in encoder counts.""" - return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.saddle_backlash_distance, self.bar.metric_distances) + return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_saddle_backlash_distance, self.app.els.at_metric_distances) def _get_backlash_cusion_encoder_steps(self) -> int: """Get the backlash cushion distance in encoder counts.""" - return self._convert_distance_units_to_encoder(self.saddle_scale, self.bar.backlash_cushion, self.bar.metric_distances) + return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_backlash_cushion, self.app.els.at_metric_distances) def _check_servo_threading_done(self, next_step: int, *args): - dev = self.app.device + dev = self.app.board.device dev['assistedThreadingData'].refresh() threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] threadEnabled = dev['assistedThreadingData']['threadEnabled'] @@ -560,7 +578,7 @@ def _get_threading_servo_delta_steps(self) -> int: effective_dir = self._get_saddle_scale_effective_dir() - current_encoder = self.saddle_scale.encoderCurrent + current_encoder = self.saddle_input.encoderCurrent target_encoder = self.bar.stop_position delta_enc = target_encoder - current_encoder @@ -572,7 +590,7 @@ def _get_threading_servo_delta_steps(self) -> int: ) # Convert encoder delta → servo steps - scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) delta_steps = int(delta_enc * scale_ratio / servo_ratio) @@ -636,7 +654,7 @@ def _calculate_thread_depth(self): # Account for cross-slide diameter mode # Formulas are for radial depth; in diameter mode multiply by 2 - if self.bar.cross_slide_diameter_mode: + if self.app.els.at_cross_slide_diameter_mode: depth = depth * 2 # Convert depth to match current display format if needed @@ -648,7 +666,7 @@ def _calculate_thread_depth(self): # Calculated in inches but displaying in mm depth = depth * 25.4 - log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.bar.cross_slide_diameter_mode})") + log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") return depth @@ -656,12 +674,12 @@ def _is_cross_slide_at_final_cutting_depth(self): #TODO check if this is working correctly with reversed scales and inner vs outer threads """Check if the cross slide is at or more than the final cutting depth position.""" effective_dir = self._get_cross_slide_scale_effective_dir() - current = self.cross_slide_scale.encoderCurrent + current = self.cross_slide_input.encoderCurrent log.info(f"Checking if at cutting depth: last_cutting_depth={self.bar.last_cutting_depth}, cutting_depth={self.bar.cutting_depth}, effective_dir={effective_dir}") return (self.bar.last_cutting_depth - self.bar.cutting_depth) * effective_dir >= 0 def _stop_servo(self): - if not self.app.connected: + if not self.app.board.connected: return self.servo.set_max_speed(self.servo.maxSpeed) # restore speed self.servo.servoEnable = 0 # disable @@ -677,24 +695,24 @@ def _clear_bar_display(self): self.bar.display_value = "" def _apply_original_servo_acceleration(self): - self.app.device['servo']['acceleration'] = self.servo.acceleration + self.app.board.device['servo']['acceleration'] = self.servo.acceleration def _apply_reversing_adjusting_acceleration(self): - rate = self.bar.reversing_adjusting_acceleration + rate = self.app.els.at_reversing_adjusting_acceleration if rate and rate > 0: - self.app.device['servo']['acceleration'] = rate + self.app.board.device['servo']['acceleration'] = rate else: self._apply_original_servo_acceleration() def _apply_threading_acceleration(self): - rate = self.bar.threading_acceleration + rate = self.app.els.at_threading_acceleration if rate and rate > 0: - self.app.device['servo']['acceleration'] = rate + self.app.board.device['servo']['acceleration'] = rate else: self._apply_original_servo_acceleration() def _apply_threading_max_speed(self): - target_speed = self.bar.threading_max_speed + target_speed = self.app.els.at_threading_max_speed if target_speed and target_speed > 0: self.servo.set_max_speed(target_speed) else: @@ -708,15 +726,15 @@ def _bind_threading_progress_display(self): - Rem = remaining distance until final thread depth """ self.bar.unbind_all_display_value() - self._progress_display_scale = self.cross_slide_scale + self._progress_display_scale = self.cross_slide_input def on_cross_slide_update(instance, value): try: is_metric = self.app.formats.current_format == "MM" - current_encoder = self.cross_slide_scale.encoderCurrent + current_encoder = self.cross_slide_input.encoderCurrent last_cutting_depth_encoder = self.bar.last_cutting_depth factor = float(self.app.formats.factor) - scale_ratio = abs(Fraction(self.cross_slide_scale.ratioNum, self.cross_slide_scale.ratioDen) * factor) + scale_ratio = abs(Fraction(self.cross_slide_input.ratioNum, self.cross_slide_input.ratioDen) * factor) # Calculate incremental cut depth in encoder units incremental_cut_encoder = last_cutting_depth_encoder - current_encoder if self.bar.inner_thread else current_encoder - last_cutting_depth_encoder @@ -734,8 +752,8 @@ def on_cross_slide_update(instance, value): except Exception as e: log.error(f"Error updating threading progress display: {e}") self._on_threading_progress_update = on_cross_slide_update - self.cross_slide_scale.bind(encoderCurrent=on_cross_slide_update) - on_cross_slide_update(self.cross_slide_scale, self.cross_slide_scale.encoderCurrent) + self.cross_slide_input.bind(encoderCurrent=on_cross_slide_update) + on_cross_slide_update(self.cross_slide_input, self.cross_slide_input.encoderCurrent) def _get_cross_slide_scale_effective_dir(self) -> int: """Get the cross slide effective direction, considering thread type (internal/external) and scale direction.""" @@ -743,7 +761,7 @@ def _get_cross_slide_scale_effective_dir(self) -> int: thread_dir = 1 if self.bar.inner_thread else -1 # Encoder direction: positive if scale ratio is positive, negative if reversed - scale_dir = 1 if self.cross_slide_scale.ratioNum * self.cross_slide_scale.ratioDen > 0 else -1 + scale_dir = 1 if self.cross_slide_input.ratioNum * self.cross_slide_input.ratioDen > 0 else -1 # Combined effective direction return thread_dir * scale_dir @@ -755,16 +773,16 @@ def _get_saddle_scale_effective_dir(self) -> int: thread_dir = 1 if self.bar.left_hand_thread else -1 # Scale direction from ratio sign - scale_dir = 1 if self.saddle_scale.ratioNum * self.saddle_scale.ratioDen > 0 else -1 + scale_dir = 1 if self.saddle_input.ratioNum * self.saddle_input.ratioDen > 0 else -1 return thread_dir * scale_dir def _command_move_to_encoder(self, target_encoder, speed): self._reset_encoder_stability_check() - current_enc = self.saddle_scale.encoderCurrent + current_enc = self.saddle_input.encoderCurrent - scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) delta = int((target_encoder - current_enc) * scale_ratio / servo_ratio) @@ -776,10 +794,10 @@ def _command_move_to_encoder(self, target_encoder, speed): self.bar.bind_display_value_to_servo_position() self.servo.set_max_speed(speed) - self.app.device['servo']['direction'] = delta + self.app.board.device['servo']['direction'] = delta def _watch_retracting_stopped(self, *_): - if not self._encoder_is_stable(self.bar.saddle_encoder_stability_tolerance, self.bar.saddle_encoder_stability_samples): + if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): return self._reset_servo_watch_callback() @@ -807,7 +825,7 @@ def _reset_encoder_stability_check(self): self._stable_count = 0 def _encoder_is_stable(self, tolerance, samples): - current = self.saddle_scale.encoderCurrent + current = self.saddle_input.encoderCurrent if self._last_saddle_encoder_value is None: self._last_saddle_encoder_value = current @@ -827,7 +845,7 @@ def _motion_complete(self): if self.app.fast_data_values['stepsToGo'] != 0: return False - if not self._encoder_is_stable(self.bar.saddle_encoder_stability_tolerance, self.bar.saddle_encoder_stability_samples): + if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): return False return True @@ -838,12 +856,12 @@ def _start_preload_move(self): log.info("Retract complete, starting preload move") backlash_preload_steps = int(abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25) # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion - preload_target = self.saddle_scale.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps + preload_target = self.saddle_input.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps self._apply_reversing_adjusting_acceleration() self._command_move_to_encoder( preload_target, - speed=self.bar.preload_adjust_speed + speed=self.app.els.at_preload_adjust_speed ) self._servo_watch_callback = self._watch_go_to_start @@ -858,7 +876,7 @@ def _start_adjust_move(self): self._apply_reversing_adjusting_acceleration() self._command_move_to_encoder( self.bar.start_position, - speed=self.bar.preload_adjust_speed + speed=self.app.els.at_preload_adjust_speed ) self._servo_watch_callback = self._watch_go_to_start @@ -883,11 +901,11 @@ def _check_valid_start_position(self) -> bool: start_position_preloaded flag was bypassed or the saddle moved after preload.""" backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) log.info( - f"Validating start position: current={self.saddle_scale.encoderCurrent}, " + f"Validating start position: current={self.saddle_input.encoderCurrent}, " f"start={self.bar.start_position}, " f"backlash_cushion={backlash_cushion}" ) - delta = abs(self.saddle_scale.encoderCurrent - self.bar.start_position) + delta = abs(self.saddle_input.encoderCurrent - self.bar.start_position) if delta > backlash_cushion: message = ( "Not at valid start position including backlash cushion. " @@ -906,8 +924,9 @@ def _check_valid_start_position(self) -> bool: def _check_spindle_turning_forward(self) -> bool: """Return True if the spindle scale exists and is turning in the right/positive/CCW direction. Shows a warning popup and redirects to step 6 if not.""" - spindle_scale = self.app.get_spindle_scale() - if spindle_scale is None: + spindle_axis = self.app.els.get_spindle_axis() + spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None + if spindle_inp is None: log.warning("No spindle scale configured — cannot verify spindle direction") CustomPopup( title="Warning", @@ -917,8 +936,8 @@ def _check_spindle_turning_forward(self) -> bool: ).open() return False - spindle_speed = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_scale.inputIndex] - log.info(f"Validating spindle direction: scaleSpeed[{spindle_scale.inputIndex}]={spindle_speed}") + spindle_speed = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + log.info(f"Validating spindle direction: scaleSpeed[{spindle_inp.inputIndex}]={spindle_speed}") if spindle_speed <= 0: message = ( @@ -938,11 +957,12 @@ def _check_spindle_turning_forward(self) -> bool: def _check_spindle_speed_for_pitch(self) -> bool: """Return True if the current spindle RPM is within the servo's speed limit for the selected pitch. Shows a warning popup and redirects to step 6 if not.""" - spindle_scale = self.app.get_spindle_scale() - if spindle_scale is None: + spindle_axis = self.app.els.get_spindle_axis() + spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None + if spindle_inp is None: return True # already caught by _check_spindle_turning_forward - spindle_steps_per_sec = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_scale.inputIndex] + spindle_steps_per_sec = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] try: pitch_str = self.bar.selected_pitch.strip() @@ -960,31 +980,31 @@ def _check_spindle_speed_for_pitch(self) -> bool: return True pitch_mm = 25.4 / pitch_val # TPI → mm/rev - spindle_rev_per_sec = spindle_steps_per_sec / spindle_scale.ratioDen + spindle_rev_per_sec = spindle_steps_per_sec / spindle_inp.ratioDen feed_mm_per_sec = spindle_rev_per_sec * pitch_mm - encoder_steps_per_sec = feed_mm_per_sec * self.saddle_scale.stepsPerMM + encoder_steps_per_sec = feed_mm_per_sec * self.saddle_input.stepsPerMM - scale_ratio = Fraction(abs(self.saddle_scale.ratioNum), abs(self.saddle_scale.ratioDen)) + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) - steps_per_mm_per_rev = pitch_mm * self.saddle_scale.stepsPerMM * float(scale_ratio / servo_ratio) - max_rpm = (self.bar.threading_max_speed / steps_per_mm_per_rev) * 60 if steps_per_mm_per_rev > 0 else 0 + steps_per_mm_per_rev = pitch_mm * self.saddle_input.stepsPerMM * float(scale_ratio / servo_ratio) + max_rpm = (self.app.els.at_threading_max_speed / steps_per_mm_per_rev) * 60 if steps_per_mm_per_rev > 0 else 0 log.info( f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " - f"required_servo={required:.1f} steps/s, max={self.bar.threading_max_speed}, " - f"max_rpm={max_rpm:.1f}, greater={required > self.bar.threading_max_speed}" + f"required_servo={required:.1f} steps/s, max={self.app.els.at_threading_max_speed}, " + f"max_rpm={max_rpm:.1f}, greater={required > self.app.els.at_threading_max_speed}" ) - if required > self.bar.threading_max_speed: + if required > self.app.els.at_threading_max_speed: spindle_rpm = spindle_rev_per_sec * 60 pitch_label = f"{pitch_mm:.3g} mm" if self.bar.metric_mode else f"{self.bar.selected_pitch} TPI" message = ( f"Spindle speed ({spindle_rpm:.0f} RPM) is too fast for {pitch_label} pitch. " f"Required servo speed ({required:.0f} steps/s) exceeds the threading limit " - f"({self.bar.threading_max_speed} steps/s). " + f"({self.app.els.at_threading_max_speed} steps/s). " f"Max allowed spindle speed for this pitch is {max_rpm:.0f} RPM. " "Reduce spindle speed or increase the threading max speed limit." ) diff --git a/rcp/components/screens/els_setup_screen.kv b/rcp/components/screens/els_setup_screen.kv index f9291a4..47ff6f5 100644 --- a/rcp/components/screens/els_setup_screen.kv +++ b/rcp/components/screens/els_setup_screen.kv @@ -35,3 +35,60 @@ name: "Cross Slide Axis (X)" help_file: "els_axis_roles.md" on_value: root.on_x_selected(self, self.value) + + TitleItem: + name: "Assisted Threading" + BooleanItem: + name: "Cross Slide Diameter Mode" + value: app.els.at_cross_slide_diameter_mode + on_value: app.els.at_cross_slide_diameter_mode = self.value + + TitleItem: + name: "Assisted Threading: Speed" + NumberItem: + name: "Reversing Speed (Steps/s)" + value: app.els.at_reversing_speed + on_value: root.set_at_reversing_speed(self.value) + NumberItem: + name: "Preload/Adjust Speed (Steps/s)" + value: app.els.at_preload_adjust_speed + on_value: root.set_at_preload_adjust_speed(self.value) + NumberItem: + name: "Threading Max Speed (Steps/s)" + value: app.els.at_threading_max_speed + on_value: root.set_at_threading_max_speed(self.value) + NumberItem: + name: "Reversing/Adjusting Acceleration (Steps/s^2)" + value: app.els.at_reversing_adjusting_acceleration + on_value: root.set_at_reversing_adjusting_acceleration(self.value) + NumberItem: + name: "Threading Acceleration (Steps/s^2)" + value: app.els.at_threading_acceleration + on_value: root.set_at_threading_acceleration(self.value) + + TitleItem: + name: "Assisted Threading: Tolerances" + NumberItem: + name: "Rotary Encoder sync tolerance (Steps)" + value: app.els.at_rotary_encoder_sync_tolerance + on_value: app.els.at_rotary_encoder_sync_tolerance = self.value + NumberItem: + name: "Saddle Encoder stability tolerance (Steps)" + value: app.els.at_saddle_encoder_stability_tolerance + on_value: app.els.at_saddle_encoder_stability_tolerance = int(self.value) + NumberItem: + name: "Saddle Encoder stability samples" + value: app.els.at_saddle_encoder_stability_samples + on_value: app.els.at_saddle_encoder_stability_samples = int(self.value) + BooleanItem: + name: "Metric Distances" + value: app.els.at_metric_distances + on_value: app.els.at_metric_distances = self.value + NumberItem: + name: "Saddle backlash distance (MM)" if app.els.at_metric_distances else "Saddle backlash distance (IN)" + value: app.els.at_saddle_backlash_distance + on_value: app.els.at_saddle_backlash_distance = self.value + NumberItem: + name: "Saddle backlash cushion (MM)" if app.els.at_metric_distances else "Saddle backlash cushion (IN)" + value: app.els.at_backlash_cushion + on_value: app.els.at_backlash_cushion = self.value diff --git a/rcp/components/screens/els_setup_screen.py b/rcp/components/screens/els_setup_screen.py index 4d1f754..adfc538 100644 --- a/rcp/components/screens/els_setup_screen.py +++ b/rcp/components/screens/els_setup_screen.py @@ -39,6 +39,42 @@ def on_z_selected(self, instance, value): def on_x_selected(self, instance, value): self.els.x_axis_index = self._name_to_index(value) + def set_at_reversing_speed(self, val): + try: + self.app.els.at_reversing_speed = min(int(val), self.app.servo.maxSpeed) + except ValueError: + pass + + def set_at_preload_adjust_speed(self, val): + try: + self.app.els.at_preload_adjust_speed = min(int(val), self.app.servo.maxSpeed) + except ValueError: + pass + + def set_at_threading_max_speed(self, val): + try: + speed = min(int(val), self.app.servo.maxSpeed) + if speed > 0: + self.app.els.at_threading_max_speed = speed + except (ValueError, TypeError): + pass + + def set_at_reversing_adjusting_acceleration(self, val): + try: + acc = int(val) + if acc > 0: + self.app.els.at_reversing_adjusting_acceleration = acc + except ValueError: + pass + + def set_at_threading_acceleration(self, val): + try: + acc = int(val) + if acc > 0: + self.app.els.at_threading_acceleration = acc + except ValueError: + pass + def _name_to_index(self, name: str) -> int: if name == NONE_LABEL: return -1 diff --git a/rcp/components/screens/setup_screen.kv b/rcp/components/screens/setup_screen.kv index 84a580b..d05a47c 100644 --- a/rcp/components/screens/setup_screen.kv +++ b/rcp/components/screens/setup_screen.kv @@ -29,9 +29,6 @@ SetupButton: text: "ELS" on_release: app.manager.goto("els_setup") - SetupButton: - text: "Assisted\nThreading" - on_release: app.goto("assisted_threading") SetupButton: text: "Network" on_release: app.manager.goto("network") diff --git a/rcp/components/setup/assisted_threading_screen.kv b/rcp/components/setup/assisted_threading_screen.kv deleted file mode 100644 index 66e059a..0000000 --- a/rcp/components/setup/assisted_threading_screen.kv +++ /dev/null @@ -1,89 +0,0 @@ -: - BoxLayout: - title: "Assisted Threading Settings" - orientation: "vertical" - padding: 10 - ScreenHeader: - text: "Assisted Threading Settings" - - ScrollView: - do_scroll_x: False - do_scroll_y: True - GridLayout: - id: grid_layout - cols: 1 - spacing: 1 - size_hint_y: None - height: self.minimum_height - - TitleItem: - name: "Scales Settings" - DropDownItem: - id: saddle_dropdown - height: 60 - name: "Saddle Scale" - options: root.get_saddle_scale_options() - value: root.get_label_for_scale_id(root.assistedThreadingBar.selected_saddle_scale_id) if root.assistedThreadingBar else "" - on_value: root.on_saddle_scale_selected(self.value) - DropDownItem: - id: cross_slide_dropdown - height: 60 - name: "Cross Slide Scale" - options: root.get_cross_slide_scale_options() - value: root.get_label_for_scale_id(root.assistedThreadingBar.selected_cross_slide_scale_id) if root.assistedThreadingBar else "" - on_value: root.on_cross_slide_scale_selected(self.value) - - BooleanItem: - name: "Cross Slide Diameter Mode" - value: root.assistedThreadingBar.cross_slide_diameter_mode - on_value: root.assistedThreadingBar.cross_slide_diameter_mode = self.value - - TitleItem: - name: "Speed Settings" - NumberItem: - name: "Reversing Speed (Steps/s)" - value: root.assistedThreadingBar.reversing_speed - on_value: root.set_reversing_speed(self.value) - NumberItem: - name: "Preload/Adjust Speed (Steps/s)" - value: root.assistedThreadingBar.preload_adjust_speed - on_value: root.set_preload_adjust_speed(self.value) - NumberItem: - name: "Threading Max Speed (Steps/s)" - value: root.assistedThreadingBar.threading_max_speed - on_value: root.set_threading_max_speed(self.value) - NumberItem: - name: "Reversing/Adjusting Acceleration (Steps/s^2)" - value: root.assistedThreadingBar.reversing_adjusting_acceleration - on_value: root.set_reversing_adjusting_acceleration(self.value) - NumberItem: - name: "Threading Acceleration (Steps/s^2)" - value: root.assistedThreadingBar.threading_acceleration - on_value: root.set_threading_acceleration(self.value) - - TitleItem: - name: "Distance and Tolerance Settings" - NumberItem: - name: "Rotary Encoder sync tolerance (Steps)" - value: root.assistedThreadingBar.rotary_encoder_sync_tolerance - on_value: root.assistedThreadingBar.rotary_encoder_sync_tolerance = self.value - NumberItem: - name: "Saddle Encoder stability tolerance (Steps)" - value: root.assistedThreadingBar.saddle_encoder_stability_tolerance - on_value: root.assistedThreadingBar.saddle_encoder_stability_tolerance = int(self.value) - NumberItem: - name: "Saddle Encoder stability samples" - value: root.assistedThreadingBar.saddle_encoder_stability_samples - on_value: root.assistedThreadingBar.saddle_encoder_stability_samples = int(self.value) - BooleanItem: - name: "Metric Distances" - value: root.assistedThreadingBar.metric_distances - on_value: root.assistedThreadingBar.metric_distances = self.value - NumberItem: - name: "Saddle backlash distance (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash distance (IN)" - value: root.assistedThreadingBar.saddle_backlash_distance - on_value: root.assistedThreadingBar.saddle_backlash_distance = self.value - NumberItem: - name: "Saddle backlash cushion (MM)" if root.assistedThreadingBar.metric_distances else "Saddle backlash cushion (IN)" - value: root.assistedThreadingBar.backlash_cushion - on_value: root.assistedThreadingBar.backlash_cushion = self.value \ No newline at end of file diff --git a/rcp/components/setup/assisted_threading_screen.py b/rcp/components/setup/assisted_threading_screen.py deleted file mode 100644 index 0e8b373..0000000 --- a/rcp/components/setup/assisted_threading_screen.py +++ /dev/null @@ -1,124 +0,0 @@ -import os - -from kivy.lang import Builder -from kivy.logger import Logger -from kivy.properties import ObjectProperty, ListProperty -from kivy.uix.screenmanager import Screen - -from rcp.components.home.assisted_threading_bar import AssistedThreadingBar -from rcp.components.home.coordbar import CoordBar - -log = Logger.getChild(__name__) -kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) -if os.path.exists(kv_file): - log.info(f"Loading KV file: {kv_file}") - Builder.load_file(kv_file) - - -class AssistedThreadingScreen(Screen): - assistedThreadingBar: AssistedThreadingBar = ObjectProperty() - servo = ObjectProperty() - scales = ListProperty() - scales_labels = ListProperty() - scales_mapping = {} - - def __init__(self, **kv): - super().__init__(**kv) - Logger.info("AssistedThreadingScreen initialized.") # Log an info message - self.update_scales_labels() - - def update_scales_labels(self): - """Update scales_labels and scales_mapping based on the current scales.""" - Logger.debug(f"Updating scales_labels with scales: {self.scales}") # Log a debug message - self.scales_labels = [ f"Scale {scale.inputIndex}: {scale.axisName}" for scale in self.scales if isinstance(scale, CoordBar) and not scale.spindleMode] - - # Update the mapping - self.scales_mapping = { - f"Scale {scale.inputIndex}: {scale.axisName}": scale.inputIndex - for scale in self.scales if isinstance(scale, CoordBar) - } - - Logger.info(f"Updated scales_labels: {self.scales_labels}") # Log an info message - - def on_saddle_scale_selected(self, selected_label): - if selected_label in self.scales_mapping: - self.assistedThreadingBar.selected_saddle_scale_id = self.scales_mapping[selected_label] - Logger.info(f"Selected saddle scale: {self.assistedThreadingBar.selected_saddle_scale_id}") - else: - Logger.warning(f"Selected label not found in mapping: {selected_label}") - - # Update the other dropdown options dynamically - cross_dropdown = self.ids.cross_slide_dropdown - cross_dropdown.options = self.get_cross_slide_scale_options() - - def on_cross_slide_scale_selected(self, selected_label): - if selected_label in self.scales_mapping: - self.assistedThreadingBar.selected_cross_slide_scale_id = self.scales_mapping[selected_label] - Logger.info(f"Selected cross slide scale: {self.assistedThreadingBar.selected_cross_slide_scale_id}") - else: - Logger.warning(f"Selected label not found in mapping: {selected_label}") - - # Update the other dropdown options dynamically - saddle_dropdown = self.ids.saddle_dropdown - saddle_dropdown.options = self.get_saddle_scale_options() - - - def set_reversing_speed(self, val): - try: - self.assistedThreadingBar.reversing_speed = min(int(val), self.servo.maxSpeed) - except ValueError: - pass - - def set_preload_adjust_speed(self, val): - try: - self.assistedThreadingBar.preload_adjust_speed = min(int(val), self.servo.maxSpeed) - except ValueError: - pass - - def set_threading_max_speed(self, val): - try: - speed = min(int(val), self.servo.maxSpeed) - if speed > 0: - self.assistedThreadingBar.threading_max_speed = speed - except (ValueError, TypeError): - pass - - def set_reversing_adjusting_acceleration(self, val): - try: - acc = int(val) - if acc > 0: - self.assistedThreadingBar.reversing_adjusting_acceleration = acc - except ValueError: - pass - - def set_threading_acceleration(self, val): - try: - acc = int(val) - if acc > 0: - self.assistedThreadingBar.threading_acceleration = acc - except ValueError: - pass - - def get_label_for_scale_id(self, scale_id): - if not self.scales_mapping: - self.update_scales_labels() - for label, sid in self.scales_mapping.items(): - if sid == scale_id: - return label - return "" - - def get_saddle_scale_options(self): - """Return available options for the Saddle Scale dropdown.""" - if not self.scales_labels: - self.update_scales_labels() - cross_label = self.get_label_for_scale_id(self.assistedThreadingBar.selected_cross_slide_scale_id) - return [label for label in self.scales_labels if label != cross_label] - - def get_cross_slide_scale_options(self): - """Return available options for the Cross Slide Scale dropdown.""" - if not self.scales_labels: - self.update_scales_labels() - saddle_label = self.get_label_for_scale_id(self.assistedThreadingBar.selected_saddle_scale_id) - return [label for label in self.scales_labels if label != saddle_label] - - diff --git a/rcp/dispatchers/els.py b/rcp/dispatchers/els.py index f6deb57..8137028 100644 --- a/rcp/dispatchers/els.py +++ b/rcp/dispatchers/els.py @@ -1,5 +1,5 @@ from kivy.logger import Logger -from kivy.properties import NumericProperty +from kivy.properties import BooleanProperty, NumericProperty from rcp.dispatchers.saving_dispatcher import SavingDispatcher @@ -7,16 +7,35 @@ class ElsDispatcher(SavingDispatcher): - """Persists ELS axis role assignments: spindle, saddle (Z), and cross slide (X).""" + """Persists ELS axis role assignments and Assisted Threading machine settings.""" _save_class_name = "Els" _skip_save = ["x", "y", "width", "height", "size_hint_x", "size_hint_y", "pos", "size", "minimum_height", "minimum_width", "padding", "spacing"] + # ── ELS axis roles ──────────────────────────────────────────────── spindle_axis_index = NumericProperty(-1) z_axis_index = NumericProperty(-1) x_axis_index = NumericProperty(-1) + # ── Assisted Threading: thread geometry ─────────────────────────── + at_cross_slide_diameter_mode = BooleanProperty(False) + + # ── Assisted Threading: speed & acceleration ────────────────────── + at_reversing_speed = NumericProperty(500) + at_preload_adjust_speed = NumericProperty(500) + at_threading_max_speed = NumericProperty(2000) + at_reversing_adjusting_acceleration = NumericProperty(1000) + at_threading_acceleration = NumericProperty(1000) + + # ── Assisted Threading: tolerances & backlash ───────────────────── + at_rotary_encoder_sync_tolerance = NumericProperty(5) + at_saddle_encoder_stability_tolerance = NumericProperty(1) + at_saddle_encoder_stability_samples = NumericProperty(3) + at_metric_distances = BooleanProperty(True) + at_saddle_backlash_distance = NumericProperty(10) + at_backlash_cushion = NumericProperty(2) + def __init__(self, **kwargs): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() diff --git a/rcp/dispatchers/input.py b/rcp/dispatchers/input.py index b5a911f..2a931bf 100644 --- a/rcp/dispatchers/input.py +++ b/rcp/dispatchers/input.py @@ -41,12 +41,14 @@ class InputDispatcher(SavingDispatcher): position = NumericProperty(0) scaled_value = NumericProperty(0) steps_per_second = NumericProperty(0) + encoderCurrent = NumericProperty(0) _skip_save = [ "position", "scaled_value", "steps_per_second", "_spindle_wrap_steps", + "encoderCurrent", ] def __init__(self, board, **kv): @@ -57,7 +59,6 @@ def __init__(self, board, **kv): # Encoder tracking state self.encoderPrevious = 0 - self.encoderCurrent = 0 # Bindings self.board.bind(update_tick=self._on_update_tick) From 521030097b7cc334feb4e9e197caf23fe851745d Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sun, 29 Mar 2026 18:00:51 +0200 Subject: [PATCH 50/62] WIP - Additional clean-up of existing code; --- rcp/components/home/assisted_threading_bar.py | 9 +- .../home/assisted_threading_settings_popup.py | 11 +-- .../home/assisted_threading_wizard.py | 98 ++++++++----------- 3 files changed, 47 insertions(+), 71 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 7e001cb..012a032 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -1,6 +1,3 @@ -import os - -from kivy.lang import Builder from kivy.logger import Logger from kivy.uix.boxlayout import BoxLayout from kivy.properties import NumericProperty, BooleanProperty, StringProperty @@ -10,13 +7,11 @@ from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.thread_type import ThreadType from rcp.dispatchers.saving_dispatcher import SavingDispatcher +from rcp.utils.kv_loader import load_kv log = Logger.getChild(__name__) -kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) -if os.path.exists(kv_file): - log.info(f"Loading KV file: {kv_file}") - Builder.load_file(kv_file) +load_kv(__file__) class AssistedThreadingBar(BoxLayout, SavingDispatcher): # ── Per-job thread settings (saved on the bar) ──────────────────── diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading_settings_popup.py index a73d151..6bd536e 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading_settings_popup.py @@ -1,20 +1,13 @@ -import os - -from kivy.lang import Builder from kivy.logger import Logger from kivy.uix.popup import Popup from kivy.properties import ObjectProperty -from rcp import feeds -from rcp.components.home.coordbar import CoordBar from rcp.components.home.thread_type import ThreadType +from rcp.utils.kv_loader import load_kv log = Logger.getChild(__name__) -kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) -if os.path.exists(kv_file): - log.info(f"Loading KV file: {kv_file}") - Builder.load_file(kv_file) +load_kv(__file__) class AssistedThreadingSettingsPopup(Popup): diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index b7ab443..0b8441a 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -1,4 +1,6 @@ +import logging from fractions import Fraction + from kivy.logger import Logger from rcp.components.widgets.custom_popup import CustomPopup @@ -6,6 +8,17 @@ from rcp.utils.devices import SCALES_COUNT log = Logger.getChild(__name__) + +MM_PER_INCH = 25.4 + + +class GoToStartPhase: + IDLE = 0 + RETRACT = 1 + PRELOAD = 2 + ADJUST = 3 + + class AssistedThreadingWizard: @property def saddle_scale(self): @@ -354,7 +367,7 @@ def _is_cross_slide_retracted(self): """ Check if the cross slide is safely retracted when the saddle has moved beyond the threading start position. """ - log.info("Checking if cross slide is retracted for threading start...") + log.debug("Checking if cross slide is retracted for threading start...") # --- Saddle direction check (Z axis) --- saddle_dir = self._get_saddle_scale_effective_dir() @@ -363,13 +376,13 @@ def _is_cross_slide_retracted(self): saddle_beyond_start = saddle_delta * saddle_dir > 0 if not saddle_beyond_start: - log.info("Saddle is not beyond start position, no need to check cross slide") + log.debug("Saddle is not beyond start position, no need to check cross slide") return True - log.info("Saddle is beyond start position, checking cross slide retraction") + log.debug("Saddle is beyond start position, checking cross slide retraction") # --- Cross-slide retraction check (X axis) --- - retract_dir = -self._get_cross_slide_scale_effective_dir() #TODO test this + retract_dir = -self._get_cross_slide_scale_effective_dir() cross_delta = self.cross_slide_input.encoderCurrent - self.bar.material_width return cross_delta * retract_dir > 0 @@ -519,44 +532,26 @@ def _check_servo_threading_done(self, next_step: int, *args): threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] threadEnabled = dev['assistedThreadingData']['threadEnabled'] - # dev['assistedThreadingData'].refresh() - # threadRequest = dev['assistedThreadingData']['threadRequest'] - # threadReset = dev['assistedThreadingData']['threadReset'] - # spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] - # spindleCountsPerRev = dev['assistedThreadingData']['spindleCountsPerRev'] - # spindlePhaseTolerance = dev['assistedThreadingData']['spindlePhaseTolerance'] - # threadRemainingSteps = dev['assistedThreadingData']['threadRemainingSteps'] - # threadStartSteps = dev['assistedThreadingData']['threadStartSteps'] - # threadPhaseRef = dev['assistedThreadingData']['threadPhaseRef'] - # currentThreadPhase = dev['assistedThreadingData']['currentThreadPhase'] - # desiredSteps = dev['servo']['desiredSteps'] - # currentSteps = dev['servo']['currentSteps'] - # stepsToGo = dev['servo']['direction'] - # syncEnable = dev['scales'][spindleScaleIndex]['syncEnable'] - # position = dev['scales'][spindleScaleIndex]['position'] - - # log.info( - # f"Checking servo done: " - # f"spindleScaleIndex={spindleScaleIndex}, " - # f"spindleCountsPerRev={spindleCountsPerRev}, " - # f"spindlePhaseTolerance={spindlePhaseTolerance}, " - - # f"threadRequest={threadRequest}, " - # f"threadReset={threadReset}, " - # f"threadPhaseActive={threadPhaseActive}, " - # f"threadEnabled={threadEnabled}, " - # f"syncEnable={syncEnable}, " - - # f"threadPhaseRef={threadPhaseRef}, " - # f"currentThreadPhase={currentThreadPhase}, " - # f"spindleEncoderposition={position}, " - - # f"threadRemainingSteps={threadRemainingSteps}, " - # f"threadStartSteps={threadStartSteps}, " - # f"desiredSteps={desiredSteps}, " - # f"currentSteps={currentSteps}, " - # f"stepsToGo={stepsToGo}, " - # ) + if log.isEnabledFor(logging.DEBUG): + spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] + log.debug( + f"Checking servo done: " + f"spindleScaleIndex={spindleScaleIndex}, " + f"spindleCountsPerRev={dev['assistedThreadingData']['spindleCountsPerRev']}, " + f"spindlePhaseTolerance={dev['assistedThreadingData']['spindlePhaseTolerance']}, " + f"threadRequest={dev['assistedThreadingData']['threadRequest']}, " + f"threadReset={dev['assistedThreadingData']['threadReset']}, " + f"threadPhaseActive={threadPhaseActive}, " + f"threadEnabled={threadEnabled}, " + f"syncEnable={dev['scales'][spindleScaleIndex]['syncEnable']}, " + f"threadPhaseRef={dev['assistedThreadingData']['threadPhaseRef']}, " + f"currentThreadPhase={dev['assistedThreadingData']['currentThreadPhase']}, " + f"spindleEncoderPosition={dev['scales'][spindleScaleIndex]['position']}, " + f"threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, " + f"threadStartSteps={dev['assistedThreadingData']['threadStartSteps']}, " + f"desiredSteps={dev['servo']['desiredSteps']}, " + f"currentSteps={dev['servo']['currentSteps']}, " + ) if threadEnabled == 1 or threadPhaseActive == 1: self._threading_active_confirmed = True @@ -628,7 +623,7 @@ def _calculate_thread_depth(self): # In imperial mode, selected_pitch is TPI (threads per inch) # Convert TPI to pitch in inches tpi = float(self.bar.selected_pitch) - pitch = 25.4 / tpi + pitch = MM_PER_INCH / tpi except (ValueError, TypeError): log.warning(f"Could not parse pitch from: {self.bar.selected_pitch}") return None @@ -661,17 +656,16 @@ def _calculate_thread_depth(self): is_current_format_metric = self.app.formats.current_format == "MM" if self.bar.metric_mode and not is_current_format_metric: # Calculated in mm but displaying in inches - depth = depth / 25.4 + depth = depth / MM_PER_INCH elif not self.bar.metric_mode and is_current_format_metric: # Calculated in inches but displaying in mm - depth = depth * 25.4 + depth = depth * MM_PER_INCH log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") return depth def _is_cross_slide_at_final_cutting_depth(self): - #TODO check if this is working correctly with reversed scales and inner vs outer threads """Check if the cross slide is at or more than the final cutting depth position.""" effective_dir = self._get_cross_slide_scale_effective_dir() current = self.cross_slide_input.encoderCurrent @@ -748,7 +742,7 @@ def on_cross_slide_update(instance, value): self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" else: self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" - log.info(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") + log.debug(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") except Exception as e: log.error(f"Error updating threading progress display: {e}") self._on_threading_progress_update = on_cross_slide_update @@ -978,7 +972,7 @@ def _check_spindle_speed_for_pitch(self) -> bool: else: if pitch_val == 0: return True - pitch_mm = 25.4 / pitch_val # TPI → mm/rev + pitch_mm = MM_PER_INCH / pitch_val # TPI → mm/rev spindle_rev_per_sec = spindle_steps_per_sec / spindle_inp.ratioDen feed_mm_per_sec = spindle_rev_per_sec * pitch_mm @@ -1016,10 +1010,4 @@ def _check_spindle_speed_for_pitch(self) -> bool: on_dismiss_callback=lambda: self.goto_step(5), ).open() return False - return True - -class GoToStartPhase: - IDLE = 0 - RETRACT = 1 - PRELOAD = 2 - ADJUST = 3 \ No newline at end of file + return True \ No newline at end of file From 751ef7fd5a3563fa8181847bd5d8204a486a87bc Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Sun, 29 Mar 2026 18:39:06 +0200 Subject: [PATCH 51/62] Added check to make sure that the scales are set before starting the AT wizard --- rcp/components/home/assisted_threading_bar.py | 16 +++++++ rcp/components/home/at_mode_layout.py | 44 ++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading_bar.py index 012a032..2cca7e5 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading_bar.py @@ -3,6 +3,7 @@ from kivy.properties import NumericProperty, BooleanProperty, StringProperty from rcp import feeds +from rcp.components.widgets.custom_popup import CustomPopup from rcp.components.widgets.hold_button import HoldButton from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard from rcp.components.home.thread_type import ThreadType @@ -68,6 +69,21 @@ def __init__(self, **kv): self.bind(left_hand_thread=self.update_feeds_ratio) def toggle_is_running(self): + if not self.is_running: + missing = [] + if self.app.els.get_spindle_axis() is None: + missing.append("Spindle") + if self.app.els.get_z_axis() is None: + missing.append("Saddle (Z)") + if self.app.els.get_x_axis() is None: + missing.append("Cross-slide (X)") + if missing: + CustomPopup( + title="Axes Not Configured", + message=f"The following axes are not set in ELS: {', '.join(missing)}. Please configure them in Settings.", + button_text="OK", + ).open() + return self.is_running = not self.is_running if self.is_running: self.wizard.start() diff --git a/rcp/components/home/at_mode_layout.py b/rcp/components/home/at_mode_layout.py index cfdc231..f0f9bbc 100644 --- a/rcp/components/home/at_mode_layout.py +++ b/rcp/components/home/at_mode_layout.py @@ -1,3 +1,5 @@ +from kivy.uix.widget import Widget + from rcp.components.home.assisted_threading_bar import AssistedThreadingBar from rcp.components.home.coordbar import CoordBar from rcp.components.home.dro_coordbar import DroCoordBar @@ -10,19 +12,51 @@ class AtModeLayout(ModeLayout): def __init__(self, at_bar: AssistedThreadingBar, **kwargs): super().__init__(**kwargs) self.at_bar = at_bar + self.spacer = Widget() self.build_axis_bars() + self.add_widget(self.spacer) self.add_widget(self.at_bar) + # Rebuild when ELS axis assignments change (e.g. saved settings load) + self.app.els.bind( + spindle_axis_index=lambda *_: self.rebuild_axes(), + z_axis_index=lambda *_: self.rebuild_axes(), + x_axis_index=lambda *_: self.rebuild_axes(), + ) + + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + + def _update_row_heights(self, *_): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + available = self.height - self.at_bar.height + row_height = min(available / num_rows, self.app.formats.max_row_height) + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + # spacer absorbs remaining space + def build_axis_bars(self): - for axis in self.app.axes: - if axis.spindleMode: - cb = CoordBar(axis=axis) - else: - cb = DroCoordBar(axis=axis) + seen = set() + for axis in [ + self.app.els.get_z_axis(), + self.app.els.get_x_axis(), + self.app.els.get_spindle_axis(), + ]: + if axis is None or id(axis) in seen: + continue + seen.add(id(axis)) + cb = CoordBar(axis=axis) if axis.spindleMode else DroCoordBar(axis=axis) self.axis_bars.append(cb) self.add_widget(cb) def rebuild_axes(self): + self.remove_widget(self.spacer) self.remove_widget(self.at_bar) super().rebuild_axes() + self.add_widget(self.spacer) self.add_widget(self.at_bar) + self._update_row_heights() From 3eb4652c38bfd4957b2346b555ba9e5935bb8b50 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 30 Mar 2026 07:03:27 +0200 Subject: [PATCH 52/62] Fixed issues with some properties still not migrated to app.board --- .../home/assisted_threading_wizard.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 0b8441a..0734338 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -157,7 +157,7 @@ def stop_retracting(self): self.servo.jogSpeed = 0 self._servo_watch_callback = self._watch_retracting_stopped - self.app.bind(update_tick=self._servo_watch_callback) + self.app.board.bind(update_tick=self._servo_watch_callback) # Instruction steps #Step 1 @@ -288,7 +288,7 @@ def _go_to_start(self, *args): self._command_move_to_encoder(retract_target, speed=self.app.els.at_reversing_speed) self._servo_watch_callback = self._watch_go_to_start - self.app.bind(update_tick=self._servo_watch_callback) + self.app.board.bind(update_tick=self._servo_watch_callback) return False @@ -337,11 +337,11 @@ def _start_threading_operation(self, *args): dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state - log.info(f"Threading requested: threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, servoCurrent={self.app.fast_data_values['servoCurrent']}, calculatedDeltaSteps={self._calculated_threading_delta_steps}") + log.info(f"Threading requested: threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, servoCurrent={self.app.board.fast_data_values['servoCurrent']}, calculatedDeltaSteps={self._calculated_threading_delta_steps}") # Watch until done - then go back to step 6 (Go to start) self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) - self.app.bind(update_tick=self._servo_watch_callback) + self.app.board.bind(update_tick=self._servo_watch_callback) return False # tell goto_next_step not to advance immediately @@ -681,7 +681,7 @@ def _stop_servo(self): def _reset_servo_watch_callback(self): if self._servo_watch_callback: - self.app.unbind(update_tick=self._servo_watch_callback) + self.app.board.unbind(update_tick=self._servo_watch_callback) self._servo_watch_callback = None def _clear_bar_display(self): @@ -836,7 +836,7 @@ def _encoder_is_stable(self, tolerance, samples): return self._stable_count >= samples def _motion_complete(self): - if self.app.fast_data_values['stepsToGo'] != 0: + if self.app.board.fast_data_values['stepsToGo'] != 0: return False if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): @@ -859,7 +859,7 @@ def _start_preload_move(self): ) self._servo_watch_callback = self._watch_go_to_start - self.app.bind(update_tick=self._servo_watch_callback) + self.app.board.bind(update_tick=self._servo_watch_callback) def _start_adjust_move(self): self._reset_servo_watch_callback() @@ -874,7 +874,7 @@ def _start_adjust_move(self): ) self._servo_watch_callback = self._watch_go_to_start - self.app.bind(update_tick=self._servo_watch_callback) + self.app.board.bind(update_tick=self._servo_watch_callback) def _finish_go_to_start(self): self._reset_servo_watch_callback() @@ -930,7 +930,7 @@ def _check_spindle_turning_forward(self) -> bool: ).open() return False - spindle_speed = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + spindle_speed = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] log.info(f"Validating spindle direction: scaleSpeed[{spindle_inp.inputIndex}]={spindle_speed}") if spindle_speed <= 0: @@ -956,7 +956,7 @@ def _check_spindle_speed_for_pitch(self) -> bool: if spindle_inp is None: return True # already caught by _check_spindle_turning_forward - spindle_steps_per_sec = self.app.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + spindle_steps_per_sec = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] try: pitch_str = self.bar.selected_pitch.strip() From 87ffed10f28aa097e30592b9b1adb37de315c384 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 30 Mar 2026 08:41:22 +0200 Subject: [PATCH 53/62] WIP - fixed bug when getting distance from scale --- rcp/components/home/assisted_threading_wizard.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 0734338..8819060 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -464,11 +464,9 @@ def _convert_position_units_to_encoder(self, f"delta from start: {delta_in_start_units})" ) - # Compute encoder counts using inverse of AxisDispatcher.scaledPosition + # delta_in_start_units is already relative to the start position — offsets do not apply inp = scale._primary_input() - encoder_counts = ( - (delta_in_start_units / factor_at_start_position) - scale.offsets[self.app.currentOffset] - ) * (float(inp.ratioDen) / float(inp.ratioNum)) + encoder_counts = (delta_in_start_units / factor_at_start_position) * (float(inp.ratioDen) / float(inp.ratioNum)) # Offset by the captured start position final_encoder_position = int(round(start_encoder_units + encoder_counts)) @@ -504,10 +502,8 @@ def _convert_distance_units_to_encoder(self, scale, distance: float, is_metric: inp = scale._primary_input() encoder_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) - # Compute encoder counts using inverse of AxisDispatcher.scaledPosition - encoder_counts = ( - (distance / encoder_factor) - scale.offsets[self.app.currentOffset] - ) * (float(inp.ratioDen) / float(inp.ratioNum)) + # Pure distance conversion — offsets do not apply (those are DRO zero offsets for positions, not distances) + encoder_counts = (distance / encoder_factor) * (float(inp.ratioDen) / float(inp.ratioNum)) final_encoder_distance = int(round(encoder_counts)) From 2718a522ce1b4856f3060541cd89dfed3c0750ee Mon Sep 17 00:00:00 2001 From: Pawcu Date: Tue, 31 Mar 2026 16:25:34 +0200 Subject: [PATCH 54/62] Fixed issue with spindle sync calculation taking the degrees into consideration when it shouldn't as the PPR is per revolution not per degree --- rcp/components/home/assisted_threading_wizard.py | 2 +- rcp/dispatchers/axis.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py index 8819060..04d6416 100644 --- a/rcp/components/home/assisted_threading_wizard.py +++ b/rcp/components/home/assisted_threading_wizard.py @@ -78,7 +78,7 @@ def start(self): if spindle_axis is not None: inp = spindle_axis._primary_input() if inp is not None: - dev['assistedThreadingData']['spindleCountsPerRev'] = inp.ratioDen + dev['assistedThreadingData']['spindleCountsPerRev'] = int(spindle_axis._steps_per_revolution()) dev['assistedThreadingData']['spindleScaleIndex'] = inp.inputIndex self.goto_step(0) diff --git a/rcp/dispatchers/axis.py b/rcp/dispatchers/axis.py index e6b05c8..c5bb1ef 100644 --- a/rcp/dispatchers/axis.py +++ b/rcp/dispatchers/axis.py @@ -230,7 +230,7 @@ def _set_sync_ratio(self, *args, **kv): if self.spindleMode: scale_ratio = Fraction( - 360 * inp.gear_ratio_den, + inp.gear_ratio_den, inp.encoder_ppr * inp.gear_ratio_num, ) else: From e7c6f13fc4bd286074daa3b90064a6a06f62961b Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 6 Apr 2026 18:53:29 +0200 Subject: [PATCH 55/62] Split the assisted threading wizard in separate files; --- .gitignore | 1 + .../home/assisted_threading/__init__.py | 0 .../bar.kv} | 2 +- .../bar.py} | 42 +- .../home/assisted_threading/calculations.py | 240 ++++ .../home/assisted_threading/motion.py | 177 +++ .../home/assisted_threading/safety.py | 186 +++ .../settings_popup.kv} | 2 +- .../settings_popup.py} | 20 +- .../{ => assisted_threading}/thread_type.py | 0 .../home/assisted_threading/wizard.py | 508 +++++++++ .../home/assisted_threading_wizard.py | 1009 ----------------- rcp/components/home/at_mode_layout.py | 2 +- rcp/components/screens/home_screen.py | 2 +- tests/components/__init__.py | 0 tests/components/home/__init__.py | 0 .../home/assisted_threading/__init__.py | 0 .../home/assisted_threading/conftest.py | 120 ++ .../assisted_threading/test_calculations.py | 395 +++++++ .../home/assisted_threading/test_motion.py | 133 +++ .../home/assisted_threading/test_safety.py | 408 +++++++ .../home/assisted_threading/test_wizard.py | 204 ++++ tests/conftest.py | 57 + uv.lock | 4 +- 24 files changed, 2466 insertions(+), 1046 deletions(-) create mode 100644 rcp/components/home/assisted_threading/__init__.py rename rcp/components/home/{assisted_threading_bar.kv => assisted_threading/bar.kv} (99%) rename rcp/components/home/{assisted_threading_bar.py => assisted_threading/bar.py} (96%) create mode 100644 rcp/components/home/assisted_threading/calculations.py create mode 100644 rcp/components/home/assisted_threading/motion.py create mode 100644 rcp/components/home/assisted_threading/safety.py rename rcp/components/home/{assisted_threading_settings_popup.kv => assisted_threading/settings_popup.kv} (99%) rename rcp/components/home/{assisted_threading_settings_popup.py => assisted_threading/settings_popup.py} (93%) rename rcp/components/home/{ => assisted_threading}/thread_type.py (100%) create mode 100644 rcp/components/home/assisted_threading/wizard.py delete mode 100644 rcp/components/home/assisted_threading_wizard.py create mode 100644 tests/components/__init__.py create mode 100644 tests/components/home/__init__.py create mode 100644 tests/components/home/assisted_threading/__init__.py create mode 100644 tests/components/home/assisted_threading/conftest.py create mode 100644 tests/components/home/assisted_threading/test_calculations.py create mode 100644 tests/components/home/assisted_threading/test_motion.py create mode 100644 tests/components/home/assisted_threading/test_safety.py create mode 100644 tests/components/home/assisted_threading/test_wizard.py create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore index 56bea6b..f7ae84d 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ cython_debug/ /.idea/ config.ini /rcp/settings/ +.claude/settings.local.json diff --git a/rcp/components/home/assisted_threading/__init__.py b/rcp/components/home/assisted_threading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rcp/components/home/assisted_threading_bar.kv b/rcp/components/home/assisted_threading/bar.kv similarity index 99% rename from rcp/components/home/assisted_threading_bar.kv rename to rcp/components/home/assisted_threading/bar.kv index 27acad0..fb6fc15 100644 --- a/rcp/components/home/assisted_threading_bar.kv +++ b/rcp/components/home/assisted_threading/bar.kv @@ -57,7 +57,7 @@ halign: "center" on_release: root.on_action_button_clicked() disabled: not root.action_button_enabled - + HoldButton: id: btn_retract font_name: "fonts/iosevka-regular.ttf" diff --git a/rcp/components/home/assisted_threading_bar.py b/rcp/components/home/assisted_threading/bar.py similarity index 96% rename from rcp/components/home/assisted_threading_bar.py rename to rcp/components/home/assisted_threading/bar.py index 2cca7e5..9ab4746 100644 --- a/rcp/components/home/assisted_threading_bar.py +++ b/rcp/components/home/assisted_threading/bar.py @@ -5,8 +5,8 @@ from rcp import feeds from rcp.components.widgets.custom_popup import CustomPopup from rcp.components.widgets.hold_button import HoldButton -from rcp.components.home.assisted_threading_wizard import AssistedThreadingWizard -from rcp.components.home.thread_type import ThreadType +from rcp.components.home.assisted_threading.wizard import AssistedThreadingWizard +from rcp.components.home.assisted_threading.thread_type import ThreadType from rcp.dispatchers.saving_dispatcher import SavingDispatcher from rcp.utils.kv_loader import load_kv @@ -23,7 +23,7 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): shaft_diameter = NumericProperty(1) left_hand_thread = BooleanProperty(False) inner_thread = BooleanProperty(False) - + is_running = BooleanProperty(False) action_button_enabled = BooleanProperty(True) label_text = StringProperty("") @@ -56,7 +56,7 @@ def __init__(self, **kv): self.action_button_condition_fn = None self.retract_button_condition_fn = None super().__init__(**kv) - + self.current_feeds_table = feeds.table["Thread MM"] if self.metric_mode else feeds.table["Thread IN"] self.update_feeds_ratio(self, None) @@ -67,7 +67,7 @@ def __init__(self, **kv): self.app.bind(current_mode=self.on_mode_change) self.bind(left_hand_thread=self.update_feeds_ratio) - + def toggle_is_running(self): if not self.is_running: missing = [] @@ -89,10 +89,10 @@ def toggle_is_running(self): self.wizard.start() else: self.stop_wizard() - + def stop_wizard(self): self.wizard.stop() - + def on_metric_mode(self, instance, value): self.current_feeds_table = feeds.table["Thread MM"] if value else feeds.table["Thread IN"] @@ -105,20 +105,20 @@ def on_retract_button_pressed(self): if not self.retract_button_enabled: return self.wizard.start_retracting() - + def on_retract_button_released(self): """Called when the retract button is released.""" if not self.retract_button_enabled: return self.wizard.stop_retracting() - + def on_action_button_clicked(self): """Called when the right button is pressed.""" if self.is_running: self.wizard.goto_next_step() else: self.open_settings() - + def update_feeds_ratio(self, instance, value): if self.app.current_mode != 5: return # only sync in AT mode @@ -130,12 +130,12 @@ def update_feeds_ratio(self, instance, value): spindle_axis.syncRatioNum = ratio.numerator * direction spindle_axis.syncRatioDen = ratio.denominator log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}, left_hand_thread={self.left_hand_thread}") - + def open_settings(self): - from rcp.components.home.assisted_threading_settings_popup import AssistedThreadingSettingsPopup + from rcp.components.home.assisted_threading.settings_popup import AssistedThreadingSettingsPopup popup = AssistedThreadingSettingsPopup(assistedThreadingBar=self) popup.open() - + def bind_display_value_to_scale(self, axis): """Bind display_value to an AxisDispatcher's formattedPosition with strict keypad override support.""" @@ -179,12 +179,12 @@ def bind_display_value_to_servo_position(self): # Unbind any previous bindings self.unbind_all_display_value() self._bound_servo = self.app.servo - + def on_servo_position_update(instance, value): self.display_value = value - + self._on_servo_position_update = on_servo_position_update - + # Bind to servo's formattedPosition self.app.servo.bind(formattedPosition=on_servo_position_update) @@ -196,16 +196,16 @@ def bind_btn_value_on_release(self, on_release_fn): # Store the binding function self._on_value_button_release = on_release_fn - + if(on_release_fn is None): # If None is passed, disable the button self.ids.btn_value.disabled = True return - + self.ids.btn_value.disabled = False # Bind the new function self.ids.btn_value.bind(on_release=on_release_fn) - + def unbind_all_display_value(self): if hasattr(self, "_bound_scale") and self._bound_scale is not None: inp = self._bound_scale._primary_input() @@ -228,8 +228,8 @@ def update_buttons_state(self): self.action_button_enabled = self.action_button_condition_fn() else: self.action_button_enabled = True - + if self.retract_button_condition_fn: self.retract_button_enabled = self.retract_button_condition_fn() else: - self.retract_button_enabled = True \ No newline at end of file + self.retract_button_enabled = True diff --git a/rcp/components/home/assisted_threading/calculations.py b/rcp/components/home/assisted_threading/calculations.py new file mode 100644 index 0000000..d8d345e --- /dev/null +++ b/rcp/components/home/assisted_threading/calculations.py @@ -0,0 +1,240 @@ +from fractions import Fraction + +from kivy.logger import Logger + +from rcp.components.home.assisted_threading.thread_type import ThreadType + +log = Logger.getChild(__name__) + +MM_PER_INCH = 25.4 + + +class AssistedThreadingCalculationsMixin: + # --------------------------------------------------------------------------- + # Direction helpers + # --------------------------------------------------------------------------- + + def _get_cross_slide_scale_effective_dir(self) -> int: + """Get the cross slide effective direction, considering thread type (internal/external) and scale direction.""" + # Physical cutting direction: internal → outward (+), external → inward (-) + thread_dir = 1 if self.bar.inner_thread else -1 + + # Encoder direction: positive if scale ratio is positive, negative if reversed + scale_dir = 1 if self.cross_slide_input.ratioNum * self.cross_slide_input.ratioDen > 0 else -1 + + # Combined effective direction + return thread_dir * scale_dir + + def _get_saddle_scale_effective_dir(self) -> int: + """Get the saddle scale effective direction, considering if it's left/right hand tread and scale direction.""" + # Thread direction: LH → +, RH → - + thread_dir = 1 if self.bar.left_hand_thread else -1 + + # Scale direction from ratio sign + scale_dir = 1 if self.saddle_input.ratioNum * self.saddle_input.ratioDen > 0 else -1 + + return thread_dir * scale_dir + + # --------------------------------------------------------------------------- + # Unit conversion + # --------------------------------------------------------------------------- + + def _convert_distance_units_to_encoder(self, scale, distance: float, is_metric: bool) -> int: + """ + Convert a pure distance (mm or inch) into encoder counts. + scale: AxisDispatcher + """ + inp = scale._primary_input() + encoder_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) + + # Pure distance conversion — offsets do not apply (those are DRO zero offsets for positions, not distances) + encoder_counts = (distance / encoder_factor) * (float(inp.ratioDen) / float(inp.ratioNum)) + + final_encoder_distance = int(round(encoder_counts)) + + log.info( + f"Converted distance to encoder counts: {final_encoder_distance} " + f"(input distance={distance}, encoder delta={encoder_counts})" + ) + + return final_encoder_distance + + def _convert_position_units_to_encoder(self, + scale, + manual_position: float, + is_original_position_metric_mode: bool, + original_scaled_position, + start_encoder_units: int) -> int: + """ + Convert a user-entered stop position (MM/IN) into encoder counts. + Handles: + - unit changes (MM ↔ IN) + - offsets + - zero start positions + """ + + # Determine factors + current_factor = float(self.app.formats.factor) + factor_at_start_position = float(self.app.formats.MM_FRACTION if is_original_position_metric_mode else self.app.formats.INCHES_FRACTION) + + # Normalize manual input to the units used at start + manual_in_start_units = manual_position * (factor_at_start_position / current_factor) + + # Compute delta relative to start scaled position + delta_in_start_units = manual_in_start_units - original_scaled_position + + log.info( + f"Manual input: {manual_position} " + f"(converted to start units: {manual_in_start_units}, " + f"delta from start: {delta_in_start_units})" + ) + + # delta_in_start_units is already relative to the start position — offsets do not apply + inp = scale._primary_input() + encoder_counts = (delta_in_start_units / factor_at_start_position) * (float(inp.ratioDen) / float(inp.ratioNum)) + + # Offset by the captured start position + final_encoder_position = int(round(start_encoder_units + encoder_counts)) + + log.info( + f"Computed encoder counts: {final_encoder_position} " + f"(start_position={start_encoder_units}, encoder delta={encoder_counts})" + ) + + return final_encoder_position + + def _get_stop_position_units(self) -> float: + scale = self.saddle_scale + if self.manual_stop_length is not None: + log.info(f"Using manual stop length: {self.manual_stop_length}") + result = self._convert_position_units_to_encoder( + scale, + self.manual_stop_length, + self._isStartPositionMetricMode, + self._startScaledPosition, + self.bar.start_position + ) + log.info(f"Converted manual stop length to encoder units: {result}") + return result + log.info(f"Using live encoder value: {self.saddle_input.encoderCurrent}") + return self.saddle_input.encoderCurrent + + # --------------------------------------------------------------------------- + # Backlash distances + # --------------------------------------------------------------------------- + + def _get_saddle_backlash_distance_encoder_steps(self) -> int: + """Get the retraction distance in encoder counts.""" + return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_saddle_backlash_distance, self.app.els.at_metric_distances) + + def _get_backlash_cusion_encoder_steps(self) -> int: + """Get the backlash cushion distance in encoder counts.""" + return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_backlash_cushion, self.app.els.at_metric_distances) + + # --------------------------------------------------------------------------- + # Threading servo delta + # --------------------------------------------------------------------------- + + def _get_threading_servo_delta_steps(self) -> int: + """ + Compute the servo step delta needed to move the saddle + from the current position to the stop position + in the cutting direction. + """ + + effective_dir = self._get_saddle_scale_effective_dir() + + current_encoder = self.saddle_input.encoderCurrent + target_encoder = self.bar.stop_position + + delta_enc = target_encoder - current_encoder + if delta_enc * effective_dir <= 0: + log.warning( + "Threading delta is opposite to effective cutting direction " + f"(current={current_encoder}, stop={target_encoder}, " + f"effective_dir={effective_dir})" + ) + + # Convert encoder delta → servo steps + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) + + delta_steps = int(delta_enc * scale_ratio / servo_ratio) + + log.info( + f"Computed threading servo delta: {delta_steps} steps " + f"(current_enc={current_encoder}, stop_enc={target_encoder}, " + f"delta_enc={delta_enc}, " + f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " + f"effective_dir={effective_dir})" + ) + + return delta_steps + + # --------------------------------------------------------------------------- + # Thread depth calculation + # --------------------------------------------------------------------------- + + def _calculate_thread_depth(self): + """ + Calculate thread depth based on selected pitch and thread profile type. + + Uses metric_mode to determine if selected_pitch is in mm or TPI. + Formulas provided are for radial depth; multiply by 2 if diameter mode is enabled. + + Returns: + Thread depth in the selected units (mm or inches), or None if invalid + """ + if not self.bar.selected_pitch: + log.warning("No pitch selected for depth calculation") + return None + + # Determine effective pitch based on metric_mode + try: + if self.bar.metric_mode: + # In metric mode, selected_pitch is the pitch in mm + pitch = float(self.bar.selected_pitch) + else: + # In imperial mode, selected_pitch is TPI (threads per inch) + # Convert TPI to pitch in inches + tpi = float(self.bar.selected_pitch) + pitch = MM_PER_INCH / tpi + except (ValueError, TypeError): + log.warning(f"Could not parse pitch from: {self.bar.selected_pitch}") + return None + + if pitch <= 0: + log.warning(f"Invalid pitch value: {pitch}") + return None + + # Determine thread profile and calculate radial depth + thread_type = ThreadType(self.bar.thread_profile_type) + + if thread_type == ThreadType.ISO_METRIC: + depth = 0.61343 * pitch + elif thread_type == ThreadType.UNIFIED: + depth = 0.64952 * pitch + elif thread_type == ThreadType.WHITWORTH: + depth = 0.6403 * pitch + elif thread_type == ThreadType.ACME: + depth = 0.5 * pitch + else: + log.warning(f"Unknown thread profile: {thread_type}") + return None + + # Account for cross-slide diameter mode + # Formulas are for radial depth; in diameter mode multiply by 2 + if self.app.els.at_cross_slide_diameter_mode: + depth = depth * 2 + + # Convert depth to match current display format if needed + is_current_format_metric = self.app.formats.current_format == "MM" + if self.bar.metric_mode and not is_current_format_metric: + # Calculated in mm but displaying in inches + depth = depth / MM_PER_INCH + elif not self.bar.metric_mode and is_current_format_metric: + # Calculated in inches but displaying in mm + depth = depth * MM_PER_INCH + + log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") + return depth diff --git a/rcp/components/home/assisted_threading/motion.py b/rcp/components/home/assisted_threading/motion.py new file mode 100644 index 0000000..a4e5e63 --- /dev/null +++ b/rcp/components/home/assisted_threading/motion.py @@ -0,0 +1,177 @@ +from fractions import Fraction + +from kivy.logger import Logger + +log = Logger.getChild(__name__) + + +class GoToStartPhase: + IDLE = 0 + RETRACT = 1 + PRELOAD = 2 + ADJUST = 3 + + +class AssistedThreadingMotionMixin: + # --------------------------------------------------------------------------- + # Step 6 callback — go to start (motion logic only) + # --------------------------------------------------------------------------- + + def _go_to_start(self, *args): + if not self.app.board.connected: + self.stop() + return False + + self.bar.retract_button_enabled = False # Disable retract button during move to start + self.bar.action_button_enabled = False # Disable action button during move to start + + self._apply_reversing_adjusting_acceleration() + self._start_position_preloaded = False + self._goto_start_phase = GoToStartPhase.RETRACT + + effective_dir = self._get_saddle_scale_effective_dir() + + retraction = abs(self._get_saddle_backlash_distance_encoder_steps() * 1.5) # retract 1.5x backlash distance + retraction_dir = -effective_dir # retract opposite to cutting direction + log.info(f"Starting retract to go to start: effective_dir={effective_dir}, retraction={retraction}, retraction_dir={retraction_dir}") + retract_target = self.bar.start_position + retraction_dir * retraction + + self._command_move_to_encoder(retract_target, speed=self.app.els.at_reversing_speed) + + self._servo_watch_callback = self._watch_go_to_start + self.app.board.bind(update_tick=self._servo_watch_callback) + + return False + + # --------------------------------------------------------------------------- + # Low-level move command + # --------------------------------------------------------------------------- + + def _command_move_to_encoder(self, target_encoder, speed): + self._reset_encoder_stability_check() + + current_enc = self.saddle_input.encoderCurrent + + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) + + delta = int((target_encoder - current_enc) * scale_ratio / servo_ratio) + + log.info( + f"Move to encoder: current={current_enc}, " + f"target={target_encoder}, delta={delta}" + ) + + self.bar.bind_display_value_to_servo_position() + self.servo.set_max_speed(speed) + self.app.board.device['servo']['direction'] = delta + + # --------------------------------------------------------------------------- + # Watch callbacks (polled on update_tick) + # --------------------------------------------------------------------------- + + def _watch_retracting_stopped(self, *_): + if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): + return + + self._reset_servo_watch_callback() + self.servo.set_max_speed(self.servo.maxSpeed) + self.servo.servoEnable = 1 # back to normal servo mode + + self.goto_step(5) # Go back to step 6 - Go to start position + + def _watch_go_to_start(self, *_): + if not self._motion_complete(): + return + + if self._goto_start_phase == GoToStartPhase.RETRACT: + self._start_preload_move() + + elif self._goto_start_phase == GoToStartPhase.PRELOAD: + self._start_adjust_move() + + elif self._goto_start_phase == GoToStartPhase.ADJUST: + self._finish_go_to_start() + + # --------------------------------------------------------------------------- + # Encoder stability check + # --------------------------------------------------------------------------- + + def _reset_encoder_stability_check(self): + self._last_saddle_encoder_value = None + self._stable_count = 0 + + def _encoder_is_stable(self, tolerance, samples): + current = self.saddle_input.encoderCurrent + + if self._last_saddle_encoder_value is None: + self._last_saddle_encoder_value = current + self._stable_count = 0 + return False + + if abs(current - self._last_saddle_encoder_value) <= tolerance: + self._stable_count += 1 + else: + self._stable_count = 0 + + self._last_saddle_encoder_value = current + + return self._stable_count >= samples + + def _motion_complete(self): + if self.app.board.fast_data_values['stepsToGo'] != 0: + return False + + if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): + return False + + return True + + # --------------------------------------------------------------------------- + # Preload / adjust / finish phases + # --------------------------------------------------------------------------- + + def _start_preload_move(self): + self._reset_servo_watch_callback() + self._goto_start_phase = GoToStartPhase.PRELOAD + + log.info("Retract complete, starting preload move") + backlash_preload_steps = int(abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25) # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion + preload_target = self.saddle_input.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps + + self._apply_reversing_adjusting_acceleration() + self._command_move_to_encoder( + preload_target, + speed=self.app.els.at_preload_adjust_speed + ) + + self._servo_watch_callback = self._watch_go_to_start + self.app.board.bind(update_tick=self._servo_watch_callback) + + def _start_adjust_move(self): + self._reset_servo_watch_callback() + self._goto_start_phase = GoToStartPhase.ADJUST + + log.info("Preload move complete, starting final adjust move") + + self._apply_reversing_adjusting_acceleration() + self._command_move_to_encoder( + self.bar.start_position, + speed=self.app.els.at_preload_adjust_speed + ) + + self._servo_watch_callback = self._watch_go_to_start + self.app.board.bind(update_tick=self._servo_watch_callback) + + def _finish_go_to_start(self): + self._reset_servo_watch_callback() + + log.info("Start position reached with backlash preloaded") + + self._start_position_preloaded = True + + next_step = self.current_step + 1 + if self._is_cross_slide_at_final_cutting_depth(): + next_step += 1 + + self.goto_step(next_step) diff --git a/rcp/components/home/assisted_threading/safety.py b/rcp/components/home/assisted_threading/safety.py new file mode 100644 index 0000000..c1154a8 --- /dev/null +++ b/rcp/components/home/assisted_threading/safety.py @@ -0,0 +1,186 @@ +from kivy.logger import Logger + +from rcp.components.home.assisted_threading.calculations import MM_PER_INCH +from rcp.components.widgets.custom_popup import CustomPopup +from rcp.utils.devices import SCALES_COUNT + +log = Logger.getChild(__name__) + + +class AssistedThreadingSafetyMixin: + # --------------------------------------------------------------------------- + # Button condition functions (enable/disable action buttons) + # --------------------------------------------------------------------------- + + def _is_valid_stop_position(self): + """Check if the stop position is valid given the start position and thread direction. + - For right-hand threads, stop must be less than start. + - For left-hand threads, stop must be greater than start. + - Stop position must be greater than the backlash cushion distance from start position - if stop is too small, the saddle may not have enough room to cut properly. + - Depending on sign of the scale ratioNum/ratioDen, this will also affect the calculation""" + + effective_dir = self._get_saddle_scale_effective_dir() + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) + stop = self._get_stop_position_units() + min_stop = self.bar.start_position + effective_dir * backlash_cushion + return (stop - min_stop) * effective_dir > 0 + + def _is_cross_slide_retracted(self): + """ + Check if the cross slide is safely retracted when the saddle has moved beyond the threading start position. + """ + log.debug("Checking if cross slide is retracted for threading start...") + + # --- Saddle direction check (Z axis) --- + saddle_dir = self._get_saddle_scale_effective_dir() + + saddle_delta = self.saddle_input.encoderCurrent - self.bar.start_position + saddle_beyond_start = saddle_delta * saddle_dir > 0 + + if not saddle_beyond_start: + log.debug("Saddle is not beyond start position, no need to check cross slide") + return True + + log.debug("Saddle is beyond start position, checking cross slide retraction") + + # --- Cross-slide retraction check (X axis) --- + retract_dir = -self._get_cross_slide_scale_effective_dir() + + cross_delta = self.cross_slide_input.encoderCurrent - self.bar.material_width + return cross_delta * retract_dir > 0 + + def _is_cross_slide_at_final_cutting_depth(self): + """Check if the cross slide is at or more than the final cutting depth position.""" + effective_dir = self._get_cross_slide_scale_effective_dir() + current = self.cross_slide_input.encoderCurrent + log.info(f"Checking if at cutting depth: last_cutting_depth={self.bar.last_cutting_depth}, cutting_depth={self.bar.cutting_depth}, effective_dir={effective_dir}") + return (self.bar.last_cutting_depth - self.bar.cutting_depth) * effective_dir >= 0 + + # --------------------------------------------------------------------------- + # Pre-threading safety checks + # --------------------------------------------------------------------------- + + def _check_valid_start_position(self) -> bool: + """Return True if the saddle is within the backlash cushion of the start position. + Shows a warning popup and redirects to step 6 if not. Sanity check in case the + start_position_preloaded flag was bypassed or the saddle moved after preload.""" + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) + log.info( + f"Validating start position: current={self.saddle_input.encoderCurrent}, " + f"start={self.bar.start_position}, " + f"backlash_cushion={backlash_cushion}" + ) + delta = abs(self.saddle_input.encoderCurrent - self.bar.start_position) + if delta > backlash_cushion: + message = ( + "Not at valid start position including backlash cushion. " + "Aborting threading operation. Go back to start position." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + + def _check_spindle_turning_forward(self) -> bool: + """Return True if the spindle scale exists and is turning in the right/positive/CCW direction. + Shows a warning popup and redirects to step 6 if not.""" + spindle_axis = self.app.els.get_spindle_axis() + spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None + if spindle_inp is None: + log.warning("No spindle scale configured — cannot verify spindle direction") + CustomPopup( + title="Warning", + message="No spindle scale configured. Cannot verify spindle is turning.", + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + + spindle_speed = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + log.info(f"Validating spindle direction: scaleSpeed[{spindle_inp.inputIndex}]={spindle_speed}") + + if spindle_speed <= 0: + message = ( + "Spindle is not turning in the right/positive/CCW direction. " + "Ensure the spindle is running forward before starting the threading operation." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + + def _check_spindle_speed_for_pitch(self) -> bool: + """Return True if the current spindle RPM is within the servo's speed limit + for the selected pitch. Shows a warning popup and redirects to step 6 if not.""" + from fractions import Fraction + + spindle_axis = self.app.els.get_spindle_axis() + spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None + if spindle_inp is None: + return True # already caught by _check_spindle_turning_forward + + spindle_steps_per_sec = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + + try: + pitch_str = self.bar.selected_pitch.strip() + if not pitch_str: + return True # no pitch selected yet — skip + pitch_val = float(pitch_str) + except ValueError: + log.warning(f"Cannot parse selected_pitch={self.bar.selected_pitch!r} — skipping speed check") + return True + + if self.bar.metric_mode: + pitch_mm = pitch_val + else: + if pitch_val == 0: + return True + pitch_mm = MM_PER_INCH / pitch_val # TPI → mm/rev + + spindle_rev_per_sec = spindle_steps_per_sec / spindle_inp.ratioDen + feed_mm_per_sec = spindle_rev_per_sec * pitch_mm + encoder_steps_per_sec = feed_mm_per_sec * self.saddle_input.stepsPerMM + + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) + required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) + + steps_per_mm_per_rev = pitch_mm * self.saddle_input.stepsPerMM * float(scale_ratio / servo_ratio) + max_rpm = (self.app.els.at_threading_max_speed / steps_per_mm_per_rev) * 60 if steps_per_mm_per_rev > 0 else 0 + + log.info( + f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " + f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " + f"required_servo={required:.1f} steps/s, max={self.app.els.at_threading_max_speed}, " + f"max_rpm={max_rpm:.1f}, greater={required > self.app.els.at_threading_max_speed}" + ) + + if required > self.app.els.at_threading_max_speed: + spindle_rpm = spindle_rev_per_sec * 60 + pitch_label = f"{pitch_mm:.3g} mm" if self.bar.metric_mode else f"{self.bar.selected_pitch} TPI" + message = ( + f"Spindle speed ({spindle_rpm:.0f} RPM) is too fast for {pitch_label} pitch. " + f"Required servo speed ({required:.0f} steps/s) exceeds the threading limit " + f"({self.app.els.at_threading_max_speed} steps/s). " + f"Max allowed spindle speed for this pitch is {max_rpm:.0f} RPM. " + "Reduce spindle speed or increase the threading max speed limit." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True diff --git a/rcp/components/home/assisted_threading_settings_popup.kv b/rcp/components/home/assisted_threading/settings_popup.kv similarity index 99% rename from rcp/components/home/assisted_threading_settings_popup.kv rename to rcp/components/home/assisted_threading/settings_popup.kv index 6ab1166..96ebd51 100644 --- a/rcp/components/home/assisted_threading_settings_popup.kv +++ b/rcp/components/home/assisted_threading/settings_popup.kv @@ -44,4 +44,4 @@ BooleanItem: name: "Inner Thread" value: root.assistedThreadingBar.inner_thread - on_value: root.assistedThreadingBar.inner_thread = self.value \ No newline at end of file + on_value: root.assistedThreadingBar.inner_thread = self.value diff --git a/rcp/components/home/assisted_threading_settings_popup.py b/rcp/components/home/assisted_threading/settings_popup.py similarity index 93% rename from rcp/components/home/assisted_threading_settings_popup.py rename to rcp/components/home/assisted_threading/settings_popup.py index 6bd536e..e5720a7 100644 --- a/rcp/components/home/assisted_threading_settings_popup.py +++ b/rcp/components/home/assisted_threading/settings_popup.py @@ -2,7 +2,7 @@ from kivy.uix.popup import Popup from kivy.properties import ObjectProperty -from rcp.components.home.thread_type import ThreadType +from rcp.components.home.assisted_threading.thread_type import ThreadType from rcp.utils.kv_loader import load_kv log = Logger.getChild(__name__) @@ -12,23 +12,23 @@ class AssistedThreadingSettingsPopup(Popup): assistedThreadingBar = ObjectProperty(None) - + def __init__(self, **kv): super().__init__(**kv) - + def get_pitches(self): if not self.assistedThreadingBar: return [] return [f.name for f in self.assistedThreadingBar.current_feeds_table] - + def get_thread_types(self): """Get available thread types based on metric mode.""" if self.assistedThreadingBar.metric_mode: return [ThreadType.ISO_METRIC.value, ThreadType.ACME.value] else: return [ThreadType.UNIFIED.value, ThreadType.WHITWORTH.value, ThreadType.ACME.value] - + def on_metric_mode_changed(self, value): self.assistedThreadingBar.metric_mode = value pitches_dropdown = self.ids.pitches_dropdown @@ -37,7 +37,7 @@ def on_metric_mode_changed(self, value): first_pitch = pitches[0] if pitches else "" pitches_dropdown.value = first_pitch self.on_pitch_selected(0, first_pitch) - + # Update thread type options based on metric mode thread_type_dropdown = self.ids.thread_type_dropdown thread_type_dropdown.options = self.get_thread_types() @@ -45,15 +45,15 @@ def on_metric_mode_changed(self, value): first_type = self.get_thread_types()[0] if self.get_thread_types() else ThreadType.ISO_METRIC.value thread_type_dropdown.value = first_type self.assistedThreadingBar.thread_profile_type = ThreadType(first_type).value - + log.info(f"Metric mode changed to: {value}") - + def on_pitch_selected(self, index, selected_pitch): self.assistedThreadingBar.selected_pitch = selected_pitch self.assistedThreadingBar.current_feeds_index = index self.assistedThreadingBar.update_feeds_ratio(None,None) log.info(f"Selected pitch: {selected_pitch}") - + def on_thread_type_selected(self, value): """Handle thread type selection.""" try: @@ -62,4 +62,4 @@ def on_thread_type_selected(self, value): self.assistedThreadingBar.thread_profile_type = thread_type.value log.info(f"Selected thread type: {thread_type}") except ValueError: - log.warning(f"Invalid thread type value: {value}") \ No newline at end of file + log.warning(f"Invalid thread type value: {value}") diff --git a/rcp/components/home/thread_type.py b/rcp/components/home/assisted_threading/thread_type.py similarity index 100% rename from rcp/components/home/thread_type.py rename to rcp/components/home/assisted_threading/thread_type.py diff --git a/rcp/components/home/assisted_threading/wizard.py b/rcp/components/home/assisted_threading/wizard.py new file mode 100644 index 0000000..8331010 --- /dev/null +++ b/rcp/components/home/assisted_threading/wizard.py @@ -0,0 +1,508 @@ +import logging +from fractions import Fraction + +from kivy.logger import Logger + +from rcp.components.home.assisted_threading.calculations import AssistedThreadingCalculationsMixin +from rcp.components.home.assisted_threading.motion import AssistedThreadingMotionMixin +from rcp.components.home.assisted_threading.safety import AssistedThreadingSafetyMixin + +log = Logger.getChild(__name__) + + +class AssistedThreadingWizard( + AssistedThreadingCalculationsMixin, + AssistedThreadingMotionMixin, + AssistedThreadingSafetyMixin, +): + # --------------------------------------------------------------------------- + # Axis / input accessors + # --------------------------------------------------------------------------- + + @property + def saddle_scale(self): + """Returns the AxisDispatcher for the saddle (Z) axis.""" + return self.app.els.get_z_axis() + + @property + def cross_slide_scale(self): + """Returns the AxisDispatcher for the cross-slide (X) axis.""" + return self.app.els.get_x_axis() + + @property + def saddle_input(self): + """Returns the InputDispatcher (raw encoder) for the saddle axis.""" + axis = self.saddle_scale + return axis._primary_input() if axis is not None else None + + @property + def cross_slide_input(self): + """Returns the InputDispatcher (raw encoder) for the cross-slide axis.""" + axis = self.cross_slide_scale + return axis._primary_input() if axis is not None else None + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + def __init__(self, bar): + from rcp.app import MainApp + log.info("Initializing AssistedThreadingWizard") + self.bar = bar + self.app: MainApp = MainApp.get_running_app() + self.servo = self.app.servo + self.current_step = 0 + self._threading_started = False + self._threading_active_confirmed = False + self._calculated_threading_delta_steps = 0 + self._current_callback = None + self._servo_watch_callback = None + self.manual_stop_length = None + self.manual_cutting_depth = None + self._last_saddle_encoder_value = None + self._start_position_preloaded = False + self._steps = [ + self._step_set_initial_position, # Step 1 + self._step_set_stop_position, # Step 2 + self._step_set_material_width_position, # Step 3 + self._step_set_final_cutting_depth_position, # Step 4 + self._step_engage_half_nut, # Step 5 + self._step_go_to_start, # Step 6 + self._step_cut_thread, # Step 7 + self._step_depth_reached # Step 8 + ] + + def start(self): + dev = self.app.board.device + dev['assistedThreadingData']['spindlePhaseTolerance'] = self.app.els.at_rotary_encoder_sync_tolerance + + spindle_axis = self.app.els.get_spindle_axis() + if spindle_axis is not None: + inp = spindle_axis._primary_input() + if inp is not None: + dev['assistedThreadingData']['spindleCountsPerRev'] = int(spindle_axis._steps_per_revolution()) + dev['assistedThreadingData']['spindleScaleIndex'] = inp.inputIndex + + self.goto_step(0) + + def stop(self): + # Reset wizard_area to default content + log.info("Wizard finished") + self._current_callback = None + self._threading_started = False + self._threading_active_confirmed = False + self.bar.label_text = "" + self.bar.display_value = "" + self.bar.action_button_enabled = True + self.bar.action_button_condition_fn = None + self.bar.is_running = False + self.bar.retract_button_visible = False + self._clear_bar_display() + self._reset_servo_watch_callback() + self._reset_encoder_stability_check() + + if self.app.board.connected: + self.app.board.device['assistedThreadingData']['threadReset'] = 1 + self._stop_servo() + + def goto_step(self, index): + self.current_step = index + if 0 <= index < len(self._steps): + self._steps[index]() + else: + self.stop() + + def goto_next_step(self, *args): + # call the callback; it may return False to tell us "do not auto-advance" + result = None + if self._current_callback: + result = self._current_callback(*args) + + # If callback returned exactly False => callback will handle advancement later + if result is False: + return + + if self.bar.is_running: # check to ensure still running and we didn't stop in the callback + self.goto_step(self.current_step + 1) + + # --------------------------------------------------------------------------- + # Instruction / UI setup + # --------------------------------------------------------------------------- + + def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False, retract_button_condition_fn=None): + self.bar.label_text = label_text + self.bar.next_button_text = next_button_text + self._current_callback = next_button_callback + self.bar.bind_btn_value_on_release(value_button_fn) + self.bar.action_button_condition_fn = action_button_condition_fn + self.bar.retract_button_visible = retract_button_visible + self.bar.retract_button_condition_fn = retract_button_condition_fn + + # --------------------------------------------------------------------------- + # Retract control + # --------------------------------------------------------------------------- + + def start_retracting(self): + log.info("Retract button pressed") + self.bar.action_button_enabled = False # disable action button while retracting + + if not self.app.board.connected: + return + self.bar.bind_display_value_to_servo_position() # bind to servo position + servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 + self.servo.jogSpeed = - servo_direction * self.app.els.at_reversing_speed # set to reversing speed + self._apply_reversing_adjusting_acceleration() + self.servo.set_max_speed(self.app.els.at_reversing_speed) # ensure step rate supports jog speed + self.servo.servoEnable = 2 + + def stop_retracting(self): + log.info("Retract button released") + self.bar.action_button_enabled = True # re-enable action button + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_buttons_state() + + if not self.app.board.connected: + return + self.servo.jogSpeed = 0 + + self._servo_watch_callback = self._watch_retracting_stopped + self.app.board.bind(update_tick=self._servo_watch_callback) + + # --------------------------------------------------------------------------- + # Step definitions + # --------------------------------------------------------------------------- + + # Step 1 + def _step_set_initial_position(self): + self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) + self.bar.bind_display_value_to_scale(self.saddle_scale) + + # Step 2 + def _step_set_stop_position(self): + self.bar.action_button_enabled = False # Disable until valid + self.set_instruction("Go to or input stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) + self.bar.bind_display_value_to_scale(self.saddle_scale) + + # Step 3 + def _step_set_material_width_position(self): + self.set_instruction("Go to material width and press Set", "Set", self._capture_material_width_position) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + + # Step 4 + def _step_set_final_cutting_depth_position(self): + self._clear_bar_display() + + # Calculate thread depth and show immediately + calculated_depth = self._calculate_thread_depth() + self.manual_cutting_depth = None # Reset manual override + if calculated_depth is not None: + is_metric = self.app.formats.current_format == "MM" + self.bar.display_value = f"{calculated_depth:.3f}" if is_metric else f"{calculated_depth:.4f}" + else: + self.bar.display_value = "" + + self.set_instruction( + "Enter Final Cutting Depth (auto-calculated shown, tap to override)", + "Set", + self._capture_final_cutting_depth_position, + self._open_final_cutting_depth_position_keypad + ) + + # Step 5 + def _step_engage_half_nut(self): + self.set_instruction("Engage half nut and press Next", "Next", None) + self._clear_bar_display() + + # Step 6 + def _step_go_to_start(self): + self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid + self.servo.servoEnable = 1 # Ensure servo enabled + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True, self._is_cross_slide_retracted) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_buttons_state() + + # Step 7 + def _step_cut_thread(self): + self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid + self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, None, True) + self._bind_threading_progress_display() # Bind to progress display + self.bar.update_buttons_state() + + # Step 8 + def _step_depth_reached(self): + self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid + self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, None, True) + self._bind_threading_progress_display() # Bind to progress display + self.bar.update_buttons_state() + + # --------------------------------------------------------------------------- + # Step callbacks + # --------------------------------------------------------------------------- + + # Step 1 + def _capture_initial_position(self, *args): + self.bar.start_position = self.saddle_input.encoderCurrent + self._isStartPositionMetricMode = self.app.formats.current_format == "MM" + self._startScaledPosition = self.saddle_scale.scaledPosition + log.info(f"Initial position set to: {self.bar.start_position}") + return True # advance to next step + + # Step 2 + def _capture_stop_position(self, *args): + self.bar.stop_position = self._get_stop_position_units() + self.manual_stop_length = None # reset for next run + log.info(f"Stop position set - (start={self.bar.start_position}, stop={self.bar.stop_position})") + return True # advance to next step + + # Step 3 + def _capture_material_width_position(self, *args): + self.bar.material_width = self.cross_slide_input.encoderCurrent + self.bar.last_cutting_depth = self.bar.material_width # Initialize last_cutting_depth to material_width + self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" + self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition + log.info(f"Material width set to: {self.bar.material_width}") + return True # advance to next step + + # Step 4 + def _capture_final_cutting_depth_position(self, *args): + # Use manual override if set, otherwise use calculated depth + is_metric = self.app.formats.current_format == "MM" + depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() + encoder_cutting_depth = self._convert_distance_units_to_encoder(self.cross_slide_scale, depth, is_metric) + + self.bar.cutting_depth = self.cross_slide_input.encoderCurrent - (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) + + log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") + self.bar.display_value = f"{depth:.3f}" if is_metric else f"{depth:.4f}" + return True # advance to next step + + # Step 7 + def _start_threading_operation(self, *args): + if not self.app.board.connected: + self.stop() + return False # tell goto_next_step not to advance immediately + + if not self._start_position_preloaded: + log.warning("Threading requested without start preload") + self.goto_step(5) + return False + + if not self._check_valid_start_position(): + return False + + if not self._check_spindle_turning_forward(): + return False + + if not self._check_spindle_speed_for_pitch(): + return False + + log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) + self.bar.last_cutting_depth = self.cross_slide_input.encoderCurrent # Update last cutting depth to current position + + self._apply_threading_acceleration() + self._apply_threading_max_speed() + self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition + self.bar.action_button_enabled = False # Disable action button during threading + self.bar.retract_button_visible = False # Hide retract button during threading + + # Write the fields into firmware via modbus/device wrapper + dev = self.app.board.device + + # Request latch+wait. Firmware will latch current spindle phase and wait until matched. + if (self._threading_started is False): + # First time starting threading - latch phase and enable + self._threading_started = True + self._threading_active_confirmed = False + self._calculated_threading_delta_steps = self._get_threading_servo_delta_steps() # Calculate threading delta steps - we only calculate it once including backlash + dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps + dev['assistedThreadingData']['threadRequest'] = 1 + else: + self._threading_active_confirmed = False + dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps + dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state + + log.info(f"Threading requested: threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, servoCurrent={self.app.board.fast_data_values['servoCurrent']}, calculatedDeltaSteps={self._calculated_threading_delta_steps}") + + # Watch until done - then go back to step 6 (Go to start) + self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) + self.app.board.bind(update_tick=self._servo_watch_callback) + + return False # tell goto_next_step not to advance immediately + + def _check_servo_threading_done(self, next_step: int, *args): + dev = self.app.board.device + dev['assistedThreadingData'].refresh() + threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] + threadEnabled = dev['assistedThreadingData']['threadEnabled'] + + if log.isEnabledFor(logging.DEBUG): + spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] + log.debug( + f"Checking servo done: " + f"spindleScaleIndex={spindleScaleIndex}, " + f"spindleCountsPerRev={dev['assistedThreadingData']['spindleCountsPerRev']}, " + f"spindlePhaseTolerance={dev['assistedThreadingData']['spindlePhaseTolerance']}, " + f"threadRequest={dev['assistedThreadingData']['threadRequest']}, " + f"threadReset={dev['assistedThreadingData']['threadReset']}, " + f"threadPhaseActive={threadPhaseActive}, " + f"threadEnabled={threadEnabled}, " + f"syncEnable={dev['scales'][spindleScaleIndex]['syncEnable']}, " + f"threadPhaseRef={dev['assistedThreadingData']['threadPhaseRef']}, " + f"currentThreadPhase={dev['assistedThreadingData']['currentThreadPhase']}, " + f"spindleEncoderPosition={dev['scales'][spindleScaleIndex]['position']}, " + f"threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, " + f"threadStartSteps={dev['assistedThreadingData']['threadStartSteps']}, " + f"desiredSteps={dev['servo']['desiredSteps']}, " + f"currentSteps={dev['servo']['currentSteps']}, " + ) + + if threadEnabled == 1 or threadPhaseActive == 1: + self._threading_active_confirmed = True + + if self._threading_active_confirmed and threadEnabled == 0 and threadPhaseActive == 0: + log.info("Servo reached desired position") + + # Stop watching + self._reset_servo_watch_callback() + + self.goto_step(next_step) + + # --------------------------------------------------------------------------- + # Manual input keypads + # --------------------------------------------------------------------------- + + def _open_stop_position_keypad(self, *args): + from rcp.components.popups.keypad import Keypad + + is_metric = self.app.formats.current_format == "MM" + + keypad = Keypad(title="Enter Stop Length (" + ("mm" if is_metric else "in") + ")") + keypad.integer = False + + def on_done(value): + try: + self.manual_stop_length = float(value) + log.info(f"Manual stop length entered: {self.manual_stop_length}") + # Display this override until user moves scale again + self.bar.display_value = f"{self.manual_stop_length:.3f}" if is_metric else f"{self.manual_stop_length:.4f}" + except ValueError: + log.warning(f"Invalid stop length input: {value}") + finally: + self.bar.update_buttons_state() + + keypad.show_with_callback(callback_fn=on_done, + current_value=self.manual_stop_length or 0.0) + + def _open_final_cutting_depth_position_keypad(self, *args): + from rcp.components.popups.keypad import Keypad + is_metric = self.app.formats.current_format == "MM" + # Always use calculated depth as default + calculated_depth = self._calculate_thread_depth() + default_value = calculated_depth if calculated_depth is not None else 0.0 + depth_unit = "mm" if is_metric else "in" + keypad = Keypad(title=f"Enter Final Cutting Depth ({depth_unit})") + keypad.integer = False + + def on_done(value): + try: + self.manual_cutting_depth = abs(float(value)) + log.info(f"Manual cutting depth entered: {self.manual_cutting_depth}") + self.bar.display_value = f"{self.manual_cutting_depth:.3f}" if is_metric else f"{self.manual_cutting_depth:.4f}" + self.bar.action_button_enabled = True + except ValueError: + log.warning(f"Invalid cutting depth input: {value}") + self.bar.action_button_enabled = False + + log.info(f"Opening cutting depth keypad with calculated default: {default_value:.4f}") + keypad.show_with_callback(callback_fn=on_done, + current_value=self.manual_cutting_depth if self.manual_cutting_depth is not None else default_value) + + # --------------------------------------------------------------------------- + # Servo helpers + # --------------------------------------------------------------------------- + + def _stop_servo(self): + if not self.app.board.connected: + return + self.servo.set_max_speed(self.servo.maxSpeed) # restore speed + self.servo.servoEnable = 0 # disable + self._apply_original_servo_acceleration() # restore original acceleration if it was changed + + def _reset_servo_watch_callback(self): + if self._servo_watch_callback: + self.app.board.unbind(update_tick=self._servo_watch_callback) + self._servo_watch_callback = None + + def _clear_bar_display(self): + self.bar.unbind_all_display_value() + self.bar.display_value = "" + + def _apply_original_servo_acceleration(self): + self.app.board.device['servo']['acceleration'] = self.servo.acceleration + + def _apply_reversing_adjusting_acceleration(self): + rate = self.app.els.at_reversing_adjusting_acceleration + if rate and rate > 0: + self.app.board.device['servo']['acceleration'] = rate + else: + self._apply_original_servo_acceleration() + + def _apply_threading_acceleration(self): + rate = self.app.els.at_threading_acceleration + if rate and rate > 0: + self.app.board.device['servo']['acceleration'] = rate + else: + self._apply_original_servo_acceleration() + + def _apply_threading_max_speed(self): + target_speed = self.app.els.at_threading_max_speed + if target_speed and target_speed > 0: + self.servo.set_max_speed(target_speed) + else: + self.servo.set_max_speed(self.servo.maxSpeed) + + # --------------------------------------------------------------------------- + # Threading progress display + # --------------------------------------------------------------------------- + + def _bind_threading_progress_display(self): + """ + Bind display to show threading progress: "Last: | Rem: " + where: + - Last = incremental cut since last_cutting_depth + - Rem = remaining distance until final thread depth + """ + self.bar.unbind_all_display_value() + self._progress_display_scale = self.cross_slide_input + + def on_cross_slide_update(instance, value): + try: + is_metric = self.app.formats.current_format == "MM" + current_encoder = self.cross_slide_input.encoderCurrent + last_cutting_depth_encoder = self.bar.last_cutting_depth + factor = float(self.app.formats.factor) + + scale_ratio = abs(Fraction(self.cross_slide_input.ratioNum, self.cross_slide_input.ratioDen) * factor) + + # Calculate incremental cut depth in encoder units + incremental_cut_encoder = last_cutting_depth_encoder - current_encoder if self.bar.inner_thread else current_encoder - last_cutting_depth_encoder + + incremental_cut_display = incremental_cut_encoder * scale_ratio + # Calculate remaining depth + final_depth_encoder = current_encoder - self.bar.cutting_depth if self.bar.inner_thread else self.bar.cutting_depth - current_encoder + remaining_display = final_depth_encoder * scale_ratio + + if is_metric: + self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" + else: + self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" + log.debug(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") + except Exception as e: + log.error(f"Error updating threading progress display: {e}") + + self._on_threading_progress_update = on_cross_slide_update + self.cross_slide_input.bind(encoderCurrent=on_cross_slide_update) + on_cross_slide_update(self.cross_slide_input, self.cross_slide_input.encoderCurrent) diff --git a/rcp/components/home/assisted_threading_wizard.py b/rcp/components/home/assisted_threading_wizard.py deleted file mode 100644 index 04d6416..0000000 --- a/rcp/components/home/assisted_threading_wizard.py +++ /dev/null @@ -1,1009 +0,0 @@ -import logging -from fractions import Fraction - -from kivy.logger import Logger - -from rcp.components.widgets.custom_popup import CustomPopup -from rcp.components.home.thread_type import ThreadType -from rcp.utils.devices import SCALES_COUNT - -log = Logger.getChild(__name__) - -MM_PER_INCH = 25.4 - - -class GoToStartPhase: - IDLE = 0 - RETRACT = 1 - PRELOAD = 2 - ADJUST = 3 - - -class AssistedThreadingWizard: - @property - def saddle_scale(self): - """Returns the AxisDispatcher for the saddle (Z) axis.""" - return self.app.els.get_z_axis() - - @property - def cross_slide_scale(self): - """Returns the AxisDispatcher for the cross-slide (X) axis.""" - return self.app.els.get_x_axis() - - @property - def saddle_input(self): - """Returns the InputDispatcher (raw encoder) for the saddle axis.""" - axis = self.saddle_scale - return axis._primary_input() if axis is not None else None - - @property - def cross_slide_input(self): - """Returns the InputDispatcher (raw encoder) for the cross-slide axis.""" - axis = self.cross_slide_scale - return axis._primary_input() if axis is not None else None - - def __init__(self, bar): - from rcp.app import MainApp - log.info("Initializing AssistedThreadingWizard") - self.bar = bar - self.app: MainApp = MainApp.get_running_app() - self.servo = self.app.servo - self.current_step = 0 - self._threading_started = False - self._threading_active_confirmed = False - self._calculated_threading_delta_steps = 0 - self._current_callback = None - self._servo_watch_callback = None - self.manual_stop_length = None - self.manual_cutting_depth = None - self._last_saddle_encoder_value = None - self._start_position_preloaded = False - self._steps = [ - self._step_set_initial_position, # Step 1 - self._step_set_stop_position, # Step 2 - self._step_set_material_width_position, # Step 3 - self._step_set_final_cutting_depth_position, # Step 4 - self._step_engage_half_nut, # Step 5 - self._step_go_to_start, # Step 6 - self._step_cut_thread, # Step 7 - self._step_depth_reached # Step 8 - ] - - - def start(self): - dev = self.app.board.device - dev['assistedThreadingData']['spindlePhaseTolerance'] = self.app.els.at_rotary_encoder_sync_tolerance - - spindle_axis = self.app.els.get_spindle_axis() - if spindle_axis is not None: - inp = spindle_axis._primary_input() - if inp is not None: - dev['assistedThreadingData']['spindleCountsPerRev'] = int(spindle_axis._steps_per_revolution()) - dev['assistedThreadingData']['spindleScaleIndex'] = inp.inputIndex - - self.goto_step(0) - - def stop(self): - # Reset wizard_area to default content - log.info("Wizard finished") - self._current_callback = None - self._threading_started = False - self._threading_active_confirmed = False - self.bar.label_text = "" - self.bar.display_value = "" - self.bar.action_button_enabled = True - self.bar.action_button_condition_fn = None - self.bar.is_running = False - self.bar.retract_button_visible = False - self._clear_bar_display() - self._reset_servo_watch_callback() - self._reset_encoder_stability_check() - - if self.app.board.connected: - self.app.board.device['assistedThreadingData']['threadReset'] = 1 - self._stop_servo() - - - def goto_step(self, index): - self.current_step = index - if 0 <= index < len(self._steps): - self._steps[index]() - else: - self.stop() - - def goto_next_step(self, *args): - # call the callback; it may return False to tell us "do not auto-advance" - result = None - if self._current_callback: - result = self._current_callback(*args) - - # If callback returned exactly False => callback will handle advancement later - if result is False: - return - - if self.bar.is_running: # check to ensure still running and we didn't stop in the callback - self.goto_step(self.current_step + 1) - - def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False, retract_button_condition_fn=None): - self.bar.label_text = label_text - self.bar.next_button_text = next_button_text - self._current_callback = next_button_callback - self.bar.bind_btn_value_on_release(value_button_fn) - self.bar.action_button_condition_fn = action_button_condition_fn - self.bar.retract_button_visible = retract_button_visible - self.bar.retract_button_condition_fn = retract_button_condition_fn - - def start_retracting(self): - log.info("Retract button pressed") - self.bar.action_button_enabled = False # disable action button while retracting - - if not self.app.board.connected: - return - self.bar.bind_display_value_to_servo_position() # bind to servo position - servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 - self.servo.jogSpeed = - servo_direction * self.app.els.at_reversing_speed # set to reversing speed - self._apply_reversing_adjusting_acceleration() - self.servo.set_max_speed(self.app.els.at_reversing_speed) # ensure step rate supports jog speed - self.servo.servoEnable = 2 - - def stop_retracting(self): - log.info("Retract button released") - self.bar.action_button_enabled = True # re-enable action button - self.bar.bind_display_value_to_scale(self.cross_slide_scale) - self.bar.update_buttons_state() - - if not self.app.board.connected: - return - self.servo.jogSpeed = 0 - - self._servo_watch_callback = self._watch_retracting_stopped - self.app.board.bind(update_tick=self._servo_watch_callback) - - # Instruction steps - #Step 1 - def _step_set_initial_position(self): - self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) - self.bar.bind_display_value_to_scale(self.saddle_scale) - - #Step 2 - def _step_set_stop_position(self): - self.bar.action_button_enabled = False # Disable until valid - self.set_instruction("Go to or input stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) - self.bar.bind_display_value_to_scale(self.saddle_scale) - - #Step 3 - def _step_set_material_width_position(self): - self.set_instruction("Go to material width and press Set", "Set", self._capture_material_width_position) - self.bar.bind_display_value_to_scale(self.cross_slide_scale) - - #Step 4 - def _step_set_final_cutting_depth_position(self): - self._clear_bar_display() - - # Calculate thread depth and show immediately - calculated_depth = self._calculate_thread_depth() - self.manual_cutting_depth = None # Reset manual override - if calculated_depth is not None: - is_metric = self.app.formats.current_format == "MM" - self.bar.display_value = f"{calculated_depth:.3f}" if is_metric else f"{calculated_depth:.4f}" - else: - self.bar.display_value = "" - - self.set_instruction( - "Enter Final Cutting Depth (auto-calculated shown, tap to override)", - "Set", - self._capture_final_cutting_depth_position, - self._open_final_cutting_depth_position_keypad - ) - - #Step 5 - def _step_engage_half_nut(self): - self.set_instruction("Engage half nut and press Next", "Next", None) - self._clear_bar_display() - - #Step 6 - def _step_go_to_start(self): - self.bar.action_button_enabled = False # Disable until valid - self.bar.retract_button_enabled = False # Disable until valid - self.servo.servoEnable = 1 # Ensure servo enabled - self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True, self._is_cross_slide_retracted) - self.bar.bind_display_value_to_scale(self.cross_slide_scale) - self.bar.update_buttons_state() - - #Step 7 - def _step_cut_thread(self): - self.bar.action_button_enabled = False # Disable until valid - self.bar.retract_button_enabled = False # Disable until valid - self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, None, True) - self._bind_threading_progress_display() # Bind to progress display - self.bar.update_buttons_state() - - #Step 8 - def _step_depth_reached(self): - self.bar.action_button_enabled = False # Disable until valid - self.bar.retract_button_enabled = False # Disable until valid - self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, None, True) - self._bind_threading_progress_display() # Bind to progress display - self.bar.update_buttons_state() - - # Step callbacks - # Step 1 - def _capture_initial_position(self, *args): - self.bar.start_position = self.saddle_input.encoderCurrent - self._isStartPositionMetricMode = self.app.formats.current_format == "MM" - self._startScaledPosition = self.saddle_scale.scaledPosition - log.info(f"Initial position set to: {self.bar.start_position}") - return True # advance to next step - - #Step 2 - def _capture_stop_position(self, *args): - self.bar.stop_position = self._get_stop_position_units() - self.manual_stop_length = None # reset for next run - log.info(f"Stop position set - (start={self.bar.start_position}, stop={self.bar.stop_position})") - return True # advance to next step - - #Step 3 - def _capture_material_width_position(self, *args): - self.bar.material_width = self.cross_slide_input.encoderCurrent - self.bar.last_cutting_depth = self.bar.material_width # Initialize last_cutting_depth to material_width - self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" - self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition - log.info(f"Material width set to: {self.bar.material_width}") - return True # advance to next step - - #Step 4 - def _capture_final_cutting_depth_position(self, *args): - # Use manual override if set, otherwise use calculated depth - is_metric = self.app.formats.current_format == "MM" - depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() - encoder_cutting_depth = self._convert_distance_units_to_encoder(self.cross_slide_scale, depth, is_metric) - - self.bar.cutting_depth = self.cross_slide_input.encoderCurrent - (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) - - - log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") - self.bar.display_value = f"{depth:.3f}" if is_metric else f"{depth:.4f}" - return True # advance to next step - - #Step 6 - def _go_to_start(self, *args): - if not self.app.board.connected: - self.stop() - return False - - self.bar.retract_button_enabled = False # Disable retract button during move to start - self.bar.action_button_enabled = False # Disable action button during move to start - - self._apply_reversing_adjusting_acceleration() - self._start_position_preloaded = False - self._goto_start_phase = GoToStartPhase.RETRACT - - effective_dir = self._get_saddle_scale_effective_dir() - - retraction = abs(self._get_saddle_backlash_distance_encoder_steps() * 1.5) # retract 1.5x backlash distance - retraction_dir = -effective_dir # retract opposite to cutting direction - log.info(f"Starting retract to go to start: effective_dir={effective_dir}, retraction={retraction}, retraction_dir={retraction_dir}") - retract_target = self.bar.start_position + retraction_dir * retraction - - self._command_move_to_encoder(retract_target, speed=self.app.els.at_reversing_speed) - - self._servo_watch_callback = self._watch_go_to_start - self.app.board.bind(update_tick=self._servo_watch_callback) - - return False - - #Step 7 - def _start_threading_operation(self, *args): - if not self.app.board.connected: - self.stop() - return False # tell goto_next_step not to advance immediately - - if not self._start_position_preloaded: - log.warning("Threading requested without start preload") - self.goto_step(5) - return False - - if not self._check_valid_start_position(): - return False - - if not self._check_spindle_turning_forward(): - return False - - if not self._check_spindle_speed_for_pitch(): - return False - - log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) - self.bar.last_cutting_depth = self.cross_slide_input.encoderCurrent # Update last cutting depth to current position - - self._apply_threading_acceleration() - self._apply_threading_max_speed() - self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition - self.bar.action_button_enabled = False # Disable action button during threading - self.bar.retract_button_visible = False # Hide retract button during threading - - # Write the fields into firmware via modbus/device wrapper - dev = self.app.board.device - - # Request latch+wait. Firmware will latch current spindle phase and wait until matched. - if (self._threading_started is False): - # First time starting threading - latch phase and enable - self._threading_started = True - self._threading_active_confirmed = False - self._calculated_threading_delta_steps = self._get_threading_servo_delta_steps() # Calculate threading delta steps - we only calculate it once including backlash - dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps - dev['assistedThreadingData']['threadRequest'] = 1 - else: - self._threading_active_confirmed = False - dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps - dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state - - log.info(f"Threading requested: threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, servoCurrent={self.app.board.fast_data_values['servoCurrent']}, calculatedDeltaSteps={self._calculated_threading_delta_steps}") - - # Watch until done - then go back to step 6 (Go to start) - self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) - self.app.board.bind(update_tick=self._servo_watch_callback) - - return False # tell goto_next_step not to advance immediately - - - #Step Action button condition functions - #Step 2 - def _is_valid_stop_position(self): - """Check if the stop position is valid given the start position and thread direction. - - For right-hand threads, stop must be less than start. - - For left-hand threads, stop must be greater than start. - - Stop position must be greater than the backlash cushion distance from start position - if stop is too small, the saddle may not have enough room to cut properly. - - Depending on sign of the scale ratioNum/ratioDen, this will also affect the calculation""" - - effective_dir = self._get_saddle_scale_effective_dir() - backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) - stop = self._get_stop_position_units() - min_stop = self.bar.start_position + effective_dir * backlash_cushion - return (stop - min_stop) * effective_dir > 0 - - - #Step 6 - def _is_cross_slide_retracted(self): - """ - Check if the cross slide is safely retracted when the saddle has moved beyond the threading start position. - """ - log.debug("Checking if cross slide is retracted for threading start...") - - # --- Saddle direction check (Z axis) --- - saddle_dir = self._get_saddle_scale_effective_dir() - - saddle_delta = self.saddle_input.encoderCurrent - self.bar.start_position - saddle_beyond_start = saddle_delta * saddle_dir > 0 - - if not saddle_beyond_start: - log.debug("Saddle is not beyond start position, no need to check cross slide") - return True - - log.debug("Saddle is beyond start position, checking cross slide retraction") - - # --- Cross-slide retraction check (X axis) --- - retract_dir = -self._get_cross_slide_scale_effective_dir() - - cross_delta = self.cross_slide_input.encoderCurrent - self.bar.material_width - return cross_delta * retract_dir > 0 - - # Manual input handlers - def _open_stop_position_keypad(self, *args): - from rcp.components.popups.keypad import Keypad - - is_metric = self.app.formats.current_format == "MM" - - keypad = Keypad(title="Enter Stop Length (" + ("mm" if is_metric else "in") + ")") - keypad.integer = False - - def on_done(value): - try: - self.manual_stop_length = float(value) - log.info(f"Manual stop length entered: {self.manual_stop_length}") - # Display this override until user moves scale again - self.bar.display_value = f"{self.manual_stop_length:.3f}" if is_metric else f"{self.manual_stop_length:.4f}" - except ValueError: - log.warning(f"Invalid stop length input: {value}") - finally: - self.bar.update_buttons_state() - - keypad.show_with_callback(callback_fn=on_done, - current_value=self.manual_stop_length or 0.0) - - def _open_final_cutting_depth_position_keypad(self, *args): - from rcp.components.popups.keypad import Keypad - is_metric = self.app.formats.current_format == "MM" - # Always use calculated depth as default - calculated_depth = self._calculate_thread_depth() - default_value = calculated_depth if calculated_depth is not None else 0.0 - depth_unit = "mm" if is_metric else "in" - keypad = Keypad(title=f"Enter Final Cutting Depth ({depth_unit})") - keypad.integer = False - def on_done(value): - try: - self.manual_cutting_depth = abs(float(value)) - log.info(f"Manual cutting depth entered: {self.manual_cutting_depth}") - self.bar.display_value = f"{self.manual_cutting_depth:.3f}" if is_metric else f"{self.manual_cutting_depth:.4f}" - self.bar.action_button_enabled = True - except ValueError: - log.warning(f"Invalid cutting depth input: {value}") - self.bar.action_button_enabled = False - - log.info(f"Opening cutting depth keypad with calculated default: {default_value:.4f}") - keypad.show_with_callback(callback_fn=on_done, - current_value=self.manual_cutting_depth if self.manual_cutting_depth is not None else default_value) - - # Utilities - def _convert_position_units_to_encoder(self, - scale, - manual_position: float, - is_original_position_metric_mode: bool, - original_scaled_position, - start_encoder_units: int) -> int: - """ - Convert a user-entered stop position (MM/IN) into encoder counts. - Handles: - - unit changes (MM ↔ IN) - - offsets - - zero start positions - """ - - # Determine factors - current_factor = float(self.app.formats.factor) - factor_at_start_position = float(self.app.formats.MM_FRACTION if is_original_position_metric_mode else self.app.formats.INCHES_FRACTION) - - # Normalize manual input to the units used at start - manual_in_start_units = manual_position * (factor_at_start_position / current_factor) - - # Compute delta relative to start scaled position - delta_in_start_units = manual_in_start_units - original_scaled_position - - log.info( - f"Manual input: {manual_position} " - f"(converted to start units: {manual_in_start_units}, " - f"delta from start: {delta_in_start_units})" - ) - - # delta_in_start_units is already relative to the start position — offsets do not apply - inp = scale._primary_input() - encoder_counts = (delta_in_start_units / factor_at_start_position) * (float(inp.ratioDen) / float(inp.ratioNum)) - - # Offset by the captured start position - final_encoder_position = int(round(start_encoder_units + encoder_counts)) - - log.info( - f"Computed encoder counts: {final_encoder_position} " - f"(start_position={start_encoder_units}, encoder delta={encoder_counts})" - ) - - return final_encoder_position - - def _get_stop_position_units(self) -> float: - scale = self.saddle_scale - if self.manual_stop_length is not None: - log.info(f"Using manual stop length: {self.manual_stop_length}") - result = self._convert_position_units_to_encoder( - scale, - self.manual_stop_length, - self._isStartPositionMetricMode, - self._startScaledPosition, - self.bar.start_position - ) - log.info(f"Converted manual stop length to encoder units: {result}") - return result - log.info(f"Using live encoder value: {self.saddle_input.encoderCurrent}") - return self.saddle_input.encoderCurrent - - def _convert_distance_units_to_encoder(self, scale, distance: float, is_metric: bool) -> int: - """ - Convert a pure distance (mm or inch) into encoder counts. - scale: AxisDispatcher - """ - inp = scale._primary_input() - encoder_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) - - # Pure distance conversion — offsets do not apply (those are DRO zero offsets for positions, not distances) - encoder_counts = (distance / encoder_factor) * (float(inp.ratioDen) / float(inp.ratioNum)) - - final_encoder_distance = int(round(encoder_counts)) - - log.info( - f"Converted distance to encoder counts: {final_encoder_distance} " - f"(input distance={distance}, encoder delta={encoder_counts})" - ) - - return final_encoder_distance - - def _get_saddle_backlash_distance_encoder_steps(self) -> int: - """Get the retraction distance in encoder counts.""" - return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_saddle_backlash_distance, self.app.els.at_metric_distances) - - def _get_backlash_cusion_encoder_steps(self) -> int: - """Get the backlash cushion distance in encoder counts.""" - return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_backlash_cushion, self.app.els.at_metric_distances) - - def _check_servo_threading_done(self, next_step: int, *args): - dev = self.app.board.device - dev['assistedThreadingData'].refresh() - threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] - threadEnabled = dev['assistedThreadingData']['threadEnabled'] - - if log.isEnabledFor(logging.DEBUG): - spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] - log.debug( - f"Checking servo done: " - f"spindleScaleIndex={spindleScaleIndex}, " - f"spindleCountsPerRev={dev['assistedThreadingData']['spindleCountsPerRev']}, " - f"spindlePhaseTolerance={dev['assistedThreadingData']['spindlePhaseTolerance']}, " - f"threadRequest={dev['assistedThreadingData']['threadRequest']}, " - f"threadReset={dev['assistedThreadingData']['threadReset']}, " - f"threadPhaseActive={threadPhaseActive}, " - f"threadEnabled={threadEnabled}, " - f"syncEnable={dev['scales'][spindleScaleIndex]['syncEnable']}, " - f"threadPhaseRef={dev['assistedThreadingData']['threadPhaseRef']}, " - f"currentThreadPhase={dev['assistedThreadingData']['currentThreadPhase']}, " - f"spindleEncoderPosition={dev['scales'][spindleScaleIndex]['position']}, " - f"threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, " - f"threadStartSteps={dev['assistedThreadingData']['threadStartSteps']}, " - f"desiredSteps={dev['servo']['desiredSteps']}, " - f"currentSteps={dev['servo']['currentSteps']}, " - ) - - if threadEnabled == 1 or threadPhaseActive == 1: - self._threading_active_confirmed = True - - if self._threading_active_confirmed and threadEnabled == 0 and threadPhaseActive == 0: - log.info("Servo reached desired position") - - # Stop watching - self._reset_servo_watch_callback() - - self.goto_step(next_step) - - def _get_threading_servo_delta_steps(self) -> int: - """ - Compute the servo step delta needed to move the saddle - from the current position to the stop position - in the cutting direction. - """ - - effective_dir = self._get_saddle_scale_effective_dir() - - current_encoder = self.saddle_input.encoderCurrent - target_encoder = self.bar.stop_position - - delta_enc = target_encoder - current_encoder - if delta_enc * effective_dir <= 0: - log.warning( - "Threading delta is opposite to effective cutting direction " - f"(current={current_encoder}, stop={target_encoder}, " - f"effective_dir={effective_dir})" - ) - - # Convert encoder delta → servo steps - scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) - servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) - - delta_steps = int(delta_enc * scale_ratio / servo_ratio) - - log.info( - f"Computed threading servo delta: {delta_steps} steps " - f"(current_enc={current_encoder}, stop_enc={target_encoder}, " - f"delta_enc={delta_enc}, " - f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " - f"effective_dir={effective_dir})" - ) - - return delta_steps - - def _calculate_thread_depth(self): - """ - Calculate thread depth based on selected pitch and thread profile type. - - Uses metric_mode to determine if selected_pitch is in mm or TPI. - Formulas provided are for radial depth; multiply by 2 if diameter mode is enabled. - - Returns: - Thread depth in the selected units (mm or inches), or None if invalid - """ - if not self.bar.selected_pitch: - log.warning("No pitch selected for depth calculation") - return None - - # Determine effective pitch based on metric_mode - try: - if self.bar.metric_mode: - # In metric mode, selected_pitch is the pitch in mm - pitch = float(self.bar.selected_pitch) - else: - # In imperial mode, selected_pitch is TPI (threads per inch) - # Convert TPI to pitch in inches - tpi = float(self.bar.selected_pitch) - pitch = MM_PER_INCH / tpi - except (ValueError, TypeError): - log.warning(f"Could not parse pitch from: {self.bar.selected_pitch}") - return None - - if pitch <= 0: - log.warning(f"Invalid pitch value: {pitch}") - return None - - # Determine thread profile and calculate radial depth - thread_type = ThreadType(self.bar.thread_profile_type) - - if thread_type == ThreadType.ISO_METRIC: - depth = 0.61343 * pitch - elif thread_type == ThreadType.UNIFIED: - depth = 0.64952 * pitch - elif thread_type == ThreadType.WHITWORTH: - depth = 0.6403 * pitch - elif thread_type == ThreadType.ACME: - depth = 0.5 * pitch - else: - log.warning(f"Unknown thread profile: {thread_type}") - return None - - # Account for cross-slide diameter mode - # Formulas are for radial depth; in diameter mode multiply by 2 - if self.app.els.at_cross_slide_diameter_mode: - depth = depth * 2 - - # Convert depth to match current display format if needed - is_current_format_metric = self.app.formats.current_format == "MM" - if self.bar.metric_mode and not is_current_format_metric: - # Calculated in mm but displaying in inches - depth = depth / MM_PER_INCH - elif not self.bar.metric_mode and is_current_format_metric: - # Calculated in inches but displaying in mm - depth = depth * MM_PER_INCH - - log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") - return depth - - - def _is_cross_slide_at_final_cutting_depth(self): - """Check if the cross slide is at or more than the final cutting depth position.""" - effective_dir = self._get_cross_slide_scale_effective_dir() - current = self.cross_slide_input.encoderCurrent - log.info(f"Checking if at cutting depth: last_cutting_depth={self.bar.last_cutting_depth}, cutting_depth={self.bar.cutting_depth}, effective_dir={effective_dir}") - return (self.bar.last_cutting_depth - self.bar.cutting_depth) * effective_dir >= 0 - - def _stop_servo(self): - if not self.app.board.connected: - return - self.servo.set_max_speed(self.servo.maxSpeed) # restore speed - self.servo.servoEnable = 0 # disable - self._apply_original_servo_acceleration() # restore original acceleration if it was changed - - def _reset_servo_watch_callback(self): - if self._servo_watch_callback: - self.app.board.unbind(update_tick=self._servo_watch_callback) - self._servo_watch_callback = None - - def _clear_bar_display(self): - self.bar.unbind_all_display_value() - self.bar.display_value = "" - - def _apply_original_servo_acceleration(self): - self.app.board.device['servo']['acceleration'] = self.servo.acceleration - - def _apply_reversing_adjusting_acceleration(self): - rate = self.app.els.at_reversing_adjusting_acceleration - if rate and rate > 0: - self.app.board.device['servo']['acceleration'] = rate - else: - self._apply_original_servo_acceleration() - - def _apply_threading_acceleration(self): - rate = self.app.els.at_threading_acceleration - if rate and rate > 0: - self.app.board.device['servo']['acceleration'] = rate - else: - self._apply_original_servo_acceleration() - - def _apply_threading_max_speed(self): - target_speed = self.app.els.at_threading_max_speed - if target_speed and target_speed > 0: - self.servo.set_max_speed(target_speed) - else: - self.servo.set_max_speed(self.servo.maxSpeed) - - def _bind_threading_progress_display(self): - """ - Bind display to show threading progress: "Last: | Rem: " - where: - - Last = incremental cut since last_cutting_depth - - Rem = remaining distance until final thread depth - """ - self.bar.unbind_all_display_value() - self._progress_display_scale = self.cross_slide_input - def on_cross_slide_update(instance, value): - try: - is_metric = self.app.formats.current_format == "MM" - current_encoder = self.cross_slide_input.encoderCurrent - last_cutting_depth_encoder = self.bar.last_cutting_depth - factor = float(self.app.formats.factor) - - scale_ratio = abs(Fraction(self.cross_slide_input.ratioNum, self.cross_slide_input.ratioDen) * factor) - - # Calculate incremental cut depth in encoder units - incremental_cut_encoder = last_cutting_depth_encoder - current_encoder if self.bar.inner_thread else current_encoder - last_cutting_depth_encoder - - incremental_cut_display = incremental_cut_encoder * scale_ratio - # Calculate remaining depth - final_depth_encoder = current_encoder - self.bar.cutting_depth if self.bar.inner_thread else self.bar.cutting_depth - current_encoder - remaining_display = final_depth_encoder * scale_ratio - - if is_metric: - self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" - else: - self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" - log.debug(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") - except Exception as e: - log.error(f"Error updating threading progress display: {e}") - self._on_threading_progress_update = on_cross_slide_update - self.cross_slide_input.bind(encoderCurrent=on_cross_slide_update) - on_cross_slide_update(self.cross_slide_input, self.cross_slide_input.encoderCurrent) - - def _get_cross_slide_scale_effective_dir(self) -> int: - """Get the cross slide effective direction, considering thread type (internal/external) and scale direction.""" - # Physical cutting direction: internal → outward (+), external → inward (-) - thread_dir = 1 if self.bar.inner_thread else -1 - - # Encoder direction: positive if scale ratio is positive, negative if reversed - scale_dir = 1 if self.cross_slide_input.ratioNum * self.cross_slide_input.ratioDen > 0 else -1 - - # Combined effective direction - return thread_dir * scale_dir - - - def _get_saddle_scale_effective_dir(self) -> int: - """Get the saddle scale effective direction, considering if it's left/right hand tread and scale direction.""" - # Thread direction: LH → +, RH → - - thread_dir = 1 if self.bar.left_hand_thread else -1 - - # Scale direction from ratio sign - scale_dir = 1 if self.saddle_input.ratioNum * self.saddle_input.ratioDen > 0 else -1 - - return thread_dir * scale_dir - - def _command_move_to_encoder(self, target_encoder, speed): - self._reset_encoder_stability_check() - - current_enc = self.saddle_input.encoderCurrent - - scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) - servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) - - delta = int((target_encoder - current_enc) * scale_ratio / servo_ratio) - - log.info( - f"Move to encoder: current={current_enc}, " - f"target={target_encoder}, delta={delta}" - ) - - self.bar.bind_display_value_to_servo_position() - self.servo.set_max_speed(speed) - self.app.board.device['servo']['direction'] = delta - - def _watch_retracting_stopped(self, *_): - if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): - return - - self._reset_servo_watch_callback() - self.servo.set_max_speed(self.servo.maxSpeed) - self.servo.servoEnable = 1 # back to normal servo mode - - self.goto_step(5) # Go back to step 6 - Go to start position - - - def _watch_go_to_start(self, *_): - if not self._motion_complete(): - return - - if self._goto_start_phase == GoToStartPhase.RETRACT: - self._start_preload_move() - - elif self._goto_start_phase == GoToStartPhase.PRELOAD: - self._start_adjust_move() - - elif self._goto_start_phase == GoToStartPhase.ADJUST: - self._finish_go_to_start() - - def _reset_encoder_stability_check(self): - self._last_saddle_encoder_value = None - self._stable_count = 0 - - def _encoder_is_stable(self, tolerance, samples): - current = self.saddle_input.encoderCurrent - - if self._last_saddle_encoder_value is None: - self._last_saddle_encoder_value = current - self._stable_count = 0 - return False - - if abs(current - self._last_saddle_encoder_value) <= tolerance: - self._stable_count += 1 - else: - self._stable_count = 0 - - self._last_saddle_encoder_value = current - - return self._stable_count >= samples - - def _motion_complete(self): - if self.app.board.fast_data_values['stepsToGo'] != 0: - return False - - if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): - return False - - return True - - def _start_preload_move(self): - self._reset_servo_watch_callback() - self._goto_start_phase = GoToStartPhase.PRELOAD - - log.info("Retract complete, starting preload move") - backlash_preload_steps = int(abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25) # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion - preload_target = self.saddle_input.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps - - self._apply_reversing_adjusting_acceleration() - self._command_move_to_encoder( - preload_target, - speed=self.app.els.at_preload_adjust_speed - ) - - self._servo_watch_callback = self._watch_go_to_start - self.app.board.bind(update_tick=self._servo_watch_callback) - - def _start_adjust_move(self): - self._reset_servo_watch_callback() - self._goto_start_phase = GoToStartPhase.ADJUST - - log.info("Preload move complete, starting final adjust move") - - self._apply_reversing_adjusting_acceleration() - self._command_move_to_encoder( - self.bar.start_position, - speed=self.app.els.at_preload_adjust_speed - ) - - self._servo_watch_callback = self._watch_go_to_start - self.app.board.bind(update_tick=self._servo_watch_callback) - - def _finish_go_to_start(self): - self._reset_servo_watch_callback() - - log.info("Start position reached with backlash preloaded") - - self._start_position_preloaded = True - - next_step = self.current_step + 1 - if self._is_cross_slide_at_final_cutting_depth(): - next_step += 1 - - self.goto_step(next_step) - - def _check_valid_start_position(self) -> bool: - """Return True if the saddle is within the backlash cushion of the start position. - Shows a warning popup and redirects to step 6 if not. Sanity check in case the - start_position_preloaded flag was bypassed or the saddle moved after preload.""" - backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) - log.info( - f"Validating start position: current={self.saddle_input.encoderCurrent}, " - f"start={self.bar.start_position}, " - f"backlash_cushion={backlash_cushion}" - ) - delta = abs(self.saddle_input.encoderCurrent - self.bar.start_position) - if delta > backlash_cushion: - message = ( - "Not at valid start position including backlash cushion. " - "Aborting threading operation. Go back to start position." - ) - log.warning(message) - CustomPopup( - title="Warning", - message=message, - button_text="Got it", - on_dismiss_callback=lambda: self.goto_step(5), - ).open() - return False - return True - - def _check_spindle_turning_forward(self) -> bool: - """Return True if the spindle scale exists and is turning in the right/positive/CCW direction. - Shows a warning popup and redirects to step 6 if not.""" - spindle_axis = self.app.els.get_spindle_axis() - spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None - if spindle_inp is None: - log.warning("No spindle scale configured — cannot verify spindle direction") - CustomPopup( - title="Warning", - message="No spindle scale configured. Cannot verify spindle is turning.", - button_text="Got it", - on_dismiss_callback=lambda: self.goto_step(5), - ).open() - return False - - spindle_speed = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] - log.info(f"Validating spindle direction: scaleSpeed[{spindle_inp.inputIndex}]={spindle_speed}") - - if spindle_speed <= 0: - message = ( - "Spindle is not turning in the right/positive/CCW direction. " - "Ensure the spindle is running forward before starting the threading operation." - ) - log.warning(message) - CustomPopup( - title="Warning", - message=message, - button_text="Got it", - on_dismiss_callback=lambda: self.goto_step(5), - ).open() - return False - return True - - def _check_spindle_speed_for_pitch(self) -> bool: - """Return True if the current spindle RPM is within the servo's speed limit - for the selected pitch. Shows a warning popup and redirects to step 6 if not.""" - spindle_axis = self.app.els.get_spindle_axis() - spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None - if spindle_inp is None: - return True # already caught by _check_spindle_turning_forward - - spindle_steps_per_sec = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] - - try: - pitch_str = self.bar.selected_pitch.strip() - if not pitch_str: - return True # no pitch selected yet — skip - pitch_val = float(pitch_str) - except ValueError: - log.warning(f"Cannot parse selected_pitch={self.bar.selected_pitch!r} — skipping speed check") - return True - - if self.bar.metric_mode: - pitch_mm = pitch_val - else: - if pitch_val == 0: - return True - pitch_mm = MM_PER_INCH / pitch_val # TPI → mm/rev - - spindle_rev_per_sec = spindle_steps_per_sec / spindle_inp.ratioDen - feed_mm_per_sec = spindle_rev_per_sec * pitch_mm - encoder_steps_per_sec = feed_mm_per_sec * self.saddle_input.stepsPerMM - - scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) - servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) - required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) - - steps_per_mm_per_rev = pitch_mm * self.saddle_input.stepsPerMM * float(scale_ratio / servo_ratio) - max_rpm = (self.app.els.at_threading_max_speed / steps_per_mm_per_rev) * 60 if steps_per_mm_per_rev > 0 else 0 - - log.info( - f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " - f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " - f"required_servo={required:.1f} steps/s, max={self.app.els.at_threading_max_speed}, " - f"max_rpm={max_rpm:.1f}, greater={required > self.app.els.at_threading_max_speed}" - ) - - if required > self.app.els.at_threading_max_speed: - spindle_rpm = spindle_rev_per_sec * 60 - pitch_label = f"{pitch_mm:.3g} mm" if self.bar.metric_mode else f"{self.bar.selected_pitch} TPI" - message = ( - f"Spindle speed ({spindle_rpm:.0f} RPM) is too fast for {pitch_label} pitch. " - f"Required servo speed ({required:.0f} steps/s) exceeds the threading limit " - f"({self.app.els.at_threading_max_speed} steps/s). " - f"Max allowed spindle speed for this pitch is {max_rpm:.0f} RPM. " - "Reduce spindle speed or increase the threading max speed limit." - ) - log.warning(message) - CustomPopup( - title="Warning", - message=message, - button_text="Got it", - on_dismiss_callback=lambda: self.goto_step(5), - ).open() - return False - return True \ No newline at end of file diff --git a/rcp/components/home/at_mode_layout.py b/rcp/components/home/at_mode_layout.py index f0f9bbc..b2e8411 100644 --- a/rcp/components/home/at_mode_layout.py +++ b/rcp/components/home/at_mode_layout.py @@ -1,6 +1,6 @@ from kivy.uix.widget import Widget -from rcp.components.home.assisted_threading_bar import AssistedThreadingBar +from rcp.components.home.assisted_threading.bar import AssistedThreadingBar from rcp.components.home.coordbar import CoordBar from rcp.components.home.dro_coordbar import DroCoordBar from rcp.components.home.mode_layout import ModeLayout diff --git a/rcp/components/screens/home_screen.py b/rcp/components/screens/home_screen.py index a275e79..d2a537d 100644 --- a/rcp/components/screens/home_screen.py +++ b/rcp/components/screens/home_screen.py @@ -5,7 +5,7 @@ from kivy.clock import Clock from kivy.uix.screenmanager import Screen -from rcp.components.home.assisted_threading_bar import AssistedThreadingBar +from rcp.components.home.assisted_threading.bar import AssistedThreadingBar from rcp.components.home.at_mode_layout import AtModeLayout from rcp.components.home.dro_mode_layout import DroModeLayout from rcp.components.home.els_mode_layout import ElsModeLayout diff --git a/tests/components/__init__.py b/tests/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/home/__init__.py b/tests/components/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/home/assisted_threading/__init__.py b/tests/components/home/assisted_threading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/home/assisted_threading/conftest.py b/tests/components/home/assisted_threading/conftest.py new file mode 100644 index 0000000..789637b --- /dev/null +++ b/tests/components/home/assisted_threading/conftest.py @@ -0,0 +1,120 @@ +""" +Shared fixtures for AssistedThreadingWizard tests. + +All test modules in this directory can use make_wizard() directly — pytest +discovers conftest.py automatically and makes module-level names available +when imported explicitly. The factory is also exported as a pytest fixture. +""" + +from fractions import Fraction +from unittest.mock import MagicMock + +import pytest + +from rcp.components.home.assisted_threading.wizard import AssistedThreadingWizard + + +# --------------------------------------------------------------------------- +# Builder helpers +# --------------------------------------------------------------------------- + +def _make_input(ratioNum: int, ratioDen: int, encoderCurrent: int = 0): + """Create a mock InputDispatcher (raw encoder).""" + inp = MagicMock() + inp.ratioNum = ratioNum + inp.ratioDen = ratioDen + inp.encoderCurrent = encoderCurrent + inp.stepsPerMM = abs(ratioDen / ratioNum) + inp.inputIndex = 0 + return inp + + +def _make_axis(inp, scaledPosition: float = 0.0): + """Create a mock AxisDispatcher that delegates to *inp* via _primary_input().""" + axis = MagicMock() + axis.scaledPosition = scaledPosition + axis.offsets = [0] * 100 + axis._primary_input.return_value = inp + return axis + + +def make_wizard( + ratioNum: int = 1, + ratioDen: int = 6926, + saddle_encoderCurrent: int = 0, + cross_encoderCurrent: int = 0, + left_hand_thread: bool = False, + inner_thread: bool = False, + metric_mode: bool = True, + is_metric_format: bool = True, +): + """Create an AssistedThreadingWizard with mocked dependencies. + + Returns (wizard, bar, saddle_input, cross_input, app). + Note: saddle_scale and cross_slide_scale (AxisDispatchers) are accessible + via app.els.get_z_axis() and app.els.get_x_axis() respectively. + """ + bar = MagicMock() + bar.left_hand_thread = left_hand_thread + bar.inner_thread = inner_thread + bar.metric_mode = metric_mode + bar.start_position = 0 + bar.stop_position = 0 + bar.material_width = 0 + bar.cutting_depth = 0 + bar.last_cutting_depth = 0 + bar.reversing_speed = 500 + bar.selected_pitch = "1.5" + bar.thread_profile_type = "ISO Metric" + + saddle_inp = _make_input(ratioNum, ratioDen, saddle_encoderCurrent) + cross_inp = _make_input(ratioNum, ratioDen, cross_encoderCurrent) + + saddle_axis = _make_axis(saddle_inp) + cross_axis = _make_axis(cross_inp) + + els = MagicMock() + els.get_z_axis.return_value = saddle_axis + els.get_x_axis.return_value = cross_axis + els.get_spindle_axis.return_value = None # override per-test when needed + # Machine config properties (moved from bar in v1.3.0) + els.at_saddle_backlash_distance = 0.5 # mm + els.at_backlash_cushion = 0.1 # mm + els.at_metric_distances = True + els.at_threading_max_speed = 2000 + els.at_reversing_speed = 500 + els.at_cross_slide_diameter_mode = False + + board = MagicMock() + board.connected = True + board.fast_data_values = {"stepsToGo": 0, "scaleSpeed": [0] * 4, "servoCurrent": 0} + + app = MagicMock() + app.formats.factor = Fraction(1, 1) + app.formats.MM_FRACTION = Fraction(1, 1) + app.formats.INCHES_FRACTION = Fraction(10, 254) + app.formats.current_format = "MM" if is_metric_format else "IN" + app.currentOffset = 0 + app.els = els + app.board = board + + wizard = AssistedThreadingWizard.__new__(AssistedThreadingWizard) + wizard.bar = bar + wizard.app = app + wizard.servo = MagicMock() + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 1 + wizard.current_step = 0 + wizard._threading_started = False + wizard._threading_active_confirmed = False + wizard._current_callback = None + wizard._servo_watch_callback = None + wizard.manual_stop_length = None + wizard.manual_cutting_depth = None + wizard._last_saddle_encoder_value = None + wizard._stable_count = 0 + wizard._start_position_preloaded = False + wizard._isStartPositionMetricMode = True + wizard._startScaledPosition = 0.0 + + return wizard, bar, saddle_inp, cross_inp, app diff --git a/tests/components/home/assisted_threading/test_calculations.py b/tests/components/home/assisted_threading/test_calculations.py new file mode 100644 index 0000000..795f945 --- /dev/null +++ b/tests/components/home/assisted_threading/test_calculations.py @@ -0,0 +1,395 @@ +""" +Unit tests for AssistedThreadingCalculationsMixin methods. +""" + +from fractions import Fraction +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 1. _convert_distance_units_to_encoder +# --------------------------------------------------------------------------- + +class TestConvertDistanceUnitsToEncoder: + def test_mm_gives_correct_counts(self): + """0.1 mm should give ~693 counts (0.1 * 6926).""" + wizard, _, _, _, app = make_wizard() + axis = app.els.get_z_axis() + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=True) + assert result == 693 # round(0.1 * 6926) + + def test_non_zero_axis_offset_does_not_contaminate(self): + """v1.3.0 fix: offset on the AxisDispatcher must NOT affect pure distance + conversion. With offset=-12.885 the result should still be ~693.""" + wizard, _, _, _, app = make_wizard() + axis = app.els.get_z_axis() + axis.offsets[0] = -12.885 + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=True) + assert abs(result) < 1_000, ( + f"Offset contamination: expected ~693, got {result}" + ) + + def test_inch_distance(self): + """0.1 inch; INCHES_FRACTION=10/254 → counts = 0.1/(10/254)*6926 ≈ 17594.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + axis = app.els.get_z_axis() + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=False) + expected = round(0.1 / (10 / 254) * 6926) + assert result == expected + + def test_negative_ratio_inverts_direction(self): + """A negative ratioNum should invert the sign of the result.""" + wizard, _, _, _, app = make_wizard(ratioNum=-1, ratioDen=6926) + axis = app.els.get_z_axis() + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=True) + # (0.1 / 1.0) * (6926 / -1) = -692.6 → -693 + assert result == -693 + + +# --------------------------------------------------------------------------- +# 2. _get_saddle_scale_effective_dir +# --------------------------------------------------------------------------- + +class TestGetSaddleScaleEffectiveDir: + def test_rht_positive_ratio_gives_minus_one(self): + wizard, bar, _, _, _ = make_wizard(left_hand_thread=False) + assert wizard._get_saddle_scale_effective_dir() == -1 + + def test_lht_positive_ratio_gives_plus_one(self): + wizard, bar, _, _, _ = make_wizard(left_hand_thread=True) + assert wizard._get_saddle_scale_effective_dir() == 1 + + def test_rht_negative_ratio_gives_plus_one(self): + wizard, bar, saddle, _, _ = make_wizard(left_hand_thread=False, ratioNum=-1) + assert wizard._get_saddle_scale_effective_dir() == 1 + + def test_lht_negative_ratio_gives_minus_one(self): + wizard, bar, saddle, _, _ = make_wizard(left_hand_thread=True, ratioNum=-1) + assert wizard._get_saddle_scale_effective_dir() == -1 + + +# --------------------------------------------------------------------------- +# 3. _get_cross_slide_scale_effective_dir +# --------------------------------------------------------------------------- + +class TestGetCrossSlideScaleEffectiveDir: + def test_external_thread_positive_ratio_gives_minus_one(self): + wizard, bar, _, _, _ = make_wizard(inner_thread=False) + assert wizard._get_cross_slide_scale_effective_dir() == -1 + + def test_internal_thread_positive_ratio_gives_plus_one(self): + wizard, bar, _, _, _ = make_wizard(inner_thread=True) + assert wizard._get_cross_slide_scale_effective_dir() == 1 + + def test_external_thread_negative_ratio_gives_plus_one(self): + wizard, bar, _, cross, _ = make_wizard(inner_thread=False, ratioNum=-1) + assert wizard._get_cross_slide_scale_effective_dir() == 1 + + def test_internal_thread_negative_ratio_gives_minus_one(self): + wizard, bar, _, cross, _ = make_wizard(inner_thread=True, ratioNum=-1) + assert wizard._get_cross_slide_scale_effective_dir() == -1 + + +# --------------------------------------------------------------------------- +# 7. _get_backlash_cushion_encoder_steps / _get_saddle_backlash_distance_encoder_steps +# --------------------------------------------------------------------------- + +class TestBacklashEncoderSteps: + def test_cushion_gives_sane_counts(self): + """0.1 mm backlash cushion at 6926 counts/mm → ~693 counts.""" + wizard, _, _, _, app = make_wizard() + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + + result = wizard._get_backlash_cusion_encoder_steps() + + assert 600 < abs(result) < 800, f"Expected ~693, got {result}" + + def test_backlash_distance_gives_sane_counts(self): + """0.5 mm saddle backlash distance → ~3463 counts.""" + wizard, _, _, _, app = make_wizard() + app.els.at_saddle_backlash_distance = 0.5 + app.els.at_metric_distances = True + + result = wizard._get_saddle_backlash_distance_encoder_steps() + + assert 3000 < abs(result) < 4000, f"Expected ~3463, got {result}" + + def test_axis_offset_does_not_affect_cushion(self): + """v1.3.0 fix: DRO zero offset must NOT inflate the cushion. + With offset=-12.885 the result should still be ~693 (not ~89930).""" + wizard, _, _, _, app = make_wizard() + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + app.els.get_z_axis().offsets[0] = -12.885 + + result = wizard._get_backlash_cusion_encoder_steps() + + assert 600 < abs(result) < 800, ( + f"Offset contamination: expected ~693, got {result}" + ) + + def test_inch_backlash_cushion(self): + """0.004 inch backlash cushion with inch scale factor → ~704 counts.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + # encoder_factor = 10/254 ≈ 0.03937 + # counts = (0.004 / 0.03937) * 6926 ≈ 704 + result = wizard._get_backlash_cusion_encoder_steps() + assert 600 < abs(result) < 800, f"Expected ~704, got {result}" + + +# --------------------------------------------------------------------------- +# 8. _get_stop_position_units — live encoder vs manual entry +# --------------------------------------------------------------------------- + +class TestGetStopPositionUnits: + def test_returns_live_encoder_when_no_manual_override(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = -92812 + wizard.manual_stop_length = None + + assert wizard._get_stop_position_units() == -92812 + + def test_manual_override_converts_distance_to_encoder(self): + """User typed -14.665 mm; initial scaled position = -1.305 mm at + encoder -90140. Delta = -13.36 mm → result must be past start in -ve dir.""" + wizard, bar, _, _, app = make_wizard() + bar.start_position = -90140 + wizard._isStartPositionMetricMode = True + wizard._startScaledPosition = -1.305 + wizard.manual_stop_length = -14.665 + app.formats.current_format = "MM" + app.formats.factor = Fraction(1, 1) + app.formats.MM_FRACTION = Fraction(1, 1) + + result = wizard._get_stop_position_units() + + assert result < bar.start_position, ( + f"Manual stop {result} should be more negative than start {bar.start_position}" + ) + + +# --------------------------------------------------------------------------- +# 9. _calculate_thread_depth — all thread profiles +# --------------------------------------------------------------------------- + +class TestCalculateThreadDepth: + """Thread depth formulas (radial, metric pitch, metric display).""" + + def _w(self, pitch="1.5", profile="ISO Metric", metric_mode=True, + diameter_mode=False, is_metric_format=True): + wizard, bar, _, _, app = make_wizard(metric_mode=metric_mode, + is_metric_format=is_metric_format) + bar.selected_pitch = pitch + bar.thread_profile_type = profile + bar.metric_mode = metric_mode + app.els.at_cross_slide_diameter_mode = diameter_mode # moved to els in v1.3.0 + return wizard + + def test_iso_metric_1_5mm(self): + w = self._w("1.5", "ISO Metric") + assert abs(w._calculate_thread_depth() - 0.61343 * 1.5) < 0.001 + + def test_unified_1_5mm(self): + w = self._w("1.5", "Unified") + assert abs(w._calculate_thread_depth() - 0.64952 * 1.5) < 0.001 + + def test_whitworth_1_5mm(self): + w = self._w("1.5", "Whitworth") + assert abs(w._calculate_thread_depth() - 0.6403 * 1.5) < 0.001 + + def test_acme_1_5mm(self): + w = self._w("1.5", "ACME") + assert abs(w._calculate_thread_depth() - 0.5 * 1.5) < 0.001 + + def test_imperial_16_tpi_iso_metric(self): + """16 TPI, display in inches → no unit conversion applied. + pitch = 25.4/16 mm; depth = 0.61343 × pitch (raw formula).""" + w = self._w("16", "ISO Metric", metric_mode=False, is_metric_format=False) + pitch_mm = 25.4 / 16 + assert abs(w._calculate_thread_depth() - 0.61343 * pitch_mm) < 0.001 + + def test_diameter_mode_doubles_depth(self): + w = self._w("1.5", "ISO Metric", diameter_mode=True) + assert abs(w._calculate_thread_depth() - 0.61343 * 1.5 * 2) < 0.001 + + def test_empty_pitch_returns_none(self): + w = self._w("") + assert w._calculate_thread_depth() is None + + def test_zero_pitch_returns_none(self): + w = self._w("0") + assert w._calculate_thread_depth() is None + + def test_non_numeric_pitch_returns_none(self): + w = self._w("abc") + assert w._calculate_thread_depth() is None + + def test_metric_pitch_displayed_in_inches(self): + """Metric pitch but display in inches: depth converted by /25.4.""" + w = self._w("1.5", "ISO Metric", metric_mode=True, is_metric_format=False) + depth_mm = 0.61343 * 1.5 + expected_in = depth_mm / 25.4 + assert abs(w._calculate_thread_depth() - expected_in) < 0.0001 + + def test_imperial_tpi_displayed_in_mm(self): + """TPI pitch but display in MM: depth converted by *25.4.""" + w = self._w("16", "ISO Metric", metric_mode=False, is_metric_format=True) + expected = 0.61343 * (25.4 / 16) * 25.4 + assert abs(w._calculate_thread_depth() - expected) < 0.001 + + +# --------------------------------------------------------------------------- +# 11. _get_threading_servo_delta_steps +# --------------------------------------------------------------------------- + +class TestGetThreadingServoDeltaSteps: + def test_delta_equals_encoder_difference_when_ratios_equal(self): + """When scale ratio == servo ratio the delta in servo steps equals + the delta in encoder counts.""" + wizard, bar, saddle_inp, _, _ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 # same ratio → conversion factor = 1 + saddle_inp.encoderCurrent = -90140 + bar.stop_position = -92812 # 2672 counts away + + result = wizard._get_threading_servo_delta_steps() + + assert result == -2672 + + def test_servo_ratio_scales_result(self): + """If scale is 1:6926 and servo is 1:1 the servo steps are much smaller + than encoder counts (factor = 1/6926).""" + wizard, bar, saddle_inp, _, _ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 1 # servo 1:1, scale 1:6926 + saddle_inp.encoderCurrent = -90140 + bar.stop_position = -92812 # delta_enc = -2672 + + # delta_steps = -2672 * (1/6926) / (1/1) = -2672/6926 ≈ -0.39 → 0 + result = wizard._get_threading_servo_delta_steps() + + assert result == 0 + + +# --------------------------------------------------------------------------- +# 16. Inch-mode coverage — backlash distance in inches +# --------------------------------------------------------------------------- + +class TestBacklashEncoderStepsInches: + """Complement to TestBacklashEncoderSteps — uses at_metric_distances=False.""" + + def test_saddle_backlash_distance_in_inches(self): + """0.02 inch saddle backlash distance. + factor = 10/254; counts = (0.02 / (10/254)) * 6926 ≈ 3518.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + app.els.at_saddle_backlash_distance = 0.02 + app.els.at_metric_distances = False + + result = wizard._get_saddle_backlash_distance_encoder_steps() + + expected = round(0.02 / (10 / 254) * 6926) + assert abs(result) == abs(expected), f"Expected {expected}, got {result}" + + def test_cushion_and_distance_proportional_in_inches(self): + """Doubling the backlash cushion value should double the encoder count.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + app.els.at_metric_distances = False + + app.els.at_backlash_cushion = 0.004 + single = abs(wizard._get_backlash_cusion_encoder_steps()) + + app.els.at_backlash_cushion = 0.008 + doubled = abs(wizard._get_backlash_cusion_encoder_steps()) + + # Allow ±1 for integer rounding + assert abs(doubled - single * 2) <= 1 + + +# --------------------------------------------------------------------------- +# 18. Inch-mode coverage — manual stop position in inches +# --------------------------------------------------------------------------- + +class TestGetStopPositionUnitsInches: + def test_manual_override_in_inches(self): + """Start captured at -0.5124 in (≈ -90140 counts). + User enters -1.0 in → final encoder must be more negative than start.""" + wizard, bar, _, _, app = make_wizard(is_metric_format=False) + bar.start_position = -90140 + wizard._isStartPositionMetricMode = False + wizard._startScaledPosition = -90140 / (6926 * 25.4) + wizard.manual_stop_length = -1.0 + app.formats.current_format = "IN" + app.formats.factor = Fraction(10, 254) + app.formats.INCHES_FRACTION = Fraction(10, 254) + + result = wizard._get_stop_position_units() + + assert result < bar.start_position, ( + f"Manual inch stop {result} should be more negative than start {bar.start_position}" + ) + + def test_manual_override_same_units_at_start_no_conversion_applied(self): + """Start=0, both formats inches, -1.0 in → delta = -1.0in / factor * ratio.""" + wizard, bar, _, _, app = make_wizard(is_metric_format=False) + bar.start_position = 0 + wizard._isStartPositionMetricMode = False + wizard._startScaledPosition = 0.0 + wizard.manual_stop_length = -1.0 + app.formats.current_format = "IN" + app.formats.factor = Fraction(10, 254) + app.formats.INCHES_FRACTION = Fraction(10, 254) + + result = wizard._get_stop_position_units() + + expected = round(-1.0 / (10 / 254) * 6926) # ≈ -175921 + assert result == expected, f"Expected {expected}, got {result}" + + +# --------------------------------------------------------------------------- +# 19. Inch-mode coverage — _calculate_thread_depth, remaining TPI profiles +# --------------------------------------------------------------------------- + +class TestCalculateThreadDepthTpi: + """All four thread profiles with TPI input and inch display.""" + + def _w(self, pitch: str, profile: str, diameter_mode: bool = False): + wizard, bar, _, _, app = make_wizard(metric_mode=False, is_metric_format=False) + bar.selected_pitch = pitch + bar.thread_profile_type = profile + bar.metric_mode = False + app.els.at_cross_slide_diameter_mode = diameter_mode + return wizard + + def _pitch_mm(self, tpi: str) -> float: + return 25.4 / float(tpi) + + def test_unified_16_tpi_inch_display(self): + w = self._w("16", "Unified") + assert abs(w._calculate_thread_depth() - 0.64952 * self._pitch_mm("16")) < 0.001 + + def test_whitworth_16_tpi_inch_display(self): + w = self._w("16", "Whitworth") + assert abs(w._calculate_thread_depth() - 0.6403 * self._pitch_mm("16")) < 0.001 + + def test_acme_16_tpi_inch_display(self): + w = self._w("16", "ACME") + assert abs(w._calculate_thread_depth() - 0.5 * self._pitch_mm("16")) < 0.001 + + def test_diameter_mode_doubles_tpi_depth(self): + w = self._w("16", "ISO Metric", diameter_mode=True) + assert abs(w._calculate_thread_depth() - 0.61343 * self._pitch_mm("16") * 2) < 0.001 + + def test_zero_tpi_raises(self): + """Zero TPI → ZeroDivisionError: the code does `25.4 / tpi` before the + `pitch <= 0` guard, so zero TPI is not handled gracefully in this version. + This documents the deficiency — the fixed branch should return None instead.""" + w = self._w("0", "ISO Metric") + with pytest.raises(ZeroDivisionError): + w._calculate_thread_depth() diff --git a/tests/components/home/assisted_threading/test_motion.py b/tests/components/home/assisted_threading/test_motion.py new file mode 100644 index 0000000..b55aaad --- /dev/null +++ b/tests/components/home/assisted_threading/test_motion.py @@ -0,0 +1,133 @@ +""" +Unit tests for AssistedThreadingMotionMixin methods. +""" + +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 10. _encoder_is_stable — full state machine +# --------------------------------------------------------------------------- + +class TestEncoderIsStable: + def test_first_call_returns_false_and_initialises(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = 100 + wizard._reset_encoder_stability_check() + + assert wizard._encoder_is_stable(tolerance=5, samples=3) is False + assert wizard._last_saddle_encoder_value == 100 + assert wizard._stable_count == 0 + + def test_stable_for_n_samples_returns_true(self): + wizard, _, saddle_inp, _, _ = make_wizard() + wizard._reset_encoder_stability_check() + saddle_inp.encoderCurrent = 100 + + wizard._encoder_is_stable(5, 3) # initialise + wizard._encoder_is_stable(5, 3) + wizard._encoder_is_stable(5, 3) + result = wizard._encoder_is_stable(5, 3) + + assert result is True + assert wizard._stable_count >= 3 + + def test_jump_outside_tolerance_resets_count(self): + wizard, _, saddle_inp, _, _ = make_wizard() + wizard._reset_encoder_stability_check() + saddle_inp.encoderCurrent = 100 + + wizard._encoder_is_stable(5, 3) # initialise + wizard._encoder_is_stable(5, 3) # count=1 + + saddle_inp.encoderCurrent = 200 # jump > 5 + result = wizard._encoder_is_stable(5, 3) + + assert result is False + assert wizard._stable_count == 0 + + def test_within_tolerance_does_not_reset(self): + wizard, _, saddle_inp, _, _ = make_wizard() + wizard._reset_encoder_stability_check() + saddle_inp.encoderCurrent = 100 + + wizard._encoder_is_stable(5, 3) # init + saddle_inp.encoderCurrent = 103 # within 5 + wizard._encoder_is_stable(5, 3) # count=1 + saddle_inp.encoderCurrent = 105 # within 5 of 103 + wizard._encoder_is_stable(5, 3) # count=2 + + assert wizard._stable_count == 2 + + def test_reset_clears_state(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = 100 + wizard._encoder_is_stable(5, 3) # prime some state + + wizard._reset_encoder_stability_check() + + assert wizard._last_saddle_encoder_value is None + assert wizard._stable_count == 0 + + +# --------------------------------------------------------------------------- +# TestFinishGoToStart (new) +# --------------------------------------------------------------------------- + +class TestFinishGoToStart: + """Tests for _finish_go_to_start() in AssistedThreadingMotionMixin.""" + + def test_not_at_depth_goes_to_next_step(self): + """When cross slide is NOT at final cutting depth, advance by 1 step.""" + wizard, _, _, _, _ = make_wizard() + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._reset_servo_watch_callback = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=False) + + wizard._finish_go_to_start() + + wizard.goto_step.assert_called_once_with(6) + + def test_at_depth_skips_to_step_8(self): + """When cross slide IS at final cutting depth, skip step 7 → go to step 8.""" + wizard, _, _, _, _ = make_wizard() + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._reset_servo_watch_callback = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=True) + + wizard._finish_go_to_start() + + wizard.goto_step.assert_called_once_with(7) + + def test_sets_preloaded_flag(self): + """_start_position_preloaded must be True after the call.""" + wizard, _, _, _, _ = make_wizard() + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._reset_servo_watch_callback = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=False) + wizard._start_position_preloaded = False + + wizard._finish_go_to_start() + + assert wizard._start_position_preloaded is True + + def test_resets_servo_watch_callback(self): + """The servo watch callback must be cleared before advancing.""" + wizard, _, _, _, app = make_wizard() + cb = MagicMock() + wizard._servo_watch_callback = cb + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=False) + + wizard._finish_go_to_start() + + assert wizard._servo_watch_callback is None + app.board.unbind.assert_called_once_with(update_tick=cb) diff --git a/tests/components/home/assisted_threading/test_safety.py b/tests/components/home/assisted_threading/test_safety.py new file mode 100644 index 0000000..c7a66b0 --- /dev/null +++ b/tests/components/home/assisted_threading/test_safety.py @@ -0,0 +1,408 @@ +""" +Unit tests for AssistedThreadingSafetyMixin methods. +""" + +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 4. _is_valid_stop_position +# --------------------------------------------------------------------------- + +class TestIsValidStopPosition: + def test_rht_saddle_moved_past_cushion_returns_true(self): + """RHT, saddle moved 2672 counts (-0.39 mm) past start — beyond the + 0.1 mm cushion (693 counts), so the stop is valid.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = -92812 # 2672 counts past start in -ve dir + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + def test_rht_saddle_not_moved_enough_returns_false(self): + """RHT, saddle moved only 100 counts — less than 693-count cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = -90240 # only 100 counts past start + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is False + + def test_lht_stop_must_be_in_positive_direction(self): + """LHT: effective_dir=+1, so stop must be greater than start+cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=True) + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 5000 # well past cushion in +ve dir + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + def test_stop_exactly_at_min_stop_returns_false(self): + """Stop equal to min_stop gives (stop - min_stop)*dir == 0 → False.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # 693 counts + app.els.at_metric_distances = True + # effective_dir = -1, min_stop = 0 + (-1)*693 = -693 + saddle_inp.encoderCurrent = -693 # exactly at min_stop + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is False + + def test_axis_offset_does_not_affect_stop_validation(self): + """v1.3.0 fix: DRO zero offset on the axis must NOT inflate the cushion. + With offset=-12.885 a legitimate 2672-count stop must still be valid.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + axis = app.els.get_z_axis() + axis.offsets[0] = -12.885 # simulate DRO zero + saddle_inp.encoderCurrent = -92812 + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + +# --------------------------------------------------------------------------- +# 12. _is_cross_slide_retracted +# --------------------------------------------------------------------------- + +class TestIsCrossSlideRetracted: + def test_saddle_not_beyond_start_returns_true_without_checking_cross(self): + """Saddle hasn't moved past start → True (cross slide check skipped).""" + wizard, bar, saddle_inp, _, _ = make_wizard(left_hand_thread=False) + bar.start_position = 0 + # effective_dir = -1 (RHT), "beyond start" means negative encoder + saddle_inp.encoderCurrent = 100 # positive — NOT beyond start for RHT + + assert wizard._is_cross_slide_retracted() is True + + def test_saddle_beyond_start_cross_retracted_returns_true(self): + """Saddle past start; cross slide moved in retract direction → True.""" + wizard, bar, saddle_inp, cross_inp, _ = make_wizard( + left_hand_thread=False, # saddle effective_dir=-1 + inner_thread=False, # cross effective_dir=-1, retract_dir=+1 + ) + bar.start_position = 0 + saddle_inp.encoderCurrent = -1000 # beyond start (RHT) + bar.material_width = 0 + cross_inp.encoderCurrent = 500 # moved +500 from material_width → retracted + + assert wizard._is_cross_slide_retracted() is True + + def test_saddle_beyond_start_cross_not_retracted_returns_false(self): + """Saddle past start; cross slide still in cutting direction → False.""" + wizard, bar, saddle_inp, cross_inp, _ = make_wizard( + left_hand_thread=False, + inner_thread=False, + ) + bar.start_position = 0 + saddle_inp.encoderCurrent = -1000 + bar.material_width = 0 + cross_inp.encoderCurrent = -500 # moved in cutting direction → not retracted + + assert wizard._is_cross_slide_retracted() is False + + +# --------------------------------------------------------------------------- +# 13. _is_cross_slide_at_final_cutting_depth +# --------------------------------------------------------------------------- + +class TestIsCrossSlideAtFinalCuttingDepth: + def test_at_depth_returns_true(self): + """External thread (inner=False, ratio +ve) → effective_dir=-1. + last_cutting_depth more negative than cutting_depth → at depth.""" + wizard, bar, _, cross, _ = make_wizard(inner_thread=False) + bar.last_cutting_depth = -1000 # deeper (more negative) + bar.cutting_depth = -800 + + # (last - cutting) * dir = (-1000 - -800) * -1 = 200 >= 0 → True + assert wizard._is_cross_slide_at_final_cutting_depth() is True + + def test_not_yet_at_depth_returns_false(self): + wizard, bar, _, cross, _ = make_wizard(inner_thread=False) + bar.last_cutting_depth = -500 + bar.cutting_depth = -800 + + # (-500 - -800) * -1 = -300 < 0 → False + assert wizard._is_cross_slide_at_final_cutting_depth() is False + + def test_exactly_at_depth_returns_true(self): + """Equal values → difference is 0 → 0 * dir = 0 >= 0 → True.""" + wizard, bar, _, cross, _ = make_wizard(inner_thread=False) + bar.last_cutting_depth = -800 + bar.cutting_depth = -800 + + assert wizard._is_cross_slide_at_final_cutting_depth() is True + + def test_internal_thread_flips_direction(self): + """Internal thread → effective_dir=+1, so cutting goes in + direction.""" + wizard, bar, _, cross, _ = make_wizard(inner_thread=True) + bar.last_cutting_depth = 1000 # deeper in + direction + bar.cutting_depth = 800 + + # (1000 - 800) * 1 = 200 >= 0 → True + assert wizard._is_cross_slide_at_final_cutting_depth() is True + + +# --------------------------------------------------------------------------- +# 14. _check_spindle_speed_for_pitch +# --------------------------------------------------------------------------- + +class TestCheckSpindleSpeedForPitch: + """Uses servo ratioDen=6926 (same as scale) so scale/servo ratio = 1.""" + + def _make_spindle_wizard(self, spindle_speed: int, pitch: str = "1.5", + max_speed: int = 2000): + wizard, bar, saddle_inp, _, app = make_wizard() + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + + spindle_inp = MagicMock() + spindle_inp.ratioDen = 1000 # 1000 counts per revolution + spindle_inp.inputIndex = 0 + spindle_axis = MagicMock() + spindle_axis._primary_input.return_value = spindle_inp + app.els.get_spindle_axis.return_value = spindle_axis + app.board.fast_data_values = {"scaleSpeed": [spindle_speed, 0, 0, 0]} + + bar.selected_pitch = pitch + bar.metric_mode = True + app.els.at_threading_max_speed = max_speed + saddle_inp.stepsPerMM = 6926 # already set by factory but make explicit + + return wizard + + def test_spindle_too_fast_returns_false(self): + """300 steps/s / 1000 cpr = 0.3 rev/s × 1.5mm × 6926 = 3117 steps/s > 2000.""" + wizard = self._make_spindle_wizard(spindle_speed=300) + assert wizard._check_spindle_speed_for_pitch() is False + + def test_spindle_within_limit_returns_true(self): + """100 steps/s / 1000 = 0.1 rev/s × 1.5mm × 6926 = 1039 steps/s < 2000.""" + wizard = self._make_spindle_wizard(spindle_speed=100) + assert wizard._check_spindle_speed_for_pitch() is True + + def test_no_spindle_scale_returns_true(self): + """No spindle configured → skip check.""" + wizard, _, _, _, app = make_wizard() + app.els.get_spindle_axis.return_value = None + + assert wizard._check_spindle_speed_for_pitch() is True + + def test_empty_pitch_returns_true(self): + """Empty pitch string → skip check.""" + wizard = self._make_spindle_wizard(spindle_speed=300, pitch="") + assert wizard._check_spindle_speed_for_pitch() is True + + +# --------------------------------------------------------------------------- +# 17. Inch-mode coverage — _is_valid_stop_position with inch cushion +# --------------------------------------------------------------------------- + +class TestIsValidStopPositionInches: + def test_rht_inch_cushion_saddle_past_cushion_returns_true(self): + """RHT, saddle moved 2672 counts. 0.004 inch cushion ≈ 704 counts → valid.""" + wizard, bar, saddle_inp, _, app = make_wizard( + left_hand_thread=False, is_metric_format=False + ) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + saddle_inp.encoderCurrent = -92812 # 2672 counts past start + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + def test_rht_inch_cushion_saddle_not_moved_enough_returns_false(self): + """RHT, saddle moved only 100 counts — less than the 704-count cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard( + left_hand_thread=False, is_metric_format=False + ) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + saddle_inp.encoderCurrent = -90240 # only 100 counts + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is False + + def test_lht_inch_cushion_valid_stop_in_positive_direction(self): + """LHT (effective_dir=+1), stop 5000 counts in +ve direction, 704-count cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard( + left_hand_thread=True, is_metric_format=False + ) + bar.start_position = 0 + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + saddle_inp.encoderCurrent = 5000 + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + +# --------------------------------------------------------------------------- +# 20. Inch-mode coverage — spindle speed check with TPI pitch +# --------------------------------------------------------------------------- + +class TestCheckSpindleSpeedForPitchTpi: + def _make_tpi_wizard(self, spindle_speed: int, tpi: str = "16", + max_speed: int = 2000): + wizard, bar, saddle_inp, _, app = make_wizard() + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + + spindle_inp = MagicMock() + spindle_inp.ratioDen = 1000 + spindle_inp.inputIndex = 0 + spindle_axis = MagicMock() + spindle_axis._primary_input.return_value = spindle_inp + app.els.get_spindle_axis.return_value = spindle_axis + app.board.fast_data_values = {"scaleSpeed": [spindle_speed, 0, 0, 0]} + + bar.selected_pitch = tpi + bar.metric_mode = False + app.els.at_threading_max_speed = max_speed + saddle_inp.stepsPerMM = 6926 + + return wizard + + def test_tpi_pitch_too_fast_returns_false(self): + """300 steps/s / 1000 cpr = 0.3 rev/s × 1.5875mm × 6926 = 3298 steps/s > 2000.""" + wizard = self._make_tpi_wizard(spindle_speed=300) + assert wizard._check_spindle_speed_for_pitch() is False + + def test_tpi_pitch_within_limit_returns_true(self): + """50 steps/s / 1000 = 0.05 rev/s × 1.5875mm × 6926 = 550 steps/s < 2000.""" + wizard = self._make_tpi_wizard(spindle_speed=50) + assert wizard._check_spindle_speed_for_pitch() is True + + def test_tpi_boundary_exactly_at_limit_returns_true(self): + """Required == max_speed: condition is `required > max`, so equal → True.""" + wizard = self._make_tpi_wizard(spindle_speed=181) + assert wizard._check_spindle_speed_for_pitch() is True + + def test_non_numeric_tpi_skips_check(self): + """Unparseable TPI value → skips check and returns True.""" + wizard = self._make_tpi_wizard(spindle_speed=9999, tpi="bad") + assert wizard._check_spindle_speed_for_pitch() is True + + +# --------------------------------------------------------------------------- +# TestCheckValidStartPosition (new) +# --------------------------------------------------------------------------- + +class TestCheckValidStartPosition: + """Tests for _check_valid_start_position() in AssistedThreadingSafetyMixin.""" + + def test_within_cushion_returns_true(self): + """Saddle delta < backlash cushion → True, no popup opened.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, saddle_inp, _, app = make_wizard() + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # ~693 counts + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 100 # 100 counts delta — well within cushion + + result = wizard._check_valid_start_position() + + assert result is True + CustomPopup.assert_not_called() + + def test_outside_cushion_returns_false_and_opens_popup(self): + """Saddle delta > backlash cushion → False, CustomPopup instantiated.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, saddle_inp, _, app = make_wizard() + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # ~693 counts + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 5000 # 5000 counts — far outside cushion + + result = wizard._check_valid_start_position() + + assert result is False + CustomPopup.assert_called_once() + + def test_exactly_at_boundary_returns_true(self): + """delta == cushion → True (condition is strict `>`, not `>=`).""" + wizard, bar, saddle_inp, _, app = make_wizard() + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # 693 counts + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 693 # exactly at cushion boundary + + result = wizard._check_valid_start_position() + + assert result is True + + +# --------------------------------------------------------------------------- +# TestCheckSpindleTurningForward (new) +# --------------------------------------------------------------------------- + +class TestCheckSpindleTurningForward: + """Tests for _check_spindle_turning_forward() in AssistedThreadingSafetyMixin.""" + + def _setup(self, spindle_speed: int): + wizard, _, _, _, app = make_wizard() + spindle_inp = MagicMock() + spindle_inp.inputIndex = 0 + spindle_axis = MagicMock() + spindle_axis._primary_input.return_value = spindle_inp + app.els.get_spindle_axis.return_value = spindle_axis + app.board.fast_data_values = { + "scaleSpeed": [spindle_speed, 0, 0, 0], + "stepsToGo": 0, + "servoCurrent": 0, + } + return wizard + + def test_positive_speed_returns_true(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard = self._setup(spindle_speed=100) + assert wizard._check_spindle_turning_forward() is True + CustomPopup.assert_not_called() + + def test_zero_speed_returns_false(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard = self._setup(spindle_speed=0) + assert wizard._check_spindle_turning_forward() is False + CustomPopup.assert_called_once() + + def test_negative_speed_returns_false(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard = self._setup(spindle_speed=-50) + assert wizard._check_spindle_turning_forward() is False + CustomPopup.assert_called_once() + + def test_no_spindle_axis_returns_false(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, _, _, _, app = make_wizard() + app.els.get_spindle_axis.return_value = None + + assert wizard._check_spindle_turning_forward() is False + CustomPopup.assert_called_once() diff --git a/tests/components/home/assisted_threading/test_wizard.py b/tests/components/home/assisted_threading/test_wizard.py new file mode 100644 index 0000000..07b20a3 --- /dev/null +++ b/tests/components/home/assisted_threading/test_wizard.py @@ -0,0 +1,204 @@ +""" +Unit tests for AssistedThreadingWizard core methods (lifecycle, step management). +""" + +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 5. _capture_initial_position +# --------------------------------------------------------------------------- + +class TestCaptureInitialPosition: + def test_records_encoder_and_scaled_position(self): + wizard, bar, saddle_inp, _, app = make_wizard() + saddle_inp.encoderCurrent = -90140 + app.els.get_z_axis().scaledPosition = -13.0 # scaledPosition on AxisDispatcher + + wizard._capture_initial_position() + + assert bar.start_position == -90140 + assert wizard._isStartPositionMetricMode is True + assert wizard._startScaledPosition == -13.0 + + def test_records_inch_format_flag(self): + wizard, bar, saddle_inp, _, _ = make_wizard(is_metric_format=False) + saddle_inp.encoderCurrent = 500 + + wizard._capture_initial_position() + + assert wizard._isStartPositionMetricMode is False + assert bar.start_position == 500 + + def test_returns_true_to_advance_step(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = 0 + + result = wizard._capture_initial_position() + + assert result is True + + +# --------------------------------------------------------------------------- +# 6. _capture_material_width_position +# --------------------------------------------------------------------------- + +class TestCaptureMaterialWidthPosition: + def test_records_cross_encoder_as_material_width(self): + wizard, bar, _, cross_inp, _ = make_wizard() + cross_inp.encoderCurrent = 5000 + + wizard._capture_material_width_position() + + assert bar.material_width == 5000 + + def test_initialises_last_cutting_depth_to_material_width(self): + wizard, bar, _, cross_inp, _ = make_wizard() + cross_inp.encoderCurrent = 5000 + + wizard._capture_material_width_position() + + assert bar.last_cutting_depth == 5000 + + def test_returns_true_to_advance_step(self): + wizard, _, _, cross_inp, _ = make_wizard() + cross_inp.encoderCurrent = 0 + + result = wizard._capture_material_width_position() + + assert result is True + + +# --------------------------------------------------------------------------- +# 15. stop() — resets all wizard state +# --------------------------------------------------------------------------- + +class TestStop: + def test_stop_resets_running_state(self): + wizard, _, _, _, app = make_wizard() + app.board.connected = True + wizard._threading_started = True + wizard._current_callback = MagicMock() + + wizard.stop() + + assert wizard._threading_started is False + assert wizard._current_callback is None + + def test_stop_clears_bar_labels(self): + wizard, bar, _, _, app = make_wizard() + app.board.connected = False + + wizard.stop() + + assert bar.label_text == "" + assert bar.action_button_condition_fn is None + assert bar.retract_button_visible is False + + def test_stop_when_disconnected_does_not_write_device(self): + wizard, _, _, _, app = make_wizard() + app.board.connected = False + + wizard.stop() + + app.board.device.__getitem__.assert_not_called() + + def test_stop_resets_servo_watch_callback(self): + wizard, _, _, _, app = make_wizard() + app.board.connected = False + cb = MagicMock() + wizard._servo_watch_callback = cb + + wizard.stop() + + assert wizard._servo_watch_callback is None + app.board.unbind.assert_called_once_with(update_tick=cb) + + +# --------------------------------------------------------------------------- +# TestGotoNextStep (new) +# --------------------------------------------------------------------------- + +class TestGotoNextStep: + """Tests for goto_next_step() in AssistedThreadingWizard.""" + + def test_callback_true_advances(self): + """Callback returns True + bar.is_running=True → goto_step(current+1).""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 3 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = MagicMock(return_value=True) + + wizard.goto_next_step() + + wizard.goto_step.assert_called_once_with(4) + + def test_callback_false_stays(self): + """Callback returns False → step must NOT advance.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 3 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = MagicMock(return_value=False) + + wizard.goto_next_step() + + wizard.goto_step.assert_not_called() + + def test_callback_none_advances(self): + """Callback returns None (not False) → advances.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 2 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = MagicMock(return_value=None) + + wizard.goto_next_step() + + wizard.goto_step.assert_called_once_with(3) + + def test_no_callback_advances(self): + """No callback set → advances directly.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 1 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = None + + wizard.goto_next_step() + + wizard.goto_step.assert_called_once_with(2) + + def test_bar_not_running_does_not_advance(self): + """Callback returns True but bar.is_running=False → no advance.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 3 + wizard.goto_step = MagicMock() + bar.is_running = False + wizard._current_callback = MagicMock(return_value=True) + + wizard.goto_next_step() + + wizard.goto_step.assert_not_called() + + def test_past_last_step_calls_stop(self): + """Advancing past the last valid step index triggers stop().""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 7 # last index (0-based, 8 steps) + bar.is_running = True + wizard._current_callback = MagicMock(return_value=True) + + # goto_step(8) calls stop() because 8 >= len(self._steps) + # We need the real goto_step but mock stop() + wizard.stop = MagicMock() + # Rebuild the steps list so goto_step works without Kivy + wizard._steps = [MagicMock() for _ in range(8)] + + wizard.goto_next_step() + + wizard.stop.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..da9ea36 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +""" +Shared pytest configuration. + +Installs lightweight stubs into sys.modules before any test module imports +code that depends on Kivy. This lets pure-logic unit tests run without a +display or the full Kivy runtime. + +Strategy +-------- +* Stub only *specific* submodules (e.g. ``kivy.logger``), never the top-level + ``kivy`` package itself. Replacing ``sys.modules["kivy"]`` with a plain + ModuleType makes it "not a package", which prevents *all* kivy submodule + imports and breaks tests that need real Kivy (dispatchers, screens, etc.). +* Stub ``rcp.*`` modules whose import chains would otherwise drag in Kivy UI + classes (boxlayout, popup, etc.) that require a display or OpenGL. +* Leave ``rcp.utils.devices`` un-stubbed: it only uses kivy.logger (stubbed + above) and is safe to import headlessly. +""" + +import sys +import types +from unittest.mock import MagicMock + + +def _install_kivy_stubs() -> None: + """Populate sys.modules with minimal stubs.""" + + def _mock_module(name: str, **attrs) -> types.ModuleType: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + return mod + + # kivy.logger — used by every rcp module for Logger.getChild(...) + # Stub the submodule directly; do NOT replace sys.modules["kivy"] itself + # (that would make kivy "not a package" and break dispatcher/screen tests). + mock_logger = MagicMock() + mock_logger.getChild.side_effect = lambda name: MagicMock() + _mock_module( + "kivy.logger", + Logger=mock_logger, + LOG_LEVELS={}, + logger_config_update=MagicMock(), + file_log_handler=MagicMock(), + ) + + # rcp.components.widgets.custom_popup — stubbing the whole module prevents + # its import chain (kivy.properties, kivy.uix.*) from running, which + # would fail without a display. + _mock_module( + "rcp.components.widgets.custom_popup", + CustomPopup=MagicMock(), + ) + + +_install_kivy_stubs() diff --git a/uv.lock b/uv.lock index 3fff04c..08cd916 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <4.0" [[package]] @@ -1490,7 +1490,7 @@ wheels = [ [[package]] name = "rcp" -version = "1.3.0" +version = "1.3.0rc22" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 9448b53cc04233048e3f9dbdd481b31a0182dc97 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Tue, 7 Apr 2026 18:34:31 +0200 Subject: [PATCH 56/62] Added logic for compound slide cutting - requires testing --- rcp/components/home/assisted_threading/bar.py | 3 + .../home/assisted_threading/calculations.py | 85 +++++ .../home/assisted_threading/safety.py | 23 ++ .../home/assisted_threading/settings_popup.kv | 29 ++ .../home/assisted_threading/settings_popup.py | 4 + .../home/assisted_threading/wizard.py | 82 +++-- .../home/assisted_threading/conftest.py | 2 + .../test_compound_infeed.py | 326 ++++++++++++++++++ 8 files changed, 533 insertions(+), 21 deletions(-) create mode 100644 tests/components/home/assisted_threading/test_compound_infeed.py diff --git a/rcp/components/home/assisted_threading/bar.py b/rcp/components/home/assisted_threading/bar.py index 9ab4746..9a382b7 100644 --- a/rcp/components/home/assisted_threading/bar.py +++ b/rcp/components/home/assisted_threading/bar.py @@ -24,6 +24,9 @@ class AssistedThreadingBar(BoxLayout, SavingDispatcher): left_hand_thread = BooleanProperty(False) inner_thread = BooleanProperty(False) + compound_infeed_mode = BooleanProperty(False) + compound_infeed_offset_degrees = NumericProperty(1.0) + is_running = BooleanProperty(False) action_button_enabled = BooleanProperty(True) label_text = StringProperty("") diff --git a/rcp/components/home/assisted_threading/calculations.py b/rcp/components/home/assisted_threading/calculations.py index d8d345e..f4bef7b 100644 --- a/rcp/components/home/assisted_threading/calculations.py +++ b/rcp/components/home/assisted_threading/calculations.py @@ -1,4 +1,5 @@ from fractions import Fraction +from math import radians, tan from kivy.logger import Logger @@ -8,6 +9,14 @@ MM_PER_INCH = 25.4 +# Thread half-angles in degrees, keyed by ThreadType +_THREAD_HALF_ANGLES: dict[ThreadType, float] = { + ThreadType.ISO_METRIC: 30.0, + ThreadType.UNIFIED: 30.0, + ThreadType.WHITWORTH: 27.5, + ThreadType.ACME: 14.5, +} + class AssistedThreadingCalculationsMixin: # --------------------------------------------------------------------------- @@ -211,13 +220,26 @@ def _calculate_thread_depth(self): thread_type = ThreadType(self.bar.thread_profile_type) if thread_type == ThreadType.ISO_METRIC: + # ISO 68-1 (60°): H = (√3/2) * pitch; + # actual thread depth ≈ 0.61343 * pitch depth = 0.61343 * pitch + elif thread_type == ThreadType.UNIFIED: + # ASME B1.1 (60°): H = (√3/2) * pitch; + # actual thread depth from truncation ≈ 0.64952 * pitch depth = 0.64952 * pitch + elif thread_type == ThreadType.WHITWORTH: + # BSW (55°): rounded crest/root; + # theoretical H ≈ 0.9605 * pitch; + # actual thread depth ≈ 0.6403 * pitch depth = 0.6403 * pitch + elif thread_type == ThreadType.ACME: + # ASME B1.5 (29°): trapezoidal profile; + # basic thread height ≈ 0.5 * pitch depth = 0.5 * pitch + else: log.warning(f"Unknown thread profile: {thread_type}") return None @@ -238,3 +260,66 @@ def _calculate_thread_depth(self): log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") return depth + + # --------------------------------------------------------------------------- + # Compound infeed + # --------------------------------------------------------------------------- + + def _get_compound_angle_degrees(self) -> float: + """ + Return the effective compound angle in degrees for the selected thread type. + = thread half-angle − user-configured offset (default 1°). + """ + try: + thread_type = ThreadType(self.bar.thread_profile_type) + except ValueError: + log.warning(f"Unknown thread profile for compound angle: {self.bar.thread_profile_type}") + return 29.0 # safe fallback (ISO metric at 1° offset) + + half_angle = _THREAD_HALF_ANGLES.get(thread_type, 30.0) + offset = float(self.bar.compound_infeed_offset_degrees) + effective = half_angle - offset + log.info(f"Compound angle: half_angle={half_angle}°, offset={offset}°, effective={effective}° (thread_type={thread_type})") + return effective + + def _get_compound_z_offset_encoder(self) -> int: + """ + Compute the saddle (Z) encoder shift for compound infeed. + + ΔZ = ΔX_mm × tan(compound_angle) + + ΔX is the absolute physical depth from the material surface. + abs() is intentional: outer threading moves X inward (negative encoder delta), + inner threading moves X outward (positive delta). Physical cut depth is always + a positive magnitude. Direction is applied by the caller via + _get_saddle_scale_effective_dir(). + + Returns 0 if compound infeed is disabled or the cross-slide is at material width. + """ + if not self.bar.compound_infeed_mode: + return 0 + + delta_x_enc = abs(self.cross_slide_input.encoderCurrent - self.bar.material_width) + if delta_x_enc == 0: + return 0 + + # Convert X encoder delta to mm + cross_inp = self.cross_slide_input + # encoder_factor converts encoder counts to mm (same factor used in formattedPosition) + encoder_factor = float(self.app.formats.MM_FRACTION) + cross_scale_factor = float(cross_inp.ratioDen) / float(cross_inp.ratioNum) if cross_inp.ratioNum != 0 else 1.0 + delta_x_mm = abs(delta_x_enc * encoder_factor / cross_scale_factor) + + compound_angle = self._get_compound_angle_degrees() + delta_z_mm = delta_x_mm * tan(radians(compound_angle)) + + # abs() because _convert_distance_units_to_encoder is signed (reflects scale ratioNum sign). + # We return a positive magnitude; direction is applied by the caller via _get_saddle_scale_effective_dir(). + z_encoder = abs(self._convert_distance_units_to_encoder(self.saddle_scale, delta_z_mm, is_metric=True)) + + log.info( + f"Compound Z offset: delta_x_enc={delta_x_enc}, delta_x_mm={delta_x_mm:.4f}, " + f"compound_angle={compound_angle:.2f}°, delta_z_mm={delta_z_mm:.4f}, " + f"z_encoder={z_encoder}" + ) + return z_encoder diff --git a/rcp/components/home/assisted_threading/safety.py b/rcp/components/home/assisted_threading/safety.py index c1154a8..4df7a5f 100644 --- a/rcp/components/home/assisted_threading/safety.py +++ b/rcp/components/home/assisted_threading/safety.py @@ -60,6 +60,29 @@ def _is_cross_slide_at_final_cutting_depth(self): # Pre-threading safety checks # --------------------------------------------------------------------------- + def _check_saddle_not_past_stop(self) -> bool: + """Return True if the saddle has not moved past (or to) the stop position. + Protects against the compound infeed ΔZ shift consuming all remaining thread length. + Shows a warning popup and redirects to step 6 if not.""" + effective_dir = self._get_saddle_scale_effective_dir() + current_saddle = self.saddle_input.encoderCurrent + remaining = (self.bar.stop_position - current_saddle) * effective_dir + if remaining <= 0: + log.warning( + f"Saddle at or past stop position " + f"(current={current_saddle}, stop={self.bar.stop_position}, " + f"effective_dir={effective_dir}) — aborting threading" + ) + CustomPopup( + title="Warning", + message="Compound infeed shift has consumed the remaining thread length. " + "Reduce infeed depth or increase thread start clearance.", + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + def _check_valid_start_position(self) -> bool: """Return True if the saddle is within the backlash cushion of the start position. Shows a warning popup and redirects to step 6 if not. Sanity check in case the diff --git a/rcp/components/home/assisted_threading/settings_popup.kv b/rcp/components/home/assisted_threading/settings_popup.kv index 96ebd51..6902b9b 100644 --- a/rcp/components/home/assisted_threading/settings_popup.kv +++ b/rcp/components/home/assisted_threading/settings_popup.kv @@ -45,3 +45,32 @@ name: "Inner Thread" value: root.assistedThreadingBar.inner_thread on_value: root.assistedThreadingBar.inner_thread = self.value + + BooleanItem: + name: "Compound Infeed" + value: root.assistedThreadingBar.compound_infeed_mode + on_value: root.on_compound_infeed_mode_changed(self.value) + + BoxLayout: + size_hint_y: None + height: 60 if root.assistedThreadingBar.compound_infeed_mode else 0 + opacity: 1 if root.assistedThreadingBar.compound_infeed_mode else 0 + disabled: not root.assistedThreadingBar.compound_infeed_mode + orientation: "horizontal" + Label: + size_hint_x: 0.45 + text: "Compound Offset" + font_name: "fonts/iosevka-regular.ttf" + font_size: 24 + Slider: + size_hint_x: 0.40 + min: 0 + max: 5 + step: 1 + value: root.assistedThreadingBar.compound_infeed_offset_degrees + on_value: root.assistedThreadingBar.compound_infeed_offset_degrees = self.value + Label: + size_hint_x: 0.15 + text: "{}°".format(int(root.assistedThreadingBar.compound_infeed_offset_degrees)) + font_name: "fonts/iosevka-regular.ttf" + font_size: 28 diff --git a/rcp/components/home/assisted_threading/settings_popup.py b/rcp/components/home/assisted_threading/settings_popup.py index e5720a7..a9771d7 100644 --- a/rcp/components/home/assisted_threading/settings_popup.py +++ b/rcp/components/home/assisted_threading/settings_popup.py @@ -63,3 +63,7 @@ def on_thread_type_selected(self, value): log.info(f"Selected thread type: {thread_type}") except ValueError: log.warning(f"Invalid thread type value: {value}") + + def on_compound_infeed_mode_changed(self, value): + self.assistedThreadingBar.compound_infeed_mode = value + log.info(f"Compound infeed mode changed to: {value}") diff --git a/rcp/components/home/assisted_threading/wizard.py b/rcp/components/home/assisted_threading/wizard.py index 8331010..92c6899 100644 --- a/rcp/components/home/assisted_threading/wizard.py +++ b/rcp/components/home/assisted_threading/wizard.py @@ -54,7 +54,6 @@ def __init__(self, bar): self.current_step = 0 self._threading_started = False self._threading_active_confirmed = False - self._calculated_threading_delta_steps = 0 self._current_callback = None self._servo_watch_callback = None self.manual_stop_length = None @@ -283,7 +282,7 @@ def _capture_final_cutting_depth_position(self, *args): def _start_threading_operation(self, *args): if not self.app.board.connected: self.stop() - return False # tell goto_next_step not to advance immediately + return False if not self._start_position_preloaded: log.warning("Threading requested without start preload") @@ -300,38 +299,67 @@ def _start_threading_operation(self, *args): return False log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) - self.bar.last_cutting_depth = self.cross_slide_input.encoderCurrent # Update last cutting depth to current position + self.bar.last_cutting_depth = self.cross_slide_input.encoderCurrent + self.bar.action_button_enabled = False + self.bar.retract_button_visible = False + compound_z_offset = self._get_compound_z_offset_encoder() + + if compound_z_offset != 0: + # Compound infeed mode: physically shift the saddle by ΔZ before latching + target = self.bar.start_position + self._get_saddle_scale_effective_dir() * compound_z_offset + log.info(f"Compound infeed: shifting saddle by {compound_z_offset} encoder counts to {target}") + self._apply_reversing_adjusting_acceleration() + self._command_move_to_encoder(target, speed=self.app.els.at_preload_adjust_speed) + self._servo_watch_callback = self._watch_compound_z_move_done + self.app.board.bind(update_tick=self._servo_watch_callback) + else: + self._prepare_and_send_thread_latch() + + return False + + def _watch_compound_z_move_done(self, *_): + if not self._motion_complete(): + return + self._reset_servo_watch_callback() + log.info("Compound Z move complete, switching to threading acceleration and latching spindle") + self._prepare_and_send_thread_latch() + + def _prepare_and_send_thread_latch(self): + """Apply threading parameters, bind UI to servo, and send the latch command.""" self._apply_threading_acceleration() self._apply_threading_max_speed() - self.bar.bind_display_value_to_servo_position() # Bind UI to servo position so progress/pos displays scaledPosition - self.bar.action_button_enabled = False # Disable action button during threading - self.bar.retract_button_visible = False # Hide retract button during threading + self.bar.bind_display_value_to_servo_position() + self._send_thread_latch() + + def _send_thread_latch(self): + """Write threading registers to firmware. threadRemainingSteps is calculated + fresh from the current saddle position every pass.""" + if not self._check_saddle_not_past_stop(): + return - # Write the fields into firmware via modbus/device wrapper + threading_delta_steps = self._get_threading_servo_delta_steps() dev = self.app.board.device - # Request latch+wait. Firmware will latch current spindle phase and wait until matched. - if (self._threading_started is False): - # First time starting threading - latch phase and enable + if not self._threading_started: self._threading_started = True self._threading_active_confirmed = False - self._calculated_threading_delta_steps = self._get_threading_servo_delta_steps() # Calculate threading delta steps - we only calculate it once including backlash - dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps + dev['assistedThreadingData']['threadRemainingSteps'] = threading_delta_steps dev['assistedThreadingData']['threadRequest'] = 1 else: self._threading_active_confirmed = False - dev['assistedThreadingData']['threadRemainingSteps'] = self._calculated_threading_delta_steps - dev['assistedThreadingData']['threadEnabled'] = 1 # Continue threading from previous state + dev['assistedThreadingData']['threadRemainingSteps'] = threading_delta_steps + dev['assistedThreadingData']['threadEnabled'] = 1 - log.info(f"Threading requested: threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, servoCurrent={self.app.board.fast_data_values['servoCurrent']}, calculatedDeltaSteps={self._calculated_threading_delta_steps}") + log.info( + f"Threading latch sent: threadRemainingSteps={threading_delta_steps}, " + f"servoCurrent={self.app.board.fast_data_values['servoCurrent']}, " + f"saddle_current={self.saddle_input.encoderCurrent}, stop={self.bar.stop_position}" + ) - # Watch until done - then go back to step 6 (Go to start) self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) self.app.board.bind(update_tick=self._servo_watch_callback) - return False # tell goto_next_step not to advance immediately - def _check_servo_threading_done(self, next_step: int, *args): dev = self.app.board.device dev['assistedThreadingData'].refresh() @@ -495,10 +523,22 @@ def on_cross_slide_update(instance, value): final_depth_encoder = current_encoder - self.bar.cutting_depth if self.bar.inner_thread else self.bar.cutting_depth - current_encoder remaining_display = final_depth_encoder * scale_ratio - if is_metric: - self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" + if self.bar.compound_infeed_mode: + # Show compound Z offset in saddle display units + z_enc = self._get_compound_z_offset_encoder() + saddle_inp = self.saddle_input + saddle_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) + saddle_scale_factor = abs(float(saddle_inp.ratioDen) / float(saddle_inp.ratioNum)) if saddle_inp.ratioNum != 0 else 1.0 + z_display = z_enc * saddle_factor / saddle_scale_factor + if is_metric: + self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f} | Z+{z_display:.3f}" + else: + self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f} | Z+{z_display:.4f}" else: - self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" + if is_metric: + self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" + else: + self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" log.debug(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") except Exception as e: log.error(f"Error updating threading progress display: {e}") diff --git a/tests/components/home/assisted_threading/conftest.py b/tests/components/home/assisted_threading/conftest.py index 789637b..2a0ae55 100644 --- a/tests/components/home/assisted_threading/conftest.py +++ b/tests/components/home/assisted_threading/conftest.py @@ -66,6 +66,8 @@ def make_wizard( bar.reversing_speed = 500 bar.selected_pitch = "1.5" bar.thread_profile_type = "ISO Metric" + bar.compound_infeed_mode = False + bar.compound_infeed_offset_degrees = 1.0 saddle_inp = _make_input(ratioNum, ratioDen, saddle_encoderCurrent) cross_inp = _make_input(ratioNum, ratioDen, cross_encoderCurrent) diff --git a/tests/components/home/assisted_threading/test_compound_infeed.py b/tests/components/home/assisted_threading/test_compound_infeed.py new file mode 100644 index 0000000..b1d4538 --- /dev/null +++ b/tests/components/home/assisted_threading/test_compound_infeed.py @@ -0,0 +1,326 @@ +""" +Unit tests for compound infeed mode calculations. + +Covers: +- _get_compound_angle_degrees: all thread types, offset subtraction +- _get_compound_z_offset_encoder: outer/inner magnitude equality, zero at surface, known value +- Direction: saddle shift sign for RHT/LHT and positive/negative scale +- _get_threading_servo_delta_steps: recalculated per pass, decreases when saddle shifts +- Stop-overshoot guard in _send_thread_latch +""" + +from math import radians, tan +from fractions import Fraction +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard +from rcp.components.home.assisted_threading.thread_type import ThreadType + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _w_compound( + thread_type: str = "ISO Metric", + offset_degrees: float = 1.0, + cross_encoderCurrent: int = 0, + material_width: int = 0, + inner_thread: bool = False, + left_hand_thread: bool = False, + ratioNum: int = 1, + ratioDen: int = 6926, +): + """Wizard with compound infeed enabled.""" + wizard, bar, saddle_inp, cross_inp, app = make_wizard( + ratioNum=ratioNum, + ratioDen=ratioDen, + cross_encoderCurrent=cross_encoderCurrent, + inner_thread=inner_thread, + left_hand_thread=left_hand_thread, + ) + bar.thread_profile_type = thread_type + bar.compound_infeed_mode = True + bar.compound_infeed_offset_degrees = offset_degrees + bar.material_width = material_width + return wizard, bar, saddle_inp, cross_inp, app + + +# --------------------------------------------------------------------------- +# 1. _get_compound_angle_degrees +# --------------------------------------------------------------------------- + +class TestGetCompoundAngleDegrees: + def test_iso_metric_default_offset(self): + """ISO Metric: half-angle=30°, offset=1° → effective=29°.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(29.0) + + def test_unified_default_offset(self): + """Unified: half-angle=30°, offset=1° → effective=29°.""" + wizard, bar, *_ = _w_compound("Unified", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(29.0) + + def test_whitworth_default_offset(self): + """Whitworth: half-angle=27.5°, offset=1° → effective=26.5°.""" + wizard, bar, *_ = _w_compound("Whitworth", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(26.5) + + def test_acme_default_offset(self): + """ACME: half-angle=14.5°, offset=1° → effective=13.5°.""" + wizard, bar, *_ = _w_compound("ACME", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(13.5) + + def test_zero_offset(self): + """Zero offset → effective angle equals the half-angle.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=0.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(30.0) + + def test_max_offset(self): + """Max slider value (5°) → effective = 25° for ISO Metric.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=5.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(25.0) + + def test_fractional_offset(self): + """2.5° offset → 27.5° effective for ISO Metric.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=2.5) + assert wizard._get_compound_angle_degrees() == pytest.approx(27.5) + + +# --------------------------------------------------------------------------- +# 2. _get_compound_z_offset_encoder — magnitude +# --------------------------------------------------------------------------- + +class TestGetCompoundZOffsetEncoder: + def test_disabled_returns_zero(self): + """compound_infeed_mode=False → always 0.""" + wizard, bar, saddle_inp, cross_inp, app = make_wizard(cross_encoderCurrent=-693) + bar.material_width = 0 + bar.compound_infeed_mode = False + bar.compound_infeed_offset_degrees = 1.0 + bar.thread_profile_type = "ISO Metric" + assert wizard._get_compound_z_offset_encoder() == 0 + + def test_at_material_surface_returns_zero(self): + """Cross-slide at material_width (no depth yet) → ΔZ = 0.""" + wizard, bar, *_ = _w_compound(cross_encoderCurrent=0, material_width=0) + assert wizard._get_compound_z_offset_encoder() == 0 + + def test_outer_and_inner_same_magnitude(self): + """Inner thread moves X outward (+693), outer moves X inward (-693). + Physical depth is the same → ΔZ encoder magnitude must be equal.""" + depth_enc = 693 # ~0.1 mm at 6926 counts/mm + + wizard_out, bar_out, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, inner_thread=False + ) + wizard_in, bar_in, *_ = _w_compound( + cross_encoderCurrent=depth_enc, material_width=0, inner_thread=True + ) + + z_outer = wizard_out._get_compound_z_offset_encoder() + z_inner = wizard_in._get_compound_z_offset_encoder() + + assert z_outer > 0, "Z offset should be positive (applied in threading direction)" + assert z_inner > 0, "Z offset should be positive (applied in threading direction)" + assert z_outer == z_inner, ( + f"Outer ({z_outer}) and inner ({z_inner}) Z offsets should be equal magnitude" + ) + + def test_known_value_iso_metric_1mm_0_3mm_depth(self): + """ + ISO Metric, 1mm pitch, 1° offset → compound angle = 29°. + X depth = 0.3 mm → ΔX_enc = round(0.3 * 6926) = 2078 counts + delta_x_mm = 2078 * 1.0 / (6926/1) ≈ 0.3 mm + ΔZ = 0.3 × tan(29°) ≈ 0.1663 mm + z_encoder = round(0.1663 * 6926) ≈ 1152 + """ + depth_enc = round(0.3 * 6926) # outer: moved inward + wizard, bar, saddle_inp, cross_inp, app = _w_compound( + thread_type="ISO Metric", + offset_degrees=1.0, + cross_encoderCurrent=-depth_enc, + material_width=0, + inner_thread=False, + ) + + result = wizard._get_compound_z_offset_encoder() + + delta_z_mm = 0.3 * tan(radians(29.0)) + expected = round(delta_z_mm * 6926) + assert abs(result - expected) <= 2, ( + f"Expected ~{expected}, got {result}" + ) + + def test_larger_depth_gives_larger_z_offset(self): + """Deeper X cut → proportionally larger ΔZ.""" + enc_shallow = round(0.1 * 6926) + enc_deep = round(0.3 * 6926) + + w1, *_ = _w_compound(cross_encoderCurrent=-enc_shallow, material_width=0) + w2, *_ = _w_compound(cross_encoderCurrent=-enc_deep, material_width=0) + + assert w2._get_compound_z_offset_encoder() > w1._get_compound_z_offset_encoder() + + +# --------------------------------------------------------------------------- +# 3. Direction: ΔZ applied in saddle threading direction +# --------------------------------------------------------------------------- + +class TestCompoundZDirection: + def test_rht_positive_scale_z_applied_in_negative_direction(self): + """RHT + positive scale → effective_dir = -1 → target = start + (-1 * z_offset).""" + depth_enc = round(0.3 * 6926) + wizard, bar, saddle_inp, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, + left_hand_thread=False, ratioNum=1, ratioDen=6926, + ) + bar.start_position = 0 + + z_offset = wizard._get_compound_z_offset_encoder() + effective_dir = wizard._get_saddle_scale_effective_dir() + + target = bar.start_position + effective_dir * z_offset + assert target < bar.start_position, ( + f"RHT saddle should move negative (toward chuck), got target={target}" + ) + + def test_lht_positive_scale_z_applied_in_positive_direction(self): + """LHT + positive scale → effective_dir = +1 → target = start + (+1 * z_offset).""" + depth_enc = round(0.3 * 6926) + wizard, bar, saddle_inp, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, + left_hand_thread=True, ratioNum=1, ratioDen=6926, + ) + bar.start_position = 0 + + z_offset = wizard._get_compound_z_offset_encoder() + effective_dir = wizard._get_saddle_scale_effective_dir() + + target = bar.start_position + effective_dir * z_offset + assert target > bar.start_position, ( + f"LHT saddle should move positive (away from chuck), got target={target}" + ) + + def test_rht_negative_scale_z_applied_in_positive_direction(self): + """RHT + negative scale → effective_dir = +1.""" + depth_enc = round(0.3 * 6926) + wizard, bar, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, + left_hand_thread=False, ratioNum=-1, ratioDen=6926, + ) + bar.start_position = 0 + + z_offset = wizard._get_compound_z_offset_encoder() + effective_dir = wizard._get_saddle_scale_effective_dir() + + target = bar.start_position + effective_dir * z_offset + assert target > bar.start_position + + +# --------------------------------------------------------------------------- +# 4. threadRemainingSteps recalculated per pass +# --------------------------------------------------------------------------- + +class TestThreadRemainingStepsRecalculated: + def test_steps_from_start_to_stop(self): + """Saddle at start_position=0, stop=-6926 (1 mm). With equal ratios, + delta_steps == delta_enc.""" + wizard, bar, saddle_inp, *_ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + saddle_inp.encoderCurrent = 0 + bar.start_position = 0 + bar.stop_position = -6926 + + result = wizard._get_threading_servo_delta_steps() + + assert result == -6926 + + def test_steps_decrease_when_saddle_shifted_toward_stop(self): + """After a ΔZ move, saddle is closer to stop → threadRemainingSteps smaller.""" + wizard, bar, saddle_inp, *_ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + bar.stop_position = -6926 + + saddle_inp.encoderCurrent = 0 + steps_before = wizard._get_threading_servo_delta_steps() + + saddle_inp.encoderCurrent = -100 # shifted 100 counts toward stop + steps_after = wizard._get_threading_servo_delta_steps() + + assert abs(steps_after) < abs(steps_before), ( + f"Steps should decrease after Z shift: before={steps_before}, after={steps_after}" + ) + + def test_steps_change_proportional_to_z_shift(self): + """Step delta difference equals the encoder shift.""" + wizard, bar, saddle_inp, *_ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + bar.stop_position = -100_000 + + saddle_inp.encoderCurrent = 0 + steps_before = wizard._get_threading_servo_delta_steps() + + shift = 500 + saddle_inp.encoderCurrent = -shift + steps_after = wizard._get_threading_servo_delta_steps() + + assert abs(steps_before) - abs(steps_after) == shift + + +# --------------------------------------------------------------------------- +# 5. Stop-overshoot guard in _send_thread_latch +# --------------------------------------------------------------------------- + +class TestStopOvershootGuard: + def _wizard_for_latch(self, saddle_current: int, stop_position: int, left_hand_thread: bool = False): + wizard, bar, saddle_inp, cross_inp, app = make_wizard( + ratioNum=1, ratioDen=6926, left_hand_thread=left_hand_thread + ) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + saddle_inp.encoderCurrent = saddle_current + bar.start_position = 0 + bar.stop_position = stop_position + bar.compound_infeed_mode = False # guard is mode-independent + wizard._threading_started = False + wizard._threading_active_confirmed = False + return wizard, bar, app + + def test_no_guard_when_saddle_before_stop(self): + """Saddle at 0, stop at -6926 (RHT) → remaining > 0, no popup.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, app = self._wizard_for_latch(0, -6926) + app.board.device = MagicMock() + wizard._send_thread_latch() + + CustomPopup.assert_not_called() + + def test_guard_triggers_when_saddle_at_stop(self): + """Saddle exactly at stop → remaining == 0 → popup shown.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, app = self._wizard_for_latch(-6926, -6926) + app.board.device = MagicMock() + wizard._send_thread_latch() + + CustomPopup.assert_called_once() + + def test_guard_triggers_when_saddle_past_stop(self): + """Saddle past stop (RHT: more negative than stop) → popup shown.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, app = self._wizard_for_latch(-7000, -6926) + app.board.device = MagicMock() + wizard._send_thread_latch() + + CustomPopup.assert_called_once() From ae9e650d4ab78e80ed0426d9f2fd7a8d71704819 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Tue, 14 Apr 2026 15:07:53 +0200 Subject: [PATCH 57/62] Added help files for assisted threading settings; Disabled sync toggle on scale when in Assisted Threading mode - this will be handled automatically; Fixed compound z offset calculation --- .../home/assisted_threading/calculations.py | 30 ++++----- .../home/assisted_threading/settings_popup.kv | 6 ++ rcp/components/home/coordbar.py | 2 + rcp/components/screens/els_setup_screen.kv | 12 ++++ rcp/help/at_backlash_cushion.rst | 62 +++++++++++++++++ rcp/help/at_compound_infeed.rst | 67 +++++++++++++++++++ rcp/help/at_cross_slide_diameter_mode.rst | 43 ++++++++++++ rcp/help/at_inner_thread.rst | 44 ++++++++++++ rcp/help/at_left_hand_thread.rst | 42 ++++++++++++ rcp/help/at_metric_distances.rst | 35 ++++++++++ rcp/help/at_metric_mode.rst | 40 +++++++++++ rcp/help/at_pitch.rst | 58 ++++++++++++++++ rcp/help/at_preload_adjust_speed.rst | 50 ++++++++++++++ .../at_reversing_adjusting_acceleration.rst | 46 +++++++++++++ rcp/help/at_reversing_speed.rst | 49 ++++++++++++++ rcp/help/at_rotary_encoder_sync_tolerance.rst | 55 +++++++++++++++ rcp/help/at_saddle_backlash_distance.rst | 50 ++++++++++++++ .../at_saddle_encoder_stability_samples.rst | 46 +++++++++++++ .../at_saddle_encoder_stability_tolerance.rst | 44 ++++++++++++ rcp/help/at_thread_type.rst | 58 ++++++++++++++++ rcp/help/at_threading_acceleration.rst | 44 ++++++++++++ rcp/help/at_threading_max_speed.rst | 48 +++++++++++++ .../test_compound_infeed.py | 34 +++++++++- 23 files changed, 948 insertions(+), 17 deletions(-) create mode 100644 rcp/help/at_backlash_cushion.rst create mode 100644 rcp/help/at_compound_infeed.rst create mode 100644 rcp/help/at_cross_slide_diameter_mode.rst create mode 100644 rcp/help/at_inner_thread.rst create mode 100644 rcp/help/at_left_hand_thread.rst create mode 100644 rcp/help/at_metric_distances.rst create mode 100644 rcp/help/at_metric_mode.rst create mode 100644 rcp/help/at_pitch.rst create mode 100644 rcp/help/at_preload_adjust_speed.rst create mode 100644 rcp/help/at_reversing_adjusting_acceleration.rst create mode 100644 rcp/help/at_reversing_speed.rst create mode 100644 rcp/help/at_rotary_encoder_sync_tolerance.rst create mode 100644 rcp/help/at_saddle_backlash_distance.rst create mode 100644 rcp/help/at_saddle_encoder_stability_samples.rst create mode 100644 rcp/help/at_saddle_encoder_stability_tolerance.rst create mode 100644 rcp/help/at_thread_type.rst create mode 100644 rcp/help/at_threading_acceleration.rst create mode 100644 rcp/help/at_threading_max_speed.rst diff --git a/rcp/components/home/assisted_threading/calculations.py b/rcp/components/home/assisted_threading/calculations.py index f4bef7b..c03784c 100644 --- a/rcp/components/home/assisted_threading/calculations.py +++ b/rcp/components/home/assisted_threading/calculations.py @@ -288,27 +288,27 @@ def _get_compound_z_offset_encoder(self) -> int: ΔZ = ΔX_mm × tan(compound_angle) - ΔX is the absolute physical depth from the material surface. - abs() is intentional: outer threading moves X inward (negative encoder delta), - inner threading moves X outward (positive delta). Physical cut depth is always - a positive magnitude. Direction is applied by the caller via - _get_saddle_scale_effective_dir(). - - Returns 0 if compound infeed is disabled or the cross-slide is at material width. + ΔX is the physical depth from the material surface in the cutting direction. + Uses _get_cross_slide_scale_effective_dir() so the sign correctly reflects + whether the tool is cutting (positive) or retracted past the surface (≤ 0). + Returns 0 when the cross-slide is at or retracted past the material surface. + Direction of the Z shift is applied by the caller via _get_saddle_scale_effective_dir(). """ if not self.bar.compound_infeed_mode: return 0 - delta_x_enc = abs(self.cross_slide_input.encoderCurrent - self.bar.material_width) - if delta_x_enc == 0: + cross_dir = self._get_cross_slide_scale_effective_dir() + cross_inp = self.cross_slide_input + + # Positive = tool is deeper than surface (cutting). Zero/negative = at surface or retracted. + delta_x_enc_in_cut_dir = (cross_inp.encoderCurrent - self.bar.material_width) * cross_dir + if delta_x_enc_in_cut_dir <= 0: return 0 - # Convert X encoder delta to mm - cross_inp = self.cross_slide_input - # encoder_factor converts encoder counts to mm (same factor used in formattedPosition) + # Convert X encoder depth to mm. + # cross_dir already accounts for ratioNum sign, so use abs(ratioNum/ratioDen) for magnitude. encoder_factor = float(self.app.formats.MM_FRACTION) - cross_scale_factor = float(cross_inp.ratioDen) / float(cross_inp.ratioNum) if cross_inp.ratioNum != 0 else 1.0 - delta_x_mm = abs(delta_x_enc * encoder_factor / cross_scale_factor) + delta_x_mm = delta_x_enc_in_cut_dir * encoder_factor * abs(float(cross_inp.ratioNum) / float(cross_inp.ratioDen)) if cross_inp.ratioDen != 0 else 0.0 compound_angle = self._get_compound_angle_degrees() delta_z_mm = delta_x_mm * tan(radians(compound_angle)) @@ -318,7 +318,7 @@ def _get_compound_z_offset_encoder(self) -> int: z_encoder = abs(self._convert_distance_units_to_encoder(self.saddle_scale, delta_z_mm, is_metric=True)) log.info( - f"Compound Z offset: delta_x_enc={delta_x_enc}, delta_x_mm={delta_x_mm:.4f}, " + f"Compound Z offset: delta_x_enc_in_cut_dir={delta_x_enc_in_cut_dir}, delta_x_mm={delta_x_mm:.4f}, " f"compound_angle={compound_angle:.2f}°, delta_z_mm={delta_z_mm:.4f}, " f"z_encoder={z_encoder}" ) diff --git a/rcp/components/home/assisted_threading/settings_popup.kv b/rcp/components/home/assisted_threading/settings_popup.kv index 6902b9b..b3febcc 100644 --- a/rcp/components/home/assisted_threading/settings_popup.kv +++ b/rcp/components/home/assisted_threading/settings_popup.kv @@ -17,6 +17,7 @@ BooleanItem: name: "Metric Mode" + help_file: "at_metric_mode.md" value: root.assistedThreadingBar.metric_mode on_value: root.on_metric_mode_changed(self.value) @@ -24,6 +25,7 @@ id: pitches_dropdown height: 60 name: "Pitch in MM" if root.assistedThreadingBar.metric_mode else "Pitch in IN" + help_file: "at_pitch.md" options: root.get_pitches() value: str(root.assistedThreadingBar.selected_pitch) if root.assistedThreadingBar.selected_pitch is not None else "" on_value: root.on_pitch_selected(self.selected_index, self.value) @@ -32,22 +34,26 @@ id: thread_type_dropdown height: 60 name: "Thread Type" + help_file: "at_thread_type.md" options: root.get_thread_types() value: root.assistedThreadingBar.thread_profile_type on_value: root.on_thread_type_selected(self.value) BooleanItem: name: "Left Hand Thread" + help_file: "at_left_hand_thread.md" value: root.assistedThreadingBar.left_hand_thread on_value: root.assistedThreadingBar.left_hand_thread = self.value BooleanItem: name: "Inner Thread" + help_file: "at_inner_thread.md" value: root.assistedThreadingBar.inner_thread on_value: root.assistedThreadingBar.inner_thread = self.value BooleanItem: name: "Compound Infeed" + help_file: "at_compound_infeed.md" value: root.assistedThreadingBar.compound_infeed_mode on_value: root.on_compound_infeed_mode_changed(self.value) diff --git a/rcp/components/home/coordbar.py b/rcp/components/home/coordbar.py index 0f7b071..7fb5378 100644 --- a/rcp/components/home/coordbar.py +++ b/rcp/components/home/coordbar.py @@ -25,6 +25,8 @@ def toggle_sync(self): if self.axis is not None: from rcp.app import MainApp app = MainApp.get_running_app() + if app.current_mode == 5: + return self.axis.toggle_sync(all_axes=list(app.axes)) def on_zero_press(self): diff --git a/rcp/components/screens/els_setup_screen.kv b/rcp/components/screens/els_setup_screen.kv index 47ff6f5..464a8b8 100644 --- a/rcp/components/screens/els_setup_screen.kv +++ b/rcp/components/screens/els_setup_screen.kv @@ -40,6 +40,7 @@ name: "Assisted Threading" BooleanItem: name: "Cross Slide Diameter Mode" + help_file: "at_cross_slide_diameter_mode.md" value: app.els.at_cross_slide_diameter_mode on_value: app.els.at_cross_slide_diameter_mode = self.value @@ -47,22 +48,27 @@ name: "Assisted Threading: Speed" NumberItem: name: "Reversing Speed (Steps/s)" + help_file: "at_reversing_speed.md" value: app.els.at_reversing_speed on_value: root.set_at_reversing_speed(self.value) NumberItem: name: "Preload/Adjust Speed (Steps/s)" + help_file: "at_preload_adjust_speed.md" value: app.els.at_preload_adjust_speed on_value: root.set_at_preload_adjust_speed(self.value) NumberItem: name: "Threading Max Speed (Steps/s)" + help_file: "at_threading_max_speed.md" value: app.els.at_threading_max_speed on_value: root.set_at_threading_max_speed(self.value) NumberItem: name: "Reversing/Adjusting Acceleration (Steps/s^2)" + help_file: "at_reversing_adjusting_acceleration.md" value: app.els.at_reversing_adjusting_acceleration on_value: root.set_at_reversing_adjusting_acceleration(self.value) NumberItem: name: "Threading Acceleration (Steps/s^2)" + help_file: "at_threading_acceleration.md" value: app.els.at_threading_acceleration on_value: root.set_at_threading_acceleration(self.value) @@ -70,25 +76,31 @@ name: "Assisted Threading: Tolerances" NumberItem: name: "Rotary Encoder sync tolerance (Steps)" + help_file: "at_rotary_encoder_sync_tolerance.md" value: app.els.at_rotary_encoder_sync_tolerance on_value: app.els.at_rotary_encoder_sync_tolerance = self.value NumberItem: name: "Saddle Encoder stability tolerance (Steps)" + help_file: "at_saddle_encoder_stability_tolerance.md" value: app.els.at_saddle_encoder_stability_tolerance on_value: app.els.at_saddle_encoder_stability_tolerance = int(self.value) NumberItem: name: "Saddle Encoder stability samples" + help_file: "at_saddle_encoder_stability_samples.md" value: app.els.at_saddle_encoder_stability_samples on_value: app.els.at_saddle_encoder_stability_samples = int(self.value) BooleanItem: name: "Metric Distances" + help_file: "at_metric_distances.md" value: app.els.at_metric_distances on_value: app.els.at_metric_distances = self.value NumberItem: name: "Saddle backlash distance (MM)" if app.els.at_metric_distances else "Saddle backlash distance (IN)" + help_file: "at_saddle_backlash_distance.md" value: app.els.at_saddle_backlash_distance on_value: app.els.at_saddle_backlash_distance = self.value NumberItem: name: "Saddle backlash cushion (MM)" if app.els.at_metric_distances else "Saddle backlash cushion (IN)" + help_file: "at_backlash_cushion.md" value: app.els.at_backlash_cushion on_value: app.els.at_backlash_cushion = self.value diff --git a/rcp/help/at_backlash_cushion.rst b/rcp/help/at_backlash_cushion.rst new file mode 100644 index 0000000..c2d55fe --- /dev/null +++ b/rcp/help/at_backlash_cushion.rst @@ -0,0 +1,62 @@ +Saddle Backlash Cushion +======================= + +A small distance value that serves two safety roles in the threading +wizard. + +Role 1 — Minimum Thread Length +-------------------------------- + +The stop position you set in the wizard must be at least this distance +from the start position. If the stop is closer than the cushion, the +system rejects it. + +This prevents attempting to cut a thread so short that the preload +and go-to-start sequence cannot work reliably within the available +length. + +Role 2 — Start Position Validation +----------------------------------- + +Before each threading cut begins, the system checks that the saddle +is within this distance of the configured start position. If the +saddle has drifted further away (e.g. overshot during preload), the +cut is aborted and you are returned to the go-to-start step. + +This ensures every cut starts from a consistent position, which is +especially important for fine pitches where a small positional error +shifts the thread by a significant fraction of the pitch. + +How to Set +---------- + +Use a small fraction of your Saddle Backlash Distance value, and +keep it smaller than the shortest pitch you intend to cut: + +- Example: 2 mm backlash → 0.1 mm cushion +- Rule of thumb: cushion < backlash distance / 10 +- Also ensure: cushion < your minimum thread pitch + +Too Large +--------- + +- Short threads are unnecessarily rejected +- A larger tolerance is allowed at the start position — the saddle + may be further off the ideal start, which for fine pitches can cause + the thread to start at the wrong phase and damage the workpiece + +Too Small +--------- + +- Higher accuracy is required from the go-to-start sequence +- Depending on the machine, it may be difficult to consistently land + within a very tight tolerance — this causes frequent false aborts + before cuts + +Notes +----- + +- Set the Metric Distances toggle above to match the unit before + entering this value +- If you see repeated "not at valid start position" warnings, consider + slightly increasing the cushion or reducing the preload/adjust speed diff --git a/rcp/help/at_compound_infeed.rst b/rcp/help/at_compound_infeed.rst new file mode 100644 index 0000000..7ebe06e --- /dev/null +++ b/rcp/help/at_compound_infeed.rst @@ -0,0 +1,67 @@ +Compound Infeed +=============== + +Enables angled infeed for threading — each pass shifts the thread +start position slightly forward along the Z axis instead of plunging +the tool straight radially. + +How It Works +------------ + +In standard (radial) infeed, the tool plunges straight in and cuts +both flanks of the thread simultaneously on every pass. This is simple +but increases cutting force as depth increases. + +With compound infeed active, each new pass begins at a Z start +position offset slightly toward the thread stop. The tool therefore +cuts predominantly on the **leading flank**, reducing the chip load +per pass. This is similar to using a compound slide set at the thread +half-angle. + +Benefits +-------- + +- Reduced cutting force per pass +- Less tendency for chatter, especially on coarser pitches +- Better surface finish on the thread flanks +- Easier on tool inserts and HSS tools + +Compound Offset (0–5°) +---------------------- + +The offset angle in degrees, subtracted from the thread half-angle to +give the actual compound infeed angle: + + **compound angle = thread half-angle − offset** + +The half-angle depends on the selected Thread Type: + +============ =========== ========================== +Thread Type Half-angle Compound angle at 1° offset +============ =========== ========================== +ISO Metric 30° 29° +Unified 30° 29° +Whitworth 27.5° 26.5° +ACME 14.5° 13.5° +============ =========== ========================== + +**Typical setting:** 1° works well for most materials and pitches. +Increasing the offset angles the infeed more steeply toward the +leading flank. + +When to Use +----------- + +- Coarse pitches (> 1.5 mm / < 17 TPI): compound infeed gives the + greatest benefit +- Fine pitches (< 1 mm / > 25 TPI): the Z offset is very small; + compound infeed provides little benefit and can be left OFF + +Notes +----- + +- If a warning appears that the compound Z shift has consumed the + remaining thread length, reduce the cross-slide infeed depth or + increase the thread start clearance before pressing Cut +- ACME threads have a shallow half-angle (14.5°); keep the offset + small (1° or less) to avoid too steep an approach angle diff --git a/rcp/help/at_cross_slide_diameter_mode.rst b/rcp/help/at_cross_slide_diameter_mode.rst new file mode 100644 index 0000000..757850b --- /dev/null +++ b/rcp/help/at_cross_slide_diameter_mode.rst @@ -0,0 +1,43 @@ +Cross Slide Diameter Mode +========================= + +Tells the system how your cross-slide scale is physically configured — +whether it reads full diameter across the workpiece or radius (distance +from centre to tool tip). + +This is not a setting that changes scale behaviour. It is a declaration +of how you have set up and zeroed your DRO. + +Diameter Mode (ON) +------------------ + +The scale reads the full diameter of the material (both sides). For +example, zeroing with the tool touching the outside of a 20 mm shaft +gives a reading of 20 mm. + +When this mode is active, the auto-calculated thread cutting depth is +doubled — because reducing the diameter by the full thread depth +requires removing that depth from both sides of the workpiece. + +Radius Mode (OFF) +----------------- + +The scale reads radius — the distance from the centre of rotation to +the tool tip. This is the more common DRO convention for lathes. A +thread depth of 0.9 mm means the tool moves 0.9 mm inward. + +Which to Choose +--------------- + +Set this to match however your cross-slide DRO is zeroed: + +- **Touching outer surface → reading = diameter** → enable Diameter Mode +- **Touching outer surface → reading = radius (half diameter)** → leave OFF + +Notes +----- + +- This setting only affects the automatically calculated thread depth + shown in the wizard; it does not affect encoder or position readings +- If your finished threads are consistently too shallow or too deep by + a factor of 2, check that this setting matches your scale configuration diff --git a/rcp/help/at_inner_thread.rst b/rcp/help/at_inner_thread.rst new file mode 100644 index 0000000..f673a1c --- /dev/null +++ b/rcp/help/at_inner_thread.rst @@ -0,0 +1,44 @@ +Inner Thread +============ + +When enabled, the threading wizard drives the cross slide outward +for each cutting pass instead of inward, for cutting an internal +(female) thread inside a bore. + +External vs Internal +-------------------- + +**OFF (default) — External thread:** +The cross slide advances inward (toward the workpiece centreline) to +increase cutting depth. Used for cutting threads on the outside of a +shaft or bar. + +**ON — Internal thread:** +The cross slide advances outward (away from the centreline) to +increase cutting depth into a pre-bored hole. Used for cutting threads +on the inside of a hole. + +Preparation +----------- + +Before using the threading wizard for an internal thread: + +1. Drill or bore the hole to the correct tapping diameter for the + chosen thread and pitch +2. Ensure the boring bar or threading insert clears the hole diameter + through the full thread length + +Thread Depth +------------ + +The auto-calculated thread depth applies in both modes. In inner +thread mode, this depth is taken outward from the bore wall. + +Notes +----- + +- Changing this setting reverses the cross-slide direction check in + the wizard — the "retracted" state and "at cutting depth" condition + are both interpreted relative to the bore wall, not the centreline +- This setting does not affect the saddle (Z) direction — that is + controlled by Left Hand Thread diff --git a/rcp/help/at_left_hand_thread.rst b/rcp/help/at_left_hand_thread.rst new file mode 100644 index 0000000..6df1663 --- /dev/null +++ b/rcp/help/at_left_hand_thread.rst @@ -0,0 +1,42 @@ +Left Hand Thread +================ + +When enabled, the saddle feed direction is reversed so the thread +tightens when rotated counter-clockwise (viewed from the end). + +Standard vs Left Hand +--------------------- + +**OFF (default) — Right hand thread:** +The saddle moves toward the headstock during the cut. The thread +tightens by turning clockwise. + +**ON — Left hand thread:** +The saddle moves away from the headstock during the cut. The thread +tightens by turning counter-clockwise. + +Common Uses +----------- + +- Left-side pedal crank threads on bicycles +- Left-hand adjusters and lock nuts (to prevent loosening under + rotation) +- Specialty jigs and fixtures +- Turnbuckle bodies (one end right, one end left) + +What Changes +------------ + +Enabling left hand thread reverses the ELS sync ratio sign on the +spindle axis, causing the servo to drive in the opposite direction +relative to spindle rotation. The displayed sync ratio reflects this. + +Notes +----- + +- The spindle must still be turning in the same direction as for a + right-hand thread — do not reverse the lathe spindle; the firmware + handles feed direction reversal +- The threading wizard start and stop positions use the same + convention; set your start position where the thread begins and + stop position where it ends, in the physical direction of travel diff --git a/rcp/help/at_metric_distances.rst b/rcp/help/at_metric_distances.rst new file mode 100644 index 0000000..2dbbfb1 --- /dev/null +++ b/rcp/help/at_metric_distances.rst @@ -0,0 +1,35 @@ +Metric Distances +================ + +Selects the unit for the backlash settings on this screen. + +When ON, the Saddle Backlash Distance and Saddle Backlash Cushion +values are entered and interpreted in millimetres. + +When OFF, both values are entered in inches. + +Scope +----- + +This setting applies **only** to the two backlash fields immediately +below it: + +- Saddle Backlash Distance +- Saddle Backlash Cushion + +It does not affect: + +- Thread pitch units (set by Metric Mode in the threading settings popup) +- DRO display units (set in the Formats screen) +- Any other measurements in the application + +Notes +----- + +- Change this before entering backlash values — existing values are + stored as-is and are not automatically converted between units +- For metric measurements, leave this ON and enter distances in mm +- For imperial measurements, turn this OFF and enter + distances in inches +- This is purely for preference and ease of use to the user; the system + will automatically convert the entered values to the correct encoder units diff --git a/rcp/help/at_metric_mode.rst b/rcp/help/at_metric_mode.rst new file mode 100644 index 0000000..7ab697d --- /dev/null +++ b/rcp/help/at_metric_mode.rst @@ -0,0 +1,40 @@ +Metric Mode +=========== + +Selects the unit system for thread pitch in the threading settings. + +When ON, pitches are in **millimetres** (metric threads — ISO, ACME). +When OFF, pitches are in **threads per inch** (TPI — imperial threads, +Unified, Whitworth, ACME). + +Effect on Other Settings +------------------------ + +Changing this setting rebuilds both the pitch dropdown and the +available thread type options, as some thread types are only +defined for metric or imperial pitch systems. + +This setting does **not** affect: + +- DRO display units (set in the Formats screen) +- Backlash distance units (set by Metric Distances in ELS Setup) +- Encoder or servo configuration + +Which to Choose +--------------- + +========== ========================================== +Mode Use when +========== ========================================== +ON (MM) Cutting ISO metric threads (M-series) +OFF (TPI) Cutting Unified (UNC/UNF) or Whitworth threads +========== ========================================== + +ACME threads are available in both modes — select the mode that +matches the pitch specification of your lead screw or part drawing. + +Notes +----- + +- Switching this resets the pitch dropdown to the first available + option; re-select your desired pitch after changing diff --git a/rcp/help/at_pitch.rst b/rcp/help/at_pitch.rst new file mode 100644 index 0000000..36b1005 --- /dev/null +++ b/rcp/help/at_pitch.rst @@ -0,0 +1,58 @@ +Thread Pitch +============ + +The pitch of the thread to be cut. + +In **Metric Mode** the pitch is in millimetres — the distance +between adjacent thread crests measured along the axis. + +In **Imperial Mode** the pitch is in TPI (threads per inch) — the +number of complete thread crests in one inch of travel. + +How It Is Used +-------------- + +The selected pitch determines: + +1. **ELS sync ratio:** the ratio of spindle rotation to saddle + movement, set automatically on the spindle axis +2. **Auto-calculated thread depth:** shown as the default cutting + depth in the wizard (can be overridden) +3. **Maximum spindle RPM check:** if the pitch and spindle speed + require more servo speed than the Threading Max Speed limit, the + system warns you before the cut + +Common Pitches +-------------- + +**Metric (MM)** + +===== ================== +Pitch Typical use +===== ================== +0.5 M3, M4 fine +0.75 M5, M6 coarse +1.0 M6 fine, M8 coarse +1.25 M8 fine, M10 +1.5 M10 fine, M12 +2.0 M14, M16 +===== ================== + +**Imperial (TPI)** + +==== ======================= +TPI Typical use +==== ======================= +40 ¼-40 UNF +32 #10-32 UNF +20 ¼-20 UNC +13 ½-13 UNC +8 ¾-8 UNC +4 1½-4 UNC (coarse) +==== ======================= + +Notes +----- + +- The full pitch list is defined in the application's feeds table +- Switch Metric Mode first to see pitches in the correct unit system diff --git a/rcp/help/at_preload_adjust_speed.rst b/rcp/help/at_preload_adjust_speed.rst new file mode 100644 index 0000000..4f77399 --- /dev/null +++ b/rcp/help/at_preload_adjust_speed.rst @@ -0,0 +1,50 @@ +Preload / Adjust Speed (Steps/s) +================================ + +The servo speed used when positioning the saddle back to the thread +start during the go-to-start sequence. + +How It Works +------------ + +After retracting past the start position, the system performs two +precision moves before the next cut: + +1. **Preload move:** the saddle advances in the cutting direction to + take up the drive backlash (1.25× the configured backlash distance) +2. **Adjust move:** the saddle makes a final fine move to land exactly + at the thread start position + +Both moves use this speed. Slow, controlled movement here ensures the +saddle lands accurately at the start and the drive is fully loaded. + +Guidelines +---------- + +- Keep this speed low — positional accuracy is the priority here, + not throughput +- Too fast risks overshooting the start position or inadequate + backlash preloading, which leads to a thread out of phase on the + next pass + +Typical Ranges +-------------- + +=================== ===================== +Servo Max Speed Typical Preload Speed +=================== ===================== +500 steps/s 50–75 steps/s +1000 steps/s 100–150 steps/s +2000 steps/s 200–300 steps/s +3000 steps/s 300–450 steps/s +=================== ===================== + +**Typical setting:** 10–15% of your servo max speed. + +Notes +----- + +- Value is clamped to the servo max speed +- If the saddle consistently overshoots the start position, reduce + this value +- See also: Saddle Backlash Distance, Reversing/Adjusting Acceleration diff --git a/rcp/help/at_reversing_adjusting_acceleration.rst b/rcp/help/at_reversing_adjusting_acceleration.rst new file mode 100644 index 0000000..e0a3b1f --- /dev/null +++ b/rcp/help/at_reversing_adjusting_acceleration.rst @@ -0,0 +1,46 @@ +Reversing / Adjusting Acceleration (Steps/s²) +============================================= + +How quickly the servo ramps up and slows down during retraction, +preload, and adjust moves — the non-cutting phases of the threading +cycle. + +How It Works +------------ + +This acceleration is applied to three moves: + +1. **Retract:** saddle moves backward after a threading pass +2. **Preload:** saddle advances to load drive backlash before returning + to start +3. **Adjust:** final fine move to the exact thread start position + +For these positioning moves, smooth and controlled motion is more +important than speed. A lower acceleration produces gentler +deceleration into the start position, reducing overshoot. + +Guidelines +---------- + +- Keep this value lower than the threading acceleration — the priority + here is accurate stopping, not fast ramp-up +- Too high: the saddle may overshoot the start position, requiring + the system to detect it is out of position and abort the cut +- Too low: very gradual starts and stops; the retract and preload + moves feel sluggish + +Typical Range +------------- + +**Typical setting:** 50–100% of your Reversing Speed value. + +For example, if Reversing Speed = 1000 steps/s, set this to +500–1000 steps/s². + +Notes +----- + +- This is a separate setting from Threading Acceleration, which + handles the actual cutting moves +- If cuts are frequently aborted with a "not at valid start position" + warning, try reducing this value to improve stopping accuracy diff --git a/rcp/help/at_reversing_speed.rst b/rcp/help/at_reversing_speed.rst new file mode 100644 index 0000000..fba1ddf --- /dev/null +++ b/rcp/help/at_reversing_speed.rst @@ -0,0 +1,49 @@ +Reversing Speed (Steps/s) +========================= + +The servo speed used when the retract button is held, and during the +retract phase of the automatic go-to-start sequence. + +How It Works +------------ + +**Retract button (manual):** while held, the saddle jogs away from the +workpiece at this speed. This is useful when you want to move the saddle +clear of the workpiece — for example to test thread fit you might want to move +the saddle out of the way further than the start position. Releasing the button stops the saddle. + +**Go-to-start (automatic):** when you press Go to start between passes, +the system automatically retracts past the start position at this speed +before slowing to the preload/adjust speed to return precisely to start. +You do not need to use the retract button for normal between-pass +operation — the go-to-start button handles the full sequence. + +Guidelines +---------- + +- This speed can be set close to your servo max speed — quick + retraction saves time on each pass, especially for longer threads +- There is no requirement for this to be slow; accuracy is not + critical during retraction +- Higher values reduce the time between passes + +Typical Ranges +-------------- + +=================== =================== +Servo Max Speed Typical Reversing Speed +=================== =================== +500 steps/s 250–500 steps/s +1000 steps/s 500–1000 steps/s +2000 steps/s 1000–2000 steps/s +3000 steps/s 1500–3000 steps/s +=================== =================== + +**Typical setting:** 50–100% of your servo max speed. + +Notes +----- + +- Value is clamped to the servo max speed — it cannot exceed it +- The Reversing/Adjusting Acceleration setting controls how quickly + the saddle ramps up and down to this speed diff --git a/rcp/help/at_rotary_encoder_sync_tolerance.rst b/rcp/help/at_rotary_encoder_sync_tolerance.rst new file mode 100644 index 0000000..6f3f66c --- /dev/null +++ b/rcp/help/at_rotary_encoder_sync_tolerance.rst @@ -0,0 +1,55 @@ +Rotary Encoder Sync Tolerance (Counts) +======================================= + +Defines the ±window (in spindle encoder counts) around the reference +spindle angle at which each threading pass re-engages. + +How It Works +------------ + +When you press Cut for the first time, the firmware records the current +spindle angle as the **phase reference** for this thread. For every +subsequent pass, the firmware waits until the spindle returns to within +±tolerance counts of that same reference angle before enabling the +servo threading move. + +This phase-locking ensures every pass starts at the same point in the +spindle rotation, so successive cuts follow the same helical path and +do not cross-thread. + +Units +----- + +The value is in **spindle encoder counts** — the same scale as your +spindle encoder's counts per revolution (spindleCountsPerRev). + +To convert to degrees: **tolerance_degrees = (tolerance / counts_per_rev) × 360** + +Example: 1000-count encoder, tolerance = 5 → ±1.8° window. + +Guidelines +---------- + +- **Smaller value:** tighter re-engagement angle — more consistent + thread helix, but may delay the trigger if the encoder has noise +- **Larger value:** wider window — triggers more readily but the start + angle may vary slightly between passes + +Typical Range +------------- + +=========== ============================================================== +Tolerance Effect +=========== ============================================================== +1–3 counts Very tight — use only if encoder signal is clean +5 counts Default — suitable for most setups +10–15 counts Wider window — useful if threading start is unreliable +=========== ============================================================== + +Notes +----- + +- If threading passes never start (the servo just waits), increase + this value to widen the trigger window +- If cut helix alignment is poor between passes, reduce this value +- Value must be a positive integer diff --git a/rcp/help/at_saddle_backlash_distance.rst b/rcp/help/at_saddle_backlash_distance.rst new file mode 100644 index 0000000..ac42866 --- /dev/null +++ b/rcp/help/at_saddle_backlash_distance.rst @@ -0,0 +1,50 @@ +Saddle Backlash Distance +======================== + +The total free play in the saddle (Z-axis) drive — the distance the +handwheel travels before the saddle actually begins to move when +direction is reversed. + +How It Is Used +-------------- + +This distance is used to calculate the retract and preload move +distances in the go-to-start sequence: + +- **Retract:** moves 1.5× this distance past the thread start +- **Preload:** advances 1.25× this distance back toward the start + (to fully load the drive in the cutting direction) +- **Adjust:** final fine move to land on the exact start position + +Setting this correctly ensures the drive is fully preloaded before +the threading cut begins, eliminating backlash-induced position error +on the first stroke of each pass. + +How to Measure +-------------- + +1. Engage the half nut on the lathe +2. Turn the handwheel in the cutting direction until it stops + (the lead screw is loaded in one direction) +3. Zero the DRO +4. Turn the handwheel in the opposite direction until it stops +5. Read the DRO value — this is your backlash distance +6. Repeat at several points along the saddle travel and average + the readings for accuracy + +Common Values +------------- + +Backlash varies widely between machines. Freshly adjusted or +re-fitted nuts have less backlash; worn machines may have +significantly more. + +Notes +----- + +- Set the Metric Distances toggle above to match the unit you intend + to use before entering this value +- **Too low:** drive is not fully preloaded → first cut of each pass + may be shallower than expected +- **Too high:** unnecessarily long retract and preload moves, slowing + down each pass diff --git a/rcp/help/at_saddle_encoder_stability_samples.rst b/rcp/help/at_saddle_encoder_stability_samples.rst new file mode 100644 index 0000000..c0d8491 --- /dev/null +++ b/rcp/help/at_saddle_encoder_stability_samples.rst @@ -0,0 +1,46 @@ +Saddle Encoder Stability Samples +================================= + +The number of consecutive stable encoder readings required before the +saddle is confirmed as fully stopped. + +How It Works +------------ + +After a servo move, the system polls the saddle encoder on each +control tick. A reading is considered "stable" if the change in +position since the last reading is within the Saddle Encoder Stability +Tolerance. This setting specifies how many stable readings in a row +must be observed before the wizard advances. + +This guards against brief pauses in motion (such as when a fast- +moving saddle momentarily slows through the target) being mistakenly +treated as a full stop. + +Guidelines +---------- + +- **Higher value:** more conservative — requires more consecutive + stable ticks, reducing false stops; adds a small delay after motion +- **Lower value:** faster response after the saddle stops, but may + advance the wizard too early if the saddle is still settling + +Typical Range +------------- + +========= ========================================================= +Samples Behaviour +========= ========================================================= +2 Minimal confirmation — fast but may trigger on a brief lull +3 Default — suitable for most setups +4–5 More reliable on machines with resonance or encoder noise +========= ========================================================= + +Notes +----- + +- If the wizard advances before the saddle has fully stopped, increase + this value +- If the wizard is slow to advance after motion completes, reduce it +- Works in conjunction with Saddle Encoder Stability Tolerance +- Value must be a positive integer diff --git a/rcp/help/at_saddle_encoder_stability_tolerance.rst b/rcp/help/at_saddle_encoder_stability_tolerance.rst new file mode 100644 index 0000000..c576e96 --- /dev/null +++ b/rcp/help/at_saddle_encoder_stability_tolerance.rst @@ -0,0 +1,44 @@ +Saddle Encoder Stability Tolerance (Counts) +============================================ + +The maximum allowed variation in saddle encoder counts between +consecutive readings before the saddle is considered stationary. + +How It Works +------------ + +After every servo move (retract, preload, adjust), the system polls +the saddle encoder to confirm it has fully come to rest. On each poll, +it checks whether the absolute change in encoder position since the +last reading is within this tolerance. When the change stays within +tolerance for the required number of samples (see Saddle Encoder +Stability Samples), the saddle is declared stopped and the wizard +advances to the next step. + +Guidelines +---------- + +- **Smaller value:** stricter definition of "stopped" — use on rigid + machines where the saddle truly stops quickly with minimal bounce +- **Larger value:** more forgiving — useful if the encoder shows slight + jitter or the saddle takes time to settle completely + +Typical Range +------------- + +======= ==================================================== +Counts Behaviour +======= ==================================================== +1 Default — very strict; typical for clean setups +2–3 Slightly more forgiving; reduces false "still moving" +4+ Only needed if encoder is noisy or saddle bounces +======= ==================================================== + +Notes +----- + +- If the wizard seems to hang after a move and never advances, try + increasing this value by 1–2 counts +- This setting works together with Saddle Encoder Stability Samples — + both must be satisfied before the saddle is considered stopped +- Value must be a positive integer diff --git a/rcp/help/at_thread_type.rst b/rcp/help/at_thread_type.rst new file mode 100644 index 0000000..e67447c --- /dev/null +++ b/rcp/help/at_thread_type.rst @@ -0,0 +1,58 @@ +Thread Type +=========== + +The thread profile geometry — the cross-sectional shape of the +thread form being cut. + +This setting determines: + +- The formula used to auto-calculate cutting depth +- The compound infeed angle (if compound infeed mode is enabled) + +Available Types +--------------- + +ISO Metric +^^^^^^^^^^ +Standard metric fastener thread. 60° included angle (30° half-angle). +Covers all M-series screws and bolts per ISO 68-1. + +Unified +^^^^^^^ +Standard US/imperial fastener thread (UNC and UNF). Also a 60° included +angle. Per ASME B1.1. Use this for most inch-dimensioned fasteners. + +Whitworth +^^^^^^^^^ +Older British standard thread (BSW and BSF). 55° included angle +(27.5° half-angle). Used on older British-made machines and +replacement parts for vintage equipment. + +ACME +^^^^ +Trapezoidal thread profile. 29° included angle (14.5° half-angle). +Per ASME B1.5. Commonly used for lead screws, vise screws, and +other power transmission applications where self-locking and +strength are important. + +Thread Depth Formulas +--------------------- + +========== =========== ======================== +Type Factor Depth formula +========== =========== ======================== +ISO Metric 0.61343 depth = 0.61343 × pitch +Unified 0.64952 depth = 0.64952 × pitch +Whitworth 0.6403 depth = 0.6403 × pitch +ACME 0.5 depth = 0.5 × pitch +========== =========== ======================== + +*Depth is radial. If Cross Slide Diameter Mode is ON, this value is +doubled.* + +Notes +----- + +- ACME is available in both metric and imperial pitch modes +- Changing thread type updates the compound infeed angle automatically + when compound infeed mode is enabled diff --git a/rcp/help/at_threading_acceleration.rst b/rcp/help/at_threading_acceleration.rst new file mode 100644 index 0000000..d5a5257 --- /dev/null +++ b/rcp/help/at_threading_acceleration.rst @@ -0,0 +1,44 @@ +Threading Acceleration (Steps/s²) +================================== + +How quickly the servo ramps up to threading speed at the start of +each cut, and decelerates to a stop at the thread end. + +How It Works +------------ + +The servo uses a trapezoidal motion profile during the threading cut: + +1. Accelerate at this rate from rest up to the threading max speed +2. Cruise at threading max speed for the length of the thread +3. Decelerate at this rate to stop at the end position + +A higher acceleration reaches full cutting speed sooner, which is +important for short threads where a slow ramp-up would consume most +of the thread length before the servo is up to speed. + +Guidelines +---------- + +- Higher values: faster ramp-up, shorter effective lead-in — better + for short threads +- Lower values: gentler start, less mechanical stress — better for + heavy setups with large inertia +- Too high with a heavy load: risk of missed steps during the ramp + +Typical Range +------------- + +**Typical starting point:** approximately 2× your Threading Max Speed +value (e.g., max speed 1000 steps/s → acceleration 2000 steps/s²). + +Increase gradually until the ramp feels responsive without causing +missed steps. + +Notes +----- + +- Value must be a positive integer +- The same rate is used for both accelerating and decelerating +- For general servo acceleration guidance see the Servo Acceleration + help (servo_acceleration.md) in the Servo setup screen diff --git a/rcp/help/at_threading_max_speed.rst b/rcp/help/at_threading_max_speed.rst new file mode 100644 index 0000000..5d712e1 --- /dev/null +++ b/rcp/help/at_threading_max_speed.rst @@ -0,0 +1,48 @@ +Threading Max Speed (Steps/s) +============================= + +The maximum servo speed allowed during the threading cut itself. + +How It Works +------------ + +When a threading pass begins, the firmware drives the saddle at the +speed required by the spindle RPM and selected pitch. This value acts +as a hard cap — the servo will not exceed it regardless of how fast +the spindle is turning. + +The system also uses this limit to calculate the **maximum allowable +spindle RPM** for the selected pitch. If the spindle is turning too +fast when you press Cut, a warning is shown and the cut is blocked +until the spindle speed is reduced. + +Spindle Speed Limit +------------------- + +The relationship between spindle RPM and required servo speed is: + + required steps/s = (spindle RPM / 60) × pitch × steps per mm × scale/servo ratio + +A higher threading max speed allows a faster spindle, but the machine +must be rigid enough to handle the increased feed rate. + +Typical Ranges +-------------- + +=================== ====================== +Servo Max Speed Typical Threading Speed +=================== ====================== +1000 steps/s 500–1000 steps/s +2000 steps/s 1000–2000 steps/s +3000 steps/s 1500–3000 steps/s +=================== ====================== + +Notes +----- + +- Value is clamped to the servo max speed +- For coarse pitches (> 2 mm / < 13 TPI), consider a lower value to + reduce chatter and tool load +- If the system warns that your spindle is too fast, either reduce + spindle RPM or increase this setting (within your machine's limits) +- See also: Servo Max Speed in the Servo setup screen diff --git a/tests/components/home/assisted_threading/test_compound_infeed.py b/tests/components/home/assisted_threading/test_compound_infeed.py index b1d4538..1f5a76c 100644 --- a/tests/components/home/assisted_threading/test_compound_infeed.py +++ b/tests/components/home/assisted_threading/test_compound_infeed.py @@ -165,6 +165,34 @@ def test_larger_depth_gives_larger_z_offset(self): assert w2._get_compound_z_offset_encoder() > w1._get_compound_z_offset_encoder() +# --------------------------------------------------------------------------- +# 2b. _get_compound_z_offset_encoder — retraction past material surface +# --------------------------------------------------------------------------- + +class TestCompoundZOffsetRetraction: + def test_outer_retracted_past_surface_returns_zero(self): + """Outer thread: cross-slide moved outward past material_width (positive encoder, + opposite to cutting direction) → no compound shift.""" + depth_enc = round(0.3 * 6926) + # Outer thread with positive ratioNum: cutting direction is inward (negative encoder). + # Moving outward (+depth_enc) is a retraction past the surface. + wizard, bar, *_ = _w_compound( + cross_encoderCurrent=depth_enc, material_width=0, inner_thread=False + ) + assert wizard._get_compound_z_offset_encoder() == 0 + + def test_inner_retracted_past_surface_returns_zero(self): + """Inner thread: cross-slide moved inward past material_width (negative encoder, + opposite to cutting direction) → no compound shift.""" + depth_enc = round(0.3 * 6926) + # Inner thread with positive ratioNum: cutting direction is outward (positive encoder). + # Moving inward (-depth_enc) is a retraction past the surface. + wizard, bar, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, inner_thread=True + ) + assert wizard._get_compound_z_offset_encoder() == 0 + + # --------------------------------------------------------------------------- # 3. Direction: ΔZ applied in saddle threading direction # --------------------------------------------------------------------------- @@ -205,10 +233,12 @@ def test_lht_positive_scale_z_applied_in_positive_direction(self): ) def test_rht_negative_scale_z_applied_in_positive_direction(self): - """RHT + negative scale → effective_dir = +1.""" + """RHT + negative scale → effective_dir = +1. + With ratioNum=-1 on outer threading, cross_dir = +1, so encoder must + INCREASE to cut deeper. Use +depth_enc to represent a real cutting depth.""" depth_enc = round(0.3 * 6926) wizard, bar, *_ = _w_compound( - cross_encoderCurrent=-depth_enc, material_width=0, + cross_encoderCurrent=+depth_enc, material_width=0, left_hand_thread=False, ratioNum=-1, ratioDen=6926, ) bar.start_position = 0 From cddec71a702d63fd4906792bf8e9e21692fffcff Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Tue, 14 Apr 2026 17:08:14 +0200 Subject: [PATCH 58/62] Bumped version to 1.3.0-rc.23 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 28eae04..ad2322c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rcp" -version = "1.3.0-rc.22" +version = "1.3.0-rc.23" description = "Rotary Controller Python" authors = [ { name = "Stefano Bertelli", email = "stefano@provvedo.com" } From 22b6c350fbed2d29a4ca522a3ae6d52c16f8d6b5 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 20 Apr 2026 09:01:24 +0200 Subject: [PATCH 59/62] Fixed bug in compound offset calculation when in diameter mode --- .../home/assisted_threading/calculations.py | 4 ++++ .../assisted_threading/test_compound_infeed.py | 14 ++++++++++++++ uv.lock | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/rcp/components/home/assisted_threading/calculations.py b/rcp/components/home/assisted_threading/calculations.py index c03784c..5d8b31e 100644 --- a/rcp/components/home/assisted_threading/calculations.py +++ b/rcp/components/home/assisted_threading/calculations.py @@ -310,6 +310,10 @@ def _get_compound_z_offset_encoder(self) -> int: encoder_factor = float(self.app.formats.MM_FRACTION) delta_x_mm = delta_x_enc_in_cut_dir * encoder_factor * abs(float(cross_inp.ratioNum) / float(cross_inp.ratioDen)) if cross_inp.ratioDen != 0 else 0.0 + # Compound formula requires radial depth; in diameter mode MM_FRACTION includes 2× multiplier + if self.app.els.at_cross_slide_diameter_mode: + delta_x_mm /= 2.0 + compound_angle = self._get_compound_angle_degrees() delta_z_mm = delta_x_mm * tan(radians(compound_angle)) diff --git a/tests/components/home/assisted_threading/test_compound_infeed.py b/tests/components/home/assisted_threading/test_compound_infeed.py index 1f5a76c..b253a1d 100644 --- a/tests/components/home/assisted_threading/test_compound_infeed.py +++ b/tests/components/home/assisted_threading/test_compound_infeed.py @@ -164,6 +164,20 @@ def test_larger_depth_gives_larger_z_offset(self): assert w2._get_compound_z_offset_encoder() > w1._get_compound_z_offset_encoder() + def test_diameter_mode_gives_half_z_offset(self): + """Diameter mode: MM_FRACTION includes 2× → must halve delta_x_mm for radial depth.""" + depth_enc = round(0.3 * 6926) + + w_radius, *_ = _w_compound(cross_encoderCurrent=-depth_enc, material_width=0) + z_radius = w_radius._get_compound_z_offset_encoder() + + w_diameter, *_ = _w_compound(cross_encoderCurrent=-depth_enc, material_width=0) + w_diameter.app.els.at_cross_slide_diameter_mode = True + z_diameter = w_diameter._get_compound_z_offset_encoder() + + assert z_radius > 0 + assert z_diameter == pytest.approx(z_radius / 2, abs=2) + # --------------------------------------------------------------------------- # 2b. _get_compound_z_offset_encoder — retraction past material surface diff --git a/uv.lock b/uv.lock index 08cd916..6b10b8f 100644 --- a/uv.lock +++ b/uv.lock @@ -1490,7 +1490,7 @@ wheels = [ [[package]] name = "rcp" -version = "1.3.0rc22" +version = "1.3.0rc23" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From b6402da5186d2465126481d70abfaef2ac1b3e61 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Thu, 23 Apr 2026 07:40:12 +0200 Subject: [PATCH 60/62] Bumped version to 1.3.0-rc.24 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ad2322c..3839679 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rcp" -version = "1.3.0-rc.23" +version = "1.3.0-rc.24" description = "Rotary Controller Python" authors = [ { name = "Stefano Bertelli", email = "stefano@provvedo.com" } From 915cc2f38d100a0fc967967e04b0c9855a9ff448 Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 27 Apr 2026 09:19:24 +0200 Subject: [PATCH 61/62] Fixed bug in thread depth calculation when working in TPI --- pyproject.toml | 2 +- rcp/components/home/assisted_threading/calculations.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3839679..134b8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rcp" -version = "1.3.0-rc.24" +version = "1.3.0-rc.25" description = "Rotary Controller Python" authors = [ { name = "Stefano Bertelli", email = "stefano@provvedo.com" } diff --git a/rcp/components/home/assisted_threading/calculations.py b/rcp/components/home/assisted_threading/calculations.py index 5d8b31e..a28a336 100644 --- a/rcp/components/home/assisted_threading/calculations.py +++ b/rcp/components/home/assisted_threading/calculations.py @@ -249,14 +249,10 @@ def _calculate_thread_depth(self): if self.app.els.at_cross_slide_diameter_mode: depth = depth * 2 - # Convert depth to match current display format if needed + # depth is always in mm at this point; convert to display units if needed is_current_format_metric = self.app.formats.current_format == "MM" - if self.bar.metric_mode and not is_current_format_metric: - # Calculated in mm but displaying in inches + if not is_current_format_metric: depth = depth / MM_PER_INCH - elif not self.bar.metric_mode and is_current_format_metric: - # Calculated in inches but displaying in mm - depth = depth * MM_PER_INCH log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") return depth From 6573a7db25d704454c524e8b54aa291e65cd219f Mon Sep 17 00:00:00 2001 From: Pawcu Camilleri Date: Mon, 27 Apr 2026 09:20:33 +0200 Subject: [PATCH 62/62] Committed fixed tests --- .../assisted_threading/test_calculations.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/components/home/assisted_threading/test_calculations.py b/tests/components/home/assisted_threading/test_calculations.py index 795f945..a8e6f6a 100644 --- a/tests/components/home/assisted_threading/test_calculations.py +++ b/tests/components/home/assisted_threading/test_calculations.py @@ -209,11 +209,11 @@ def test_acme_1_5mm(self): assert abs(w._calculate_thread_depth() - 0.5 * 1.5) < 0.001 def test_imperial_16_tpi_iso_metric(self): - """16 TPI, display in inches → no unit conversion applied. - pitch = 25.4/16 mm; depth = 0.61343 × pitch (raw formula).""" + """16 TPI, display in inches → depth converted to inches (÷ 25.4). + pitch_mm = 25.4/16; depth_in = 0.61343 × pitch_mm / 25.4 = 0.61343/16.""" w = self._w("16", "ISO Metric", metric_mode=False, is_metric_format=False) pitch_mm = 25.4 / 16 - assert abs(w._calculate_thread_depth() - 0.61343 * pitch_mm) < 0.001 + assert abs(w._calculate_thread_depth() - 0.61343 * pitch_mm / 25.4) < 0.001 def test_diameter_mode_doubles_depth(self): w = self._w("1.5", "ISO Metric", diameter_mode=True) @@ -239,9 +239,9 @@ def test_metric_pitch_displayed_in_inches(self): assert abs(w._calculate_thread_depth() - expected_in) < 0.0001 def test_imperial_tpi_displayed_in_mm(self): - """TPI pitch but display in MM: depth converted by *25.4.""" + """TPI pitch, display in MM: depth stays in mm (no extra conversion).""" w = self._w("16", "ISO Metric", metric_mode=False, is_metric_format=True) - expected = 0.61343 * (25.4 / 16) * 25.4 + expected = 0.61343 * (25.4 / 16) assert abs(w._calculate_thread_depth() - expected) < 0.001 @@ -367,24 +367,25 @@ def _w(self, pitch: str, profile: str, diameter_mode: bool = False): app.els.at_cross_slide_diameter_mode = diameter_mode return wizard - def _pitch_mm(self, tpi: str) -> float: - return 25.4 / float(tpi) + def _pitch_in(self, tpi: str) -> float: + """TPI → pitch in inches (= 1/TPI).""" + return 1.0 / float(tpi) def test_unified_16_tpi_inch_display(self): w = self._w("16", "Unified") - assert abs(w._calculate_thread_depth() - 0.64952 * self._pitch_mm("16")) < 0.001 + assert abs(w._calculate_thread_depth() - 0.64952 * self._pitch_in("16")) < 0.001 def test_whitworth_16_tpi_inch_display(self): w = self._w("16", "Whitworth") - assert abs(w._calculate_thread_depth() - 0.6403 * self._pitch_mm("16")) < 0.001 + assert abs(w._calculate_thread_depth() - 0.6403 * self._pitch_in("16")) < 0.001 def test_acme_16_tpi_inch_display(self): w = self._w("16", "ACME") - assert abs(w._calculate_thread_depth() - 0.5 * self._pitch_mm("16")) < 0.001 + assert abs(w._calculate_thread_depth() - 0.5 * self._pitch_in("16")) < 0.001 def test_diameter_mode_doubles_tpi_depth(self): w = self._w("16", "ISO Metric", diameter_mode=True) - assert abs(w._calculate_thread_depth() - 0.61343 * self._pitch_mm("16") * 2) < 0.001 + assert abs(w._calculate_thread_depth() - 0.61343 * self._pitch_in("16") * 2) < 0.001 def test_zero_tpi_raises(self): """Zero TPI → ZeroDivisionError: the code does `25.4 / tpi` before the