-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbifrost.py
More file actions
2733 lines (2307 loc) · 119 KB
/
bifrost.py
File metadata and controls
2733 lines (2307 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import sys
import paths
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import config
# Import appropriate GUI based on config
if config.USE_MODERN_GUI:
from gui_modern import Ui_MainWindow, PointEditDialog
else:
from gui import Ui_MainWindow
PointEditDialog = None # Not available in legacy GUI
from about import Ui_Dialog as About_Ui_Dialog
import serial_port_finder as spf
import inverse_kinematics as ik
import sequence_recorder as seq_rec
import differential_kinematics as diff_kin
import position_history as pos_hist
import config
import parsing_patterns
from command_builder import CommandBuilder, SerialCommandSender
from robot_controller import RobotController
from serial_manager import SerialManager
from addon_api import BifrostAPI
from addon_manager import AddonManager
from serial_thread import SerialThread
from connection_manager import ConnectionManager, ConnectionState
from movement_controller import MovementController, MovementParams, get_movement_params_from_gui
from position_display_controller import PositionDisplayController
from sequence_controller import SequenceController, JointPositions
from ik_controller import IKController
from fk_controller import FKController, JointValues
from gripper_controller import GripperController
from ui_state_manager import UIStateManager, ConnectionState as UIConnectionState, RobotState
from serial_response_router import SerialResponseRouter
from visualization_controller import VisualizationController
from position_history_manager import PositionHistoryManager
from calibration_panel import load_gripper_calibration_on_startup, load_home_position_on_startup, load_park_position_on_startup
from config_g_manager import read_m569_directions, DEFAULT_CONFIG_G_PATH
from coordinate_frames import FrameManager, pose_to_xyz_rpy
from frame_controller import FrameController
import forward_kinematics as fk
import serial
import time
import json
import logging
import numpy as np
# Import simulation hardware (always available for runtime toggle)
from simulated_hardware import SimulatedSerialManager, SimulatedSerialThread
# Configure logging for debugging using config
# Only log to file, not to console
logging.basicConfig(
level=getattr(logging, config.LOG_LEVEL),
format=config.LOG_FORMAT,
handlers=[
logging.FileHandler(config.LOG_FILE)
]
)
logger = logging.getLogger(__name__)
# Load calibration from files (if they exist) before GUI starts
load_gripper_calibration_on_startup()
load_home_position_on_startup()
load_park_position_on_startup()
# This application is designed for RepRapFirmware (RRF) only
# All parsing patterns are now in parsing_patterns.py module
# Global serial manager instance (real or simulated based on config)
if config.USE_SIMULATION_MODE:
logger.info("=== STARTING IN SIMULATION MODE ===")
logger.info("No hardware required - using simulated robot")
s0 = SimulatedSerialManager()
else:
s0 = SerialManager()
class WAlignmentDialog(QtWidgets.QDialog):
"""Dialog for manually aligning J6 (wrist roll) during homing.
W has no endstop, so the user must jog J6 to the desired zero position
and confirm before homing finalizes. Pure J6 rotation requires equal
V+W movement (differential kinematics). The cumulative offset is
tracked so the caller can restore V to its homed position afterwards.
"""
def __init__(self, command_sender, parent=None):
super().__init__(parent)
self.command_sender = command_sender
self.total_offset = 0
self.setWindowTitle("Align J6 (Wrist Roll)")
self.setModal(True)
self.setMinimumWidth(320)
layout = QtWidgets.QVBoxLayout(self)
label = QtWidgets.QLabel(
"J6 (wrist roll) has no endstop.\n\n"
"Use the buttons below to rotate J6\n"
"to the desired zero position, then click OK."
)
layout.addWidget(label)
jog_layout = QtWidgets.QHBoxLayout()
for step, text in [(-10, "-10\u00b0"), (-1, "-1\u00b0"), (1, "+1\u00b0"), (10, "+10\u00b0")]:
btn = QtWidgets.QPushButton(text)
btn.clicked.connect(lambda checked, s=step: self._jog(s))
jog_layout.addWidget(btn)
layout.addLayout(jog_layout)
ok_btn = QtWidgets.QPushButton("OK - Set as Zero")
ok_btn.clicked.connect(self.accept)
layout.addWidget(ok_btn)
def _jog(self, step):
# Pure J6 rotation: move V and W equally (ΔArt5=0, ΔArt6=step)
self.command_sender.send_if_connected("G91")
self.command_sender.send_if_connected(f"G1 V{step} W{step} F1800")
self.command_sender.send_if_connected("G90")
self.total_offset += step
class HistoryLineEdit(QtWidgets.QLineEdit):
"""QLineEdit with command history navigation via up/down arrows"""
def __init__(self, parent=None):
super().__init__(parent)
self.history = []
self.history_position = -1
self.current_text = ""
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key_Up:
# Navigate up through history (older commands)
if len(self.history) > 0:
if self.history_position == -1:
# Save current text before navigating history
self.current_text = self.text()
self.history_position = len(self.history) - 1
elif self.history_position > 0:
self.history_position -= 1
if 0 <= self.history_position < len(self.history):
self.setText(self.history[self.history_position])
elif event.key() == QtCore.Qt.Key_Down:
# Navigate down through history (newer commands)
if self.history_position != -1:
if self.history_position < len(self.history) - 1:
self.history_position += 1
self.setText(self.history[self.history_position])
else:
# Back to current text
self.history_position = -1
self.setText(self.current_text)
else:
# For any other key, reset history position
if event.key() not in (QtCore.Qt.Key_Up, QtCore.Qt.Key_Down):
if self.history_position != -1:
self.history_position = -1
super().keyPressEvent(event)
def addToHistory(self, command):
"""Add a command to history"""
if command.strip(): # Don't add empty commands
# Don't add duplicate consecutive commands
if not self.history or self.history[-1] != command:
self.history.append(command)
# Limit history to prevent memory leak in long sessions
if len(self.history) > 100:
self.history.pop(0)
self.history_position = -1
self.current_text = ""
class AboutDialog(About_Ui_Dialog):
def __init__(self, dialog):
About_Ui_Dialog.__init__(self)
self.setupUi(dialog)
class ConnectionSignals(QtCore.QObject):
"""Signals for thread-safe connection callbacks"""
success = pyqtSignal(str, str) # serialPort, baudrate
error = pyqtSignal(str) # error_msg
class BifrostGUI(Ui_MainWindow):
def __init__(self, dialog):
Ui_MainWindow.__init__(self)
self.setupUi(dialog)
# Create connection manager (handles serial connection lifecycle)
self.connection_manager = ConnectionManager(s0)
self.connection_manager.set_serial_thread_class(SerialThreadClass)
# Keep old connection_signals for backward compatibility
self.connection_signals = self.connection_manager # ConnectionManager has same signals
# Replace ConsoleInput with HistoryLineEdit for command history
old_console_input = self.ConsoleInput
parent = old_console_input.parent() # Keep it on the same panel/container
layout = parent.layout() if parent else None
self.ConsoleInput = HistoryLineEdit(parent)
self.ConsoleInput.setObjectName("ConsoleInput")
# If the old widget sat in a layout, replace it in-place
if layout is not None:
layout.replaceWidget(old_console_input, self.ConsoleInput)
self.ConsoleInput.setMinimumSize(old_console_input.minimumSize())
self.ConsoleInput.setMaximumSize(old_console_input.maximumSize())
else:
# Fallback for absolute positioning
self.ConsoleInput.setGeometry(old_console_input.geometry())
old_console_input.deleteLater()
# Using RepRapFirmware (RRF) - this is the only supported firmware
# Track serial thread state
self.SerialThreadClass = None
# Track last manual command time to show responses
self.last_manual_command_time = 0
# Track homing state
self.is_homing = False
self.sync_commands_pending = False
# Initialise command sender (will use ConsoleOutput widget)
self.command_sender = SerialCommandSender(s0, None) # Console widget set later after full init
# Initialise robot controller
self.robot_controller = RobotController()
logger.info("RobotController initialised and integrated with GUI")
# Initialise movement controller (will set command_sender after full init)
self.movement_controller = MovementController(self.robot_controller, command_sender=None)
self.getSerialPorts()
# Menu bar is optional (removed in modern GUI)
if hasattr(self, 'actionAbout'):
self.actionAbout.triggered.connect(self.launchAboutWindow)
if hasattr(self, 'actionExit'):
self.actionExit.triggered.connect(self.close_application)
# Connect settings button to About dialog (modern GUI)
if hasattr(self, 'SettingsButton'):
self.SettingsButton.pressed.connect(self.launchAboutWindow)
# Setup embedded position history graph
if not config.USE_MODERN_GUI:
# Classic GUI: Full controls in separate group box
self.setupPositionHistoryControls()
else:
# Modern GUI: Compact 3D view in robot state panel
self.setupModern3DVisualization()
self.HomeButton.pressed.connect(self.sendHomingCycleCommand)
self.ZeroPositionButton.pressed.connect(self.sendHomePositionCommand)
self.ParkButton.pressed.connect(self.sendParkCommand)
self.EmergencyStopButton.pressed.connect(self.sendEmergencyStopCommand)
# Movement type now controlled by axis column - connect its signal
if hasattr(self, 'axis_column'):
self.axis_column.movement_type_changed.connect(self._onMovementTypeChanged)
# Dynamic FK control connections (80% code reduction via loops)
# Replace 54 individual signal connections with generic handlers
for joint in ['Art1', 'Art2', 'Art3', 'Art4', 'Art5', 'Art6']:
# Get widgets
go_button = getattr(self, f'FKGoButton{joint}')
slider = getattr(self, f'FKSlider{joint}')
spinbox = getattr(self, f'SpinBox{joint}')
dec10_btn = getattr(self, f'FKDec10Button{joint}')
dec1_btn = getattr(self, f'FKDec1Button{joint}')
dec01_btn = getattr(self, f'FKDec0_1Button{joint}')
inc01_btn = getattr(self, f'FKInc0_1Button{joint}')
inc1_btn = getattr(self, f'FKInc1Button{joint}')
inc10_btn = getattr(self, f'FKInc10Button{joint}')
# Connect signals with lambda closures
go_button.pressed.connect(lambda j=joint: self.FKMoveJoint(j))
slider.valueChanged.connect(lambda val, j=joint: self.FKSliderUpdate(j, val))
spinbox.valueChanged.connect(lambda val, j=joint: self.FKSpinBoxUpdate(j, val))
dec10_btn.pressed.connect(lambda j=joint: self.adjustJointValue(j, -10))
dec1_btn.pressed.connect(lambda j=joint: self.adjustJointValue(j, -1))
dec01_btn.pressed.connect(lambda j=joint: self.adjustJointValue(j, -0.1))
inc01_btn.pressed.connect(lambda j=joint: self.adjustJointValue(j, 0.1))
inc1_btn.pressed.connect(lambda j=joint: self.adjustJointValue(j, 1))
inc10_btn.pressed.connect(lambda j=joint: self.adjustJointValue(j, 10))
self.FKGoAllButton.pressed.connect(self.FKMoveAll)
self.GoButtonGripper.pressed.connect(self.MoveGripper)
self.SliderGripper.valueChanged.connect(self.SliderUpdateGripper)
self.SpinBoxGripper.valueChanged.connect(self.SpinBoxUpdateGripper)
self.Dec10ButtonGripper.pressed.connect(self.Dec10Gripper)
self.Dec1ButtonGripper.pressed.connect(self.Dec1Gripper)
self.Inc1ButtonGripper.pressed.connect(self.Inc1Gripper)
self.Inc10ButtonGripper.pressed.connect(self.Inc10Gripper)
# Gripper preset buttons (Close/Open) - connect to execute movement
if hasattr(self, 'gripper_close_btn') and self.gripper_close_btn:
self.gripper_close_btn.clicked.connect(lambda: self.setGripperAndMove(0))
if hasattr(self, 'gripper_open_btn') and self.gripper_open_btn:
self.gripper_open_btn.clicked.connect(lambda: self.setGripperAndMove(100))
# New axis control column +/- buttons (use step toggle for increment amount)
if hasattr(self, 'axis_column'):
# Connect mode change signal
self.axis_column.mode_changed.connect(self._onAxisControlModeChanged)
# Connect frame change signal
self.axis_column.frame_changed.connect(self._onCoordinateFrameChanged)
# Initial connection for joint mode (default)
self._connectAxisColumnButtons()
# Gripper +/- buttons (always visible regardless of mode)
gripper_row = self.axis_column.rows["Gripper"]
gripper_row.minus_btn.pressed.connect(
lambda: self.adjustGripperValue(-self.axis_column.get_step())
)
gripper_row.plus_btn.pressed.connect(
lambda: self.adjustGripperValue(self.axis_column.get_step())
)
gripper_row.open_btn.clicked.connect(lambda: self.setGripperAndMove(100))
gripper_row.close_btn.clicked.connect(lambda: self.setGripperAndMove(0))
self.SerialPortRefreshButton.pressed.connect(self.getSerialPorts)
self.ConnectButton.pressed.connect(self.connectSerial)
# Simulation mode checkbox - enables direct visualization without serial connection
self.SimulationModeCheckBox.setChecked(config.USE_SIMULATION_MODE)
self.SimulationModeCheckBox.toggled.connect(self._onSimulationModeToggled)
self.SimulationModeCheckBox.setToolTip("Move robot in visualization without hardware connection")
# Connect console input signals after replacing with HistoryLineEdit
self.ConsoleButtonSend.pressed.connect(self.sendSerialCommand)
self.ConsoleInput.returnPressed.connect(self.sendSerialCommand)
self.ConsoleClearButton.pressed.connect(self.clearConsole)
# Quick command buttons on terminal tab
self.QuickM114Button.pressed.connect(lambda: self.sendQuickCommand("M114"))
self.QuickM119Button.pressed.connect(lambda: self.sendQuickCommand("M119"))
self.QuickG28Button.pressed.connect(lambda: self.sendQuickCommand("G28"))
self.QuickG92Button.pressed.connect(lambda: self.sendQuickCommand("G92 X0 Y0 Z0 U0 V0 W0"))
# IK Control connections with debounce (Cartesian spinbox changes → live IK preview)
self.ik_calc_timer = QtCore.QTimer()
self.ik_calc_timer.setSingleShot(True)
self.ik_calc_timer.timeout.connect(self._calculateIKDeferred)
self.IKInputSpinBoxX.valueChanged.connect(lambda: self.ik_calc_timer.start(50))
self.IKInputSpinBoxY.valueChanged.connect(lambda: self.ik_calc_timer.start(50))
self.IKInputSpinBoxZ.valueChanged.connect(lambda: self.ik_calc_timer.start(50))
self.IKInputSpinBoxA.valueChanged.connect(lambda: self.ik_calc_timer.start(50))
self.IKInputSpinBoxB.valueChanged.connect(lambda: self.ik_calc_timer.start(50))
self.IKInputSpinBoxC.valueChanged.connect(lambda: self.ik_calc_timer.start(50))
# IK jog buttons: +/- for each axis
for axis in ('X', 'Y', 'Z'):
self.control_panel.ik_jog_buttons[(axis, -1)].pressed.connect(
lambda a=axis: self.adjustIKValue(a, -self.axis_column.get_step()))
self.control_panel.ik_jog_buttons[(axis, +1)].pressed.connect(
lambda a=axis: self.adjustIKValue(a, self.axis_column.get_step()))
ori_spinbox_map = {'A': self.IKInputSpinBoxA, 'B': self.IKInputSpinBoxB, 'C': self.IKInputSpinBoxC}
for axis in ('A', 'B', 'C'):
self.control_panel.ik_jog_buttons[(axis, -1)].pressed.connect(
lambda a=axis: ori_spinbox_map[a].setValue(
ori_spinbox_map[a].value() - self.axis_column.get_step()))
self.control_panel.ik_jog_buttons[(axis, +1)].pressed.connect(
lambda a=axis: ori_spinbox_map[a].setValue(
ori_spinbox_map[a].value() + self.axis_column.get_step()))
# D-pad jog buttons (XY cross + Z up/down)
for axis in ('X', 'Y', 'Z'):
self.control_panel.ik_dpad_buttons[(axis, -1)].pressed.connect(
lambda a=axis: self.adjustIKValue(a, -self.axis_column.get_step()))
self.control_panel.ik_dpad_buttons[(axis, +1)].pressed.connect(
lambda a=axis: self.adjustIKValue(a, self.axis_column.get_step()))
# Control panel: mode toggle, action buttons
self._control_mode = "cartesian" # Default mode
self.control_panel.controlModeChanged.connect(self._onControlModeChanged)
self.GoToButton.pressed.connect(self.goToTarget)
self.GetCurrentButton.pressed.connect(self.getCurrentPosition)
# Initialise Frame Manager for coordinate frame transformations
self.frame_manager = FrameManager()
logger.info(f"Frame manager initialised with frames: {self.frame_manager.list_frames()}")
# Initialise Frame Controller (orchestrates frame teaching + management)
self.frame_controller = FrameController(
frame_manager=self.frame_manager,
get_current_tcp=self._getCurrentTCPPosition,
on_base_frame_changed=self._syncBaseTransformToVisualizer,
on_tool_changed=self._onToolChanged,
)
self.frame_controller.set_config_path(paths.get_data_dir() / "coordinate_frames.json")
self.frame_controller.load_frames()
self._syncBaseTransformToVisualizer()
self._syncToolOffsetToVisualizer()
# Initialise IK Controller with callbacks and frame manager
self.ik_controller = IKController(
output_update_callback=self._onIKOutputUpdate,
spinbox_update_callback=self._onIKSpinboxUpdate,
style_update_callback=self._onIKStyleUpdate,
move_callback=self.FKMoveAll,
frame_manager=self.frame_manager
)
# Initialise FK Controller with callbacks
self.fk_controller = FKController(
robot_controller=self.robot_controller,
command_sender=None, # Will be set after full init
spinbox_update_callback=self._onFKSpinboxUpdate,
slider_update_callback=self._onFKSliderUpdate,
visualization_update_callback=self._onFKVisualizationUpdate,
get_movement_params_callback=lambda: CommandBuilder.get_movement_params(self),
no_connection_callback=self.noSerialConnection
)
# Initialise Gripper Controller with callbacks
self.gripper_controller = GripperController(
command_sender=None, # Will be set after full init
spinbox_update_callback=self._onGripperSpinboxUpdate,
slider_update_callback=self._onGripperSliderUpdate,
no_connection_callback=self.noSerialConnection
)
# Initialise UI State Manager with callbacks
self.ui_state_manager = UIStateManager(
set_widget_enabled=self._setWidgetEnabled,
set_widget_style=self._setWidgetStyle,
set_widget_visible=self._setWidgetVisible,
set_widget_text=self._setWidgetText
)
# Initialise Sequence Controller with callbacks
self.sequence_controller = SequenceController(
command_sender=None, # Will be set after full init
list_update_callback=self._onSequencePointAdded,
list_clear_callback=self._onSequenceCleared,
list_remove_callback=self._onSequencePointRemoved,
button_state_callback=self._onSequenceButtonStateChanged,
pause_text_callback=self._onSequencePauseTextChanged,
simulation_move_callback=self._onSimulationSequenceMove
)
# Backward compatibility references
self.sequence_recorder = self.sequence_controller.recorder
self.sequence_player = None
self.is_playing_sequence = False
self.sequence_timer = QtCore.QTimer()
self.sequence_timer.timeout.connect(self.updateSequencePlayback)
# Simulation interpolation state
self._sim_interp_timer = QtCore.QTimer()
self._sim_interp_timer.timeout.connect(self._tickSimulationInterpolation)
self._sim_interp_start = None # [q1..q6, gripper] start values
self._sim_interp_end = None # [q1..q6, gripper] target values
self._sim_interp_t0 = 0.0 # wall-clock start time
self._sim_interp_duration = 0.0
# Only setup classic GUI sequence controls if not using modern GUI
# Modern GUI has these controls built into TEACH mode panel
if not config.USE_MODERN_GUI:
self.setupSequenceControls()
# NOTE: Differential motor tracking, desired positions, and position validation
# are now handled by RobotController. Local references kept for backward compatibility
# and will be updated from controller as needed.
self.current_motor_v = 0.0 # Updated from robot_controller
self.current_motor_w = 0.0 # Updated from robot_controller
self.desired_art5 = 0.0 # Updated from robot_controller
self.desired_art6 = 0.0 # Updated from robot_controller
self.position_update_count = 0 # Updated from robot_controller
self.last_gui_update_time = 0 # For throttling GUI updates
# Position history tracking
self.position_history = pos_hist.PositionHistory(max_size=config.POSITION_HISTORY_MAX_SIZE)
logger.info(f"Position history initialised (max_size={config.POSITION_HISTORY_MAX_SIZE}, sample_rate=1/{config.POSITION_HISTORY_SAMPLE_RATE})")
# Initialise position display controller with callbacks
self.position_display_controller = PositionDisplayController(
robot_controller=self.robot_controller,
position_history=self.position_history,
gui_update_callback=self._onPositionUpdate,
state_update_callback=self._onStateUpdate,
endstop_update_callback=self._onEndstopUpdate
)
# Initialise Serial Response Router with callbacks
self.serial_response_router = SerialResponseRouter(
position_handler=self.updateFKPosDisplay,
endstop_handler=self.updateEndstopDisplay,
disconnect_handler=self._handleSerialDisconnect,
get_is_homing=lambda: self.is_homing,
set_homing_complete=self._onHomingComplete,
request_position_update=lambda: self.command_sender.send_if_connected("M114"),
set_sync_pending=lambda: setattr(self, 'sync_commands_pending', True),
trigger_sync=self._triggerCommandSync
)
# Initialise Visualisation Controller with callbacks
self.visualization_controller = VisualizationController(
position_history=self.position_history,
update_canvas_callback=self._updateVisualizationCanvas,
reset_view_callback=self._resetVisualizationView,
get_trajectory_enabled=self._getTrajectoryEnabled,
get_auto_rotate_enabled=self._getAutoRotateEnabled,
get_time_window=self._getTimeWindow,
get_display_checkboxes=self._getDisplayCheckboxes,
reload_dh_parameters=self._reloadDHParameters
)
# Initialise Position History Manager with callbacks
self.position_history_manager = PositionHistoryManager(
position_history=self.position_history,
get_save_filename=self._getSaveFilename,
show_warning=self._showWarningDialog,
show_info=self._showInfoDialog,
show_error=self._showErrorDialog,
confirm_action=self._confirmAction
)
# Setup endstop status displays
self.setupEndstopDisplays()
# Setup generic increment/decrement connections
self.setupGenericControls()
# Connect connection manager signals now that methods are defined
self.connection_manager.connected.connect(self._onConnectionSuccess)
self.connection_manager.error.connect(self._onConnectionError)
self.connection_manager.disconnected.connect(self.serialDisconnected)
# Set console widget for command sender now that UI is fully initialized
self.command_sender.console_output = self.ConsoleOutput
# Set command sender for movement controller now that it's configured
self.movement_controller.command_sender = self.command_sender
# Set command sender for sequence controller
self.sequence_controller.command_sender = self.command_sender
# Connect teach panel signals (modern GUI only)
# In legacy GUI these connections are made in setupSequenceControls()
if config.USE_MODERN_GUI and hasattr(self, 'teach_panel'):
self.teach_panel.manualPointRequested.connect(self.openAddManualPointDialog)
self.teach_panel.pointEditRequested.connect(self.openEditPointDialog)
self.teach_panel.importCsvRequested.connect(self.importCsvSequence)
# Connect sequence control buttons
self.sequenceRecordButton.pressed.connect(self.recordSequencePoint)
self.sequenceDeleteButton.pressed.connect(self.deleteSequencePoint)
self.sequenceClearButton.pressed.connect(self.clearSequence)
self.sequencePlayButton.pressed.connect(self.playSequence)
self.sequencePauseButton.pressed.connect(self.pauseSequence)
self.sequenceStopButton.pressed.connect(self.stopSequence)
self.sequenceSaveButton.pressed.connect(self.saveSequence)
self.sequenceLoadButton.pressed.connect(self.loadSequence)
# Control panel sequence buttons (share same handlers)
self.ctrlRecordButton.pressed.connect(self.recordSequencePoint)
self.ctrlDeleteButton.pressed.connect(self._deleteSequencePointFromCtrl)
self.ctrlPlayButton.pressed.connect(self.playSequence)
self.ctrlPauseButton.pressed.connect(self.pauseSequence)
self.ctrlStopButton.pressed.connect(self.stopSequence)
self.ctrlSaveButton.pressed.connect(self.saveSequence)
self.ctrlLoadButton.pressed.connect(self.loadSequence)
# Sync sequence names between panels
self.teach_panel.SequenceNameEdit.textChanged.connect(
lambda t: self.ctrlSequenceNameEdit.setText(t)
if self.ctrlSequenceNameEdit.text() != t else None
)
self.ctrlSequenceNameEdit.textChanged.connect(
lambda t: self.teach_panel.SequenceNameEdit.setText(t)
if self.teach_panel.SequenceNameEdit.text() != t else None
)
# Set command sender for FK controller
self.fk_controller.command_sender = self.command_sender
# Set command sender for gripper controller
self.gripper_controller.command_sender = self.command_sender
# Wire frame management panel to controller
if config.USE_MODERN_GUI and hasattr(self, 'frames_panel'):
self.frames_panel.set_controller(self.frame_controller)
# Also sync the jog bar's frame selector with workpiece frames
if hasattr(self, 'axis_column'):
self.axis_column.set_available_frames(
self.frame_controller.get_selectable_frames()
)
# Chain onto the existing on_frames_updated callback so the
# jog bar stays in sync when frames are added/removed
_panel_cb = self.frame_controller.on_frames_updated
def _on_frames_updated_all(frames):
if _panel_cb:
_panel_cb(frames)
self.axis_column.set_available_frames(frames)
self.frame_controller.on_frames_updated = _on_frames_updated_all
# Chain tool list updates to axis column tool selector
_tool_panel_cb = self.frame_controller.on_tools_updated
def _on_tools_updated_all(tools):
if _tool_panel_cb:
_tool_panel_cb(tools)
self.axis_column.update_tools(tools)
self.frame_controller.on_tools_updated = _on_tools_updated_all
# Populate initial tool list and connect selection signal
tools = self.frame_controller.get_tools()
self.axis_column.update_tools(tools)
self.axis_column.tool_changed.connect(self._onAxisColumnToolChanged)
# Auto-select first non-default tool if one exists
user_tools = [t for t in tools if t != "default_tool"]
if user_tools:
self.frame_controller.select_tool(user_tools[0])
self.axis_column.tool_combo.blockSignals(True)
self.axis_column.tool_combo.setCurrentText(user_tools[0])
self.axis_column.tool_combo.blockSignals(False)
# --- Addon System ---
self.bifrost_api = BifrostAPI(
robot_controller=self.robot_controller,
command_sender=self.command_sender,
connection_manager=self.connection_manager,
gripper_controller=self.gripper_controller
if hasattr(self, 'gripper_controller') else None,
data_dir=paths.get_data_dir()
)
self.bifrost_api._set_simulation_callback(self._onSimulationSequenceMove)
if hasattr(self, 'position_canvas'):
self.bifrost_api._set_position_canvas(self.position_canvas)
addons_dir = paths.get_exe_dir() / config.ADDONS_DIR
self.addon_manager = AddonManager(addons_dir, self.bifrost_api)
self.addon_manager.discover_and_load()
# Register addon panels as mode tabs
self._addon_mode_map = {} # mode_index -> addon_name
for name, icon, panel in self.addon_manager.get_panels():
mode_id = self.register_addon_mode(name, icon, panel)
self._addon_mode_map[mode_id] = name
# ExecuteMovementButton no longer needed - jog mode controls in sidebar
def setupGenericControls(self):
"""
Setup generic increment/decrement methods for all joints
This replaces 36 individual methods with dynamic binding
"""
# Map joint names to their spinboxes
self.joint_spinboxes = {
'Art1': self.SpinBoxArt1,
'Art2': self.SpinBoxArt2,
'Art3': self.SpinBoxArt3,
'Art4': self.SpinBoxArt4,
'Art5': self.SpinBoxArt5,
'Art6': self.SpinBoxArt6,
'Gripper': self.SpinBoxGripper
}
# NOTE: Joint configuration is now managed by RobotController
# This reference is kept for backward compatibility with existing GUI methods
self.joint_config = self.robot_controller.joint_config
logger.info("Generic increment/decrement controls initialised (using RobotController config)")
# Initial TCP sync: set IK spinboxes from home position FK
self._syncIKFromJointAngles(
self.SpinBoxArt1.value(), self.SpinBoxArt2.value(),
self.SpinBoxArt3.value(), self.SpinBoxArt4.value(),
self.SpinBoxArt5.value(), self.SpinBoxArt6.value()
)
def adjustJointValue(self, joint_name, delta):
"""
Generic method to adjust any joint value by a delta
Args:
joint_name: Name of joint ('Art1', 'Art2', etc.)
delta: Amount to add to current value
"""
if joint_name in self.joint_spinboxes:
spinbox = self.joint_spinboxes[joint_name]
new_value = spinbox.value() + delta
# Cast to int for QSpinBox (gripper), keep float for QDoubleSpinBox
if isinstance(spinbox, QtWidgets.QSpinBox):
spinbox.setValue(int(new_value))
else:
spinbox.setValue(new_value)
# Execute movement immediately
if joint_name == 'Gripper':
self.MoveGripper()
else:
self.FKMoveJoint(joint_name)
else:
logger.warning(f"Unknown joint name: {joint_name}")
def FKSliderUpdate(self, joint_name, value):
"""Generic slider update handler - updates spinbox from slider via FK controller"""
self.fk_controller.slider_changed(joint_name, value)
if self.SimulationModeCheckBox.isChecked():
self._updateSimulationVisualization()
def FKSpinBoxUpdate(self, joint_name, value):
"""Generic spinbox update handler - updates slider from spinbox via FK controller"""
self.fk_controller.spinbox_changed(joint_name, value)
if self.SimulationModeCheckBox.isChecked():
self._updateSimulationVisualization()
def FKMoveJoint(self, joint_name):
"""
Generic joint movement handler - delegates to FK controller.
Handles simple joints, coupled motors, and differential kinematics.
In simulation mode, only updates visualization (no serial command).
"""
if joint_name not in self.joint_spinboxes:
logger.warning(f"Unknown joint: {joint_name}")
return
if self.SimulationModeCheckBox.isChecked():
self._updateSimulationVisualization()
return
joint_value = self.joint_spinboxes[joint_name].value()
# For differential joints, pass both joint values so the "hold" joint
# uses the spinbox value (desired state) instead of motor feedback
# which can be stale due to M114 polling race conditions.
other_joint_value = None
if joint_name == 'Art5':
other_joint_value = self.joint_spinboxes['Art6'].value()
elif joint_name == 'Art6':
other_joint_value = self.joint_spinboxes['Art5'].value()
self.fk_controller.move_joint(joint_name, joint_value, other_joint_value=other_joint_value)
def close_application(self):
# Properly cleanup serial connection and thread
if self.SerialThreadClass and self.SerialThreadClass.isRunning():
self.SerialThreadClass.stop()
self.SerialThreadClass.wait(config.SERIAL_THREAD_SHUTDOWN_TIMEOUT)
s0.close()
sys.exit()
def launchAboutWindow(self):
self.dialogAbout = QtWidgets.QDialog()
self.ui = AboutDialog(self.dialogAbout)
self.dialogAbout.exec_()
def sendHomingCycleCommand(self):
"""Send G28 homing command (RRF: Home all axes)"""
if self.SimulationModeCheckBox.isChecked():
# In simulation mode, reset all joints to zero
for spinbox in [self.SpinBoxArt1, self.SpinBoxArt2, self.SpinBoxArt3,
self.SpinBoxArt4, self.SpinBoxArt5, self.SpinBoxArt6]:
spinbox.setValue(0.0)
self._updateSimulationVisualization()
return
if self.command_sender.send_if_connected("G28"):
# Update button state to indicate homing in progress
self.is_homing = True
self.ui_state_manager.update_homing_state(True)
def sendHomePositionCommand(self):
"""Send command to move all axes to the calibrated home position"""
hp = config.HOME_POSITION
if self.SimulationModeCheckBox.isChecked():
self.SpinBoxArt1.setValue(hp.get('Art1', 0.0))
self.SpinBoxArt2.setValue(hp.get('Art2', 0.0))
self.SpinBoxArt3.setValue(hp.get('Art3', 0.0))
self.SpinBoxArt4.setValue(hp.get('Art4', 0.0))
self.SpinBoxArt5.setValue(hp.get('Art5', 0.0))
self.SpinBoxArt6.setValue(hp.get('Art6', 0.0))
self._updateSimulationVisualization()
return
art5 = hp.get('Art5', 0.0)
art6 = hp.get('Art6', 0.0)
motor_v, motor_w = diff_kin.DifferentialKinematics.joint_to_motor(art5, art6)
command = CommandBuilder.build_axis_command(
"G0",
{
"X": hp.get('Art1', 0.0),
"Y": hp.get('Art2', 0.0),
"Z": hp.get('Art3', 0.0),
"U": hp.get('Art4', 0.0),
"V": motor_v,
"W": motor_w,
}
)
self.command_sender.send_if_connected(command)
def sendParkCommand(self):
"""Park the robot: move to park position, close gripper, disable motors"""
pp = config.PARK_POSITION
if self.SimulationModeCheckBox.isChecked():
self.SpinBoxArt1.setValue(pp.get('Art1', 0.0))
self.SpinBoxArt2.setValue(pp.get('Art2', 0.0))
self.SpinBoxArt3.setValue(pp.get('Art3', 0.0))
self.SpinBoxArt4.setValue(pp.get('Art4', 0.0))
self.SpinBoxArt5.setValue(pp.get('Art5', 0.0))
self.SpinBoxArt6.setValue(pp.get('Art6', 0.0))
self.SpinBoxGripper.setValue(0)
self._updateSimulationVisualization()
return
art5 = pp.get('Art5', 0.0)
art6 = pp.get('Art6', 0.0)
motor_v, motor_w = diff_kin.DifferentialKinematics.joint_to_motor(art5, art6)
move_command = CommandBuilder.build_axis_command(
"G0",
{
"X": pp.get('Art1', 0.0),
"Y": pp.get('Art2', 0.0),
"Z": pp.get('Art3', 0.0),
"U": pp.get('Art4', 0.0),
"V": motor_v,
"W": motor_w,
}
)
self.command_sender.send_if_connected(move_command)
self.gripper_controller.move_to_preset('closed')
self.command_sender.send_if_connected("M400") # Wait for moves to finish
self.command_sender.send_if_connected("M84") # Disable motors
def sendKillAlarmCommand(self):
"""Send M999 command (RRF: Clear emergency stop / reset)"""
self.command_sender.send_if_connected("M999")
def sendEmergencyStopCommand(self):
"""Emergency stop - halt movement immediately while keeping motors engaged.
Sends M410 (quick stop) to freeze motors in place and aborts any running sequence.
This prevents the robot from collapsing under gravity unlike M112 which disengages motors.
"""
# Stop any running sequence first
if self.is_playing_sequence:
self.sequence_timer.stop()
self.sequence_controller.stop_playback()
self.is_playing_sequence = False
logger.warning("E-STOP: Sequence playback aborted")
# Send quick stop to freeze motors in current position
if self.command_sender.send_if_connected("M410"):
logger.error("E-STOP activated (M410) - Motors frozen in place. Movement halted.")
# Show message to user
QtWidgets.QMessageBox.warning(
None,
"E-STOP Activated",
"Emergency Stop activated!\n\n"
"Motors are frozen in place.\n"
"All movement has been halted."
)
def FeedRateBoxHide(self):
"""Legacy method - feedrate visibility now handled by axis column"""
pass # No longer used, feedrate enabled/disabled in AxisControlColumn
def _onMovementTypeChanged(self, move_type):
"""Handle movement type change from axis column (G0/G1)"""
logger.debug(f"Movement type changed to: {move_type}")
# OLD FK methods removed - replaced with generic handlers above
# (FKMoveArt1-6, FKSliderUpdateArt1-6, FKSpinBoxUpdateArt1-6, FKDec/Inc methods)
# Saved ~290 lines of duplicate code via dynamic signal binding
#FK Every Articulation Functions
def syncCommandsToActual(self):
"""Sync command spinbox values with actual robot position (e.g., after homing)"""
try:
# Get actual positions from robot controller
positions = self.robot_controller.get_current_positions()
if positions:
self.SpinBoxArt1.setValue(positions.get('Art1', 0.0))
self.SpinBoxArt2.setValue(positions.get('Art2', 0.0))
self.SpinBoxArt3.setValue(positions.get('Art3', 0.0))
self.SpinBoxArt4.setValue(positions.get('Art4', 0.0))
self.SpinBoxArt5.setValue(positions.get('Art5', 0.0))
self.SpinBoxArt6.setValue(positions.get('Art6', 0.0))
logger.info("Synced command spinboxes with actual position")
except Exception as e:
logger.warning(f"Could not sync commands to actual: {e}")
def on_dh_parameters_changed(self):
"""Handle DH parameters changed signal - reload FK parameters and update visualization"""
try:
import forward_kinematics as fk
fk.reload_dh_parameters()
# Exit preview mode since parameters are now saved
self.visualization_controller.dh_preview_mode = False
# Force visualization to redraw with new parameters
if hasattr(self, 'position_canvas') and self.position_canvas:
self.position_canvas._is_dirty = True
self._updateSimulationVisualization()
logger.info("DH parameters saved and reloaded in FK module")
except Exception as e:
logger.error(f"Error reloading DH parameters: {e}")
def on_dh_preview_changed(self):
"""Handle DH preview signal - update visualisation with current DH panel values"""
try:
dh_widget = getattr(self, '_dh_widget', None)
if dh_widget and hasattr(self, 'position_canvas'):
# Enable DH preview mode to prevent timer-based updates from overwriting
self.visualization_controller.enter_dh_preview_mode()
# Get current parameters from DH panel
params = dh_widget.get_parameters()
# Update visualisation with preview
self.position_canvas.preview_dh_parameters(params)
logger.debug("DH preview updated")
except Exception as e:
logger.error(f"Error updating DH preview: {e}")
def FKMoveAll(self):
"""Move all joints simultaneously - delegates to FK controller.
In simulation mode, only updates visualization (no serial command)."""
if self.SimulationModeCheckBox.isChecked():
self._updateSimulationVisualization()
return
# Get joint values from spinboxes
joint_values = JointValues(
art1=self.SpinBoxArt1.value(),
art2=self.SpinBoxArt2.value(),
art3=self.SpinBoxArt3.value(),
art4=self.SpinBoxArt4.value(),
art5=self.SpinBoxArt5.value(),
art6=self.SpinBoxArt6.value()
)
# Delegate to FK controller
self.fk_controller.move_all(joint_values)
# Gripper Functions - delegate to GripperController
def MoveGripper(self):
"""Move gripper to specified position - delegates to controller"""
if self.SimulationModeCheckBox.isChecked():
self._updateSimulationVisualization()
return
self.gripper_controller.move(self.SpinBoxGripper.value())
def SliderUpdateGripper(self):
"""Slider changed - update spinbox via controller"""
self.gripper_controller.slider_changed(self.SliderGripper.value())
def SpinBoxUpdateGripper(self):
"""Spinbox changed - update slider via controller"""
self.gripper_controller.spinbox_changed(self.SpinBoxGripper.value())
def Dec10Gripper(self):
self.adjustJointValue('Gripper', -10)
def Dec1Gripper(self):
self.adjustJointValue('Gripper', -1)
def Inc1Gripper(self):
self.adjustJointValue('Gripper', 1)
def Inc10Gripper(self):
self.adjustJointValue('Gripper', 10)
def adjustGripperValue(self, delta):
"""Adjust gripper value by delta amount - used by axis column +/- buttons"""
spinbox = self.joint_spinboxes.get('Gripper')
if not spinbox:
return
new_value = max(0, min(100, int(spinbox.value() + delta)))
spinbox.setValue(new_value)
# Update axis row display
if hasattr(self, 'axis_column') and "Gripper" in self.axis_column.rows:
self.axis_column.rows["Gripper"].set_value(new_value)
# Move immediately (gripper is always direct-drive, not deferred like joints)
self.MoveGripper()
def setGripperAndMove(self, value):
"""Set gripper to specific value and execute movement - delegates to controller"""
if self.SimulationModeCheckBox.isChecked():
self.SpinBoxGripper.setValue(int(value))
if hasattr(self, 'axis_column') and "Gripper" in self.axis_column.rows: