diff --git a/citestfiles/CathodeHeating/beams_off_test.py b/citestfiles/CathodeHeating/beams_off_test.py new file mode 100644 index 00000000..313681cc --- /dev/null +++ b/citestfiles/CathodeHeating/beams_off_test.py @@ -0,0 +1,143 @@ +import sys, os, unittest +from unittest.mock import MagicMock + +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +from subsystem.cathode_heating.cathode_heating import CathodeHeatingSubsystem +from utils import LogLevel + +class TestBeamsOff(unittest.TestCase): + def setUp(self): + # Bypass __init__ to avoid Tk and image loading + self.subsys = object.__new__(CathodeHeatingSubsystem) + # Inject only what turn_off_all_beams uses + self.subsys.power_supplies_initialized = True + self.subsys.power_supplies = [MagicMock(), None, MagicMock()] + self.subsys.power_supply_status = [True, False, True] + self.subsys.toggle_states = [True, True, True] + self.subsys.toggle_off_image = object() + self.subsys.toggle_buttons = [MagicMock(), MagicMock(), MagicMock()] + # Simple logger hook + self.subsys.logger = MagicMock() + self.subsys.log = lambda msg, lvl=LogLevel.INFO: None + + # Alias method under test (name in your file) + self.turn_off_all_beams = self.subsys.turn_off_all_beams + + def test_turns_off_only_initialized_ps_and_updates_ui_on_success(self): + # First and third supply return True; middle is uninitialized + self.subsys.power_supplies[0].set_output.return_value = True + self.subsys.power_supplies[2].set_output.return_value = True + + self.turn_off_all_beams() + + self.subsys.power_supplies[0].set_output.assert_called_once_with("0") + self.subsys.power_supplies[2].set_output.assert_called_once_with("0") + self.assertFalse(self.subsys.toggle_states[0]) + self.assertFalse(self.subsys.toggle_states[2]) + self.subsys.toggle_buttons[0].config.assert_called_once() + self.subsys.toggle_buttons[2].config.assert_called_once() + # Uninitialized index 1 untouched + self.subsys.toggle_buttons[1].config.assert_not_called() + + def test_does_not_update_ui_when_off_fails(self): + # Simulate failure on index 0, success on index 2 + self.subsys.power_supplies[0].set_output.return_value = False + self.subsys.power_supplies[2].set_output.return_value = True + + self.turn_off_all_beams() + + # UI should not change for failed OFF + self.assertTrue(self.subsys.toggle_states[0]) + self.subsys.toggle_buttons[0].config.assert_not_called() + + # UI should change for successful OFF + self.assertFalse(self.subsys.toggle_states[2]) + self.subsys.toggle_buttons[2].config.assert_called_once() + + def test_exceptions_are_caught_and_others_continue(self): + self.subsys.power_supplies[0].set_output.side_effect = RuntimeError("boom") + self.subsys.power_supplies[2].set_output.return_value = True + + # Should not raise + self.turn_off_all_beams() + + self.subsys.power_supplies[2].set_output.assert_called_once_with("0") + self.assertFalse(self.subsys.toggle_states[2]) + + def test_returns_early_when_not_initialized(self): + # Arrange: pretend subsystem not initialized + self.subsys.power_supplies_initialized = False + # Keep mocks to detect accidental calls + self.subsys.power_supplies = [MagicMock(), MagicMock(), MagicMock()] + self.subsys.power_supply_status = [True, True, True] + + # Act + self.turn_off_all_beams() + + # Assert: no calls made + for ps in self.subsys.power_supplies: + ps.set_output.assert_not_called() + for btn in self.subsys.toggle_buttons: + btn.config.assert_not_called() + + def test_skips_when_status_false_even_if_ps_present(self): + # Arrange: ps exists but status is False + self.subsys.power_supplies = [MagicMock(), MagicMock(), MagicMock()] + self.subsys.power_supply_status = [True, False, True] + self.subsys.power_supplies[0].set_output.return_value = True + self.subsys.power_supplies[2].set_output.return_value = True + + # Act + self.turn_off_all_beams() + + # Assert + self.subsys.power_supplies[1].set_output.assert_not_called() + + def test_updates_button_with_correct_image_on_success(self): + # Arrange + self.subsys.power_supplies[0].set_output.return_value = True + self.subsys.power_supplies[2].set_output.return_value = True + + # Act + self.turn_off_all_beams() + + # Assert exact image argument used + self.subsys.toggle_buttons[0].config.assert_called_once_with(image=self.subsys.toggle_off_image) + self.subsys.toggle_buttons[2].config.assert_called_once_with(image=self.subsys.toggle_off_image) + + def test_true_status_but_none_power_supply_is_safely_skipped(self): + # Arrange: ps None but status True for index 1 + self.subsys.power_supplies = [MagicMock(), None, MagicMock()] + self.subsys.power_supply_status = [True, True, True] + self.subsys.power_supplies[0].set_output.return_value = True + self.subsys.power_supplies[2].set_output.return_value = True + + # Act (should not raise) + self.turn_off_all_beams() + + # Assert: others still called, middle skipped + self.subsys.power_supplies[0].set_output.assert_called_once_with("0") + self.subsys.power_supplies[2].set_output.assert_called_once_with("0") + + # Button 1 should not be touched since ps is None + self.subsys.toggle_buttons[1].config.assert_not_called() + + def test_second_call_is_idempotent_and_keeps_off_state(self): + # Arrange first call success + self.subsys.power_supplies[0].set_output.return_value = True + self.subsys.power_supplies[2].set_output.return_value = True + + # Act: call twice + self.turn_off_all_beams() + self.turn_off_all_beams() + + # Assert: set_output called twice for active channels + self.assertEqual(self.subsys.power_supplies[0].set_output.call_count, 2) + self.assertEqual(self.subsys.power_supplies[2].set_output.call_count, 2) + # State remains off + self.assertFalse(self.subsys.toggle_states[0]) + self.assertFalse(self.subsys.toggle_states[2]) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/dashboard.py b/dashboard.py index 9d4bf38b..d5425d9d 100644 --- a/dashboard.py +++ b/dashboard.py @@ -5,24 +5,60 @@ import tkinter as tk from tkinter import ttk from tkinter import messagebox +import time from utils import MessagesFrame, SetupScripts, LogLevel, MachineStatus from usr.panel_config import save_pane_states, load_pane_states import serial.tools.list_ports +try: + from subsystem.beam_pulse.beam_pulse import BeamPulseSubsystem +except Exception: + BeamPulseSubsystem = None + +try: + from matplotlib.figure import Figure + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + _HAS_MATPLOTLIB = True +except Exception: + _HAS_MATPLOTLIB = False + +def resource_path(relative_path): + """Get absolute path to resource for PyInstaller.""" + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +CHANNEL_LABELS = ("A", "B", "C") + + +def channel_label(index: int) -> str: + """Return the UI-facing pulser channel label for a 0-based index.""" + if 0 <= index < len(CHANNEL_LABELS): + return CHANNEL_LABELS[index] + return str(index + 1) + + +def channel_name(index: int) -> str: + """Return a verbose UI-facing pulser channel name for a 0-based index.""" + return f"Channel {channel_label(index)}" + frames_config = [ # Row 0 ("Interlocks", 0, 1916, 41), - + # Row 1 ("Oil System", 1, 604, 130), ("Beam Steering", 1, 778, 130), ("Beam Energy", 1, 528, 130), - + # Row 2 ("Vacuum System", 2, 604, 438), ("Beam Pulse", 2, 777, 438), ("Main Control", 2, 529, 438), - + # Row 4 ("Process Monitor", 3, 339, 458), ("Cathode Heating", 3, 1041, 458), @@ -69,8 +105,17 @@ def __init__(self, root, com_ports, logger=None): self.root.title("EBEAM Control System Dashboard") self.set_com_ports = set(serial.tools.list_ports.comports()) - - + + # Load toggle images + try: + self.toggle_on_image = tk.PhotoImage(file=resource_path("media/toggle_on.png")) + self.toggle_off_image = tk.PhotoImage(file=resource_path("media/toggle_off.png")) + except Exception as e: + self.toggle_on_image = None + self.toggle_off_image = None + print(f"Could not load toggle images: {e}") + + # Restore saved pane state if one exists. if self.load_saved_pane_state(): if self.logger is not None: self.logger.info("Pane-state restore result: restored saved pane state") @@ -79,7 +124,16 @@ def __init__(self, root, com_ports, logger=None): # Initialize the frames dictionary to store various GUI components self.frames = {} - + # Optional Beam Pulse UI attributes (only used if dashboard-managed plotting is enabled) + self.beam_pulse = None + self._bp_axes = [] + self._bp_canvas = None + self._bp_data = {1: {'past': [], 'future': []}, 2: {'past': [], 'future': []}, 3: {'past': [], 'future': []}} + self._bp_history_len = 120 + self._bp_future_len = 30 + self._bp_stats = {} + self._bp_update_interval_ms = 1000 + # Set up the main pane using PanedWindow for flexible layout self.setup_main_pane() @@ -120,6 +174,158 @@ def setup_main_pane(self): for row_pane in self.rows: self.main_pane.add(row_pane, stretch='always') + def _compute_row_layout(self): + """Return structures for layout: row_max_heights, sorted_rows, row_to_y, row_x_offsets.""" + row_max_heights = {} + for _, row, _w, h in frames_config: + row_max_heights[row] = max(row_max_heights.get(row, 0), h or 0) + sorted_rows = sorted(row_max_heights.keys()) + row_to_y = {} + y_accum = 0 + for r in sorted_rows: + row_to_y[r] = y_accum + y_accum += row_max_heights[r] + # initial x offsets per row + row_x_offsets = {r: 0 for r in sorted_rows} + return row_max_heights, sorted_rows, row_to_y, row_x_offsets + + def _reflow_all(self): + """Re-place frames, sashes and grips after a resize change.""" + # Clear overlays + for s in self._sashes: + s['widget'].place_forget() + for g in self._grips: + g['widget'].place_forget() + self._sashes.clear() + self._grips.clear() + # Recreate placements + self._place_frames_and_overlays() + + def _place_frames_and_overlays(self): + row_max_heights, sorted_rows, row_to_y, row_x_offsets = self._compute_row_layout() + # Place frames + row_members = {} + for title, row, width, height in frames_config: + row_members.setdefault(row, []).append((title, width, height)) + + # Ensure frame objects exist + for title, row, width, height in frames_config: + frame = self.frames.get(title) + x = row_x_offsets.get(row, 0) + y = row_to_y.get(row, 0) + if frame: + frame.place(x=x, y=y, width=width, height=height) + # Always advance offset, even for spacer/non-rendered entries + row_x_offsets[row] = x + (width or 0) + + # Add vertical sashes between neighbors in each row + for row, members in row_members.items(): + # Recalculate X running sum for sash positions + x = 0 + y = row_to_y[row] + for idx in range(len(members) - 1): + left_title, left_w, left_h = members[idx] + right_title, right_w, right_h = members[idx + 1] + x += left_w + sash = tk.Frame(self.main_pane, cursor='sb_h_double_arrow', bg='#CCCCCC') + sash_w = 5 + sash.place(x=x - sash_w // 2, y=y, width=sash_w, height=row_max_heights[row]) + self._attach_sash_handlers(sash, row, idx) + self._sashes.append({'widget': sash, 'row': row, 'index': idx}) + + # Add bottom grips for vertical resize per frame + for title, row, width, height in frames_config: + frame = self.frames.get(title) + if not frame: + continue + y = 0 + for r in sorted_rows: + if r == row: + break + y += row_max_heights[r] + x = 0 + for t2, r2, w2, _ in frames_config: + if r2 != row: + continue + if t2 == title: + break + x += w2 + grip = tk.Frame(self.main_pane, cursor='sb_v_double_arrow', bg='#CCCCCC') + grip_h = 5 + grip.place(x=x, y=y + height - grip_h // 2, width=width, height=grip_h) + self._attach_grip_handlers(grip, row, title) + self._grips.append({'widget': grip, 'row': row, 'title': title}) + + def _attach_sash_handlers(self, sash, row, idx_in_row): + # Track state + state = {'start_x': 0, 'row': row, 'idx': idx_in_row} + def on_press(event): + state['start_x'] = event.x_root + def on_drag(event): + dx = event.x_root - state['start_x'] + self._resize_horizontal(row, idx_in_row, dx) + state['start_x'] = event.x_root + sash.bind('', on_press) + sash.bind('', on_drag) + + def _attach_grip_handlers(self, grip, row, title): + state = {'start_y': 0, 'row': row, 'title': title} + def on_press(event): + state['start_y'] = event.y_root + def on_drag(event): + dy = event.y_root - state['start_y'] + self._resize_vertical(row, title, dy) + state['start_y'] = event.y_root + grip.bind('', on_press) + grip.bind('', on_drag) + + def _resize_horizontal(self, row, idx_in_row, dx): + # Collect indices of frames in this row + indices = [i for i, (_t, r, _w, _h) in enumerate(frames_config) if r == row] + if idx_in_row >= len(indices) - 1: + return + left_i = indices[idx_in_row] + right_i = indices[idx_in_row + 1] + left_title, _r, left_w, left_h = frames_config[left_i] + right_title, _r2, right_w, right_h = frames_config[right_i] + # Apply delta with clamps + min_w = 80 + new_left = max(min_w, left_w + dx) + delta = new_left - left_w + new_right = max(min_w, right_w - delta) + # If right clamped, adjust back left accordingly + if right_w - delta < min_w: + delta = right_w - min_w + new_left = left_w + delta + new_right = min_w + frames_config[left_i] = (left_title, row, new_left, left_h) + frames_config[right_i] = (right_title, row, new_right, right_h) + + # Keep merged column width in sync across rows + if left_title in ("Beam Pulse", "Beam Steering/Pulse", "Beam Pulse Spacer"): + self._sync_merged_column_width(new_left) + if right_title in ("Beam Pulse", "Beam Steering/Pulse", "Beam Pulse Spacer"): + self._sync_merged_column_width(new_right) + + self._reflow_all() + + def _sync_merged_column_width(self, new_width): + """Ensure the merged middle column keeps the same width in all rows.""" + for i, (t, r, w, h) in enumerate(frames_config): + if t in ("Beam Pulse", "Beam Steering/Pulse", "Beam Pulse Spacer"): + frames_config[i] = (t, r, int(new_width), h) + + def _resize_vertical(self, row, title, dy): + # Change height of a single frame in the row, row stack height follows max of row + min_h = 10 + # Find the target frame index + for i, (t, r, w, h) in enumerate(frames_config): + if r == row and t == title: + new_h = max(min_h, h + dy) + frames_config[i] = (t, r, w, int(new_h)) + break + self._reflow_all() + def create_frames(self): """ Create and configure frames for all subsystems based on frames_config. @@ -128,8 +334,11 @@ def create_frames(self): global frames_config for title, row, width, height in frames_config: + if title == "Beam Pulse Spacer": + continue + if width and height and title: - frame = tk.Frame( borderwidth=1, relief="solid", width=width, height=height) + frame = tk.Frame(borderwidth=1, relief="solid", width=width, height=height) frame.pack_propagate(False) else: frame = tk.Frame(borderwidth=1, relief="solid") @@ -158,10 +367,104 @@ def create_main_control_notebook(self, frame): # TODO: add main control buttons to main tab here main_frame = ttk.Frame(main_tab, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) + # Save reference so beam_pulse subsystem can add its buttons here + self.main_control_frame = main_frame + + # Add safety beams off button (bottom) + beams_off_button = tk.Button( + main_frame, + text="BEAMS E-STOP", + bg="red", + fg="white", + font=("Helvetica",14,"bold"), + command=self.handle_beams_off + ) + beams_off_button.pack(side="bottom", fill="x", padx=10, pady=(4, 8)) # Script dropdown self.create_script_dropdown(main_frame) + # Placeholder frame for CSV sequence buttons — populated by + # BeamPulseSubsystem.create_csv_buttons() in create_subsystems(). + # Not packed here; shown only when the CSV Sequence tab is active. + self.csv_buttons_frame = ttk.Frame(main_frame) + + # --- Manual-tab panel: Beam ON/OFF + CH Enable/Disable buttons -- + # Stored as self.bp_manual_panel so the beam_pulse subsystem can swap + # it in/out when the Beam Pulse notebook tab changes. + self.bp_manual_panel = tk.Frame(main_frame) + self.bp_manual_panel.pack(side="top", fill="x", padx=10, pady=(10, 0)) + + # Beam ON/OFF row — saved so the tab-change handler can show/hide it + self.beam_on_off_frame = tk.Frame(self.bp_manual_panel) + self.beam_on_off_frame.pack(side="top", fill="x") + buttons_frame = self.beam_on_off_frame + for i in range(3): + buttons_frame.grid_columnconfigure(i, weight=1, uniform="button") + + self.beam_toggle_buttons = [] + beam_names = ["Beam A OFF", "Beam B OFF", "Beam C OFF"] + for i, beam_name in enumerate(beam_names): + btn = tk.Button( + buttons_frame, + text=beam_name, + bg="gray", + fg="white", + font=("Helvetica", 10, "bold"), + state="disabled", # disabled until armed AND channel enabled + command=lambda idx=i: self.toggle_individual_beam_with_status(idx) + ) + btn.grid(row=0, column=i, sticky="ew", padx=2) + self.beam_toggle_buttons.append(btn) + + # CH Enable/Disable row + enable_toggle_frame = tk.Frame(self.bp_manual_panel) + enable_toggle_frame.pack(side="top", fill="x", pady=(4, 0)) + for i in range(3): + enable_toggle_frame.grid_columnconfigure(i, weight=1, uniform="button") + self.enable_toggle_buttons = [] + self._ch_enable_states = [False, False, False] # dashboard mirror of firmware enable state + for i in range(3): + btn = tk.Button( + enable_toggle_frame, + text=f"CH {channel_label(i)}: Disabled", + bg="#888888", + fg="white", + font=("Helvetica", 9), + state="disabled", # Initially disabled until armed + command=lambda idx=i: self._toggle_channel_enable(idx) + ) + btn.grid(row=0, column=i, sticky="ew", padx=2) + self.enable_toggle_buttons.append(btn) + + # Add beams armed toggle + beams_armed_control_frame = tk.Frame(main_frame) + beams_armed_control_frame.pack(side="bottom", fill="x", padx=10, pady=(8, 4)) + + beams_armed_label_frame = ttk.Frame(beams_armed_control_frame) + beams_armed_label_frame.pack(pady=(0, 2)) + ttk.Label(beams_armed_label_frame, text="BEAMS ARMED", font=("Helvetica", 12, "bold")).pack() + + if self.toggle_on_image and self.toggle_off_image: + self.beams_ready_button = tk.Button( + beams_armed_control_frame, + image=self.toggle_off_image, + command=self.handle_arm_beams, + relief=tk.FLAT, + bd=0, + bg="white" + ) + else: + self.beams_ready_button = tk.Button( + beams_armed_control_frame, + text="ARM BEAMS", + bg="sky blue", + fg="white", + font=("Helvetica",16,"bold"), + command=self.handle_arm_beams + ) + self.beams_ready_button.pack() + config_frame = ttk.Frame(config_tab, padding="10") config_frame.pack(fill=tk.BOTH, expand=True) @@ -200,7 +503,7 @@ def create_post_processor_button(self, parent_frame): """Create a button to launch the standalone post-processor application""" post_processor_frame = ttk.Frame(parent_frame) post_processor_frame.pack(side=tk.TOP, anchor='nw', padx=5, pady=5) - + ttk.Button( post_processor_frame, text="Launch Log Post-processor", @@ -224,22 +527,22 @@ def launch_post_processor(self): # Launch the post-processor script if sys.platform.startswith('win'): # On Windows, use pythonw to avoid console window - subprocess.Popen([sys.executable, post_processor_path], + subprocess.Popen([sys.executable, post_processor_path], creationflags=subprocess.CREATE_NO_WINDOW) else: # On other platforms subprocess.Popen([sys.executable, post_processor_path]) - + self.logger.info("Log post-processor launched successfully") except Exception as e: self.logger.error(f"Failed to launch log post-processor: {str(e)}") - messagebox.showerror("Error", + messagebox.showerror("Error", f"Failed to launch log post-processor:\n{str(e)}") def add_title(self, frame, title): """ Add a formatted title label to a frame. - + Args: frame: Frame to add title to title: Title text to display @@ -269,16 +572,16 @@ def create_log_level_dropdown(self, parent_frame): self.log_level_var = tk.StringVar() log_levels = [level.name for level in LogLevel] log_level_dropdown = ttk.Combobox( - log_level_frame, - textvariable=self.log_level_var, - values=log_levels, - state="readonly", + log_level_frame, + textvariable=self.log_level_var, + values=log_levels, + state="readonly", width=15 ) log_level_dropdown.pack(side=tk.LEFT, padx=(5, 0)) - + current_level = self.messages_frame.get_log_level() - log_level_dropdown.set(current_level.name) + log_level_dropdown.set(current_level.name) log_level_dropdown.bind("<>", self.on_log_level_change) def file_create_log_level_dropdown(self, parent_frame): @@ -289,16 +592,16 @@ def file_create_log_level_dropdown(self, parent_frame): self.file_log_level_var = tk.StringVar() file_log_levels = ["DEBUG", "VERBOSE"] self.file_log_level_dropdown = ttk.Combobox( - file_log_frame, - textvariable=self.file_log_level_var, - values=file_log_levels, - state="readonly", + file_log_frame, + textvariable=self.file_log_level_var, + values=file_log_levels, + state="readonly", width=15 ) self.file_log_level_dropdown.pack(side=tk.LEFT, padx=(5, 0)) - + current_file_level = self.messages_frame.get_file_log_level() - self.file_log_level_dropdown.set(current_file_level.name) + self.file_log_level_dropdown.set(current_file_level.name) self.file_log_level_dropdown.bind("<>", self.on_file_log_level_change) def on_log_level_change(self, event): @@ -312,6 +615,350 @@ def on_file_log_level_change(self, event): elif selected_level == "VERBOSE": self.messages_frame.logger.file_log_level = LogLevel.VERBOSE + def handle_arm_beams(self): + """Handle ARM BEAMS toggle press with state management.""" + try: + # Check if Beam Pulse subsystem is available + if 'Beam Pulse' not in self.subsystems or self.subsystems['Beam Pulse'] is None: + self.logger.error("Beam Pulse subsystem not available") + messagebox.showerror("Error", "Beam Pulse subsystem not available") + return + + beam_pulse = self.subsystems['Beam Pulse'] + + # Check current armed state + if hasattr(beam_pulse, 'get_beams_armed_status') and beam_pulse.get_beams_armed_status(): + # Beams are already armed, so disarm them + if hasattr(beam_pulse, 'disarm_beams') and beam_pulse.disarm_beams(): + # Successfully disarmed - update toggle to OFF + if self.toggle_on_image and self.toggle_off_image: + self.beams_ready_button.config(image=self.toggle_off_image) + else: + self.beams_ready_button.config( + text="ARM BEAMS", + bg="sky blue" + ) + # Disable beam toggle buttons, enable toggle buttons and reset states + self.update_beam_toggle_states(enabled=False, reset=True) + self._update_enable_toggle_states(enabled=False) + self.logger.info("Beams disarmed via dashboard button") + else: + self.logger.error("Failed to disarm beams") + messagebox.showerror("Error", "Failed to disarm beams") + else: + # Beams are not armed, so arm them + if hasattr(beam_pulse, 'arm_beams') and beam_pulse.arm_beams(): + # Successfully armed - update toggle to ON + if self.toggle_on_image and self.toggle_off_image: + self.beams_ready_button.config(image=self.toggle_on_image) + else: + self.beams_ready_button.config( + text="BEAMS ARMED", + bg="navy" # Darker shade of blue + ) + # Enable beam toggle buttons and enable toggle buttons + self.update_beam_toggle_states(enabled=True) + self._update_enable_toggle_states(enabled=True) + self.logger.info("Beams armed via dashboard button") + else: + self.logger.error("Failed to arm beams") + messagebox.showerror("Error", "Failed to arm beams") + + except Exception as e: + self.logger.error(f"Error in handle_arm_beams: {str(e)}") + messagebox.showerror("Error", f"Error handling beam arming: {str(e)}") + + def handle_beams_off(self): + """Handle Beams E-stop button press — force stop all BCON channels, + turn off cathode heating, and disarm beams.""" + try: + # Force stop all BCON channels immediately + if 'Beam Pulse' in self.subsystems and self.subsystems['Beam Pulse'] is not None: + beam_pulse = self.subsystems['Beam Pulse'] + if hasattr(beam_pulse, 'stop_all_channels'): + beam_pulse.stop_all_channels() + self.logger.info("All BCON channels force-stopped via E-STOP") + + # Turn off cathode heating power supplies + if 'Cathode Heating' in self.subsystems and self.subsystems['Cathode Heating'] is not None: + cathode = self.subsystems['Cathode Heating'] + if hasattr(cathode, 'turn_off_all_beams'): + cathode.turn_off_all_beams() + self.logger.info("Cathode heating turned off via Beams E-stop button") + + # Disarm beams + if 'Beam Pulse' in self.subsystems and self.subsystems['Beam Pulse'] is not None: + beam_pulse = self.subsystems['Beam Pulse'] + if hasattr(beam_pulse, 'get_beams_armed_status') and beam_pulse.get_beams_armed_status(): + if hasattr(beam_pulse, 'disarm_beams') and beam_pulse.disarm_beams(): + # Update the ARM BEAMS toggle state to OFF + if self.toggle_on_image and self.toggle_off_image: + self.beams_ready_button.config(image=self.toggle_off_image) + else: + self.beams_ready_button.config( + text="ARM BEAMS", + bg="sky blue" + ) + # Disable beam toggle buttons, enable toggle buttons and reset states + self.update_beam_toggle_states(enabled=False, reset=True) + self._update_enable_toggle_states(enabled=False) + self.logger.info("Beams disarmed via Beams E-stop button") + else: + self.logger.error("Failed to disarm beams via Beams E-stop") + except Exception as e: + self.logger.error(f"Error in handle_beams_off: {str(e)}") + + def _toggle_channel_enable(self, ch_index: int): + """Toggle the hardware enable for a BCON channel (0-based index). + + Only allowed when beams are armed. When the channel is being + disabled (enabled -> disabled), also send OFF to ensure the + channel stops outputting. Button reflects ON (green) / OFF (gray). + """ + try: + beam_pulse = self.subsystems.get('Beam Pulse') + if not beam_pulse or not hasattr(beam_pulse, 'get_beams_armed_status'): + self.logger.warning("Beam Pulse subsystem not available") + return + if not beam_pulse.get_beams_armed_status(): + self.logger.warning("Cannot toggle enable — beams not armed") + return + if beam_pulse.bcon_driver: + was_enabled = beam_pulse.bcon_driver.is_channel_enabled(ch_index + 1) + new_enabled = not was_enabled + if not beam_pulse.bcon_driver.set_channel_enable(ch_index + 1, new_enabled): + self.logger.warning( + f"Failed to set {channel_name(ch_index)} enable -> " + f"{'Enabled' if new_enabled else 'Disabled'}" + ) + return + self._on_channel_enable_status_update(ch_index, new_enabled) + self.logger.info( + f"{channel_name(ch_index)} enable -> {'Enabled' if new_enabled else 'Disabled'}") + # If we just disabled the channel, force it OFF + if was_enabled: + beam_pulse.send_channel_off(ch_index) + if ch_index < len(self.beam_toggle_buttons): + self.beam_toggle_buttons[ch_index].config( + bg="gray", text=f"Beam {channel_label(ch_index)} OFF") + else: + self.logger.warning("BCON driver not available for enable toggle") + except Exception as e: + self.logger.error(f"Error toggling {channel_name(ch_index)} enable: {e}") + + def toggle_individual_beam_with_status(self, beam_index): + """Toggle individual beam on/off. + + ON = read channel config from Beam Pulse panel and send to BCON. + OFF = send OFF command for the channel. + """ + try: + if 'Beam Pulse' not in self.subsystems or self.subsystems['Beam Pulse'] is None: + self.logger.error("Beam Pulse subsystem not available") + return + + beam_pulse = self.subsystems['Beam Pulse'] + + # Get current beam status + current_status = beam_pulse.get_beam_status(beam_index) + btn = self.beam_toggle_buttons[beam_index] + + if current_status: + # Currently ON -> turn OFF + beam_pulse.send_channel_off(beam_index) + btn.config(bg="gray", text=f"Beam {channel_label(beam_index)} OFF") + self.logger.info(f"Beam {channel_label(beam_index)} turned OFF") + else: + # Currently OFF -> send channel config to BCON + ok = beam_pulse.send_channel_config(beam_index) + if ok: + btn.config(bg="green", text=f"Beam {channel_label(beam_index)} ON") + self.logger.info(f"Beam {channel_label(beam_index)} config sent to BCON") + else: + self.logger.error(f"Failed to send Beam {channel_label(beam_index)} config") + + except Exception as e: + self.logger.error(f"Error toggling beam {beam_index}: {str(e)}") + + def toggle_individual_beam(self, beam_index): + """Legacy method - redirects to new method with status bar.""" + self.toggle_individual_beam_with_status(beam_index) + + def get_beam_pulse_duration(self, beam_index): + """Get the pulse duration for a specific beam.""" + try: + if 'Beam Pulse' not in self.subsystems or self.subsystems['Beam Pulse'] is None: + return 0 + + beam_pulse = self.subsystems['Beam Pulse'] + + # Get duration from the beam pulse subsystem + if beam_index == 0 and hasattr(beam_pulse, 'beam_a_duration'): + return beam_pulse.beam_a_duration.get() + elif beam_index == 1 and hasattr(beam_pulse, 'beam_b_duration'): + return beam_pulse.beam_b_duration.get() + elif beam_index == 2 and hasattr(beam_pulse, 'beam_c_duration'): + return beam_pulse.beam_c_duration.get() + + return 100.0 # Default fallback + except Exception as e: + self.logger.error(f"Error getting beam {beam_index} duration: {str(e)}") + return 100.0 + + def auto_turn_off_beam(self, beam_index): + """Automatically turn off a beam after pulse duration.""" + try: + if 'Beam Pulse' not in self.subsystems or self.subsystems['Beam Pulse'] is None: + return + + beam_pulse = self.subsystems['Beam Pulse'] + beam_names = ["A", "B", "C"] + + # Check if beam is still on before turning off + if hasattr(beam_pulse, 'get_beam_status') and beam_pulse.get_beam_status(beam_index): + # Turn off the beam + if hasattr(beam_pulse, 'set_beam_status'): + beam_pulse.set_beam_status(beam_index, False) + + # Update button appearance + btn = self.beam_toggle_buttons[beam_index] + btn.config(bg="gray", text=f"Beam {beam_names[beam_index]} OFF") + + self.logger.info(f"Beam {beam_names[beam_index]} automatically turned OFF after pulse duration") + + except Exception as e: + self.logger.error(f"Error auto-turning off beam {beam_index}: {str(e)}") + + def handle_beam_pulse_callback(self, beam_index, status, duration=0): + """Handle beam pulse callback for button updates. + + This method is called by the beam pulse subsystem when beam status changes. + """ + try: + beam_names = ["A", "B", "C"] + + if status: + # Beam turned ON - update button display + if beam_index < len(self.beam_toggle_buttons): + self.beam_toggle_buttons[beam_index].config(bg="green", text=f"Beam {beam_names[beam_index]} ON") + + if duration > 0: + self.logger.info(f"Beam {beam_names[beam_index]} pulsed for {duration}ms") + # Schedule auto turn-off after pulse duration + self.root.after(int(duration), lambda: self.auto_turn_off_beam(beam_index)) + else: + self.logger.info(f"Beam {beam_names[beam_index]} turned ON in DC mode") + else: + # Beam turned OFF - update button display + if beam_index < len(self.beam_toggle_buttons): + self.beam_toggle_buttons[beam_index].config(bg="gray", text=f"Beam {beam_names[beam_index]} OFF") + + except Exception as e: + self.logger.error(f"Error in beam pulse callback for beam {beam_index}: {str(e)}") + + def _on_channel_status_update(self, ch: int, mode_code: int, remaining: int): + """Mirror live BCON register state onto the Beam A/B/C toggle button. + + Called on every register-poll cycle by BeamPulseSubsystem. + mode_code=0 means OFF; remaining=0 means all pulses delivered. + """ + if not hasattr(self, 'beam_toggle_buttons') or ch >= len(self.beam_toggle_buttons): + return + btn = self.beam_toggle_buttons[ch] + # DC mode never counts down, so remaining is always 0 in hardware. + # Treat DC as running whenever mode != OFF to prevent button glitching. + MODE_DC = 1 + is_running = (mode_code != 0) and (remaining > 0 or mode_code == MODE_DC) + try: + if is_running: + btn.config(bg="green", text=f"Beam {channel_label(ch)} ON") + if 'Beam Pulse' in self.subsystems and self.subsystems['Beam Pulse'] is not None: + self.subsystems['Beam Pulse'].beam_on_status[ch] = True + else: + # Only reset to gray when the button is currently green + # (avoids overwriting a manually-initiated OFF state) + if str(btn.cget('bg')) == 'green': + btn.config(bg="gray", text=f"Beam {channel_label(ch)} OFF") + if 'Beam Pulse' in self.subsystems and self.subsystems['Beam Pulse'] is not None: + self.subsystems['Beam Pulse'].beam_on_status[ch] = False + except Exception: + pass + + def _on_channel_enable_status_update(self, ch: int, enabled: bool): + """Mirror firmware-backed channel enable state onto dashboard controls.""" + try: + if hasattr(self, '_ch_enable_states') and ch < len(self._ch_enable_states): + self._ch_enable_states[ch] = bool(enabled) + + if hasattr(self, 'enable_toggle_buttons') and ch < len(self.enable_toggle_buttons): + self.enable_toggle_buttons[ch].config( + bg="#2e7d32" if enabled else "#888888", + text=f"CH {channel_label(ch)}: {'Enabled' if enabled else 'Disabled'}", + ) + + beam_pulse = self.subsystems.get('Beam Pulse') + armed = bool( + beam_pulse + and hasattr(beam_pulse, 'get_beams_armed_status') + and beam_pulse.get_beams_armed_status() + ) + + if hasattr(self, 'enable_toggle_buttons') and ch < len(self.enable_toggle_buttons): + self.enable_toggle_buttons[ch].config(state="normal" if armed else "disabled") + + self.update_beam_toggle_states(enabled=armed) + except Exception as e: + self.logger.error(f"Error updating {channel_name(ch)} enable status: {str(e)}") + + def update_beam_toggle_states(self, enabled=True, reset=False): + """Update the state of beam toggle buttons.""" + try: + if not hasattr(self, 'beam_toggle_buttons'): + return + + for i, btn in enumerate(self.beam_toggle_buttons): + if enabled: + # Only allow beam ON/OFF when the channel hardware enable is also ON + ch_enabled = ( + hasattr(self, '_ch_enable_states') + and i < len(self._ch_enable_states) + and self._ch_enable_states[i] + ) + btn.config(state="normal" if ch_enabled else "disabled") + if reset: + btn.config(bg="gray", text=f"Beam {channel_label(i)} OFF") + if 'Beam Pulse' in self.subsystems and self.subsystems['Beam Pulse'] is not None: + beam_pulse = self.subsystems['Beam Pulse'] + if hasattr(beam_pulse, 'set_beam_status'): + beam_pulse.set_beam_status(i, False) + else: + btn.config(state="disabled", bg="gray", text=f"Beam {channel_label(i)} OFF") + if reset: + if 'Beam Pulse' in self.subsystems and self.subsystems['Beam Pulse'] is not None: + beam_pulse = self.subsystems['Beam Pulse'] + if hasattr(beam_pulse, 'set_beam_status'): + beam_pulse.set_beam_status(i, False) + + except Exception as e: + self.logger.error(f"Error updating beam toggle states: {str(e)}") + + def _update_enable_toggle_states(self, enabled=True): + """Enable or disable the CH Enable toggle buttons based on armed status. + When disabling (disarmed / E-STOP), preserve the last hardware-backed + Enabled/Disabled appearance but prevent interaction. + """ + try: + if not hasattr(self, 'enable_toggle_buttons'): + return + for i, btn in enumerate(self.enable_toggle_buttons): + if enabled: + btn.config(state="normal") + else: + # Disarmed — force all to Disabled appearance and reset tracking + btn.config(state="disabled") + except Exception as e: + self.logger.error(f"Error updating enable toggle states: {str(e)}") + def create_subsystems(self): """ Initialize all subsystem objects with their respective frames and settings. @@ -320,11 +967,11 @@ def create_subsystems(self): self.subsystems = { 'Vacuum System': subsystem.VTRXSubsystem( self.frames['Vacuum System'], - serial_port=self.com_ports['VTRXSubsystem'], + serial_port=self.com_ports['VTRXSubsystem'], logger=self.logger ), 'Process Monitor [°C]': subsystem.ProcessMonitorSubsystem( - self.frames['Process Monitor'], + self.frames['Process Monitor'], com_port=self.com_ports['ProcessMonitors'], logger=self.logger, active = self.machine_status_frame.MACHINE_STATUS @@ -339,7 +986,7 @@ def create_subsystems(self): 'Oil System': subsystem.OilSubsystem( self.frames['Oil System'], logger=self.logger, - ), + ), 'Cathode Heating': subsystem.CathodeHeatingSubsystem( self.frames['Cathode Heating'], com_ports=self.com_ports, @@ -348,18 +995,131 @@ def create_subsystems(self): ) } + # Beam Pulse subsystem (BCON) + try: + bp_port = self.com_ports.get('BeamPulse', self.com_ports.get('Beam Pulse', '')) + if BeamPulseSubsystem is not None: + # Host Beam Pulse UI inside the merged pane + parent = self.frames.get('Beam Steering/Pulse', self.frames.get('Beam Pulse')) + beam_pulse_subsystem = BeamPulseSubsystem( + parent_frame=parent, + port=bp_port if bp_port else None, + unit=1, + baudrate=115200, + logger=self.logger + ) + + # Set up dashboard callback for pulse animations + beam_pulse_subsystem.set_dashboard_beam_callback(self.handle_beam_pulse_callback) + + # Add Sync Start/Stop and wire tab-aware panel visibility. + if hasattr(self, 'main_control_frame'): + manual_panel = getattr(self, 'bp_manual_panel', None) + beam_pulse_subsystem.create_external_control_buttons( + self.main_control_frame, + manual_panel_override=manual_panel, + beam_on_off_frame=getattr(self, 'beam_on_off_frame', None), + csv_frame=getattr(self, 'csv_buttons_frame', None), + ) + + # CSV sequence buttons below the script-selection dropdown + if hasattr(self, 'csv_buttons_frame'): + beam_pulse_subsystem.create_csv_buttons(self.csv_buttons_frame) + + # Mirror live BCON register state onto the Beam toggle buttons + beam_pulse_subsystem.set_channel_status_callback( + self._on_channel_status_update + ) + beam_pulse_subsystem.set_channel_enable_status_callback( + self._on_channel_enable_status_update + ) + + # Let Sync Start know which channels are hardware-enabled + beam_pulse_subsystem.set_channel_enable_getter( + lambda: list(getattr(self, '_ch_enable_states', [True, True, True])) + ) + + self.subsystems['Beam Pulse'] = beam_pulse_subsystem + else: + # placeholder if module not importable + container = self.frames.get('Beam Steering/Pulse', self.frames['Process Monitor']) + container.pack_propagate(True) + lbl = ttk.Label(container, text="BeamPulse subsystem not installed") + lbl.pack(fill=tk.BOTH, expand=True) + except Exception as e: + self.logger.error(f"Failed to initialize Beam Pulse subsystem: {e}") + # Updates machine status progress bar self.machine_status_frame.update_status(self.machine_status_frame.MACHINE_STATUS) def create_messages_frame(self): """Create a scrollable frame for displaying system messages and errors.""" - self.messages_frame = MessagesFrame(self.rows[3], width = frames_config[-2][2], height = frames_config[-2][3], logger=self.logger) + self.messages_frame = MessagesFrame(self.rows[3], width = frames_config[-2][2], height = frames_config[-2][3]) self.logger = self.messages_frame.logger def create_machine_status_frame(self): """Create a frame for displaying machine status information.""" self.machine_status_frame = MachineStatus(self.frames['Machine Status']) + def update_beam_pulse(self): + """Poll beam_pulse subsystem for new values and update plots.""" + try: + # Read amplitude registers for beams as a proxy for current waveform + # (Amplitude/phase/offset can be combined to synthesize a waveform; for now plot amplitude) + if not hasattr(self, 'beam_pulse') or self.beam_pulse is None: + return + + for i in (1, 2, 3): + regname = f'BEAM_{i}_AMPLITUDE' + val = self.beam_pulse.read_register(regname) + if val is None: + val = 0 + # push to history + buf = self._bp_data[i]['past'] + buf.append(val) + if len(buf) > self._bp_history_len: + buf.pop(0) + + # naive future prediction: repeat last value (placeholder for real predictive model) + fut = [buf[-1]] * self._bp_future_len + self._bp_data[i]['future'] = fut + + # update stats + try: + last = buf[-1] + mean = sum(buf) / len(buf) + vmin = min(buf) + vmax = max(buf) + stats = self._bp_stats.get(i) + if stats: + stats['last'].config(text=f'Last: {last}') + stats['mean'].config(text=f'Mean: {mean:.1f}') + stats['min'].config(text=f'Min: {vmin}') + stats['max'].config(text=f'Max: {vmax}') + except Exception: + pass + + # redraw plots + for idx, ax in enumerate(self._bp_axes, start=1): + ax.cla() + past = self._bp_data[idx]['past'] + future = self._bp_data[idx]['future'] + ax.plot(range(-len(past), 0), past, label='past') + ax.plot(range(0, len(future)), future, linestyle='--', label='predicted') + ax.set_title(f'Beam {idx} amplitude') + ax.legend() + + if self._bp_canvas: + self._bp_canvas.draw() + + except Exception as e: + self.logger.error(f'Error updating Beam Pulse UI: {e}') + + finally: + # schedule next update + interval = getattr(self, '_bp_update_interval_ms', 1000) + self.root.after(interval, self.update_beam_pulse) + def create_com_port_frame(self, parent_frame): """ Create the COM port configuration interface. @@ -388,6 +1148,17 @@ def create_com_port_frame(self, parent_frame): dropdown.pack(side=tk.RIGHT) self.port_dropdowns[subsystem] = dropdown + # ensure Beam Pulse key is present for users + if 'Beam Pulse' not in self.port_selections: + frame = ttk.Frame(self.com_port_menu) + frame.pack(fill=tk.X, padx=5, pady=2) + ttk.Label(frame, text="Beam Pulse:").pack(side=tk.LEFT) + port_var = tk.StringVar(value=self.com_ports.get('Beam Pulse', '')) + self.port_selections['Beam Pulse'] = port_var + dropdown = ttk.Combobox(frame, textvariable=port_var) + dropdown.pack(side=tk.RIGHT) + self.port_dropdowns['Beam Pulse'] = dropdown + ttk.Button(self.com_port_menu, text="Apply", command=self.apply_com_port_changes).pack(pady=5) def toggle_com_port_menu(self): @@ -395,7 +1166,7 @@ def toggle_com_port_menu(self): self.com_port_menu.pack_forget() self.com_port_button.config(text="Configure COM Ports") else: - self.update_available_ports() + self.update_available_ports() self.com_port_menu.pack(after=self.com_port_button, fill=tk.X, expand=True) self.com_port_button.config(text="Hide COM Port Configuration") diff --git a/instrumentctl/BCON/README.md b/instrumentctl/BCON/README.md new file mode 100644 index 00000000..250c08db --- /dev/null +++ b/instrumentctl/BCON/README.md @@ -0,0 +1,204 @@ +# BCON (Beam Controller) Driver + +## Overview + +The BCON driver provides programmatic control of the Beam Controller Arduino firmware over RS-485 serial communication. This driver handles command formatting, response parsing, telemetry monitoring, and status tracking for three independent pulser channels. + +## Hardware + +**Device:** Arduino Mega running BCON firmware +**Interface:** RS-485 serial communication +**Baud Rate:** 115200 (configurable) +**Line Termination:** `\n` (newline) + +## Features + +- **Command Interface:** Full support for all BCON firmware commands (PING, STATUS, SET CH, etc.) +- **Telemetry Monitoring:** Automatic parsing of system and channel telemetry +- **State Tracking:** Real-time monitoring of system state (READY, SAFE_INTERLOCK, SAFE_WATCHDOG) +- **Channel Control:** Independent control of 3 pulser channels (OFF, DC, PULSE modes) +- **Safety Features:** Watchdog configuration, dashboard software arming, and external interlock monitoring +- **Status Monitoring:** Per-channel status inputs (enable, power, overcurrent, gated) + +## Supported Commands + +| Command | Method | Description | +|---------|--------|-------------| +| `PING` | `ping()` | Check communication and refresh watchdog | +| `STATUS` | `get_status()` | Get full system and channel status | +| `STOP ALL` | `stop_all()` | Force all channels to OFF mode | +| `SET WATCHDOG` | `set_watchdog(ms)` | Configure watchdog timeout (50-60000 ms) | +| `SET TELEMETRY` | `set_telemetry(ms)` | Configure telemetry interval (0=disabled) | +| `SET CH OFF` | `set_channel_off(channel)` | Turn off specific channel | +| `SET CH DC` | `set_channel_dc(channel)` | Set channel to DC mode | +| `SET CH PULSE` | `set_channel_pulse(channel, duration_ms)` | Pulse channel for duration | + +## Usage Example + +```python +from instrumentctl.BCON import BCONDriver + +# Create driver instance +bcon = BCONDriver(port='COM3', baudrate=115200, timeout=1.0, debug=True) + +# Connect to hardware +if bcon.connect(): + print("Connected to BCON") + + # Ping device + if bcon.ping(): + print("BCON responding") + + # Get status + status = bcon.get_status() + print(f"System state: {status['system']['state']}") + + # Configure watchdog (1 second) + bcon.set_watchdog(1000) + + # Enable telemetry (500ms interval) + bcon.set_telemetry(500) + + # Set channel 1 to DC mode + if bcon.set_channel_dc(1): + print("Channel 1 in DC mode") + + # Pulse channel 2 for 250ms + if bcon.set_channel_pulse(2, 250): + print("Channel 2 pulsing") + + # Get real-time telemetry + telemetry = bcon.get_latest_telemetry() + print(f"Channel 1 mode: {telemetry['channels'][0]['mode']}") + + # Stop all channels + bcon.stop_all() + + # Disconnect + bcon.disconnect() +else: + print("Failed to connect to BCON") +``` + +## Telemetry Format + +### System Telemetry (`SYS` line) +``` +SYS state=READY reason=READY fault_latched=0 telemetry_ms=1000 +``` + +Fields: +- `state`: READY, SAFE_INTERLOCK, SAFE_WATCHDOG +- `reason`: Mirrors the current firmware state code +- `fault_latched`: Reserved compatibility field; current firmware always reports 0 +- `telemetry_ms`: Configured interval (0 = disabled) + +### Channel Telemetry (`CHn` line) +``` +CH1 mode=DC pulse_ms=0 en_st=1 pwr_st=1 oc_st=0 gated_st=0 +``` + +Fields: +- `mode`: OFF, DC, PULSE +- `pulse_ms`: Configured pulse duration (0 if not pulsing) +- `en_st`: Enable status input (0/1) +- `pwr_st`: Power status input (0/1) +- `oc_st`: Over-current status input (0/1) +- `gated_st`: Gated status input (0/1) + +## API Reference + +### Connection Management + +#### `connect() -> bool` +Connect to BCON hardware over serial port. Returns `True` on success. + +#### `disconnect() -> None` +Close serial connection and cleanup resources. + +#### `is_connected() -> bool` +Check if currently connected to hardware. + +### Basic Commands + +#### `ping() -> bool` +Send PING command and wait for PONG response. Also refreshes communication watchdog. + +#### `get_status() -> dict` +Request and parse full system status. Returns dictionary with `system` and `channels` keys. + +#### `stop_all() -> bool` +Force all channels to OFF mode immediately. + +### Configuration + +#### `set_watchdog(timeout_ms: int) -> bool` +Configure communication watchdog timeout (50-60000 ms). If no command received within timeout, system enters SAFE_WATCHDOG state. + +#### `set_telemetry(interval_ms: int) -> bool` +Configure periodic telemetry transmission interval. Set to 0 to disable automatic telemetry. + +### Channel Control + +#### `set_channel_off(channel: int) -> bool` +Turn off specified channel (1-3). Only works in READY state. + +#### `set_channel_dc(channel: int) -> bool` +Set channel (1-3) to DC mode (continuous output). Only works in READY state. + +#### `set_channel_pulse(channel: int, duration_ms: int) -> bool` +Pulse channel (1-3) for specified duration (1-60000 ms). Channel automatically returns to OFF after pulse completes. Only works in READY state. + +### Safety Behavior + +Current firmware does not expose a clear-fault / arm command. The dashboard's +"Arm Beams" control is a frontend software interlock, while the external +hardware interlock and communication watchdog remain the firmware-level safety +mechanisms that can force outputs off. + +### Status & Telemetry + +#### `get_latest_telemetry() -> dict` +Return most recently received telemetry data without sending a command. + +#### `get_system_state() -> str` +Return current system state: READY, SAFE_INTERLOCK, or SAFE_WATCHDOG. + +#### `get_channel_mode(channel: int) -> str` +Return current mode for channel (1-3): OFF, DC, or PULSE. + +#### `get_channel_status(channel: int) -> dict` +Return status inputs for channel (1-3): en_st, pwr_st, oc_st, gated_st. + +## Error Handling + +All command methods return `bool` or parsed data structures. Check return values to detect command failures: + +```python +if not bcon.set_channel_dc(1): + print("Failed to set channel 1 to DC mode") + # Check if system is in READY state + if bcon.get_system_state() != "READY": + print("System not in READY state") +``` + +Enable debug mode to see command/response traffic: +```python +bcon = BCONDriver(port='COM3', debug=True) +``` + +## Thread Safety + +The driver uses a threading lock (`_serial_lock`) to ensure thread-safe access to the serial port. Multiple threads can safely call driver methods concurrently. + +## Dependencies + +- `pyserial` - Serial communication library + +## Development + +Run driver standalone for testing: + +```bash +python -m instrumentctl.BCON.bcon_driver --port COM3 --test +``` diff --git a/instrumentctl/BCON/__init__.py b/instrumentctl/BCON/__init__.py new file mode 100644 index 00000000..fc0137af --- /dev/null +++ b/instrumentctl/BCON/__init__.py @@ -0,0 +1,40 @@ +"""BCON (Beam Controller) driver package — Modbus RTU.""" + +from .bcon_driver import ( + BCONDriver, + BCONMode, + BCONState, + MODE_LABEL_TO_CODE, + MODE_CODE_TO_LABEL, + STATE_LABELS, + scan_serial_ports, + # Register map constants + TOTAL_REGS, + REG_WATCHDOG_MS, + REG_TELEMETRY_MS, + REG_COMMAND, + CH_BASE, + CH_MODE_OFF, + CH_PULSE_MS_OFF, + CH_COUNT_OFF, + CH_ENABLE_SET_OFF, + CH_ENABLE_TOGGLE_OFF, + REG_SYS_STATE, + REG_SYS_REASON, + REG_FAULT_LATCHED, + REG_INTERLOCK_OK, + REG_WATCHDOG_OK, + REG_LAST_ERROR, + REG_CH_STATUS_BASE, + REG_CH_STATUS_STRIDE, +) + +__all__ = [ + 'BCONDriver', + 'BCONMode', + 'BCONState', + 'MODE_LABEL_TO_CODE', + 'MODE_CODE_TO_LABEL', + 'STATE_LABELS', + 'scan_serial_ports', +] diff --git a/instrumentctl/BCON/bcon_driver.py b/instrumentctl/BCON/bcon_driver.py new file mode 100644 index 00000000..ddc293d4 --- /dev/null +++ b/instrumentctl/BCON/bcon_driver.py @@ -0,0 +1,1561 @@ +""" +BCON (Beam Controller) Driver — Modbus RTU + +Modbus RTU master driver for Arduino Mega running BCON firmware. +Provides register-based control, status polling, and telemetry access +for three independent pulser channels with safety interlocks. + +Register map mirrors the firmware Modbus slave implementation: + Control registers 0-2 : watchdog, telemetry, command + Channel 1 params 10-13 : mode, pulse_ms, count, enable_state + Channel 2 params 20-23 : (same layout) + Channel 3 params 30-33 : (same layout) + System status 100-109 : state, reason, reserved fault slot, interlock, watchdog, error, supervisor, cmd status + CH1 status 110-118 : mode_st, pulse_ms_st, count_st, remaining, ... + CH2 status 120-128 + CH3 status 130-138 + CH1 supervisor 140-143 : run_state, stop_reason, complete, aborted + CH2 supervisor 144-147 + CH3 supervisor 148-151 + Command diagnostics 152-153: last_reject_reason, last_cmd_seq +""" + +from __future__ import annotations + +import queue +import struct +import threading +import time +from typing import Optional, Dict, List +from enum import IntEnum + +# pyserial — used directly for Modbus RTU (bypasses pymodbus v3 framer bug) +try: + import serial + import serial.tools.list_ports as list_ports +except ImportError: + serial = None + list_ports = None + + +# ======================== Register Map Constants ======================== + +TOTAL_REGS = 160 + +# --- Control registers (written by master) --- +REG_WATCHDOG_MS = 0 +REG_TELEMETRY_MS = 1 +REG_COMMAND = 2 # 0=NOP, 1=ALL_OFF, 4=APPLY_STAGED_MODES + +COMMAND_NOP = 0 +COMMAND_ALL_OFF = 1 +COMMAND_APPLY_STAGED_MODES = 4 + +COMMAND_CODE_TO_LABEL = { + COMMAND_NOP: "NOP", + COMMAND_ALL_OFF: "ALL_OFF", + COMMAND_APPLY_STAGED_MODES: "APPLY_STAGED_MODES", +} + +# --- Per-channel parameter registers --- +CH_BASE = [10, 20, 30] # base address for CH1, CH2, CH3 +CH_MODE_OFF = 0 # offset: requested mode +CH_PULSE_MS_OFF = 1 # offset: pulse duration (ms) +CH_COUNT_OFF = 2 # offset: pulse count +CH_ENABLE_SET_OFF = 3 # offset: explicit enable state (0=disabled, 1=enabled) +CH_ENABLE_TOGGLE_OFF = CH_ENABLE_SET_OFF # backwards-compatible alias + +# --- System status registers (read-only from master view) --- +REG_SYS_STATE = 100 +REG_SYS_REASON = 101 +REG_FAULT_LATCHED = 102 # Reserved compatibility slot; current firmware always reports 0. +REG_INTERLOCK_OK = 103 +REG_WATCHDOG_OK = 104 +REG_LAST_ERROR = 105 +REG_SUP_STATE = 106 +REG_CMD_QUEUE_DEPTH = 107 +REG_LAST_CMD_CODE = 108 +REG_LAST_CMD_RESULT = 109 + +# --- Per-channel status registers (read-only from master view) --- +REG_CH_STATUS_BASE = 110 +REG_CH_STATUS_STRIDE = 10 +# Offsets within each channel status block: +# +0 mode (actual) +# +1 pulse_ms (actual) +# +2 count (actual) +# +3 remaining pulses +# +4 en_st +# +5 pwr_st +# +6 oc_st +# +7 gated_st +# +8 output_level + +# --- Per-channel supervisor extension status registers (read-only) --- +REG_CH_SUP_BASE = 140 +REG_CH_SUP_STRIDE = 4 + +REG_LAST_REJECT_REASON = 152 +REG_LAST_CMD_SEQ = 153 + + +# ======================== Mode Enumerations ======================== + +class BCONMode(IntEnum): + """Channel operating modes (register values).""" + OFF = 0 + DC = 1 + PULSE = 2 + PULSE_TRAIN = 3 + +MODE_LABEL_TO_CODE = { + "OFF": BCONMode.OFF, + "DC": BCONMode.DC, + "PULSE": BCONMode.PULSE, + "PULSE_TRAIN": BCONMode.PULSE_TRAIN, +} + +MODE_CODE_TO_LABEL = {v: k for k, v in MODE_LABEL_TO_CODE.items()} + + +class BCONState(IntEnum): + """System state codes read from REG_SYS_STATE.""" + READY = 0 + SAFE_INTERLOCK = 1 + SAFE_WATCHDOG = 2 + UNKNOWN = 255 + +STATE_LABELS = { + BCONState.READY: "READY", + BCONState.SAFE_INTERLOCK: "SAFE_INTERLOCK", + BCONState.SAFE_WATCHDOG: "SAFE_WATCHDOG", + BCONState.UNKNOWN: "UNKNOWN", +} + + +class BCONSupervisorState(IntEnum): + """Supervisor summary state codes read from REG_SUP_STATE.""" + IDLE = 0 + ACTIVE = 1 + COMMAND_QUEUED = 2 + SAFE_INTERLOCK_HOLD = 3 + SAFE_WATCHDOG_HOLD = 4 + + +SUPERVISOR_STATE_LABELS = { + BCONSupervisorState.IDLE: "IDLE", + BCONSupervisorState.ACTIVE: "ACTIVE", + BCONSupervisorState.COMMAND_QUEUED: "COMMAND_QUEUED", + BCONSupervisorState.SAFE_INTERLOCK_HOLD: "SAFE_INTERLOCK_HOLD", + BCONSupervisorState.SAFE_WATCHDOG_HOLD: "SAFE_WATCHDOG_HOLD", +} + + +class BCONChannelRunState(IntEnum): + """Per-channel supervisor-visible semantic state.""" + OFF = 0 + STAGED = 1 + RUNNING_DC = 2 + RUNNING_PULSE = 3 + RUNNING_TRAIN = 4 + COMPLETE = 5 + ABORTED = 6 + + +CHANNEL_RUN_STATE_LABELS = { + BCONChannelRunState.OFF: "OFF", + BCONChannelRunState.STAGED: "STAGED", + BCONChannelRunState.RUNNING_DC: "RUNNING_DC", + BCONChannelRunState.RUNNING_PULSE: "RUNNING_PULSE", + BCONChannelRunState.RUNNING_TRAIN: "RUNNING_TRAIN", + BCONChannelRunState.COMPLETE: "COMPLETE", + BCONChannelRunState.ABORTED: "ABORTED", +} + + +class BCONStopReason(IntEnum): + """Per-channel stop/abort reason reported by the firmware supervisor.""" + NONE = 0 + NORMAL_COMPLETE = 1 + ALL_OFF_COMMAND = 2 + SAFE_INTERLOCK = 3 + SAFE_WATCHDOG = 4 + + +STOP_REASON_LABELS = { + BCONStopReason.NONE: "NONE", + BCONStopReason.NORMAL_COMPLETE: "NORMAL_COMPLETE", + BCONStopReason.ALL_OFF_COMMAND: "ALL_OFF_COMMAND", + BCONStopReason.SAFE_INTERLOCK: "SAFE_INTERLOCK", + BCONStopReason.SAFE_WATCHDOG: "SAFE_WATCHDOG", +} + + +class BCONCommandResult(IntEnum): + """Last-command execution status reported by the firmware.""" + NONE = 0 + QUEUED = 1 + EXECUTED = 2 + REJECTED = 3 + + +COMMAND_RESULT_LABELS = { + BCONCommandResult.NONE: "NONE", + BCONCommandResult.QUEUED: "QUEUED", + BCONCommandResult.EXECUTED: "EXECUTED", + BCONCommandResult.REJECTED: "REJECTED", +} + + +class BCONRejectReason(IntEnum): + """Reason the most recent firmware command was rejected.""" + NONE = 0 + INVALID_COMMAND = 1 + QUEUE_FULL = 2 + UNSAFE_INTERLOCK = 3 + UNSAFE_WATCHDOG = 4 + + +REJECT_REASON_LABELS = { + BCONRejectReason.NONE: "NONE", + BCONRejectReason.INVALID_COMMAND: "INVALID_COMMAND", + BCONRejectReason.QUEUE_FULL: "QUEUE_FULL", + BCONRejectReason.UNSAFE_INTERLOCK: "UNSAFE_INTERLOCK", + BCONRejectReason.UNSAFE_WATCHDOG: "UNSAFE_WATCHDOG", +} + + +# ======================== Utility ======================== + +def scan_serial_ports() -> List[str]: + """Return a list of available serial ports, with Arduino-like ports first.""" + if list_ports is None: + return [] + ports = list_ports.comports() + preferred, others = [], [] + for p in ports: + desc = (getattr(p, "description", "") or "").lower() + hwid = (getattr(p, "hwid", "") or "").lower() + if any(tok in desc for tok in ("arduino", "usb serial", "ch340", "cp210")) or "vid:pid=2341" in hwid: + preferred.append(p.device) + else: + others.append(p.device) + return preferred + others + + +# ======================== BCONDriver ======================== + +class BCONDriver: + """ + BCON (Beam Controller) Modbus RTU driver. + + Communicates with Arduino Mega running BCON firmware over Modbus RTU + to control three independent pulser channels with safety interlocks. + + Features: + - Raw Modbus RTU serial communication over pyserial + - Background register polling thread + - Thread-safe register cache + - Write queue for non-blocking control + - Staged/apply channel control aligned with current firmware + - Watchdog and interlock-aware safety behavior + - Auto-disconnect on repeated poll failures + """ + + # Defaults + DEFAULT_BAUD = 115200 + DEFAULT_UNIT = 1 + DEFAULT_TIMEOUT = 1.0 + DEFAULT_WATCHDOG_MS = 1500 + DEFAULT_TELEMETRY_MS = 500 + WATCHDOG_MIN_MS = 50 + WATCHDOG_MAX_MS = 60000 + POLL_INTERVAL = 0.5 # seconds between register polls + MAX_POLL_ERRORS = 15 # consecutive failures before auto-disconnect + SETTLE_TIME = 4.5 # seconds to wait after opening port (Arduino DTR reset) + WATCHDOG_HEARTBEAT_S = 0.5 # refresh at least once per poll cycle + COMMAND_CONFIRM_RETRIES = 4 + COMMAND_CONFIRM_DELAY_S = 0.02 + + def __init__(self, port: str, baudrate: int = DEFAULT_BAUD, + unit: int = DEFAULT_UNIT, timeout: float = DEFAULT_TIMEOUT, + debug: bool = False): + """ + Initialize BCON driver. + + Args: + port: Serial port name (e.g., 'COM3') + baudrate: Serial baudrate (default: 115200) + unit: Modbus slave/unit address (default: 1) + timeout: Modbus read timeout in seconds (default: 1.0) + debug: Enable debug logging (default: False) + """ + self.port = port + self.baudrate = baudrate + self.unit = unit + self.timeout = timeout + self.debug = debug + + # Serial port (raw pyserial — replaces pymodbus which has a v3 framer bug) + self._serial: Optional[serial.Serial] = None + self._serial_lock = threading.Lock() # serialize all serial I/O + self._connected = False + + # Write command queue (thread-safe) + self._cmd_queue: queue.Queue = queue.Queue() + + # Latest register snapshot + self._regs: List[int] = [0] * TOTAL_REGS + self._regs_lock = threading.Lock() + + # Polling thread + self._poll_thread: Optional[threading.Thread] = None + self._poll_running = False + self._poll_errors = 0 + + # Callbacks for UI notification (msg_type, *args) + self._ui_queue: Optional[queue.Queue] = None + + self._user_requested_disconnect = False + self._watchdog_timeout_ms = self.DEFAULT_WATCHDOG_MS + self._last_heartbeat_time: float = 0.0 # tracks last watchdog write + + def set_ui_queue(self, q: queue.Queue): + """Set an optional queue to receive UI notification messages.""" + self._ui_queue = q + + def _ui_put(self, *msg): + """Post a message to the UI queue if one is set.""" + if self._ui_queue: + self._ui_queue.put(msg) + + # ------------------------------------------------------------------ # + # Logging # + # ------------------------------------------------------------------ # + + def _log(self, message: str, level: str = "INFO"): + """Internal logging helper.""" + if self.debug or level in ("ERROR", "WARNING"): + print(f"[BCON {level}] {message}") + + def _reset_cached_state(self): + """Clear cached registers.""" + with self._regs_lock: + self._regs = [0] * TOTAL_REGS + self._watchdog_timeout_ms = self.DEFAULT_WATCHDOG_MS + self._last_heartbeat_time = 0.0 + + def _clear_cmd_queue(self) -> None: + """Drop any queued writes so reconnects never replay stale commands.""" + while True: + try: + self._cmd_queue.get_nowait() + except queue.Empty: + break + + def _set_cached_channel_enabled(self, channel: int, enabled: bool) -> None: + """Update cached enable-related registers for one channel.""" + if not (1 <= channel <= 3): + return + base = CH_BASE[channel - 1] + status_base = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + value = 1 if enabled else 0 + with self._regs_lock: + self._regs[base + CH_ENABLE_SET_OFF] = value + self._regs[status_base + 4] = value + + def reset_channel_enable_cache(self, enabled: bool = False): + """Reset cached enable state used by the dashboard UI.""" + for channel in range(1, 4): + self._set_cached_channel_enabled(channel, enabled) + + @staticmethod + def _command_label(cmd_code: int) -> str: + """Map a command register value to a stable label.""" + return COMMAND_CODE_TO_LABEL.get(int(cmd_code), f"UNKNOWN({int(cmd_code)})") + + def _get_cached_command_snapshot(self) -> Dict[str, int]: + """Read the currently cached firmware command-diagnostic registers.""" + with self._regs_lock: + return { + 'supervisor_state_code': self._regs[REG_SUP_STATE], + 'cmd_queue_depth': self._regs[REG_CMD_QUEUE_DEPTH], + 'last_command_code': self._regs[REG_LAST_CMD_CODE], + 'last_command_result_code': self._regs[REG_LAST_CMD_RESULT], + 'last_reject_reason_code': self._regs[REG_LAST_REJECT_REASON], + 'last_cmd_seq': self._regs[REG_LAST_CMD_SEQ], + } + + def _read_command_snapshot_raw(self) -> Dict[str, int]: + """Read command diagnostics directly after a COMMAND write.""" + supervisor_block = self._read_holding_registers_raw(REG_SUP_STATE, 4) + diag_block = self._read_holding_registers_raw(REG_LAST_REJECT_REASON, 2) + + snapshot = { + 'supervisor_state_code': supervisor_block[0], + 'cmd_queue_depth': supervisor_block[1], + 'last_command_code': supervisor_block[2], + 'last_command_result_code': supervisor_block[3], + 'last_reject_reason_code': diag_block[0], + 'last_cmd_seq': diag_block[1], + } + + with self._regs_lock: + self._regs[REG_SUP_STATE:REG_SUP_STATE + 4] = supervisor_block + self._regs[REG_LAST_REJECT_REASON:REG_LAST_REJECT_REASON + 2] = diag_block + + return snapshot + + def _build_command_result_payload( + self, + requested_code: int, + snapshot: Dict[str, int], + baseline: Optional[Dict[str, int]] = None, + ) -> Dict[str, object]: + """Translate raw command diagnostics into a UI-friendly payload.""" + result_code = snapshot['last_command_result_code'] + reject_code = snapshot['last_reject_reason_code'] + actual_code = snapshot['last_command_code'] + return { + 'requested_code': int(requested_code), + 'requested_label': self._command_label(requested_code), + 'last_command_code': actual_code, + 'last_command_label': self._command_label(actual_code), + 'last_command_result': self._label_from_code( + result_code, BCONCommandResult, COMMAND_RESULT_LABELS), + 'last_command_result_code': result_code, + 'last_reject_reason': self._label_from_code( + reject_code, BCONRejectReason, REJECT_REASON_LABELS), + 'last_reject_reason_code': reject_code, + 'last_cmd_seq': snapshot['last_cmd_seq'], + 'supervisor_state': self._label_from_code( + snapshot['supervisor_state_code'], BCONSupervisorState, SUPERVISOR_STATE_LABELS), + 'supervisor_state_code': snapshot['supervisor_state_code'], + 'cmd_queue_depth': snapshot['cmd_queue_depth'], + 'accepted': result_code == int(BCONCommandResult.EXECUTED), + 'rejected': result_code == int(BCONCommandResult.REJECTED), + 'fresh_snapshot': baseline is None or snapshot != baseline, + } + + def _confirm_command_write( + self, + cmd_code: int, + baseline: Optional[Dict[str, int]] = None, + ) -> Optional[Dict[str, object]]: + """Confirm a nonzero COMMAND write from LAST_CMD diagnostics.""" + cmd_code = int(cmd_code) + if cmd_code == COMMAND_NOP or not (self._serial and self._connected): + return None + + if baseline is None: + baseline = self._get_cached_command_snapshot() + + last_error: Optional[Exception] = None + last_snapshot: Optional[Dict[str, int]] = None + + for attempt in range(self.COMMAND_CONFIRM_RETRIES): + try: + snapshot = self._read_command_snapshot_raw() + last_snapshot = snapshot + if ( + snapshot['last_command_code'] == cmd_code + and snapshot['last_command_result_code'] in ( + int(BCONCommandResult.EXECUTED), + int(BCONCommandResult.REJECTED), + ) + ): + payload = self._build_command_result_payload(cmd_code, snapshot, baseline) + if payload['rejected']: + self._log( + f"Command {payload['requested_label']} rejected: " + f"{payload['last_reject_reason']} " + f"(seq={payload['last_cmd_seq']})", + "WARNING", + ) + elif self.debug: + self._log( + f"Command {payload['requested_label']} executed " + f"(seq={payload['last_cmd_seq']})", + "INFO", + ) + self._ui_put("command_result", payload) + return payload + except Exception as exc: + last_error = exc + + if attempt < self.COMMAND_CONFIRM_RETRIES - 1: + time.sleep(self.COMMAND_CONFIRM_DELAY_S) + + if last_error is not None: + message = ( + f"Command {self._command_label(cmd_code)} write completed, " + f"but diagnostics read failed: {last_error}" + ) + elif last_snapshot is not None: + message = ( + f"Command {self._command_label(cmd_code)} write completed, " + f"but diagnostics were inconclusive " + f"(last_code={last_snapshot['last_command_code']}, " + f"result={last_snapshot['last_command_result_code']}, " + f"reject={last_snapshot['last_reject_reason_code']}, " + f"seq={last_snapshot['last_cmd_seq']})" + ) + else: + message = ( + f"Command {self._command_label(cmd_code)} write completed, " + f"but diagnostics were unavailable" + ) + + self._log(message, "WARNING") + self._ui_put("error", message) + return None + + # ================================================================== # + # Connection Management # + # ================================================================== # + + def connect(self, settle_s: Optional[float] = None) -> bool: + """ + Connect to BCON hardware via Modbus RTU. + + Opening the serial port asserts DTR which resets the Arduino Mega. + The driver waits *settle_s* seconds for the firmware to finish setup() + before sending any Modbus frames. + + Args: + settle_s: Seconds to wait after port open (default: SETTLE_TIME). + + Returns: + True if connection successful, False otherwise. + """ + if serial is None: + self._log("pyserial is not installed", "ERROR") + return False + + if settle_s is None: + settle_s = self.SETTLE_TIME + + self._user_requested_disconnect = False + self._stop_poll_thread() + self._clear_cmd_queue() + + if self._serial: + try: + self._serial.close() + except Exception: + pass + + try: + self._serial = serial.Serial( + port=self.port, + baudrate=self.baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.timeout, + ) + ok = self._serial.is_open + except Exception as e: + self._serial = None + self._connected = False + self._reset_cached_state() + self._log(f"Connect failed: {e}", "ERROR") + self._ui_put("connected", False) + return False + + if ok and settle_s > 0: + self._log(f"Waiting {settle_s}s for firmware boot…", "INFO") + time.sleep(settle_s) + + # Flush any stale bytes left by the bootloader + try: + self._serial.reset_input_buffer() + self._serial.reset_output_buffer() + self._log("Serial buffers flushed after settle", "INFO") + except Exception as e: + self._log(f"Buffer flush warning: {e}", "WARNING") + + # Validate communication with a test write + read + try: + self._write_register_raw(REG_WATCHDOG_MS, self.DEFAULT_WATCHDOG_MS) + self._log("Watchdog heartbeat sent", "INFO") + except Exception as e: + self._log(f"Post-settle watchdog write failed: {e}", "WARNING") + + try: + vals = self._read_holding_registers_raw(0, 3) + self._log(f"Test read(0,3) OK: {vals}", "INFO") + except Exception as e: + self._log(f"Test read failed: {e}", "ERROR") + ok = False + + if self._user_requested_disconnect: + ok = False + if self._serial: + try: + self._serial.close() + except Exception: + pass + self._serial = None + elif not ok and self._serial: + try: + self._serial.close() + except Exception: + pass + self._serial = None + + self._connected = ok + self._poll_errors = 0 + self._reset_cached_state() + + if ok: + self._user_requested_disconnect = False + self._log(f"Connected to {self.port} at {self.baudrate} baud (unit={self.unit})", "INFO") + self._start_poll_thread() + else: + self._log("Modbus connect() returned False", "ERROR") + + self._ui_put("connected", ok) + return ok + + def disconnect(self): + """Disconnect from BCON hardware.""" + self._user_requested_disconnect = True + self._stop_poll_thread() + self._clear_cmd_queue() + if self._serial and self._connected: + try: + if self.write_register_immediate(REG_COMMAND, COMMAND_ALL_OFF): + self._log("ALL_OFF sent before disconnect", "INFO") + else: + self._log("ALL_OFF before disconnect was inconclusive; relying on watchdog", "WARNING") + except Exception as e: + self._log(f"ALL_OFF before disconnect failed: {e}", "WARNING") + self._connected = False + self._poll_errors = 0 + if self._serial: + try: + self._serial.close() + except Exception: + pass + self._serial = None + self._reset_cached_state() + self._log("Disconnected", "INFO") + self._ui_put("connected", False) + + def is_connected(self) -> bool: + """Check if connected to BCON hardware.""" + return self._connected and self._serial is not None + + # ================================================================== # + # Raw Modbus RTU I/O (bypasses pymodbus v3 framer bug) # + # ================================================================== # + + @staticmethod + def _modbus_crc16(data: bytes) -> int: + """Compute Modbus CRC-16.""" + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc + + def _serial_transaction(self, request_payload: bytes, expected_min: int) -> bytes: + """ + Send a Modbus RTU frame and read the response. + + Args: + request_payload: Frame bytes WITHOUT CRC (slave + FC + data) + expected_min: Minimum expected response bytes (including CRC) + + Returns: + Response payload (without CRC), or raises RuntimeError. + """ + crc = self._modbus_crc16(request_payload) + frame = request_payload + struct.pack(" 2 else 0xFF + raise RuntimeError( + f"Modbus exception: FC=0x{response[1]:02X} code={ex_code}") + + return response[:-2] # strip CRC + + def _write_register_raw(self, reg: int, val: int): + """Write a single holding register (FC 0x06) via raw serial.""" + payload = struct.pack(">BBHH", self.unit, 0x06, reg, int(val) & 0xFFFF) + self._serial_transaction(payload, 8) # FC 0x06 response is always 8 bytes + + def _read_holding_registers_raw(self, start: int, count: int) -> list: + """Read holding registers (FC 0x03) via raw serial. Returns list of ints.""" + payload = struct.pack(">BBHH", self.unit, 0x03, start, count) + # Response: slave(1) + FC(1) + bytecount(1) + data(2*count) + CRC(2) + expected = 3 + 2 * count + 2 + resp = self._serial_transaction(payload, expected) + # Parse register values from response payload (skip slave + FC + bytecount) + byte_count = resp[2] + values = [] + for i in range(0, byte_count, 2): + values.append(struct.unpack(">H", resp[3 + i:5 + i])[0]) + return values + + # ================================================================== # + # Write Queue # + # ================================================================== # + + def enqueue_write(self, reg: int, value: int): + """ + Enqueue a register write to be executed by the poll thread. + + This is the primary way to send commands from the UI thread. + Writes are executed before each poll cycle to minimise latency. + + Args: + reg: Register address. + value: 16-bit unsigned value. + """ + self._cmd_queue.put(("write", reg, value)) + + def write_register_immediate(self, reg: int, value: int) -> bool: + """ + Write a register synchronously (blocks until complete). + + Use this only when you need confirmation; prefer enqueue_write() + for non-blocking UI interaction. Nonzero COMMAND writes are confirmed + from LAST_CMD diagnostics rather than queue-depth or register echo timing. + + Returns: + True if the write succeeded. For nonzero COMMAND writes, True means + the firmware reported EXECUTED and False means rejected or inconclusive. + """ + if not self.is_connected(): + return False + + reg = int(reg) + value = int(value) + baseline = None + if reg == REG_COMMAND and value != COMMAND_NOP: + baseline = self._get_cached_command_snapshot() + + try: + self._write_register_raw(reg, value) + if baseline is not None: + result = self._confirm_command_write(value, baseline=baseline) + return bool(result and result.get('accepted')) + return True + except Exception as e: + self._log(f"Immediate write reg {reg}: {e}", "ERROR") + return False + + # ================================================================== # + # Background Polling Thread # + # ================================================================== # + + def _poll_thread_func(self): + """Background thread: process write queue then poll registers.""" + last_error_msg = None + + # Brief initial settle before the first poll cycle. Opening the serial + # port (even without DTR) can cause brief USB-CDC enumeration traffic; + # waiting here prevents that traffic from disrupting the first reads. + time.sleep(1.0) + + while self._poll_running: + now = time.monotonic() + + # --- Watchdog heartbeat (keeps firmware from timing out) --- + # Send a write to REG_WATCHDOG_MS if no queued writes are pending + # and enough time has elapsed. This prevents the firmware's software + # watchdog from expiring and forcing all channels OFF. + if (self._serial and self._connected + and self._cmd_queue.empty() + and (now - self._last_heartbeat_time) >= self.WATCHDOG_HEARTBEAT_S): + try: + self._write_register_raw(REG_WATCHDOG_MS, self._watchdog_timeout_ms) + self._last_heartbeat_time = now + except Exception as e: + self._log(f"Watchdog heartbeat failed: {e}", "WARNING") + + # --- Process queued writes --- + try: + while not self._cmd_queue.empty(): + cmd = self._cmd_queue.get_nowait() + if cmd[0] == "write" and self._serial and self._connected: + _, reg, val = cmd + reg = int(reg) + val = int(val) + baseline = None + if reg == REG_COMMAND and val != COMMAND_NOP: + baseline = self._get_cached_command_snapshot() + try: + self._write_register_raw(reg, val) + self._ui_put("wrote", reg, val) + if baseline is not None: + self._confirm_command_write(val, baseline=baseline) + except Exception as e: + self._log(f"Write reg {reg}: {e}", "ERROR") + self._ui_put("error", f"Write reg {reg}: {e}") + except queue.Empty: + pass + + # --- Poll registers --- + if self._serial and self._connected: + try: + regs = [0] * TOTAL_REGS + + def read_block(start, count): + try: + vals = self._read_holding_registers_raw(start, count) + for i, value in enumerate(vals): + idx = start + i + if 0 <= idx < TOTAL_REGS: + regs[idx] = value + return True + except Exception as e: + self._log(f" read_block({start}, {count}) FAILED: {e}", "WARNING") + return False + + ok = True + # Read only the register addresses that the firmware actually + # defines. Registers 3-9, 14-19, and 24-29 are gaps in the + # control map, while channel status omits the +9 stride slot. + ok &= read_block(0, 3) # control: watchdog(0), telemetry(1), command(2) + ok &= read_block(10, 4) # CH1 params: mode, pulse_ms, count, enable_toggle + ok &= read_block(20, 4) # CH2 params + ok &= read_block(30, 4) # CH3 params + ok &= read_block(100, 10) # system + supervisor status (100-109) + ok &= read_block(110, 9) # CH1 status (110-118) + ok &= read_block(120, 9) # CH2 status (120-128) + ok &= read_block(130, 9) # CH3 status (130-138) + ok &= read_block(140, 12) # CH1-CH3 supervisor status (140-151) + ok &= read_block(152, 2) # last reject reason + last cmd seq + + if not ok: + self._poll_errors += 1 + err = f"Modbus read failed ({self._poll_errors}/{self.MAX_POLL_ERRORS})" + if err != last_error_msg: + self._log(err, "WARNING") + self._ui_put("error", err) + last_error_msg = err + if self._poll_errors >= self.MAX_POLL_ERRORS: + self._auto_disconnect() + else: + self._poll_errors = 0 + last_error_msg = None + with self._regs_lock: + changed = (regs != self._regs) + self._regs = regs + if changed: + self._ui_put("regs", regs) + + except Exception as e: + self._poll_errors += 1 + err = f"Poll error ({self._poll_errors}/{self.MAX_POLL_ERRORS}): {e}" + if err != last_error_msg: + self._log(err, "WARNING") + self._ui_put("error", err) + last_error_msg = err + if self._poll_errors >= self.MAX_POLL_ERRORS: + self._auto_disconnect() + + time.sleep(self.POLL_INTERVAL) + + def _auto_disconnect(self): + """Called from poll thread when too many consecutive errors.""" + self._connected = False + self._poll_running = False # tell the poll thread to exit cleanly + self._clear_cmd_queue() + if self._serial: + try: + self._serial.close() + except Exception: + pass + self._serial = None + self._poll_errors = 0 + self._reset_cached_state() + self._log("Auto-disconnected after repeated poll failures", "WARNING") + self._ui_put("connected", False) + + def _start_poll_thread(self): + """Start the background polling thread.""" + if self._poll_thread and self._poll_thread.is_alive(): + return + self._poll_running = True + self._poll_thread = threading.Thread(target=self._poll_thread_func, daemon=True) + self._poll_thread.start() + + def _stop_poll_thread(self): + """Stop the background polling thread.""" + self._poll_running = False + if self._poll_thread: + self._poll_thread.join(timeout=3.0) + self._poll_thread = None + + # ================================================================== # + # Register Cache Access # + # ================================================================== # + + def get_registers(self) -> List[int]: + """Get a thread-safe copy of the latest register snapshot.""" + with self._regs_lock: + return self._regs.copy() + + def get_register(self, addr: int) -> int: + """Get a single register value from the cache.""" + with self._regs_lock: + if 0 <= addr < TOTAL_REGS: + return self._regs[addr] + return 0 + + # ================================================================== # + # High-Level Channel Control # + # ================================================================== # + + def _validate_channel(self, channel: int) -> bool: + """Validate a 1-based channel number.""" + if 1 <= channel <= 3: + return True + self._log(f"Invalid channel: {channel} (must be 1-3)", "ERROR") + return False + + def _stage_channel_mode(self, channel: int, mode_code: int, + duration_ms: Optional[int] = None, + count: Optional[int] = None) -> bool: + """Stage parameters plus requested mode without committing them yet.""" + if not self._validate_channel(channel): + return False + + base = CH_BASE[channel - 1] + mode_code = int(mode_code) + + if mode_code not in (int(BCONMode.OFF), int(BCONMode.DC)): + if duration_ms is None or not (1 <= int(duration_ms) <= 60000): + self._log(f"Invalid pulse duration: {duration_ms}", "ERROR") + return False + if count is None or not (1 <= int(count) <= 10000): + self._log(f"Invalid pulse count: {count}", "ERROR") + return False + self.enqueue_write(base + CH_PULSE_MS_OFF, int(duration_ms)) + self.enqueue_write(base + CH_COUNT_OFF, int(count)) + + self.enqueue_write(base + CH_MODE_OFF, mode_code) + return True + + def apply_staged_modes(self) -> None: + """Commit any staged channel mode writes in the firmware.""" + self.send_command(COMMAND_APPLY_STAGED_MODES) + + def set_channel_off(self, channel: int) -> None: + """Stage OFF for one channel and apply it immediately.""" + if self._stage_channel_mode(channel, BCONMode.OFF): + self.apply_staged_modes() + + def set_channel_dc(self, channel: int) -> None: + """Stage DC for one channel and apply it immediately.""" + if self._stage_channel_mode(channel, BCONMode.DC): + self.apply_staged_modes() + + def set_channel_pulse(self, channel: int, duration_ms: int, count: int = 1) -> None: + """Stage a single pulse request and apply it immediately.""" + if count < 1: + self._log(f"PULSE requires count >= 1, got {count}", "ERROR") + return + + effective_mode = BCONMode.PULSE if count == 1 else BCONMode.PULSE_TRAIN + if count > 1: + self._log( + f"PULSE request for CH{channel} promoted to PULSE_TRAIN because count={count}", + "WARNING", + ) + + if self._stage_channel_mode(channel, effective_mode, duration_ms=duration_ms, count=count): + self.apply_staged_modes() + + def set_channel_pulse_train(self, channel: int, duration_ms: int, count: int) -> None: + """Stage a pulse-train request and apply it immediately.""" + if count < 2: + self._log(f"PULSE_TRAIN requires count >= 2, got {count}", "ERROR") + return + if self._stage_channel_mode(channel, BCONMode.PULSE_TRAIN, duration_ms=duration_ms, count=count): + self.apply_staged_modes() + + def set_channel_mode(self, channel: int, mode: str, + duration_ms: int = 100, count: int = 1) -> None: + """Generic immediate mode setter built on the firmware stage/apply flow.""" + mode_upper = mode.strip().upper() + if mode_upper == "OFF": + self.set_channel_off(channel) + elif mode_upper == "DC": + self.set_channel_dc(channel) + elif mode_upper == "PULSE": + self.set_channel_pulse(channel, duration_ms, count) + elif mode_upper == "PULSE_TRAIN": + self.set_channel_pulse_train(channel, duration_ms, count) + else: + self._log(f"Unknown mode '{mode}'", "ERROR") + + def set_channel_params(self, channel: int, duration_ms: int, count: int) -> None: + """Write pulse parameters without changing staged or active mode.""" + if not self._validate_channel(channel): + return + base = CH_BASE[channel - 1] + if duration_ms > 0: + if not (1 <= int(duration_ms) <= 60000): + self._log(f"Invalid pulse duration: {duration_ms}", "ERROR") + return + self.enqueue_write(base + CH_PULSE_MS_OFF, int(duration_ms)) + if count > 0: + if not (1 <= int(count) <= 10000): + self._log(f"Invalid pulse count: {count}", "ERROR") + return + self.enqueue_write(base + CH_COUNT_OFF, int(count)) + + def set_channel_enable(self, channel: int, enabled: bool) -> bool: + """Set the channel enable state explicitly (0=disabled, 1=enabled).""" + if not self._validate_channel(channel): + return False + + base = CH_BASE[channel - 1] + desired = 1 if enabled else 0 + ok = self.write_register_immediate(base + CH_ENABLE_SET_OFF, desired) + if ok: + self._set_cached_channel_enabled(channel, enabled) + return ok + + def toggle_channel_enable(self, channel: int) -> bool: + """Compatibility wrapper that flips the current firmware-backed enable state.""" + if not self._validate_channel(channel): + return False + return self.set_channel_enable(channel, not self.is_channel_enabled(channel)) + + def stop_all(self) -> None: + """Force all three channels OFF using the firmware's dedicated command.""" + self.send_command(COMMAND_ALL_OFF) + + # ================================================================== # + # Synchronous Multi-Channel Start/Stop # + # ================================================================== # + + def sync_start(self, configs: List[Dict]) -> None: + """ + Stage multiple channel updates, then commit them together with COMMAND=4. + + Args: + configs: List of dicts with keys: + ch (int 1-3), mode (str), duration_ms (int), count (int) + """ + if not configs: + return + + normalized = [] + for cfg in configs: + try: + ch = int(cfg['ch']) + except Exception: + self._log(f"Invalid sync config channel: {cfg!r}", "ERROR") + return + + if not self._validate_channel(ch): + return + + mode_label = str(cfg.get('mode', 'OFF')).strip().upper() + if mode_label not in MODE_LABEL_TO_CODE: + self._log(f"Unknown sync mode '{mode_label}' for CH{ch}", "ERROR") + return + + duration_ms = int(cfg.get('duration_ms', 100) or 100) + count = int(cfg.get('count', 1) or 1) + mode_code = MODE_LABEL_TO_CODE[mode_label] + + if mode_code == BCONMode.PULSE: + if count < 1: + self._log(f"CH{ch}: PULSE requires count >= 1", "ERROR") + return + if count > 1: + mode_code = BCONMode.PULSE_TRAIN + elif mode_code == BCONMode.PULSE_TRAIN: + if count < 2: + self._log(f"CH{ch}: PULSE_TRAIN requires count >= 2", "ERROR") + return + + if mode_code not in (BCONMode.OFF, BCONMode.DC): + if not (1 <= duration_ms <= 60000): + self._log(f"CH{ch}: invalid pulse duration {duration_ms}", "ERROR") + return + if not (1 <= count <= 10000): + self._log(f"CH{ch}: invalid pulse count {count}", "ERROR") + return + + normalized.append({ + 'ch': ch, + 'mode_code': int(mode_code), + 'duration_ms': duration_ms, + 'count': count, + }) + + for cfg in normalized: + if cfg['mode_code'] in (int(BCONMode.OFF), int(BCONMode.DC)): + continue + base = CH_BASE[cfg['ch'] - 1] + self.enqueue_write(base + CH_PULSE_MS_OFF, cfg['duration_ms']) + self.enqueue_write(base + CH_COUNT_OFF, cfg['count']) + + for cfg in normalized: + self.enqueue_write(CH_BASE[cfg['ch'] - 1] + CH_MODE_OFF, cfg['mode_code']) + + self.apply_staged_modes() + + # ================================================================== # + # System Configuration # + # ================================================================== # + + def set_watchdog(self, timeout_ms: int) -> None: + """ + Configure communication watchdog timeout. + + Args: + timeout_ms: Watchdog timeout in milliseconds. + """ + timeout_ms = int(timeout_ms) + if not (self.WATCHDOG_MIN_MS <= timeout_ms <= self.WATCHDOG_MAX_MS): + self._log( + f"Invalid watchdog timeout: {timeout_ms} " + f"(must be {self.WATCHDOG_MIN_MS}-{self.WATCHDOG_MAX_MS} ms)", + "ERROR", + ) + return + self._watchdog_timeout_ms = timeout_ms + self.enqueue_write(REG_WATCHDOG_MS, timeout_ms) + + def set_telemetry(self, interval_ms: int) -> None: + """ + Configure periodic telemetry/polling interval on the firmware side. + + Args: + interval_ms: Telemetry interval in milliseconds (0 to disable). + """ + self.enqueue_write(REG_TELEMETRY_MS, interval_ms) + + def send_command(self, cmd_code: int) -> None: + """ + Queue a write to the special COMMAND register. + + Nonzero commands are confirmed from LAST_CMD diagnostics after the + write completes; queue-depth and COMMAND echo timing are not used as + completion handshakes. + """ + self.enqueue_write(REG_COMMAND, int(cmd_code)) + + # ================================================================== # + # Status / Telemetry Access # + # ================================================================== # + + @staticmethod + def _label_from_code(code: int, enum_type: type[IntEnum], labels: Dict[IntEnum, str]) -> str: + """Map an integer register value to a stable label.""" + try: + return labels.get(enum_type(code), "UNKNOWN") + except ValueError: + return "UNKNOWN" + + def get_system_state(self) -> str: + """Get current top-level safety state as a human-readable string.""" + return self._label_from_code(self.get_register(REG_SYS_STATE), BCONState, STATE_LABELS) + + def get_system_state_code(self) -> int: + """Get raw system state register value.""" + return self.get_register(REG_SYS_STATE) + + def get_supervisor_state(self) -> str: + """Get the firmware supervisor summary state as a label.""" + return self._label_from_code( + self.get_register(REG_SUP_STATE), + BCONSupervisorState, + SUPERVISOR_STATE_LABELS, + ) + + def get_supervisor_state_code(self) -> int: + """Get raw supervisor summary state register value.""" + return self.get_register(REG_SUP_STATE) + + def is_interlock_ok(self) -> bool: + """Check if the hardware interlock is satisfied.""" + return bool(self.get_register(REG_INTERLOCK_OK)) + + def is_watchdog_ok(self) -> bool: + """Check if the communication watchdog is satisfied.""" + return bool(self.get_register(REG_WATCHDOG_OK)) + + def is_fault_latched(self) -> bool: + """Compatibility shim: the reserved fault register always reads false.""" + return False + + def get_last_error(self) -> int: + """Get the last error code from firmware.""" + return self.get_register(REG_LAST_ERROR) + + def get_last_command_code(self) -> int: + """Get the raw code of the most recent supervisor command.""" + return self.get_register(REG_LAST_CMD_CODE) + + def get_last_command_result(self) -> str: + """Get the most recent supervisor command result as a label.""" + return self._label_from_code( + self.get_register(REG_LAST_CMD_RESULT), + BCONCommandResult, + COMMAND_RESULT_LABELS, + ) + + def get_last_command_result_code(self) -> int: + """Get the raw result code for the most recent supervisor command.""" + return self.get_register(REG_LAST_CMD_RESULT) + + def get_last_reject_reason(self) -> str: + """Get the most recent supervisor reject reason as a label.""" + return self._label_from_code( + self.get_register(REG_LAST_REJECT_REASON), + BCONRejectReason, + REJECT_REASON_LABELS, + ) + + def get_last_reject_reason_code(self) -> int: + """Get the raw reject reason code for the most recent supervisor command.""" + return self.get_register(REG_LAST_REJECT_REASON) + + def get_last_command_sequence(self) -> int: + """Get the firmware sequence number for the most recent accepted command.""" + return self.get_register(REG_LAST_CMD_SEQ) + + # --- Per-channel status --- + + def get_channel_mode(self, channel: int) -> str: + """Get actual operating mode for a channel (from status registers).""" + if not self._validate_channel(channel): + return "UNKNOWN" + addr = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + code = self.get_register(addr + 0) + return MODE_CODE_TO_LABEL.get(code, "UNKNOWN") + + def get_channel_remaining(self, channel: int) -> int: + """Get remaining pulse count for a channel.""" + if not self._validate_channel(channel): + return 0 + addr = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + return self.get_register(addr + 3) + + def get_channel_output_level(self, channel: int) -> int: + """Get current output level for a channel (0 or 1).""" + if not self._validate_channel(channel): + return 0 + addr = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + return self.get_register(addr + 8) + + def get_channel_supervisor_status(self, channel: int) -> Dict: + """Get the semantic supervisor status block for one channel.""" + if not self._validate_channel(channel): + return { + 'run_state': 'UNKNOWN', + 'run_state_code': -1, + 'stop_reason': 'UNKNOWN', + 'stop_reason_code': -1, + 'complete': False, + 'aborted': False, + } + + base = REG_CH_SUP_BASE + (channel - 1) * REG_CH_SUP_STRIDE + with self._regs_lock: + r = self._regs + run_state_code = r[base + 0] + stop_reason_code = r[base + 1] + return { + 'run_state': self._label_from_code(run_state_code, BCONChannelRunState, CHANNEL_RUN_STATE_LABELS), + 'run_state_code': run_state_code, + 'stop_reason': self._label_from_code(stop_reason_code, BCONStopReason, STOP_REASON_LABELS), + 'stop_reason_code': stop_reason_code, + 'complete': bool(r[base + 2]), + 'aborted': bool(r[base + 3]), + } + + def get_channel_status(self, channel: int) -> Dict: + """Get the combined live + supervisor status for one channel.""" + if not self._validate_channel(channel): + return { + 'mode': 'UNKNOWN', + 'pulse_ms': 0, + 'count': 0, + 'remaining': 0, + 'en_st': False, + 'en_st_raw': 0, + 'pwr_st': 0, + 'oc_st': 0, + 'gated_st': 0, + 'output_level': 0, + 'run_state': 'UNKNOWN', + 'run_state_code': -1, + 'stop_reason': 'UNKNOWN', + 'stop_reason_code': -1, + 'complete': False, + 'aborted': False, + } + + base = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + sup_base = REG_CH_SUP_BASE + (channel - 1) * REG_CH_SUP_STRIDE + with self._regs_lock: + r = self._regs + run_state_code = r[sup_base + 0] + stop_reason_code = r[sup_base + 1] + enabled = bool(r[base + 4]) + return { + 'mode': MODE_CODE_TO_LABEL.get(r[base + 0], "UNKNOWN"), + 'pulse_ms': r[base + 1], + 'count': r[base + 2], + 'remaining': r[base + 3], + 'en_st': enabled, + 'en_st_raw': r[base + 4], + 'pwr_st': r[base + 5], + 'oc_st': r[base + 6], + 'gated_st': r[base + 7], + 'output_level': r[base + 8], + 'run_state': self._label_from_code(run_state_code, BCONChannelRunState, CHANNEL_RUN_STATE_LABELS), + 'run_state_code': run_state_code, + 'stop_reason': self._label_from_code(stop_reason_code, BCONStopReason, STOP_REASON_LABELS), + 'stop_reason_code': stop_reason_code, + 'complete': bool(r[sup_base + 2]), + 'aborted': bool(r[sup_base + 3]), + } + + def is_channel_overcurrent(self, channel: int) -> bool: + """Check if a channel has an overcurrent condition.""" + if not self._validate_channel(channel): + return False + addr = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + 6 + return bool(self.get_register(addr)) + + def is_channel_enabled(self, channel: int) -> bool: + """Return the firmware-backed cached enable state for a channel.""" + if not self._validate_channel(channel): + return False + addr = REG_CH_STATUS_BASE + (channel - 1) * REG_CH_STATUS_STRIDE + 4 + return bool(self.get_register(addr)) + + # --- Legacy-compatible telemetry dict --- + + def get_status(self) -> Dict: + """Get full system status in a structured dictionary.""" + with self._regs_lock: + r = self._regs + state_code = r[REG_SYS_STATE] + supervisor_state_code = r[REG_SUP_STATE] + last_result_code = r[REG_LAST_CMD_RESULT] + last_reject_code = r[REG_LAST_REJECT_REASON] + + system = { + 'state': self._label_from_code(state_code, BCONState, STATE_LABELS), + 'state_code': state_code, + 'reason': r[REG_SYS_REASON], + 'fault_latched': 0, + 'interlock_ok': r[REG_INTERLOCK_OK], + 'watchdog_ok': r[REG_WATCHDOG_OK], + 'last_error': r[REG_LAST_ERROR], + 'telemetry_ms': r[REG_TELEMETRY_MS], + 'watchdog_ms': r[REG_WATCHDOG_MS], + 'supervisor_state': self._label_from_code( + supervisor_state_code, BCONSupervisorState, SUPERVISOR_STATE_LABELS), + 'supervisor_state_code': supervisor_state_code, + 'cmd_queue_depth': r[REG_CMD_QUEUE_DEPTH], + 'last_command_code': r[REG_LAST_CMD_CODE], + 'last_command_label': self._command_label(r[REG_LAST_CMD_CODE]), + 'last_command_result': self._label_from_code( + last_result_code, BCONCommandResult, COMMAND_RESULT_LABELS), + 'last_command_result_code': last_result_code, + 'last_reject_reason': self._label_from_code( + last_reject_code, BCONRejectReason, REJECT_REASON_LABELS), + 'last_reject_reason_code': last_reject_code, + 'last_cmd_seq': r[REG_LAST_CMD_SEQ], + } + + channels = [] + for ch_idx in range(3): + base = REG_CH_STATUS_BASE + ch_idx * REG_CH_STATUS_STRIDE + sup_base = REG_CH_SUP_BASE + ch_idx * REG_CH_SUP_STRIDE + run_state_code = r[sup_base + 0] + stop_reason_code = r[sup_base + 1] + channels.append({ + 'mode': MODE_CODE_TO_LABEL.get(r[base + 0], "UNKNOWN"), + 'pulse_ms': r[base + 1], + 'count': r[base + 2], + 'remaining': r[base + 3], + 'en_st': bool(r[base + 4]), + 'en_st_raw': r[base + 4], + 'pwr_st': r[base + 5], + 'oc_st': r[base + 6], + 'gated_st': r[base + 7], + 'output_level': r[base + 8], + 'run_state': self._label_from_code( + run_state_code, BCONChannelRunState, CHANNEL_RUN_STATE_LABELS), + 'run_state_code': run_state_code, + 'stop_reason': self._label_from_code( + stop_reason_code, BCONStopReason, STOP_REASON_LABELS), + 'stop_reason_code': stop_reason_code, + 'complete': bool(r[sup_base + 2]), + 'aborted': bool(r[sup_base + 3]), + }) + + return {'system': system, 'channels': channels} + + def get_latest_telemetry(self) -> Dict: + """Alias for get_status() — backwards compatibility.""" + return self.get_status() + + # --- Convenience: ping-like check --- + + def ping(self) -> bool: + """ + Check communication by reading a register block. + + Returns True if the read succeeds, False otherwise. + (There is no PING command over Modbus; this reads system status.) + """ + if not self.is_connected(): + return False + try: + self._read_holding_registers_raw(REG_SYS_STATE, 1) + return True + except Exception: + return False + + +# ==================== Standalone Test ==================== + +def main(): + """Standalone test function.""" + import argparse + + parser = argparse.ArgumentParser(description="BCON Modbus RTU Driver Test") + parser.add_argument("--port", required=True, help="Serial port (e.g., COM3)") + parser.add_argument("--baudrate", type=int, default=115200, help="Baud rate") + parser.add_argument("--unit", type=int, default=1, help="Modbus unit/slave ID") + parser.add_argument("--test", action="store_true", help="Run interactive test") + args = parser.parse_args() + + bcon = BCONDriver(port=args.port, baudrate=args.baudrate, unit=args.unit, debug=True) + + if not bcon.connect(): + print("Failed to connect to BCON") + return + + print("\n=== BCON Connected ===\n") + + try: + if args.test: + while True: + print("\nBCON Modbus Test Menu:") + print("1. Ping (register read)") + print("2. Get Status") + print("3. Set Channel 1 DC") + print("4. Set Channel 2 PULSE (250ms x1)") + print("5. Stop All") + print("6. Set Watchdog (1000ms)") + print("7. Set Telemetry (500ms)") + print("8. Show Latest Registers") + print("0. Exit") + + choice = input("\nSelect option: ").strip() + + if choice == "1": + ok = bcon.ping() + print(f"Ping: {'SUCCESS' if ok else 'FAILED'}") + elif choice == "2": + status = bcon.get_status() + print(f"\nSystem: {status['system']}") + for i, ch in enumerate(status['channels'], 1): + print(f"Channel {i}: {ch}") + elif choice == "3": + bcon.set_channel_dc(1) + print("Enqueued: CH1 -> DC") + elif choice == "4": + bcon.set_channel_pulse(2, 250) + print("Enqueued: CH2 -> PULSE 250ms") + elif choice == "5": + bcon.stop_all() + print("Enqueued: STOP ALL") + elif choice == "6": + bcon.set_watchdog(1000) + print("Enqueued: Watchdog = 1000ms") + elif choice == "7": + bcon.set_telemetry(500) + print("Enqueued: Telemetry = 500ms") + elif choice == "8": + regs = bcon.get_registers() + print(f"Control regs [0-33]: {regs[0:34]}") + print(f"System regs [100-105]: {regs[100:106]}") + for ch in range(3): + b = 110 + ch * 10 + print(f"CH{ch+1} status [{b}-{b+8}]: {regs[b:b+9]}") + elif choice == "0": + break + else: + print("Invalid option") + + time.sleep(0.5) # let poll thread update + else: + print("Running quick test...") + time.sleep(1) # let initial poll complete + + ok = bcon.ping() + print(f"Ping: {'SUCCESS' if ok else 'FAILED'}") + + status = bcon.get_status() + print(f"System state: {status['system']['state']}") + for i, ch in enumerate(status['channels'], 1): + print(f"Channel {i}: mode={ch['mode']} oc={ch['oc_st']}") + + finally: + print("\nDisconnecting...") + bcon.disconnect() + print("Done") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py index ead393da..602d0fd2 100644 --- a/main.py +++ b/main.py @@ -15,7 +15,8 @@ 'CathodeC PS', 'TempControllers', 'Interlocks', - 'ProcessMonitors' + 'ProcessMonitors', + 'BeamPulse', ] def create_dummy_port_labels(subsystems): diff --git a/subsystem/beam_pulse/README.md b/subsystem/beam_pulse/README.md index e69de29b..0730a0f6 100644 --- a/subsystem/beam_pulse/README.md +++ b/subsystem/beam_pulse/README.md @@ -0,0 +1,321 @@ +# BeamPulseSubsystem (BCON) - Pulser Control GUI + +This directory contains the Beam Pulse control subsystem for the EBEAM Dashboard. + +**Location:** `subsystem/beam_pulse/beam_pulse.py` + +## Overview + +`BeamPulseSubsystem` is a tkinter-based GUI control interface for the BCON beam pulse hardware. It provides high-level controls for configuring wave generation, pulsing behavior, and beam parameters across three independent beams (A, B, C). + +**Key Features:** +- **GUI Interface:** Tabbed interface (Main and Config tabs) with real-time controls +- **Wave Generation:** Wave type selection, frequency control, and amplitude adjustment +- **Pulsing Control:** Configure pulsing behavior and individual beam durations +- **Hardware Communication:** Uses `BCONDriver` from `instrumentctl/BCON/` for RS-485 serial communication +- **Connection Monitoring:** Real-time BCON connection status indicator +- **Safety Features:** Beam arming/disarming with safe shutdown capabilities +- **Deflection Bounds:** Configurable amplitude and frequency limits in Config tab + +## Architecture + +The subsystem integrates with the dashboard through: +- **Hardware Layer:** `BCONDriver` handles low-level RS-485 serial communication with Arduino firmware +- **Control Layer:** `BeamPulseSubsystem` provides GUI controls and logic +- **Integration:** Dashboard callbacks for beam status synchronization + +## Hardware Communication + +The subsystem communicates with BCON hardware through RS-485 serial commands (not Modbus registers). The BCON Arduino firmware supports the following command interface: + +### Command Structure + +| Command | Example | Description | +|---------|---------|-------------| +| `PING` | `PING\n` | Check communication and refresh watchdog | +| `STATUS` | `STATUS\n` | Get full system and channel status | +| `STOP ALL` | `STOP ALL\n` | Force all channels to OFF mode | +| `SET WATCHDOG` | `SET WATCHDOG 1000\n` | Set watchdog timeout (50-60000 ms) | +| `SET TELEMETRY` | `SET TELEMETRY 500\n` | Set telemetry interval (0=disabled) | +| `SET CH OFF` | `SET CH 1 OFF\n` | Turn off channel 1 | +| `SET CH DC` | `SET CH 2 DC\n` | Set channel 2 to DC mode | +| `SET CH PULSE` | `SET CH 3 PULSE 250\n` | Pulse channel 3 for 250ms | + +### System States + +- **READY** - System ready for commands, outputs can be enabled +- **SAFE_INTERLOCK** - Interlock signal low, all outputs forced OFF +- **SAFE_WATCHDOG** - Communication watchdog expired, all outputs forced OFF + +### Channel Modes + +- **OFF** - Channel output is LOW (no beam formation) +- **DC** - Channel output is HIGH (continuous beam) +- **PULSE** - Channel pulses HIGH for configured duration then returns to OFF + +### Telemetry + +BCON periodically transmits telemetry: + +``` +SYS state=READY reason=READY fault_latched=0 telemetry_ms=500 +CH1 mode=DC pulse_ms=0 en_st=1 pwr_st=1 oc_st=0 gated_st=0 +CH2 mode=OFF pulse_ms=0 en_st=0 pwr_st=1 oc_st=0 gated_st=0 +CH3 mode=PULSE pulse_ms=250 en_st=1 pwr_st=1 oc_st=0 gated_st=0 +``` + +```mermaid +%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor': '#1f78b4'}}}%% +stateDiagram-v2 + [*] --> READY: Power On / ARM + READY --> DC: SET CH n DC + READY --> PULSE: SET CH n PULSE + READY --> SAFE_INTERLOCK: Interlock Low + READY --> SAFE_WATCHDOG: Watchdog Expired + DC --> READY: SET CH n OFF + PULSE --> READY: Pulse Complete + SAFE_INTERLOCK --> READY: Interlock High + SAFE_WATCHDOG --> READY: Command Received +``` + +## GUI Controls + +### Main Tab + +**Wave Generation:** +- **Wave Type:** Dropdown selection (Sine, Triangle, Sawtooth, Square, DC) +- **Frequency:** Spinbox control with bounds (Hz) +- **Wave Amplitude:** Spinbox control with bounds (Amperes) +- **Connection Status:** Real-time BCON connection indicator + +**Pulsing Behavior:** +- Dropdown selection for pulse control mode +- Individual beam duration controls for Beams A, B, C (milliseconds) + +### Config Tab + +**Deflection Amplitude Bounds:** +- Lower and upper bounds for deflection amplitude (Amperes) +- Applied limits constrain wave amplitude spinbox range + +**Deflection Frequency Bounds:** +- Lower and upper bounds for deflection frequency (Hz) +- Applied limits constrain frequency spinbox range + +## Usage Examples + +### 1. Integrating with Dashboard (Typical Usage) + +The subsystem is typically used as part of the main dashboard: + +```python +import tkinter as tk +from subsystem.beam_pulse.beam_pulse import BeamPulseSubsystem + +# Create main window +root = tk.Tk() +root.title("Beam Pulse Control") + +# Create BeamPulseSubsystem with GUI and hardware connection +beam_pulse = BeamPulseSubsystem( + parent_frame=root, + port='COM3', + baudrate=115200, + debug=True +) + +# Connect to hardware +if beam_pulse.connect(): + print("Connected to BCON hardware") +else: + print("Failed to connect to BCON") + +# Setup GUI +beam_pulse.setup_ui() + +# Run GUI +root.mainloop() + +# Clean up on exit +beam_pulse.disconnect() +``` + +### 2. Headless Mode (Hardware Control Only) + +For automated control without GUI: + +```python +from subsystem.beam_pulse.beam_pulse import BeamPulseSubsystem + +# Create subsystem without GUI (parent_frame=None) +beam_pulse = BeamPulseSubsystem( + parent_frame=None, # No GUI + port='COM3', + baudrate=115200, + debug=True +) + +# Connect to hardware +if not beam_pulse.connect(): + raise SystemExit('Could not connect to BCON device') + +# Ping device +if beam_pulse.ping(): + print("Device responding") + +# Get system status +status = beam_pulse.get_system_status() +print(f"System state: {status['system']['state']}") + +# Set channel 1 to DC mode +if beam_pulse.set_channel_mode(0, 'DC'): + print("Channel 1 in DC mode") + +# Pulse channel 2 for 250ms +if beam_pulse.set_channel_mode(1, 'PULSE', 250): + print("Channel 2 pulsing") + +# Stop all channels +beam_pulse.stop_all_channels() + +# Safe shutdown +beam_pulse.safe_shutdown("Test complete") +beam_pulse.disconnect() +``` + +### 3. Dashboard Integration + +Setting up dashboard callback for beam status synchronization: + +```python +def beam_status_callback(beam_index, enabled): + """Handle beam status changes from dashboard.""" + print(f"Beam {beam_index} {'enabled' if enabled else 'disabled'}") + +# Set dashboard callback +beam_pulse.set_dashboard_beam_callback(beam_status_callback) + +# Get integration status +status = beam_pulse.get_integration_status() +print(f"Dashboard integration: {status}") +``` + +## API Reference + +### Connection Management + +- `connect() -> bool` - Connect to BCON hardware +- `disconnect() -> None` - Disconnect from hardware +- `is_connected() -> bool` - Check connection status +- `ping() -> bool` - Ping device to verify communication + +### System Status + +- `get_system_status() -> Dict` - Get full system and channel status +- `get_beams_armed_status() -> bool` - Check if beams are armed + +### Channel Control + +- `set_channel_mode(channel_index: int, mode: str, duration_ms: int = 0) -> bool` - Set channel mode (OFF, DC, PULSE) +- `stop_all_channels() -> bool` - Stop all channels immediately +- `set_beam_status(beam_index: int, status: bool)` - Set individual beam on/off +- `get_beam_status(beam_index: int) -> bool` - Get beam status +- `set_all_beams_status(status: bool)` - Set all beams to same status + +### Configuration + +- `get_pulsing_behavior() -> str` - Get current pulsing mode (DC or Pulsed) +- `get_beam_duration(beam_index: int) -> float` - Get beam pulse duration +- `get_deflection_bounds() -> tuple` - Get (lower, upper) amplitude bounds +- `is_deflection_within_bounds(value: float) -> bool` - Validate amplitude value + +### Safety Features + +- `arm_beams() -> bool` - Enable beam operations through the dashboard's software interlock +- `disarm_beams() -> bool` - Disable beam operations (stops all channels) +- `safe_shutdown(reason: Optional[str] = None) -> bool` - Safe shutdown of all beams +- `get_beams_armed_status() -> bool` - Check if beams are armed + +### Status Monitoring + +- `get_pulser_overcurrent_status(pulser_index: int) -> bool` - Check channel overcurrent status +- `set_bcon_connection_status(status: bool)` - Update BCON connection status indicator + +## Hardware Register Details + +**Note:** BCON firmware uses RS-485 serial commands, not Modbus registers. See the **Hardware Communication** section above for command details. + +**Communication Details:** +- **Protocol:** RS-485 serial (ASCII commands, newline terminated) +- **Baud Rate:** 115200 (configurable) +- **Parity:** None +- **Stop Bits:** 1 +- **Data Bits:** 8 +- **Flow Control:** None + +**Channel Numbers:** +- Python API uses 0-based indexing (0, 1, 2 for channels A, B, C) +- Arduino firmware uses 1-based channel numbers (1, 2, 3) +- Driver automatically converts between the two + +**Pulse Duration Range:** 1-60000 milliseconds +**Watchdog Range:** 50-60000 milliseconds + +## Troubleshooting + +### Connection Issues +- Verify serial port permissions and COM port name (e.g., 'COM3' on Windows) +- Confirm device baudrate matches (default: 115200) +- Check RS-485 transceiver connections and termination resistors +- Enable `debug=True` for verbose communication logs + +### Command Failures +- If commands return `False`, check system state with `get_system_status()` +- System must be in **READY** state to accept channel control commands +- If in SAFE_INTERLOCK, check hardware interlock signal +- If commands are blocked, check the external interlock input and watchdog health + +### GUI Issues +- If GUI doesn't appear, verify `parent_frame` is a valid tkinter widget +- If controls are disabled, check BCON connection status indicator +- Connection monitoring runs every 2 seconds - allow time for status updates + +### Hardware Issues +- Check Arduino power and RS-485 transceiver power +- Verify interlock signal is HIGH (5V) when operation is expected +- Monitor telemetry for overcurrent conditions (`oc_st`) +- Use device `PING` command to verify basic communication + +## Dependencies + +- **Python Standard Library:** tkinter, ttk, threading +- **Third-Party:** pyserial (for RS-485 serial communication) +- **Project Modules:** + - `instrumentctl.BCON.bcon_driver` - BCON RS-485 driver + - `utils` - Logging utilities (LogLevel) +- **Hardware:** Arduino Mega running BCON firmware with RS-485 interface + +## Security Notes + +- This subsystem does not implement authentication +- Operate only on trusted, isolated serial connections +- Do not expose RS-485 serial devices to untrusted networks +- Use proper physical access controls for hardware +- Interlock signal provides hardware-level safety override + +## Development + +To run the subsystem standalone for testing: + +```powershell +# From project root +python -m subsystem.beam_pulse.beam_pulse --port COM3 --test-status +``` + +Command-line arguments: +- `--port`: Serial port name (default: COM1) +- `--test-status`: Read system status on connection + +--- + +For integration examples, see the main dashboard implementation in [dashboard.py](../../dashboard.py). diff --git a/subsystem/beam_pulse/beam_pulse.py b/subsystem/beam_pulse/beam_pulse.py index ed16b7cd..9017baf0 100644 --- a/subsystem/beam_pulse/beam_pulse.py +++ b/subsystem/beam_pulse/beam_pulse.py @@ -1,10 +1,1692 @@ +import csv +import json +import os +import sys +import threading +import time +import queue +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from typing import Optional, Dict +from pathlib import Path +from datetime import datetime + +from instrumentctl.BCON import ( + BCONDriver, + BCONMode, + MODE_LABEL_TO_CODE, + MODE_CODE_TO_LABEL, + CH_BASE, + CH_MODE_OFF, + CH_PULSE_MS_OFF, + CH_COUNT_OFF, + CH_ENABLE_SET_OFF, + REG_WATCHDOG_MS, + REG_TELEMETRY_MS, + REG_COMMAND, + REG_SYS_STATE, + REG_INTERLOCK_OK, + REG_WATCHDOG_OK, + REG_CH_STATUS_BASE, + REG_CH_STATUS_STRIDE, +) +from utils import LogLevel + + +def resource_path(relative_path): + """Get absolute path to resource for PyInstaller.""" + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +CHANNEL_LABELS = ("A", "B", "C") class BeamPulseSubsystem: + """Beam Pulse subsystem (BCON) with tabbed GUI interface for pulser controls. + + Provides three control tabs aligned with pulser_test_gui functionality: + 1. Manual Separate Control — per-channel parameters, mode buttons, enable toggle + 2. Sync Manual Control — write params + synchronous start/stop across channels + 3. Auto CSV Sequence — load/run/stop CSV pulse sequences + + Hardware communication uses the BCONDriver (Modbus RTU). """ - Controls beam pulse timing and characteristics - """ - def __init__(self): - # TODO: Implement - pass + # Mode constants matching the firmware register values + MODE_OFF = int(BCONMode.OFF) + MODE_DC = int(BCONMode.DC) + MODE_PULSE = int(BCONMode.PULSE) + MODE_PULSE_TRAIN = int(BCONMode.PULSE_TRAIN) + DEFAULT_WATCHDOG_MS = BCONDriver.DEFAULT_WATCHDOG_MS + DEFAULT_TELEMETRY_MS = BCONDriver.DEFAULT_TELEMETRY_MS + + def __init__(self, parent_frame=None, port=None, unit=1, baudrate=115200, + logger=None, debug: bool = False): + """Create the BeamPulseSubsystem. + + Parameters: + parent_frame: tkinter frame for GUI components (if None, no GUI created) + port: Serial port for BCON hardware (e.g., 'COM3') + unit: Modbus unit/slave address (default: 1) + baudrate: Serial baudrate for Modbus RTU communication (default: 115200) + logger: optional logger object compatible with utils.LogLevel + debug: enable debug logs + """ + self.parent_frame = parent_frame + self.logger = logger + self.debug = debug + + # Instantiate BCONDriver if port is provided + if port: + self.bcon_driver = BCONDriver( + port=port, + baudrate=baudrate, + unit=unit, + timeout=1.0, + debug=debug, + ) + else: + self.bcon_driver = None + + # UI-facing queue for driver events (regs, connected, error, …) + self._ui_queue: queue.Queue = queue.Queue() + if self.bcon_driver: + self.bcon_driver.set_ui_queue(self._ui_queue) + + # Status indicators + self.bcon_connection_status = False + self.beams_armed_status = False + self.beam_on_status = [False, False, False] + self._active_channels: set = set() # channels currently executing (from registers) + + # Dashboard integration callback + self._dashboard_beam_callback = None + self._host_toplevel = None + self._shutdown_in_progress = False + + # CSV sequence player state + self._seq_steps: list = [] + self._seq_thread: Optional[threading.Thread] = None + self._seq_stop = threading.Event() + + # Channel status callback — set_channel_status_callback(cb) registers + # a function cb(ch, mode_code, remaining) called from register polling. + self._channel_status_callback = None + + # Channel enable status callback — set_channel_enable_status_callback(cb) + # registers a function cb(ch, enabled) called from register polling. + self._channel_enable_status_callback = None + + # Channel enable getter — set_channel_enable_getter(fn) registers a + # zero-argument callable that returns list[bool] (one entry per channel). + # _sync_start uses it to skip channels that are not hardware-enabled. + self._ch_enable_getter = None + + # Ensure directories exist for presets, logs, sequences + for d in ("presets", "sequences"): + Path(d).mkdir(exist_ok=True) + + # GUI variables (populated if parent_frame provided) + self.channel_vars: list = [] # per-channel widget references + self.sync_configs: list = [] # sync-tab per-channel entries + self.sync_ch_vars: list = [] # sync-tab include checkboxes + + # Pulse duration variables for external / non-GUI access + if parent_frame: + self.pulsing_behavior = tk.StringVar(value="DC") + self.beam_a_duration = tk.DoubleVar(value=50.0) + self.beam_b_duration = tk.DoubleVar(value=50.0) + self.beam_c_duration = tk.DoubleVar(value=50.0) + else: + self.pulsing_behavior = "DC" + self.beam_a_duration = 50.0 + self.beam_b_duration = 50.0 + self.beam_c_duration = 50.0 + + # Duration spinbox references (for enable/disable in pulsing behaviour) + self.duration_spinboxes: list = [] + + # Create GUI if parent frame is provided + if parent_frame: + self.setup_ui() + self._register_host_close_hook() + + # Auto-connect in background if a port was supplied + if self.bcon_driver: + threading.Thread(target=self._auto_connect, daemon=True).start() + + def _channel_label(self, ch: int) -> str: + """Return the UI-facing channel label for a 0-based channel index.""" + if 0 <= ch < len(CHANNEL_LABELS): + return CHANNEL_LABELS[ch] + return str(ch + 1) + + def _channel_name(self, ch: int) -> str: + """Return a verbose UI-facing channel name for a 0-based channel index.""" + return f"Channel {self._channel_label(ch)}" + + # ================================================================== # + # GUI Setup # + # ================================================================== # + + def setup_ui(self): + """Create the user interface with tabbed layout.""" + # Top status bar (BCON connection + safety) + self._build_status_bar() + + # Notebook with three tabs + self.notebook = ttk.Notebook(self.parent_frame) + self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Tab 1: Manual Separate Control + self.manual_tab = ttk.Frame(self.notebook) + self.notebook.add(self.manual_tab, text="Manual Control") + self._build_manual_tab() + + # Tab 2: Auto CSV Sequence + self.sequence_tab = ttk.Frame(self.notebook) + self.notebook.add(self.sequence_tab, text="CSV Sequence") + self._build_sequence_tab() + + # Start periodic UI update from driver queue + self._start_periodic_ui_update() + + # Start connection & pulser status monitoring + self.start_bcon_connection_monitoring() + self.start_pulser_status_monitoring() + + def _register_host_close_hook(self) -> None: + """Disconnect BCON when the owning Tk toplevel is destroyed.""" + if not self.parent_frame: + return + try: + toplevel = self.parent_frame.winfo_toplevel() + except Exception: + return + if toplevel is self._host_toplevel: + return + self._host_toplevel = toplevel + try: + toplevel.bind("", self._on_host_destroy, add="+") + except Exception: + self._host_toplevel = None + + def _shutdown_for_host_close(self) -> None: + """One-shot shutdown path used when the dashboard window is closing.""" + if self._shutdown_in_progress: + return + self._shutdown_in_progress = True + try: + self.disconnect() + except Exception: + pass + + def _on_host_destroy(self, event) -> None: + """Tear down BCON when the dashboard toplevel is being destroyed.""" + if self._host_toplevel is None or event.widget is not self._host_toplevel: + return + self._shutdown_for_host_close() + + # ----------------------------- Status bar ----------------------------- # + + def _build_status_bar(self): + """Build the top status bar with connection, interlock, arm info.""" + bar = ttk.Frame(self.parent_frame) + bar.pack(fill=tk.X, padx=5, pady=(5, 0)) + + # BCON connection indicator + conn_frame = ttk.Frame(bar) + conn_frame.pack(side=tk.LEFT, padx=(0, 10)) + ttk.Label(conn_frame, text="BCON", font=("Arial", 9, "bold")).pack(side=tk.LEFT) + self.bcon_connection_canvas = tk.Canvas(conn_frame, width=15, height=15, highlightthickness=0) + self.bcon_connection_canvas.pack(side=tk.LEFT, padx=(4, 0)) + self.bcon_connection_canvas.create_oval(2, 2, 13, 13, fill="red", outline="black", tags="indicator") + + # Safety / interlock label + self.safety_label = ttk.Label(bar, text="Interlock: -- Watchdog: --", font=("Arial", 8)) + self.safety_label.pack(side=tk.LEFT, padx=10) + + self.connect_btn = ttk.Button(bar, text="Connect", command=self._manual_connect) + self.connect_btn.pack(side=tk.RIGHT, padx=4) + + # System settings row (watchdog / telemetry) + sys_frame = ttk.Frame(self.parent_frame) + sys_frame.pack(fill=tk.X, padx=5, pady=(2, 0)) + ttk.Label(sys_frame, text="Watchdog (ms):", font=("Arial", 8)).pack(side=tk.LEFT) + self.watchdog_entry = ttk.Entry(sys_frame, width=7) + self.watchdog_entry.insert(0, str(self.DEFAULT_WATCHDOG_MS)) + self.watchdog_entry.pack(side=tk.LEFT, padx=2) + ttk.Button(sys_frame, text="Set", width=4, command=self._set_watchdog).pack(side=tk.LEFT, padx=(0, 8)) + + # Log line + self.log_label = ttk.Label(sys_frame, text="Log: ready", font=("Arial", 8), foreground="gray") + self.log_label.pack(side=tk.RIGHT, padx=4) + + # ----------------------------- Tab 1: Manual Separate Control --------- # + + def _build_manual_tab(self): + """Build per-channel control cards (like pulser_test_gui channel cards).""" + container = ttk.Frame(self.manual_tab, padding="5") + container.pack(fill=tk.BOTH, expand=True) + + self.pulser_status_canvases = [] + self.pulser_enabled_canvases = [] + + # Validation command: allow only whole numbers (digits only, may be empty) + _int_vcmd = (container.register(lambda s: s.isdigit() or s == ""), "%P") + + # --- Per-channel control cards (horizontal layout) --- + cards_frame = ttk.Frame(container) + cards_frame.pack(fill=tk.BOTH, expand=True) + cards_frame.columnconfigure(0, weight=1) + cards_frame.columnconfigure(1, weight=1) + cards_frame.columnconfigure(2, weight=1) + + self.channel_vars = [] + for ch in range(3): + frame = ttk.LabelFrame(cards_frame, text=self._channel_name(ch), padding="5") + frame.grid(row=0, column=ch, sticky="nsew", pady=4, padx=4) + + # Row 1: Mode selector + r1 = ttk.Frame(frame) + r1.pack(fill=tk.X, pady=2) + ttk.Label(r1, text="Mode:").pack(side=tk.LEFT) + mode_cb = ttk.Combobox(r1, values=["OFF", "DC", "PULSE", "PULSE_TRAIN"], + state="readonly", width=12) + mode_cb.set("PULSE") + mode_cb.pack(side=tk.LEFT, padx=4) + + # Row 2: Duration + Count (digits-only input) + r2 = ttk.Frame(frame) + r2.pack(fill=tk.X, pady=2) + ttk.Label(r2, text="Duration (ms):").pack(side=tk.LEFT) + dur_entry = ttk.Entry(r2, width=8, validate="key", validatecommand=_int_vcmd) + dur_entry.insert(0, "100") + dur_entry.pack(side=tk.LEFT, padx=(2, 10)) + ttk.Label(r2, text="Count:").pack(side=tk.LEFT) + cnt_entry = ttk.Entry(r2, width=6, validate="key", validatecommand=_int_vcmd) + cnt_entry.insert(0, "1") + cnt_entry.pack(side=tk.LEFT, padx=2) + + def _on_mode_change(event, d=dur_entry, c=cnt_entry, m=mode_cb): + mode = m.get() + if mode in ("OFF", "DC"): + d.config(state="disabled") + c.config(state="disabled") + elif mode == "PULSE": + d.config(state="normal") + c.config(state="disabled") + c.delete(0, "end") + c.insert(0, "1") + else: # PULSE_TRAIN + d.config(state="normal") + c.config(state="normal") + + mode_cb.bind("<>", _on_mode_change) + # Apply initial state (PULSE: count grayed out) + cnt_entry.config(state="disabled") + + # Row 3: Status / pulses remaining + r3 = ttk.Frame(frame) + r3.pack(fill=tk.X, pady=2) + status_lbl = ttk.Label(r3, text="Status: idle", font=("Arial", 8)) + status_lbl.pack(side=tk.LEFT, padx=(0, 15)) + pulses_lbl = ttk.Label(r3, text="Remaining: 0", font=("Arial", 8)) + pulses_lbl.pack(side=tk.LEFT) + + self.channel_vars.append({ + 'duration': dur_entry, + 'count': cnt_entry, + 'mode': mode_cb, + 'status': status_lbl, + 'pulses': pulses_lbl, + }) + + # ----------------------------- Tab 2: Sync Manual Control ------------- # + + def _build_sync_tab(self): + """Build synchronous multi-channel control table.""" + container = ttk.Frame(self.sync_tab, padding="5") + container.pack(fill=tk.BOTH, expand=True) + + ttk.Label(container, text="Synchronous Control", + font=("Arial", 10, "bold")).pack(anchor="w", pady=(0, 5)) + + table = ttk.Frame(container) + table.pack(fill=tk.X) + + # Header + for col, hdr in enumerate(("Channel", "Duration (ms)", "Count", "Mode", "Include")): + ttk.Label(table, text=hdr, font=("Arial", 9, "bold")).grid(row=0, column=col, padx=6, pady=(0, 4)) + + self.sync_ch_vars = [tk.BooleanVar(value=True) for _ in range(3)] + self.sync_configs = [] + + for ch in range(3): + r = ch + 1 + ttk.Label(table, text=self._channel_label(ch), font=("Arial", 9, "bold")).grid(row=r, column=0, padx=6, pady=3, sticky="w") + + dur_e = ttk.Entry(table, width=10) + dur_e.insert(0, "100") + dur_e.grid(row=r, column=1, padx=4, pady=3) + + cnt_e = ttk.Entry(table, width=8) + cnt_e.insert(0, "1") + cnt_e.grid(row=r, column=2, padx=4, pady=3) + + mode_cb = ttk.Combobox(table, values=["OFF", "DC", "PULSE", "PULSE_TRAIN"], + state="readonly", width=12) + mode_cb.set("PULSE") + mode_cb.grid(row=r, column=3, padx=4, pady=3) + + def _on_sync_mode_change(event, d=dur_e, c=cnt_e, m=mode_cb): + mode = m.get() + if mode in ("OFF", "DC"): + d.config(state="disabled") + c.config(state="disabled") + elif mode == "PULSE": + d.config(state="normal") + c.config(state="disabled") + c.delete(0, "end") + c.insert(0, "1") + else: # PULSE_TRAIN + d.config(state="normal") + c.config(state="normal") + + mode_cb.bind("<>", _on_sync_mode_change) + # Apply initial state (default PULSE: count grayed out) + cnt_e.config(state="disabled") + + ttk.Checkbutton(table, variable=self.sync_ch_vars[ch]).grid(row=r, column=4, padx=8, pady=3) + + self.sync_configs.append({'duration': dur_e, 'count': cnt_e, 'mode': mode_cb}) + + # (Action buttons for Sync Control are hosted in the Main Control panel) + + # ----------------------------- Tab 3: Auto CSV Sequence --------------- # + + def _build_sequence_tab(self): + """Build CSV pulse sequence player interface.""" + container = ttk.Frame(self.sequence_tab, padding="5") + container.pack(fill=tk.BOTH, expand=True) + + ttk.Label(container, text="CSV Pulse Sequence", + font=("Arial", 10, "bold")).pack(anchor="w", pady=(0, 5)) + + self.seq_file_lbl = ttk.Label(container, text="No sequence loaded", foreground="gray") + self.seq_file_lbl.pack(anchor="w", padx=4) + + self.seq_progress_lbl = ttk.Label(container, text="") + self.seq_progress_lbl.pack(anchor="w", padx=4, pady=(2, 4)) + + # (Action buttons for CSV Sequence are hosted in the Main Control panel) + + # Sequence preview (simple text view) + ttk.Label(container, text="Loaded Steps:", font=("Arial", 9, "bold")).pack(anchor="w", padx=4, pady=(4, 0)) + self.seq_preview_text = tk.Text(container, height=10, width=60, state="disabled", font=("Courier", 9)) + self.seq_preview_text.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + + # ------------------------------------------------------------------ # + # External control buttons (hosted in the Main Control panel) # + # ------------------------------------------------------------------ # + + def create_external_control_buttons(self, parent_frame, manual_panel_override=None, + beam_on_off_frame=None, csv_frame=None): + """Append Sync Start / Sync Stop buttons and wire tab-aware panel visibility. + + Tabs in the Beam Pulse notebook: + Tab 0 – Manual Control → show beam_on_off_frame + Sync row; hide csv_frame + Tab 1 – CSV Sequence → hide beam_on_off_frame + Sync row; show csv_frame + CH Enable/Disable row is always visible. + + Parameters: + parent_frame: Tkinter frame used when no manual_panel_override. + manual_panel_override: Dashboard's bp_manual_panel — Sync row is appended here. + beam_on_off_frame: Dashboard's Beam A/B/C ON/OFF row frame to show/hide. + csv_frame: Dashboard's csv_buttons_frame to show/hide. + """ + self._armed_gated_buttons: list = [] + # References used by the tab-change handler + self._beam_on_off_frame = beam_on_off_frame + self._csv_frame = csv_frame + self._sync_row = None + + if manual_panel_override is not None: + self._ext_manual_frame = manual_panel_override + + # --- Sync action row (appended below the CH Enable/Disable row) --- + self._sync_row = tk.Frame(manual_panel_override) + self._sync_row.pack(side="top", fill="x", pady=(4, 0)) + self._sync_row.grid_columnconfigure(0, weight=1, uniform="sbtn") + self._sync_row.grid_columnconfigure(1, weight=1, uniform="sbtn") + + self.sync_start_btn = tk.Button( + self._sync_row, text="Sync Start", + bg="#1565C0", fg="white", font=("Helvetica", 9, "bold"), + state="disabled", command=self._sync_start, + ) + self.sync_start_btn.grid(row=0, column=0, sticky="ew", padx=(2, 1)) + self._armed_gated_buttons.append(self.sync_start_btn) + + self.sync_stop_btn = tk.Button( + self._sync_row, text="Sync Stop", + bg="#B71C1C", fg="white", font=("Helvetica", 9, "bold"), + state="normal", command=self._sync_stop_all, + ) + self.sync_stop_btn.grid(row=0, column=1, sticky="ew", padx=(1, 2)) + + else: + # Standalone fallback: per-channel Apply buttons + Sync row + outer = ttk.Frame(parent_frame) + outer.pack(fill=tk.X, padx=6, pady=(6, 2)) + self._ext_manual_frame = outer + ttk.Label(outer, text="Manual + Sync Control", + font=("Arial", 9, "bold")).pack(fill=tk.X, pady=(0, 2)) + for ch in range(3): + btn = ttk.Button( + outer, text=f"Apply {self._channel_name(ch)}", state="disabled", + command=lambda c=ch: self._manual_apply( + c, + self.channel_vars[c]['duration'], + self.channel_vars[c]['count'], + self.channel_vars[c]['mode'], + ), + ) + btn.pack(fill=tk.X, pady=1) + self._armed_gated_buttons.append(btn) + self._sync_row = ttk.Frame(outer) + self._sync_row.pack(fill=tk.X) + sync_start = ttk.Button(self._sync_row, text="Sync Start", state="disabled", + command=self._sync_start) + sync_start.pack(fill=tk.X, pady=1) + self._armed_gated_buttons.append(sync_start) + ttk.Button(self._sync_row, text="Sync Stop", + command=self._sync_stop_all).pack(fill=tk.X, pady=1) + + # ---- Tab-switching logic ----------------------------------------- + def _apply_tab(idx: int): + """Show/hide frames to match the selected Beam Pulse notebook tab.""" + is_manual = (idx == 0) + # Beam ON/OFF row + if self._beam_on_off_frame is not None: + try: + if is_manual: + self._beam_on_off_frame.pack(side="top", fill="x") + else: + self._beam_on_off_frame.pack_forget() + except Exception: + pass + # Sync Start/Stop row + if self._sync_row is not None: + try: + if is_manual: + self._sync_row.pack(side="top", fill="x", pady=(4, 0)) + else: + self._sync_row.pack_forget() + except Exception: + pass + # CSV buttons frame + if self._csv_frame is not None: + try: + if is_manual: + self._csv_frame.pack_forget() + else: + self._csv_frame.pack(side="top", fill="x") + except Exception: + pass + + def _on_tab_changed(event=None): + try: + idx = self.notebook.index(self.notebook.select()) + except Exception: + idx = 0 + _apply_tab(idx) + + self.notebook.bind("<>", _on_tab_changed) + # Apply initial state (Tab 0 = Manual is selected at startup) + _apply_tab(0) + + def create_csv_buttons(self, parent_frame): + """Build CSV sequence control buttons in *parent_frame* (always visible). + + Designed to be called by the dashboard immediately after the script- + selection dropdown, so the buttons appear below it regardless of which + Beam Pulse notebook tab is active. + + Parameters: + parent_frame: Tkinter frame that will host the CSV controls. + """ + container = ttk.LabelFrame(parent_frame, text="CSV Sequence", padding="4") + container.pack(fill=tk.X, padx=6, pady=(4, 2)) + + # Load CSV / Save Template — file operations; always enabled + row1 = ttk.Frame(container) + row1.pack(fill=tk.X, pady=1) + ttk.Button(row1, text="Load CSV", + command=self._load_sequence).pack( + side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 2)) + ttk.Button(row1, text="Save Template", + command=self._save_sequence_template).pack( + side=tk.LEFT, fill=tk.X, expand=True) + + # Run / Stop — Run is gated by armed state AND sequence loaded + row2 = ttk.Frame(container) + row2.pack(fill=tk.X, pady=1) + self.seq_run_btn = ttk.Button(row2, text="Run Sequence", + state="disabled", command=self._run_sequence) + self.seq_run_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 2)) + # Stop Sequence always enabled (safety action) + self.seq_stop_btn = ttk.Button(row2, text="Stop Sequence", + state="disabled", command=self._stop_sequence) + self.seq_stop_btn.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # ================================================================== # + # Manual Tab Actions # + # ================================================================== # + + def _require_armed(self) -> bool: + """Return True if beams are armed; log a warning and return False otherwise. + + Call this at the top of every action that sends commands to BCON. + Stop / disarm / off actions should NOT call this — they must always work. + """ + if not self.beams_armed_status: + self._log_event("Action blocked: beams are not armed") + return False + return True + + def _update_armed_button_states(self, armed: bool) -> None: + """Enable or disable all BCON-action buttons to match the armed state. + + seq_run_btn is only re-enabled when armed AND a sequence is loaded. + Stop buttons are never touched here (they must always be accessible). + """ + new_state = "normal" if armed else "disabled" + for btn in getattr(self, '_armed_gated_buttons', []): + try: + btn.configure(state=new_state) + except Exception: + pass + # seq_run_btn: enable only when armed AND sequence already loaded + if hasattr(self, 'seq_run_btn'): + try: + if armed and self._seq_steps: + self.seq_run_btn.configure(state="normal") + else: + self.seq_run_btn.configure(state="disabled") + except Exception: + pass + + def _manual_apply(self, ch, dur_entry, cnt_entry, mode_cb): + """Apply parameters + mode for a single channel.""" + if not self._require_armed(): + return + if not self.bcon_driver: + self._log("No BCON driver", LogLevel.WARNING) + return + + config = self._validate_and_get_config(ch) + if config is None: + return # messagebox shown by helper + + mode_label = config['mode'] + duration = config['duration_ms'] + count = config['count'] + + self.bcon_driver.set_channel_mode(ch + 1, mode_label, duration_ms=duration, count=count) + self._log_event(f"Applied {self._channel_name(ch)}: mode={mode_label} dur={duration}ms count={count}") + + def _manual_set_mode(self, ch, mode_code): + """Quick mode button for a single channel.""" + if not self._require_armed(): + return + if not self.bcon_driver: + return + + label = MODE_CODE_TO_LABEL.get(mode_code, str(mode_code)) + if mode_code in (self.MODE_OFF, self.MODE_DC): + self.bcon_driver.set_channel_mode(ch + 1, label) + else: + cv = self.channel_vars[ch] if ch < len(self.channel_vars) else None + try: + duration = int(cv['duration'].get().strip()) if cv else 100 + except Exception: + messagebox.showerror("Invalid Configuration", f"{self._channel_name(ch)}: duration must be a whole number of ms") + return + if duration <= 0: + messagebox.showerror("Invalid Configuration", f"{self._channel_name(ch)}: duration must be > 0 ms") + return + + if mode_code == self.MODE_PULSE: + count = 1 + else: + try: + count = int(cv['count'].get().strip()) if cv else 2 + except Exception: + messagebox.showerror("Invalid Configuration", f"{self._channel_name(ch)}: count must be a whole number") + return + if count < 2: + messagebox.showerror("Invalid Configuration", f"{self._channel_name(ch)}: PULSE_TRAIN requires count >= 2") + return + + self.bcon_driver.set_channel_mode(ch + 1, label, duration_ms=duration, count=count) + + self._log_event(f"{self._channel_name(ch)} -> {label}") + + def _manual_toggle_enable(self, ch): + """Toggle enable for a single channel.""" + if not self._require_armed(): + return + if not self.bcon_driver: + return + current = self.bcon_driver.is_channel_enabled(ch + 1) + enabled = not current + if self.bcon_driver.set_channel_enable(ch + 1, enabled): + self._log_event(f"{self._channel_name(ch)} -> {'ENABLED' if enabled else 'DISABLED'}") + else: + self._log_event(f"{self._channel_name(ch)} enable write failed") + + # ================================================================== # + # Sync Tab Actions # + # ================================================================== # + + def _sync_write_params(self): + """Validate and write params for enabled, non-DC/OFF channels from Manual tab.""" + if not self._require_armed(): + return + if not self.bcon_driver: + return + for ch in range(3): + if ch >= len(self.channel_vars): + continue + config = self._validate_and_get_config(ch) + if config is None: + return # messagebox already shown + mode_label = config['mode'] + if mode_label in ('OFF', 'DC'): + continue # no params to write for these modes + self.bcon_driver.set_channel_params( + ch + 1, config['duration_ms'], config['count']) + self._log_event("Sync wrote params for channels") + + def _sync_start(self): + """Synchronous start of enabled channels using Manual Control tab configuration. + + Only channels that are currently hardware-enabled (per the dashboard's + CH Enable state, provided via set_channel_enable_getter) are included. + If no getter is registered all three channels are started. + """ + if not self._require_armed(): + return + if not self.bcon_driver: + return + + # Resolve which channels are enabled; default to all if no getter registered + enable_states: list + if callable(self._ch_enable_getter): + try: + enable_states = list(self._ch_enable_getter()) + except Exception: + enable_states = [True, True, True] + else: + enable_states = [True, True, True] + + configs = [] + for ch in range(3): + if ch >= len(self.channel_vars): + continue + if not enable_states[ch] if ch < len(enable_states) else False: + self._log_event(f"Sync Start: {self._channel_name(ch)} skipped (not enabled)") + continue + config = self._validate_and_get_config(ch) + if config is None: + return # messagebox already shown by helper + configs.append({ + 'ch': ch + 1, + 'mode': config['mode'], + 'duration_ms': config['duration_ms'], + 'count': config['count'], + }) + + if configs: + self.bcon_driver.sync_start(configs) + self._log_event( + "Sync Start: " + + ", ".join( + f"{self._channel_label(c['ch'] - 1)}={c['mode']}({c['duration_ms']}ms x{c['count']})" + for c in configs + ) + ) + + def _sync_stop_all(self): + """Stop all channels immediately.""" + if self.bcon_driver: + self.bcon_driver.stop_all() + self._log_event("Sync Stop: all channels -> OFF") + + # ================================================================== # + # CSV Sequence Tab Actions # + # ================================================================== # + + def _load_sequence(self): + """Load a CSV pulse sequence file.""" + fname = filedialog.askopenfilename( + initialdir="sequences", + filetypes=[("CSV Sequence", "*.csv"), ("All files", "*.*")], + title="Load Pulse Sequence CSV", + ) + if not fname: + return + try: + steps_raw: dict = {} + with open(fname, newline="") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or line.lower().startswith("step"): + continue + parts = [p.strip() for p in line.split(",")] + if len(parts) < 3: + continue + step_num = int(parts[0]) + ch_str = parts[1].upper() + mode = parts[2].upper() + dur_ms = int(parts[3]) if len(parts) > 3 and parts[3] else 100 + count = int(parts[4]) if len(parts) > 4 and parts[4] else 1 + dwell_ms = int(parts[5]) if len(parts) > 5 and parts[5] else 0 + + if mode not in MODE_LABEL_TO_CODE: + raise ValueError(f"Unknown mode '{mode}' at step {step_num}") + if mode == "PULSE_TRAIN" and count < 2: + raise ValueError(f"Step {step_num}: PULSE_TRAIN requires count >= 2") + + ch_list = list(range(3)) if ch_str == "ALL" else [int(ch_str) - 1] + if step_num not in steps_raw: + steps_raw[step_num] = {"rows": [], "dwell_ms": 0} + for ch_idx in ch_list: + steps_raw[step_num]["rows"].append( + {"ch": ch_idx, "mode": mode, "duration_ms": dur_ms, "count": count} + ) + steps_raw[step_num]["dwell_ms"] = dwell_ms + + self._seq_steps = [ + (sn, steps_raw[sn]["rows"], steps_raw[sn]["dwell_ms"]) + for sn in sorted(steps_raw.keys()) + ] + n = len(self._seq_steps) + self.seq_file_lbl.configure( + text=f"{os.path.basename(fname)} ({n} step{'s' if n != 1 else ''})") + self.seq_progress_lbl.configure(text="Ready") + # Only enable Run Sequence if beams are currently armed + if hasattr(self, 'seq_run_btn'): + self.seq_run_btn.configure( + state="normal" if self.beams_armed_status else "disabled") + + # Update preview + self.seq_preview_text.configure(state="normal") + self.seq_preview_text.delete("1.0", tk.END) + for sn, rows, dwell in self._seq_steps: + for row in rows: + self.seq_preview_text.insert(tk.END, + f"Step {sn}: {self._channel_name(row['ch'])} {row['mode']} " + f"dur={row['duration_ms']}ms cnt={row['count']} dwell={dwell}ms\n") + self.seq_preview_text.configure(state="disabled") + + self._log_event(f"Sequence loaded: {os.path.basename(fname)} ({n} steps)") + except Exception as e: + messagebox.showerror("Sequence Load Error", str(e)) + self._log_event(f"Sequence load failed: {e}") + + def _save_sequence_template(self): + """Save a CSV template file for reference.""" + fname = filedialog.asksaveasfilename( + defaultextension=".csv", + initialdir="sequences", + filetypes=[("CSV Sequence", "*.csv")], + title="Save Sequence Template", + ) + if not fname: + return + template = ( + "# BCON Pulse Sequence\n" + "# ============================================================\n" + "# Columns:\n" + "# step - integer; rows sharing a step number launch together\n" + "# ch - channel number (1, 2, 3) or ALL\n" + "# mode - OFF | DC | PULSE | PULSE_TRAIN\n" + "# duration_ms - pulse width in ms (PULSE / PULSE_TRAIN only)\n" + "# count - pulse count (PULSE_TRAIN must be >= 2)\n" + "# dwell_ms - wait AFTER this step before the next one\n" + "# (only the last row per step number is used)\n" + "# ============================================================\n" + "step,ch,mode,duration_ms,count,dwell_ms\n" + "1,1,PULSE,100,5,0\n" + "1,2,PULSE,200,1,0\n" + "1,3,DC,,,500\n" + "2,1,PULSE_TRAIN,50,10,0\n" + "2,2,OFF,,,0\n" + "2,3,OFF,,,1000\n" + "3,ALL,OFF,,,500\n" + ) + with open(fname, "w") as f: + f.write(template) + self._log_event(f"Sequence template saved: {os.path.basename(fname)}") + + def _run_sequence(self): + """Start running the loaded CSV sequence.""" + if not self._require_armed(): + return + if not self._seq_steps: + messagebox.showinfo("Sequence", "No sequence loaded.") + return + if not self.bcon_driver or not self.bcon_driver.is_connected(): + messagebox.showwarning("Sequence", "Not connected to BCON device.") + return + if self._seq_thread and self._seq_thread.is_alive(): + return + self._seq_stop.clear() + if hasattr(self, 'seq_run_btn'): + self.seq_run_btn.configure(state="disabled") + if hasattr(self, 'seq_stop_btn'): + self.seq_stop_btn.configure(state="normal") + self._seq_thread = threading.Thread(target=self._sequence_worker, daemon=True) + self._seq_thread.start() + self._log_event("Sequence started") + + def _stop_sequence(self): + """Request sequence stop.""" + self._seq_stop.set() + self._log_event("Sequence stop requested") + + def _sequence_worker(self): + """Background thread that plays the CSV sequence.""" + total = len(self._seq_steps) + for idx, (step_num, rows, dwell_ms) in enumerate(self._seq_steps): + if self._seq_stop.is_set() or not self.beams_armed_status: + break + # Update progress via queue + self._ui_queue.put(("seq_status", f"Step {idx+1}/{total} (#{step_num})")) + + configs = [ + { + "ch": row["ch"] + 1, + "mode": row["mode"], + "duration_ms": row["duration_ms"], + "count": row["count"], + } + for row in rows + ] + if self._seq_stop.is_set() or not self.beams_armed_status: + break + self.bcon_driver.sync_start(configs) + + # Dwell + deadline = time.time() + dwell_ms / 1000.0 + while time.time() < deadline and not self._seq_stop.is_set(): + time.sleep(0.05) + + final = "Sequence complete" if not self._seq_stop.is_set() else "Sequence stopped" + self._ui_queue.put(("seq_status", final)) + self._ui_queue.put(("seq_done", None)) + + # ================================================================== # + # Periodic UI Update # + # ================================================================== # + + def _start_periodic_ui_update(self): + """Poll the driver's UI queue and update widgets.""" + def _tick(): + try: + while not self._ui_queue.empty(): + msg = self._ui_queue.get_nowait() + self._handle_driver_msg(msg) + except queue.Empty: + pass + if self.parent_frame: + self.parent_frame.after(200, _tick) + if self.parent_frame: + self.parent_frame.after(200, _tick) + + def _handle_driver_msg(self, msg): + """Process a single message from the driver/UI queue.""" + typ = msg[0] + if typ == "connected": + ok = msg[1] + self.bcon_connection_status = ok + self.update_bcon_connection_status() + elif typ == "regs": + regs = msg[1] + self._update_ui_from_registers(regs) + elif typ == "wrote": + reg, val = msg[1], msg[2] + if reg == REG_COMMAND and val != 0: + return + self._log_event(f"Wrote R{reg}={val}") + elif typ == "command_result": + info = msg[1] + requested = info.get("requested_label", f"CMD_{info.get('requested_code', '?')}") + actual = info.get("last_command_label", requested) + cmd_text = requested if actual == requested else f"{requested}->{actual}" + seq = info.get("last_cmd_seq", 0) + if info.get("rejected"): + reason = info.get("last_reject_reason", "UNKNOWN") + self._log_event(f"BCON command {cmd_text} rejected: {reason} (seq={seq})") + else: + result = str(info.get("last_command_result", "UNKNOWN")).lower() + self._log_event(f"BCON command {cmd_text} {result} (seq={seq})") + elif typ == "error": + self._log_event(f"Error: {msg[1]}") + elif typ == "seq_status": + text = msg[1] + if hasattr(self, 'seq_progress_lbl'): + self.seq_progress_lbl.configure(text=text) + self._log_event(text) + elif typ == "seq_done": + self._update_armed_button_states(self.beams_armed_status) + if hasattr(self, 'seq_stop_btn'): + self.seq_stop_btn.configure(state="disabled") + + def _update_ui_from_registers(self, regs): + """Mirror register data into GUI widgets (like pulser_test_gui._handle_msg 'regs').""" + # Update manual-tab channel cards + for ch in range(3): + if ch >= len(self.channel_vars): + continue + status_base = REG_CH_STATUS_BASE + ch * REG_CH_STATUS_STRIDE + + mode_code = regs[status_base + 0] + remaining = regs[status_base + 3] + enabled_state = bool(regs[status_base + 4]) + output_level = regs[status_base + 8] + + st_text = MODE_CODE_TO_LABEL.get(mode_code, "unknown") + self.channel_vars[ch]['status'].configure(text=f"Status: {st_text} | O:{output_level}") + self.channel_vars[ch]['pulses'].configure(text=f"Remaining: {remaining}") + + # DC mode never counts down (remaining stays 0) — treat it as + # running whenever mode != OFF so the manual controls stay locked + # and the dashboard Beam button stays green. + is_running = (mode_code != self.MODE_OFF) and ( + remaining > 0 or mode_code == self.MODE_DC + ) + if is_running: + self._active_channels.add(ch) + self._set_manual_channel_lock(ch, True) + else: + self._active_channels.discard(ch) + self._set_manual_channel_lock(ch, False) + + # Notify dashboard so beam toggle button colour tracks hardware state + if callable(getattr(self, '_channel_status_callback', None)): + try: + self._channel_status_callback(ch, mode_code, remaining) + except Exception: + pass + + if callable(getattr(self, '_channel_enable_status_callback', None)): + try: + self._channel_enable_status_callback(ch, enabled_state) + except Exception: + pass + + # NOTE: do NOT push hardware mode back into the mode combobox — that + # would overwrite the user's intended configuration. The status label + # above already shows the live running mode. + + # Auto-fill duration/count from param registers if widget is empty or '0' + base = CH_BASE[ch] + pulse_ms = regs[base + CH_PULSE_MS_OFF] + count_val = regs[base + CH_COUNT_OFF] + self._safe_fill(self.channel_vars[ch]['duration'], pulse_ms) + self._safe_fill(self.channel_vars[ch]['count'], count_val) + + # Interlock / watchdog / state + interlock_ok = regs[REG_INTERLOCK_OK] + watchdog_ok = regs[REG_WATCHDOG_OK] + if hasattr(self, 'safety_label'): + self.safety_label.configure( + text=f"Interlock: {'ok' if interlock_ok else 'locked'} | " + f"Watchdog: {'ok' if watchdog_ok else 'expired'}") + + # Watchdog entry + if hasattr(self, 'watchdog_entry'): + self._safe_fill(self.watchdog_entry, regs[REG_WATCHDOG_MS]) + + # Update pulser enabled/overcurrent canvases + for i in range(3): + self.update_pulser_status_display(i) + + @staticmethod + def _safe_fill(entry_widget, value): + """Overwrite entry only if empty or '0', and only when the widget is not disabled.""" + try: + if str(entry_widget.cget("state")) == "disabled": + return + cur = entry_widget.get().strip() + except Exception: + return + if cur == '' or cur == '0': + entry_widget.delete(0, 'end') + entry_widget.insert(0, str(value)) + + def _set_manual_channel_lock(self, ch: int, locked: bool): + """Gray out (lock=True) or restore (lock=False) editable widgets for a manual-tab channel.""" + if ch >= len(self.channel_vars): + return + cv = self.channel_vars[ch] + try: + if locked: + cv['mode'].configure(state='disabled') + cv['duration'].configure(state='disabled') + cv['count'].configure(state='disabled') + else: + cv['mode'].configure(state='readonly') + mode = cv['mode'].get() + if mode in ('OFF', 'DC'): + cv['duration'].configure(state='disabled') + cv['count'].configure(state='disabled') + elif mode == 'PULSE': + cv['duration'].configure(state='normal') + cv['count'].configure(state='disabled') + else: # PULSE_TRAIN + cv['duration'].configure(state='normal') + cv['count'].configure(state='normal') + except Exception: + pass + + # ================================================================== # + # Status Monitoring # + # ================================================================== # + + def start_bcon_connection_monitoring(self): + """Periodically check BCON driver connection status.""" + def check(): + if self.bcon_driver: + connected = self.bcon_driver.is_connected() + if connected != self.bcon_connection_status: + self.bcon_connection_status = connected + self.update_bcon_connection_status() + else: + if self.bcon_connection_status: + self.bcon_connection_status = False + self.update_bcon_connection_status() + if self.parent_frame: + self.parent_frame.after(2000, check) + if self.parent_frame: + self.parent_frame.after(1000, check) + + def start_pulser_status_monitoring(self): + """Periodically refresh pulser status indicators.""" + def check(): + for i in range(3): + self.update_pulser_status_display(i) + if self.parent_frame: + self.parent_frame.after(500, check) + if self.parent_frame: + self.parent_frame.after(1000, check) + + def update_bcon_connection_status(self): + """Repaint the BCON connection indicator and sync button label.""" + if hasattr(self, 'bcon_connection_canvas'): + self.bcon_connection_canvas.delete("indicator") + color = "green" if self.bcon_connection_status else "red" + self.bcon_connection_canvas.create_oval(2, 2, 13, 13, fill=color, outline="black", tags="indicator") + if hasattr(self, 'connect_btn'): + self.connect_btn.configure( + text="Disconnect" if self.bcon_connection_status else "Reconnect", + state="normal" + ) + + def update_pulser_status_display(self, pulser_index: int): + """Update enabled + overcurrent indicators for a pulser.""" + if not (0 <= pulser_index < 3): + return + try: + # Enabled + is_enabled = False + if self.bcon_driver and self.bcon_connection_status: + is_enabled = self.bcon_driver.is_channel_enabled(pulser_index + 1) + if pulser_index < len(self.pulser_enabled_canvases): + ec = self.pulser_enabled_canvases[pulser_index] + ec.delete("indicator") + ec.create_oval(2, 2, 13, 13, + fill="green" if is_enabled else "gray", + outline="black", tags="indicator") + # Overcurrent + has_oc = self.get_pulser_overcurrent_status(pulser_index) + if pulser_index < len(self.pulser_status_canvases): + sc = self.pulser_status_canvases[pulser_index] + sc.delete("indicator") + sc.create_oval(2, 2, 13, 13, + fill="red" if has_oc else "green", + outline="black", tags="indicator") + except Exception as e: + self._log(f"Error updating pulser {pulser_index} status: {e}", LogLevel.ERROR) + + def get_pulser_overcurrent_status(self, pulser_index: int) -> bool: + """Check overcurrent from BCON driver.""" + if self.bcon_driver and self.bcon_connection_status: + try: + return self.bcon_driver.is_channel_overcurrent(pulser_index + 1) + except Exception: + pass + return False + + # ================================================================== # + # Safety / System Settings Actions # + # ================================================================== # + + def _apply_default_bcon_settings(self) -> None: + """Apply the dashboard's preferred runtime settings after connect.""" + if not self.bcon_driver or not self.bcon_driver.is_connected(): + return + self.bcon_driver.set_watchdog(self.DEFAULT_WATCHDOG_MS) + self.bcon_driver.set_telemetry(self.DEFAULT_TELEMETRY_MS) + + def _stop_sequence_worker(self) -> None: + """Stop any active CSV sequence worker before disconnecting hardware.""" + self._seq_stop.set() + if ( + self._seq_thread + and self._seq_thread.is_alive() + and threading.current_thread() is not self._seq_thread + ): + self._seq_thread.join(timeout=1.0) + self._seq_thread = None + + def _auto_connect(self): + """Background thread: open the serial port and connect to BCON.""" + port = self.bcon_driver.port + self._ui_queue.put(("seq_status", f"Connecting to BCON on {port}…")) + ok = self.bcon_driver.connect() + if self._shutdown_in_progress: + self.bcon_driver.disconnect() + return + if ok: + self._apply_default_bcon_settings() + msg = f"BCON connected on {port}" if ok else f"BCON connect failed on {port} — check port & firmware" + # Route via the UI queue so Messages & Errors is updated on the main + # thread (direct self._log() from a background thread is not safe). + self._ui_queue.put(("seq_status", msg)) + + def _manual_connect(self): + """Button handler: disconnect when connected, reconnect when disconnected.""" + if self._shutdown_in_progress: + return + if not self.bcon_driver: + messagebox.showwarning("Connect", "No port configured for BCON.") + return + if self.bcon_driver.is_connected(): + # User clicked "Disconnect" — only tear down, do NOT reconnect. + self.disconnect() + if hasattr(self, 'connect_btn'): + self.connect_btn.configure(text="Reconnect", state="normal") + self._log_event("BCON disconnected by user") + return + # User clicked "Reconnect" / "Connect" — open the port. + if hasattr(self, 'connect_btn'): + self.connect_btn.configure(state="disabled", text="Connecting…") + self.parent_frame.after(100, lambda: None) # force redraw + def _do(): + ok = self.bcon_driver.connect() + if self._shutdown_in_progress: + self.bcon_driver.disconnect() + return + if ok: + self._apply_default_bcon_settings() + if self.parent_frame: + try: + self.parent_frame.after(0, lambda: self._on_connect_done(ok)) + except Exception: + pass + threading.Thread(target=_do, daemon=True).start() + + def _on_connect_done(self, ok: bool): + """Called on the main thread after a manual connect attempt.""" + if hasattr(self, 'connect_btn'): + self.connect_btn.configure(state="normal", + text="Disconnect" if ok else "Reconnect") + self._log_event("BCON connected" if ok else "BCON connect failed — check port & firmware") + + def _arm_beam(self): + """Arm beams in software only (no hardware ARM command).""" + self.beams_armed_status = True + self._update_armed_button_states(True) + self._log_event("Beams armed (software-only)") + + def _set_watchdog(self): + """Write the watchdog timeout register.""" + val = self.watchdog_entry.get().strip() + if not val: + return + try: + ms = int(val) + except ValueError: + messagebox.showerror("Invalid", "Watchdog value must be integer") + return + if self.bcon_driver: + self.bcon_driver.set_watchdog(ms) + self._log_event(f"Set watchdog = {ms} ms") + + def _set_telemetry(self): + """Write the telemetry interval register.""" + val = self.telemetry_entry.get().strip() + if not val: + return + try: + ms = int(val) + except ValueError: + messagebox.showerror("Invalid", "Telemetry value must be integer") + return + if self.bcon_driver: + self.bcon_driver.set_telemetry(ms) + self._log_event(f"Set telemetry = {ms} ms") + + # ================================================================== # + # Event Log Helper # + # ================================================================== # + + def _log_event(self, text: str): + """Log an event to console, label, and CSV session log.""" + ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + line = f"[{ts}] {text}" + if self.debug: + print(line) + if hasattr(self, 'log_label'): + try: + self.log_label.configure(text=text) + except Exception: + pass + self._log(text, LogLevel.INFO) + + # ================================================================== # + # Public API (backward-compatible with dashboard) # + # ================================================================== # + + # --- Status access --- + + def set_bcon_connection_status(self, status: bool): + self.bcon_connection_status = status + self.update_bcon_connection_status() + + def set_beam_status(self, beam_index: int, status: bool): + if 0 <= beam_index < 3: + self.beam_on_status[beam_index] = status + if self.bcon_driver: + ch = beam_index + 1 + if status: + pulsing = self.get_pulsing_behavior() + if pulsing == "Pulsed": + dur = int(self.get_beam_duration(beam_index)) + self.bcon_driver.set_channel_pulse(ch, dur) + else: + self.bcon_driver.set_channel_dc(ch) + else: + self.bcon_driver.set_channel_off(ch) + if self._dashboard_beam_callback: + try: + self._dashboard_beam_callback(beam_index, status) + except Exception: + pass + + def get_beam_status(self, beam_index: int) -> bool: + if 0 <= beam_index < 3: + return self.beam_on_status[beam_index] + return False + + def set_all_beams_status(self, status: bool): + for i in range(3): + self.set_beam_status(i, status) + + def get_pulsing_behavior(self) -> str: + if hasattr(self.pulsing_behavior, 'get'): + return self.pulsing_behavior.get() + return self.pulsing_behavior + + def get_beam_duration(self, beam_index: int) -> float: + vars_list = [self.beam_a_duration, self.beam_b_duration, self.beam_c_duration] + if 0 <= beam_index < 3: + v = vars_list[beam_index] + return v.get() if hasattr(v, 'get') else float(v) + return 50.0 + + def set_channel_status_callback(self, callback): + """Register callback(ch, mode_code, remaining) invoked on every register poll. + + The dashboard uses this to keep the Beam A/B/C toggle buttons in sync + with live hardware state without polling from the dashboard side. + """ + self._channel_status_callback = callback + + def set_channel_enable_getter(self, getter): + """Register a zero-argument callable that returns list[bool] of channel enable states. + + _sync_start calls this to determine which channels to include. + Typically: beam_pulse.set_channel_enable_getter(lambda: self._ch_enable_states) + """ + self._ch_enable_getter = getter + + def set_channel_enable_status_callback(self, callback): + """Register callback(ch, enabled) invoked on every register poll.""" + self._channel_enable_status_callback = callback + + def set_dashboard_beam_callback(self, callback): + self._dashboard_beam_callback = callback + self._log("Dashboard beam callback registered", LogLevel.DEBUG) + + def get_integration_status(self) -> dict: + return { + 'has_dashboard_callback': self._dashboard_beam_callback is not None, + 'bcon_connected': self.bcon_connection_status, + } + + # --- Hardware driver interface --- + + def connect(self) -> bool: + if self._shutdown_in_progress: + return False + if self.bcon_driver: + success = self.bcon_driver.connect() + if self._shutdown_in_progress: + self.bcon_driver.disconnect() + return False + if success: + self._apply_default_bcon_settings() + return success + return False + + def disconnect(self) -> None: + self._stop_sequence_worker() + self.bcon_connection_status = False + self.beams_armed_status = False + self.beam_on_status = [False, False, False] + self._active_channels.clear() + self._update_armed_button_states(False) + if self.bcon_driver: + self.bcon_driver.reset_channel_enable_cache() + self.bcon_driver.disconnect() + + def close_com_ports(self) -> None: + """Dashboard cleanup hook.""" + self._shutdown_for_host_close() + + def is_connected(self) -> bool: + if self.bcon_driver: + return self.bcon_driver.is_connected() + return False + + def ping(self) -> bool: + if self.bcon_driver: + return self.bcon_driver.ping() + return False + + def get_system_status(self) -> Dict: + if self.bcon_driver: + return self.bcon_driver.get_status() + return {'system': {'state': 'UNKNOWN'}, 'channels': []} + + def set_channel_mode(self, channel_index: int, mode: str, duration_ms: int = 0) -> bool: + if not self._require_armed(): + return False + if not self.bcon_driver: + return False + channel = channel_index + 1 + if mode == 'OFF': + self.bcon_driver.set_channel_off(channel) + elif mode == 'DC': + self.bcon_driver.set_channel_dc(channel) + elif mode == 'PULSE': + self.bcon_driver.set_channel_pulse(channel, duration_ms) + elif mode == 'PULSE_TRAIN': + self.bcon_driver.set_channel_pulse_train(channel, duration_ms, 2) + else: + self._log(f"Invalid mode: {mode}", LogLevel.ERROR) + return False + return True + + def stop_all_channels(self) -> bool: + if self.bcon_driver: + self.bcon_driver.stop_all() + return True + return False + + # --- Safety --- + + def arm_beams(self) -> bool: + self.beams_armed_status = True + self._log("Beams ARMED (software-only)", LogLevel.INFO) + self._update_armed_button_states(True) + return True + + def disarm_beams(self) -> bool: + self.beams_armed_status = False + self._stop_sequence_worker() + self.set_all_beams_status(False) + if self.bcon_driver: + self.bcon_driver.stop_all() + self._log("Beams DISARMED", LogLevel.INFO) + self._update_armed_button_states(False) + return True + + def get_beams_armed_status(self) -> bool: + return self.beams_armed_status + + def get_deflect_beam_status(self) -> bool: + return any(self.beam_on_status) + + def set_deflect_beam_status(self, enable: bool) -> bool: + if enable: + if not self.beams_armed_status: + self._log("Cannot enable deflect beam - beams not armed", LogLevel.WARNING) + return False + self._apply_pulsing_behavior() + else: + self.set_all_beams_status(False) + if self.bcon_driver: + self.bcon_driver.stop_all() + return True + + def _apply_pulsing_behavior(self): + if not self.bcon_driver: + return + pulsing_mode = self.get_pulsing_behavior() + for idx in range(3): + ch = idx + 1 + if self.beam_on_status[idx]: + if pulsing_mode == "Pulsed": + dur = int(self.get_beam_duration(idx)) + self.bcon_driver.set_channel_pulse(ch, dur) + else: + self.bcon_driver.set_channel_dc(ch) + else: + self.bcon_driver.set_channel_off(ch) + + # --- Channel config access for dashboard integration --- + + def get_channel_config(self, ch: int) -> Dict: + """Return the GUI-configured params for a channel (0-based index). + + Returns dict with keys: mode (str), duration_ms (int), count (int). + Falls back to defaults if GUI widgets are not available. + """ + config = {'mode': 'PULSE', 'duration_ms': 100, 'count': 1} + if ch < len(self.channel_vars): + cv = self.channel_vars[ch] + try: + config['mode'] = cv['mode'].get().strip().upper() + except Exception: + pass + try: + config['duration_ms'] = int(cv['duration'].get()) + except (ValueError, Exception): + pass + try: + config['count'] = int(cv['count'].get()) + except (ValueError, Exception): + pass + return config + + def _validate_and_get_config(self, ch: int) -> 'Dict | None': + """Read, validate, and return the configuration for channel *ch* (0-based). + + Shows an "Invalid Configuration" messagebox and returns None on any + input error. All callers (Beam ON/OFF button, Apply, Sync Start, + Sync Write Params) delegate here so validation is in one place. + + Validation rules: + OFF / DC — always valid; duration and count are not used. + PULSE — duration > 0 ms required; count is always forced to 1. + PULSE_TRAIN — duration > 0 ms and count ≥ 2 required. + """ + channel_name = self._channel_name(ch) + if ch >= len(self.channel_vars): + return self.get_channel_config(ch) # fallback to defaults + + cv = self.channel_vars[ch] + mode_label = cv['mode'].get().strip().upper() + + if mode_label not in MODE_LABEL_TO_CODE: + messagebox.showerror("Invalid Configuration", + f"{channel_name}: unknown mode '{mode_label}'") + return None + + if mode_label in ('OFF', 'DC'): + # No duration / count involved — always valid + return {'mode': mode_label, 'duration_ms': 0, 'count': 1} + + # ---- PULSE / PULSE_TRAIN: duration required ----------------------- + dur_str = cv['duration'].get().strip() + try: + duration = int(dur_str) + except (ValueError, TypeError): + messagebox.showerror("Invalid Configuration", + f"{channel_name}: duration must be a whole number of ms") + return None + if duration <= 0: + messagebox.showerror("Invalid Configuration", + f"{channel_name}: duration must be > 0 ms") + return None + + # ---- PULSE: count is always 1 ------------------------------------ + if mode_label == 'PULSE': + return {'mode': mode_label, 'duration_ms': duration, 'count': 1} + + # ---- PULSE_TRAIN: count ≥ 2 required ----------------------------- + cnt_str = cv['count'].get().strip() + try: + count = int(cnt_str) + except (ValueError, TypeError): + messagebox.showerror("Invalid Configuration", + f"{channel_name}: count must be a whole number") + return None + if count < 2: + messagebox.showerror("Invalid Configuration", + f"{channel_name}: PULSE_TRAIN requires count \u2265 2") + return None + + return {'mode': mode_label, 'duration_ms': duration, 'count': count} + + def send_channel_config(self, ch: int) -> bool: + """Validate GUI params for channel *ch* (0-based) and write them to BCON. + + Shows an 'Invalid Configuration' popup and returns False on bad input. + Returns True on success. + """ + if not self._require_armed(): + return False + if not self.bcon_driver: + self._log("No BCON driver", LogLevel.WARNING) + return False + + config = self._validate_and_get_config(ch) + if config is None: + return False # messagebox already shown by helper + + mode_label = config['mode'] + duration = config['duration_ms'] + count = config['count'] + + self.bcon_driver.set_channel_mode(ch + 1, mode_label, duration_ms=duration, count=count) + + is_on = mode_label != 'OFF' + self.beam_on_status[ch] = is_on + self._log_event(f"Sent {self._channel_name(ch)}: mode={mode_label} dur={duration}ms count={count}") + if self._dashboard_beam_callback: + try: + self._dashboard_beam_callback(ch, is_on) + except Exception: + pass + return True + + def send_channel_off(self, ch: int) -> bool: + """Send OFF mode to a single channel (0-based index).""" + if not self.bcon_driver: + self._log("No BCON driver", LogLevel.WARNING) + return False + self.bcon_driver.set_channel_off(ch + 1) + self.beam_on_status[ch] = False + self._log_event(f"{self._channel_name(ch)} -> OFF") + if self._dashboard_beam_callback: + try: + self._dashboard_beam_callback(ch, False) + except Exception: + pass + return True + + def safe_shutdown(self, reason: Optional[str] = None) -> bool: + self._log(f"Safe shutdown: {reason or 'No reason'}", LogLevel.WARNING) + self.disarm_beams() + self.set_all_beams_status(False) + self._log("Safe shutdown complete", LogLevel.INFO) + return True + + # --- internal --- + + def _log(self, msg: str, level=LogLevel.INFO) -> None: + """Route a message to the dashboard Messages & Errors logger. + + Always thread-safe: when called from a background thread the write is + scheduled on the main thread via parent_frame.after(0, ...). + """ + if self.debug: + print(f"[{level.name}] {msg}") + if self.logger: + if self.parent_frame: + try: + self.parent_frame.after( + 0, lambda m=msg, l=level: self.logger.log(m, l)) + return + except Exception: + pass + self.logger.log(msg, level) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="BeamPulseSubsystem quick test") + parser.add_argument("--port", default="COM1", help="Serial port for Modbus RTU") + parser.add_argument("--unit", type=int, default=1, help="Modbus unit ID") + parser.add_argument("--test-status", action="store_true", help="Test status reading") + args = parser.parse_args() + + b = BeamPulseSubsystem(port=args.port, unit=args.unit, baudrate=115200, debug=True) + + if not b.connect(): + print("Could not connect to BCON device") + else: + print(f"Connected to BCON on {args.port}") + + if args.test_status: + if b.ping(): + print("Ping successful") + + status = b.get_system_status() + print(f"\nSystem: {status['system']}") + for i, ch in enumerate(status['channels'], 1): + print(f"Channel {CHANNEL_LABELS[i - 1]}: {ch}") + + b.disconnect() diff --git a/subsystem/cathode_heating/cathode_heating.py b/subsystem/cathode_heating/cathode_heating.py index c1672479..b66f153a 100644 --- a/subsystem/cathode_heating/cathode_heating.py +++ b/subsystem/cathode_heating/cathode_heating.py @@ -2012,6 +2012,154 @@ def toggle_output(self, index, control_mode: str = None): current_image = self.toggle_on_image if self.toggle_states[index] else self.toggle_off_image self.toggle_buttons[index].config(image=current_image) + def turn_off_all_beams(self): + """ + Redundantly turns off all cathode heaters by disabling power supply outputs. + Side effects: + - Disables output on all initialized power supplies + - Updates toggle button states and images + - Logs actions and any errors + """ + if not self.power_supplies_initialized or not self.power_supplies: + self.log("Power supplies not properly initialized or list is empty.", LogLevel.ERROR) + return + for i, ps in enumerate(self.power_supplies): + if ps and self.power_supply_status[i]: + try: + if ps.set_output("0"): # Turn off beam + self.log(f"Turned off heater for Cathode {['A', 'B', 'C'][i]}", LogLevel.INFO) + # Update toggle state and button image + self.toggle_states[i] = False + self.toggle_buttons[i].config(image=self.toggle_off_image) + else: + self.log(f"Failed to turn off heater for Cathode {['A', 'B', 'C'][i]}", LogLevel.ERROR) + except Exception as e: + self.log(f"Error turning off heater for Cathode {['A', 'B', 'C'][i]}: {str(e)}", LogLevel.ERROR) + else: + self.log(f"Power supply for Cathode {['A', 'B', 'C'][i]} is not initialized; cannot turn off heater.", LogLevel.WARNING) + + def set_target_current(self, index, entry_field): + """ + Set target beam current for a cathode and calculate required heater settings. + Uses the target beam current to calculate ideal emission current, then determines + the appropritate heater voltage and current using the ES440 cathode data model. + Args: + index (int): Index of the cathode (0-2) + entry_field (ttk.Entry): Entry widget containing target current value + Raises: + ValueError: If target current is negative or invalid + Side effects: + - programs power supply voltage and current settings + - updates predicted values displays (emission, grid current, temperature) + - Updates heater voltage display + - Logs actions and any errors + """ + if not self.power_supply_status[index]: + self.log(f"Power supply {index + 1} is not initialized. Cannot set target current.", LogLevel.ERROR) + msgbox.showerror("Error", f"Power supply {index + 1} is not initialized. Cannot set target current.") + return + if entry_field is None: + self.log("Target current entry field is missing", LogLevel.ERROR) + return + try: + target_current_mA = float(entry_field.get()) + ideal_emission_current = target_current_mA / 0.72 # this is from CCS Software Dev Spec _2024-06-07A + if ideal_emission_current < 0: + raise ValueError("Target current must be positive") + log_ideal_emission_current = np.log10(ideal_emission_current / 1000) + self.log(f"Calculated ideal emission current for Cathode {['A', 'B', 'C'][index]}: {ideal_emission_current:.3f}mA", LogLevel.INFO) + if ideal_emission_current == 0: + # Set all related variables to zero + self.reset_power_supply(index) + return + # Ensure current is within the data range + if ideal_emission_current < min(self.emission_current_model.y_data) * 1000 or ideal_emission_current > max(self.emission_current_model.y_data) * 1000: + self.log("Desired emission current is below the minimum range of the model.", LogLevel.DEBUG) + self.predicted_emission_current_vars[index].set('0.00') + self.predicted_grid_current_vars[index].set('0.00') + self.predicted_heater_current_vars[index].set('0.00') + self.heater_voltage_vars[index].set('0.00') + self.predicted_temperature_vars[index].set('0.00') + else: + # Calculate heater current from the ES440 model + heater_current = self.emission_current_model.interpolate(log_ideal_emission_current, inverse=True) + heater_voltage = self.heater_voltage_model.interpolate(heater_current) + self.log(f"Interpolated heater current for Cathode {['A', 'B', 'C'][index]}: {heater_current:.3f}A", LogLevel.INFO) + self.log(f"Interpolated heater voltage for Cathode {['A', 'B', 'C'][index]}: {heater_voltage:.3f}V", LogLevel.INFO) + # set_voltage handles these checks now + # current_ovp = self.get_ovp(index) + # if current_ovp is None: + # self.log(f"Unable to get current OVP for Cathode {['A', 'B', 'C'][index]}. Aborting voltage set.", LogLevel.ERROR) + # return + # if heater_voltage > current_ovp: + # self.log(f"Calculated voltage ({heater_voltage:.2f}V) exceeds OVP ({current_ovp:.2f}V) for Cathode {['A', 'B', 'C'][index]}. Aborting.", LogLevel.WARNING) + # msgbox.showwarning("Voltage Exceeds OVP", f"The calculated voltage ({heater_voltage:.2f}V) exceeds the current OVP setting ({current_ovp:.2f}V). Please adjust the OVP or choose a lower target current.") + # return + # Set Upper Current Limit on the power supply + if self.power_supplies and len(self.power_supplies) > index: + self.log(f"Setting voltage: {heater_voltage:.2f}V", LogLevel.DEBUG) + current_ovp = self.get_ovp(index) + if current_ovp is None: + self.log(f"Unable to get current OVP for Cathode {['A', 'B', 'C'][index]}. Aborting voltage set.", LogLevel.ERROR) + return + if heater_voltage > current_ovp: + self.log(f"Calculated voltage ({heater_voltage:.2f}V) exceeds OVP ({current_ovp:.2f}V) for Cathode {['A', 'B', 'C'][index]}. Aborting.", LogLevel.WARNING) + msgbox.showwarning("Voltage Exceeds OVP", f"The calculated voltage ({heater_voltage:.2f}V) exceeds the current OVP setting ({current_ovp:.2f}V). Please adjust the OVP or choose a lower target current.") + return + # Set the upper current limit on the power supply + # voltage_set_success = self.power_supplies[index].set_voltage(3, Decimal(heater_voltage)) + current_set_success = self.power_supplies[index].set_current(3, heater_current) + if current_set_success: + self.user_set_voltages[index] = heater_voltage + # Fixes issue where the power supply ramps up to the set voltage, then down, then up again + if self.toggle_states[index]: + if self.ramp_status[index]: + self.power_supplies[index].ramp_voltage( + heater_voltage, + step_size=self.slew_rates[index], + step_delay=1.0, + preset=3 + ) + self.voltage_set[index] = True + else: + self.power_supplies[index].set_voltage(3, heater_voltage) + self.voltage_set[index] = True + # Confirm the set values + _ , set_current = self.power_supplies[index].get_settings(3) + if set_current is not None: + # voltage_mismatch = abs(set_voltage - heater_voltage) > 0.01 # 0.01V tolerance + current_mismatch = abs(set_current - heater_current) > 0.01 # 0.01A tolerance + if current_mismatch: + self.log(f"Mismatch in set values for Cathode {['A', 'B', 'C'][index]}:", LogLevel.WARNING) + # if voltage_mismatch: + # self.log(f" Voltage - Intended: {heater_voltage:.2f}V, Actual: {set_voltage:.2f}V", LogLevel.WARNING) + if current_mismatch: + self.log(f" Current - Intended: {heater_current:.2f}A, Actual: {set_current:.2f}A", LogLevel.WARNING) + return + # GUI is updated with actual voltage + self.heater_voltage_vars[index].set(f"{heater_voltage:.2f}") + else: + self.log(f"Values confirmed for Cathode {['A', 'B', 'C'][index]}: {set_current:.2f}A", LogLevel.INFO) + else: + self.log(f"Failed to confirm set values for Cathode {['A', 'B', 'C'][index]}. No response received.", LogLevel.ERROR) + predicted_temperature_K = self.true_temperature_model.interpolate(heater_current) + predicted_temperature_C = predicted_temperature_K - 273.15 # Convert Kelvin to Celsius + predicted_grid_current = 0.28 * ideal_emission_current # display in milliamps + self.predicted_emission_current_vars[index].set(f'{ideal_emission_current:.2f} mA') + self.predicted_grid_current_vars[index].set(f'{predicted_grid_current:.2f} mA') + self.predicted_heater_current_vars[index].set(f'{heater_current:.2f} A') + self.predicted_temperature_vars[index].set(f'{predicted_temperature_C:.0f} C') + self.heater_voltage_vars[index].set(f'{heater_voltage:.2f}') + setattr(self, f'last_set_voltage_{index}', heater_voltage) + self.log(f"Set Cathode {['A', 'B', 'C'][index]} power supply to {heater_voltage:.2f}V, targetting {heater_current:.2f}A heater current", LogLevel.INFO) + else: + self.reset_related_variables(index) + self.log(f"Failed to set voltage/current for Cathode {['A', 'B', 'C'][index]}.", LogLevel.ERROR) + except ValueError as e: + self.log("Invalid input for target current", LogLevel.ERROR) + msgbox.showerror("Invalid Input", str(e)) + return + def reset_related_variables(self, index): """ Reset display variables when configuration action fails.