diff --git a/dashboard.py b/dashboard.py index d5425d9d..798b2d59 100644 --- a/dashboard.py +++ b/dashboard.py @@ -45,29 +45,40 @@ def channel_name(index: int) -> str: return f"Channel {channel_label(index)}" +# Total row width = 1916. Vertical guides (from left): +# x = w_be — Oil | Process Monitor lines up with Beam Energy | Cathode Heating +# x = w_bp — Process Monitor | Messages lines up with Beam Pulse | Main Control +# Top-row slice widths: Vacuum+Oil = w_be; ProcessMonitor+Messages = w_ch; PM width = w_bp - w_be. frames_config = [ - # Row 0 + # Row 0 — safety strip (full width) ("Interlocks", 0, 1916, 41), - # Row 1 - ("Oil System", 1, 604, 130), - ("Beam Steering", 1, 778, 130), - ("Beam Energy", 1, 528, 130), + # Row 1 — Vacuum | Oil | Process Monitor | Messages (left → right) + ("Vacuum System", 1, 350, 400), + ("Oil System", 1, 350, 400), + ("Process Monitor", 1, 258, 400), + ("Messages Frame", 1, 958, 400), - # Row 2 - ("Vacuum System", 2, 604, 438), - ("Beam Pulse", 2, 777, 438), - ("Main Control", 2, 529, 438), + # Row 2 — Beam Energy | Cathode Heating (w_be + w_ch = 1916) + ("Beam Energy", 2, 700, 400), + ("Cathode Heating", 2, 1216, 400), - # Row 4 - ("Process Monitor", 3, 339, 458), - ("Cathode Heating", 3, 1041, 458), - ("Messages Frame", 3, 539, 458), + # Row 3 — Beam Pulse | Main Control (w_bp + w_mc = 1916) + ("Beam Pulse", 3, 958, 450), + ("Main Control", 3, 958, 450), - # Row 5 - ("Machine Status", 4, 1916, 38) + # Row 4 — machine status + ("Machine Status", 4, 1916, 38), ] + +def _messages_frame_layout(): + """Return (row, width, height) for the Messages Frame entry in frames_config.""" + for title, row, w, h in frames_config: + if title == "Messages Frame": + return row, w, h + raise RuntimeError("frames_config must include 'Messages Frame'") + class EBEAMSystemDashboard: """ Main dashboard class that manages the EBEAM System Control Dashboard interface. @@ -351,7 +362,8 @@ def create_frames(self): if title == "Main Control": self.create_main_control_notebook(frame) - self.rows[3].add(self.messages_frame.frame, stretch='always') + _msg_row, _, _ = _messages_frame_layout() + self.rows[_msg_row].add(self.messages_frame.frame, stretch='always') self.frames['Messages Frame'] = self.messages_frame.frame def create_main_control_notebook(self, frame): @@ -992,6 +1004,11 @@ def create_subsystems(self): com_ports=self.com_ports, logger=self.logger, active = self.machine_status_frame.MACHINE_STATUS + ), + 'Beam Energy': subsystem.BeamEnergySubsystem( + self.frames['Beam Energy'], + com_ports=self.com_ports, + logger=self.logger ) } @@ -1054,7 +1071,8 @@ def create_subsystems(self): 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]) + _msg_row, _msg_w, _msg_h = _messages_frame_layout() + self.messages_frame = MessagesFrame(self.rows[_msg_row], width=_msg_w, height=_msg_h) self.logger = self.messages_frame.logger def create_machine_status_frame(self): @@ -1138,7 +1156,9 @@ def create_com_port_frame(self, parent_frame): self.port_selections = {} self.port_dropdowns = {} - for subsystem in ['VTRXSubsystem', 'CathodeA PS', 'CathodeB PS', 'CathodeC PS', 'TempControllers', 'Interlocks', 'ProcessMonitors']: + for subsystem in [ + 'VTRXSubsystem', 'CathodeA PS', 'CathodeB PS', 'CathodeC PS', + 'TempControllers', 'Interlocks', 'ProcessMonitors', 'KnobBox']: frame = ttk.Frame(self.com_port_menu) frame.pack(fill=tk.X, padx=5, pady=2) ttk.Label(frame, text=f"{subsystem}:").pack(side=tk.LEFT) @@ -1198,6 +1218,8 @@ def update_com_ports(self, new_com_ports): subsystem.update_com_port(new_com_ports.get('VTRXSubsystem')) elif subsystem_name == 'Cathode Heating': subsystem.update_com_ports(new_com_ports) + elif subsystem_name == 'Beam Energy': + subsystem.update_com_port(new_com_ports) else: self.logger.warning(f"Subsystem {subsystem_name} does not have an update_com_port method") self.logger.info(f"COM ports updated: {self.com_ports}") diff --git a/instrumentctl/knob_box/README.md b/instrumentctl/knob_box/README.md new file mode 100644 index 00000000..db48cdad --- /dev/null +++ b/instrumentctl/knob_box/README.md @@ -0,0 +1,191 @@ +# Knob Box Driver Documentation + +This README documents the dashboard-side Knob Box integration in `instrumentctl/knob_box/`. + +### System Overview + +The Knob Box is the operator panel and local monitoring/interlock interface for four high-voltage supplies: + +- `+1 kV` Matsusada +- `-1 kV` Matsusada +- `+20 kV` Bertan +- `+3 kV` Bertan + +On the hardware side, the system is split into: + +- Four monitoring Arduinos that read supply telemetry and expose it on RS-485 / Modbus RTU +- One Logic Arduino that enforces beam/interlock behavior + +Only the monitoring Arduinos speak Modbus. The Logic Arduino is visible to the dashboard only through the `+3 kV` monitoring Arduino, which republishes live logic state and latched fault history in its Modbus register map. + +Inside this dashboard repo: + +- `knob_box_modbus.py` polls all four monitor Arduinos over one RS-485 serial port +- `subsystem/beam_energy/beam_energy.py` consumes that data and updates the GUI + +### Hardware / Serial Summary + +| Item | Value | +|------|-------| +| Physical transport | RS-485 | +| Protocol | Modbus RTU | +| Active dashboard driver | `KnobBoxModbus` | +| Dashboard COM-port key | `KnobBox` | +| Baud rate | `9600` | +| Data bits | `8` | +| Parity | `N` | +| Stop bits | `1` | +| Default timeout | `0.5 s` | +| Modbus unit IDs | `1-4` | + +### Power Supply / Unit Mapping + +| Unit ID | Supply | Role in dashboard | +|---------|--------|-------------------| +| `1` | `+1 kV` Matsusada | Local telemetry plus Matsusada reset-state indication | +| `2` | `-1 kV` Matsusada | Local telemetry plus Matsusada reset-state indication | +| `3` | `+20 kV` Bertan | Local telemetry | +| `4` | `+3 kV` Bertan | Local telemetry plus Logic Arduino state, flags, and handshake status | + +### Current Dashboard Integration + +The current dashboard path is: + +```python +from instrumentctl.knob_box.knob_box_modbus import KnobBoxModbus +``` + +`KnobBoxModbus` is not exported from `instrumentctl/__init__.py`, so callers must import it from the full module path. + +#### Constructor Parameters + +| Parameter | Default | Meaning | +|-----------|---------|---------| +| `port` | required | Serial COM port for the RS-485 adapter | +| `baudrate` | `9600` | Modbus baud rate | +| `timeout` | `0.5` | Read timeout in seconds | +| `parity` | `"N"` | Serial parity | +| `stopbits` | `1` | Serial stop bits | +| `bytesize` | `8` | Serial data bits | +| `logger` | `None` | Optional dashboard logger | +| `debug_mode` | `True` | Stored flag for debug-oriented behavior/logging | + +#### Core Methods Used by the Dashboard + +| Method | Purpose | +|--------|---------| +| `connect()` | Open the Modbus serial client with exponential reconnect backoff | +| `disconnect()` | Close the Modbus serial client | +| `poll_all()` | Poll all four unit IDs, rotating start order each pass | +| `get_data_snapshot()` | Return a copy of the latest per-unit data | +| `get_unit_connection_status(uid)` | Report whether a unit has polled successfully within `CONNECTION_TIMEOUT` | +| `any_unit_connected()` | Report whether any unit has polled successfully within `CONNECTION_TIMEOUT` | +| `close()` | Compatibility alias that calls `disconnect()` | + +#### Basic Usage + +```python +from instrumentctl.knob_box.knob_box_modbus import KnobBoxModbus + +knob_box = KnobBoxModbus(port="COM13") + +if knob_box.connect(): + snapshot = knob_box.poll_all() + unit_4 = snapshot[4] + print(unit_4["actual_voltage_V"]) + print(unit_4["logic_alive"]) + +knob_box.close() +``` + +#### Polling and Reconnect Behavior + +- `UNIT_IDS` is fixed to `[1, 2, 3, 4]` +- `poll_all()` rotates the unit polling order so the same device is not always last +- Each unit read is attempted up to `3` times before that unit is marked failed for the current pass +- Failed unit polls back off exponentially from `0.5 s` up to `5.0 s` +- Failed connection attempts also back off exponentially from `0.5 s` up to `5.0 s` +- Connection freshness is based on `last_success` timestamps, not just whether the serial port is open +- `CONNECTION_TIMEOUT` is `10.0 s`; if a unit has not answered within that window, the dashboard treats that unit as disconnected + +#### How `BeamEnergySubsystem` Uses It + +`subsystem/beam_energy/beam_energy.py` instantiates `KnobBoxModbus` using the COM port stored under the `KnobBox` key in the dashboard COM-port configuration. + +The subsystem then: + +- Starts a polling thread that calls `poll_all()` every `0.2 s` +- Calls `get_data_snapshot()` during UI refresh +- Calls `get_unit_connection_status(uid)` to decide whether to show live data or placeholder values +- Calls `any_unit_connected()` to decide when to trigger reconnect behavior +- Refreshes the UI every `500 ms` + +If no unit has reported successfully within `CONNECTION_TIMEOUT`, the subsystem falls back to placeholder values and starts reconnect logic. + +### Modbus Register Contract + +The current driver expects one contiguous block of six input registers from each unit: + +| Address | Constant | Meaning | +|---------|----------|---------| +| `0` | `IREG_V_SET_ADDR` | Set voltage in integer volts | +| `1` | `IREG_V_READ_ADDR` | Measured voltage in integer volts | +| `2` | `IREG_I_READ_ADDR` | Measured current in integer microamps | +| `3` | `IREG_3KV_RESET_COUNT_ADDR` | `+3 kV` timer/reset-event counter | +| `4` | `DINPUT_UNLATCHED_SIGNALS_ADDR` | Packed unlatched signals word | +| `5` | `DINPUT_LATCHED_FLAGS_ADDR` | Packed latched flags word | + +Registers `0-5` are all read through Modbus function code `04` in one request per unit. + +#### Unlatched Signals Word (`register 4`) + +| Bit | Mask | Driver field | Meaning | +|-----|------|--------------|---------| +| `0` | `UNLATCHED_SIGNAL_MASK_HVENABLE` | raw `hv_enable` source for units `1-3` | Local HV enable switch telemetry | +| `1` | `UNLATCHED_SIGNAL_MASK_RESET_STATE_1KV` | `reset_state_1kV` | Matsusada inferred reset/overcurrent state | +| `2` | `UNLATCHED_SIGNAL_MASK_ARM80KV_ENABLE` | `arm_80kV` | Raw `Arm 80kV` switch state from the `+3 kV` monitor path | +| `3` | `UNLATCHED_SIGNAL_MASK_CCSPOWER_ENABLE` | `ccs_power` | Logic Arduino CCS enable output mirror on unit `4` | +| `4` | `UNLATCHED_SIGNAL_MASK_ARMBEAMS_ENABLE` | `arm_beams` | Logic Arduino Arm Beams output mirror on unit `4` | +| `5` | `UNLATCHED_SIGNAL_MASK_3KV_ENABLE` | `3kV_enable` | Logic Arduino `3 kV` enable output mirror on unit `4` | +| `6` | `UNLATCHED_SIGNAL_MASK_NOMOP` | `nomop_flag` | Logic Arduino Nominal Operation flag on unit `4` | +| `7` | `UNLATCHED_SIGNAL_MASK_LOGIC_ALIVE` | `logic_alive` | Logic alive heartbeat derived from D9 ack-back edge detection | + +#### Latched Flags Word (`register 5`) + +| Bit | Mask | Driver field | Meaning | +|-----|------|--------------|---------| +| `4` | `LATCHED_FLAG_MASK_3KV_TIMER` | `timer_state_3kV` | `3 kV` timer event occurred since the last ACK cycle | +| `5` | `LATCHED_FLAG_MASK_ARMBEAMS_SWITCH` | `armbeams_flag` | Arm Beams switch asserted since the last ACK cycle | +| `6` | `LATCHED_FLAG_MASK_CCSPOWER_ALLOW` | `ccspower_flag` | CCS Power Allow switch asserted since the last ACK cycle | +| `7` | `LATCHED_FLAG_MASK_ARM80KV_SWITCH` | `arm80kv_flag` | Arm 80 kV switch asserted since the last ACK cycle | +| `8` | `LATCHED_FLAG_MASK_1K_VCOMP` | `vcomp_1k_flag` | `+1 kV` voltage comparator fault since the last ACK cycle | +| `9` | `LATCHED_FLAG_MASK_1K_ICOMP` | `icomp_1k_flag` | `+1 kV` current comparator fault since the last ACK cycle | +| `10` | `LATCHED_FLAG_MASK_NEG_1K_VCOMP` | `neg_vcomp_1k_flag` | `-1 kV` voltage comparator fault since the last ACK cycle | +| `11` | `LATCHED_FLAG_MASK_NEG_1K_ICOMP` | `neg_icomp_1k_flag` | `-1 kV` current comparator fault since the last ACK cycle | +| `12` | `LATCHED_FLAG_MASK_20K_VCOMP` | `vcomp_20k_flag` | `+20 kV` voltage comparator fault since the last ACK cycle | +| `13` | `LATCHED_FLAG_MASK_20K_ICOMP` | `icomp_20k_flag` | `+20 kV` current comparator fault since the last ACK cycle | +| `14` | `LATCHED_FLAG_MASK_3K_VCOMP` | `vcomp_3k_flag` | `+3 kV` voltage comparator fault since the last ACK cycle | +| `15` | `LATCHED_FLAG_MASK_3K_ICOMP` | `icomp_3k_flag` | `+3 kV` current comparator fault since the last ACK cycle | + +Bits `0-3` are currently unused. + +#### Important Decoding Rules + +- `actual_current_mA` is derived from register `2` by dividing the integer microamp value by `1000.0` +- `3kv_reset_count` is only meaningful for unit `4`; other units should normally report `0` +- `arm_beams` and `ccs_power` are live Logic Arduino output mirrors on the `+3 kV` path, not raw switch inputs +- For unit `4`, `hv_enable` is intentionally overridden to use `3kV_enable` instead of the raw HV-enable switch bit + +That unit-`4` special case matters because the Beam Energy panel uses `hv_enable` for the `Output` indicator. For the `+3 kV` supply, that indicator therefore reflects the logic-authorized enable output, not just the front-panel request switch. + + +#### Exposed Data Shape + +`get_power_supply_data()` returns a copy of: + +| Key | Meaning | +|-----|---------| +| `set_voltage` | Parsed set voltage as `float` or `None` | +| `meas_voltage` | Parsed measured voltage as `float` or `None` | +| `meas_current` | Parsed measured current as `float` or `None` | +| `connected` | Serial connection state | diff --git a/instrumentctl/knob_box/knob_box_modbus.py b/instrumentctl/knob_box/knob_box_modbus.py new file mode 100644 index 00000000..52c35c3c --- /dev/null +++ b/instrumentctl/knob_box/knob_box_modbus.py @@ -0,0 +1,601 @@ +import queue +import threading +import time + +from pymodbus.client import ModbusSerialClient as ModbusClient +from utils import LogLevel # Ensure this module is correctly implemented + +# ============= MODBUS MAP ================================= +"""Input Registers (Function Code 04)""" +IREG_V_SET_ADDR = 0 # integer volts +IREG_V_READ_ADDR = 1 # integer volts +IREG_I_READ_ADDR = 2 # integer microamps +IREG_3KV_RESET_COUNT_ADDR = 3 # count of reset events for 3kV Bertan + +""" +Packed DINPUT words (also read with Function Code 04): + 4 = unlatched signals + 5 = latched flags +""" +DINPUT_UNLATCHED_SIGNALS_ADDR = 4 +DINPUT_LATCHED_FLAGS_ADDR = 5 + +UNLATCHED_SIGNAL_MASK_HVENABLE = 1 << 0 +UNLATCHED_SIGNAL_MASK_RESET_STATE_1KV = 1 << 1 +UNLATCHED_SIGNAL_MASK_ARM80KV_ENABLE = 1 << 2 +UNLATCHED_SIGNAL_MASK_CCSPOWER_ENABLE = 1 << 3 +UNLATCHED_SIGNAL_MASK_ARMBEAMS_ENABLE = 1 << 4 +UNLATCHED_SIGNAL_MASK_3KV_ENABLE = 1 << 5 +UNLATCHED_SIGNAL_MASK_NOMOP = 1 << 6 +UNLATCHED_SIGNAL_MASK_LOGIC_ALIVE = 1 << 7 + +LATCHED_FLAG_MASK_3KV_TIMER = 1 << 4 +LATCHED_FLAG_MASK_ARMBEAMS_SWITCH = 1 << 5 +LATCHED_FLAG_MASK_CCSPOWER_ALLOW = 1 << 6 +LATCHED_FLAG_MASK_ARM80KV_SWITCH = 1 << 7 +LATCHED_FLAG_MASK_1K_VCOMP = 1 << 8 +LATCHED_FLAG_MASK_1K_ICOMP = 1 << 9 +LATCHED_FLAG_MASK_NEG_1K_VCOMP = 1 << 10 +LATCHED_FLAG_MASK_NEG_1K_ICOMP = 1 << 11 +LATCHED_FLAG_MASK_20K_VCOMP = 1 << 12 +LATCHED_FLAG_MASK_20K_ICOMP = 1 << 13 +LATCHED_FLAG_MASK_3K_VCOMP = 1 << 14 +LATCHED_FLAG_MASK_3K_ICOMP = 1 << 15 + +# As the Modbus map is updated, update these counts. +IREG_COUNT = 4 +DINPUT_COUNT = 2 +TOTAL_REG_COUNT = IREG_COUNT + DINPUT_COUNT +# ============= END MODBUS MAP ============================= + +DATA_TEMPLATE = { + "set_voltage_V": 0.0, + "actual_voltage_V": 0.0, + "actual_current_mA": 0.0, + "3kv_reset_count": 0, + "hv_enable": 0, + "arm_80kv": 0, + "arm_beams": 0, + "ccs_power": 0, + "3kV_enable": 0, + "timer_state_3kV": 0, + "reset_state_1kV": 0, + "nomop_flag": 0, + "armbeams_flag": 0, + "ccspower_flag": 0, + "arm80kv_flag": 0, + "vcomp_1k_flag": 0, + "icomp_1k_flag": 0, + "neg_vcomp_1k_flag": 0, + "neg_icomp_1k_flag": 0, + "vcomp_20k_flag": 0, + "icomp_20k_flag": 0, + "vcomp_3k_flag": 0, + "icomp_3k_flag": 0, + "logic_alive": 0, +} + + +class KnobBoxModbus: + """ + Modbus RTU driver for multiple power supply monitoring via RS485. + + This class manages communication with multiple power supplies through a single + RS485 connection using the Modbus RTU protocol. It provides thread-safe access + to power supply data and handles connection management automatically. + + Attributes: + OUTPUT_STATUS_ADDRESS (int): Register address for output status + SET_VOLTAGE_ADDRESS (int): Register address for set voltage + ACTUAL_VOLTAGE_ADDRESS (int): Register address for measured voltage + ACTUAL_CURRENT_ADDRESS (int): Register address for measured current + UNIT_NUMBERS (list): List of valid unit addresses + MAX_ATTEMPTS (int): Maximum retry attempts for failed reads + """ + # Identifiers for power supplies: + # - 1: +1kV Matsusada + # - 2: -1kV Matsusada + # - 3: +20kV Bertan + # - 4: +3kV Bertan + UNIT_IDS = [1, 2, 3, 4] + MAX_ATTEMPTS = 5 # Max attempts for reading data + + def __init__( + self, + port, + baudrate=9600, + timeout=0.5, + parity="N", + stopbits=1, + bytesize=8, + logger=None, + debug_mode=False, + ): + """ + Initialize the KnobBoxModbus instance with serial communication parameters and optional logging. + + Parameters: + port (str): Serial port to connect. + baudrate (int): Communication baud rate (default: 9600). + timeout (int): Timeout duration for Modbus communication (default: 0.5 seconds). + parity (str): Parity setting for serial communication (default: 'N' for No Parity Bit). + stopbits (int): Number of stop bits (default: 1). + bytesize (int): Data bits size (default: 8). + logger (optional): Logger instance for output messages. + debug_mode (bool): If True, enables debug logging. + """ + self.logger = logger + self.debug_mode = debug_mode + self.modbus_lock = threading.Lock() # Lock for Modbus communication + self._main_thread_id = threading.get_ident() + self._background_log_queue = queue.SimpleQueue() + self.data_lock = threading.Lock() # Lock for data state updates + self.poll_schedule_lock = threading.Lock() # Lock for per-unit poll scheduling/backoff + self.port = port + self.connected = False + self.last_success = {uid: 0 for uid in self.UNIT_IDS} # Track last successful poll time for each unit + + '''Connection management parameters:''' + self.CONNECTION_TIMEOUT = 6.0 # seconds without successful poll before considering connection lost + self._connect_backoff_sec = 0.25 # initial time between connection attempts + self._connect_backoff_max_sec = 2.0 # max backoff between attempts + self._next_connect_time = 0.0 # used for backoff timing of connection attempts + + '''Polling management paramters:''' + self._poll_index = 0 # rotate unit polling order to avoid always lagging the same unit + self._unit_poll_backoff_base_sec = 0.25 # per-unit backoff after poll failures + self._unit_poll_backoff_max_sec = 2.0 + self._unit_poll_backoff_sec = {uid: 0.0 for uid in self.UNIT_IDS} + self._next_unit_poll_time = {uid: 0.0 for uid in self.UNIT_IDS} + + '''Switch states, flags, 3k counter (to check for changes on each read)''' + self.switch_states = [0 for _ in range(7)] # 4 HV enable signals, arm beams, ccs power, arm 80kv + self.latched_flags = [0 for _ in range(12)] # 12 latched flags from DINPUT word + self.unlatched_signals = [0 for _ in range(8)] # 8 unlatched signals from DINPUT word + self.reset_counter = 0 + + # Create data dictionary for each unit in the list of UNIT_IDS + self.data: dict[int, dict] = {uid: DATA_TEMPLATE.copy() for uid in self.UNIT_IDS} + + # Initialize Modbus client without 'method' parameter + self.client = ModbusClient( + port=port, + baudrate=baudrate, + parity=parity, + stopbits=stopbits, + bytesize=bytesize, + timeout=timeout, + retries=0 # avoid double-retry (manual retries handled in poll_one) + ) + + def connect(self): + """ + Connect to the Modbus device. Opens the serial connection if not already open. + + Returns: + bool: True if connected successfully, False otherwise. + """ + with self.modbus_lock: + try: + if self.connected: + return True + now = time.time() + if now < self._next_connect_time: + return False + if self.client.connect(): + self.connected = True + self._connect_backoff_sec = 0.5 + self._next_connect_time = 0.0 + self.log(f"Knob Box Connected to port {self.port}.", LogLevel.INFO) + return True + else: + # Connection failed --> schedule next attempt with backoff + self.log("Failed to connect to the Knob Box Modbus device.", LogLevel.ERROR) + self._next_connect_time = now + self._connect_backoff_sec + # Exponential backoff for next connection attempt + self._connect_backoff_sec = min(self._connect_backoff_sec * 2, self._connect_backoff_max_sec) + return False + except PermissionError as e: # COMx access denied + self.connected = False + try: + self.client.close() + except Exception: + pass + self.log(f"Permission error connecting to {self.port}: {str(e)}", LogLevel.ERROR) + now = time.time() + self._next_connect_time = now + self._connect_backoff_sec + self._connect_backoff_sec = min(self._connect_backoff_sec * 2, self._connect_backoff_max_sec) + return False + except Exception as e: # general catch-all for errors + self.connected = False + self.log(f"Error connecting to {self.port}: {str(e)}", LogLevel.ERROR) + now = time.time() + self._next_connect_time = now + self._connect_backoff_sec + self._connect_backoff_sec = min(self._connect_backoff_sec * 2, self._connect_backoff_max_sec) + return False + + def disconnect(self): + """Disconnect from the Modbus device.""" + with self.modbus_lock: + try: + if self.connected: + self.client.close() + self.connected = False + self.log("Disconnected from the Knob Box Modbus device.", LogLevel.INFO) + else: + self.log("Client already disconnected from Knob Box Modbus device", LogLevel.INFO) + except Exception as e: + self.log(f"Error in disconnect: {str(e)}", LogLevel.ERROR) + + def poll_all(self): + """ + Poll all power supplies and update self.data + Returns copy of data dictionary + """ + if not self.connect(): + raise RuntimeError("Unable to open Modbus serial port") + + # Rotate polling order to distribute latency across units + if self.UNIT_IDS: + start_index = self._poll_index % len(self.UNIT_IDS) + unit_order = self.UNIT_IDS[start_index:] + self.UNIT_IDS[:start_index] + self._poll_index = (start_index + 1) % len(self.UNIT_IDS) + else: + unit_order = [] + + for uid in unit_order: + now = time.time() + with self.poll_schedule_lock: + next_due = self._next_unit_poll_time.get(uid, 0.0) + if now < next_due: + continue + try: + self.poll_one(uid) + except Exception: + # poll_one already logs a single ERROR only when all retries are exhausted. + pass + + # Return a copy to avoid external mutation + with self.data_lock: + return {uid: values.copy() for uid, values in self.data.items()} + + def poll_one(self, unit_id): + """ + Poll a single power supply unit and update self.data. + + Parameters: + unit_id (int): Unit ID of the power supply to poll. + """ + last_exception = None + for attempt in range(1, self.MAX_ATTEMPTS + 1): + try: + with self.modbus_lock: + # Read the full packed input-register block: + # V set/read, I read, 3kV reset count, unlatched signals, latched flags + input_registers = self.client.read_input_registers( + address=IREG_V_SET_ADDR, + count=TOTAL_REG_COUNT, + slave=unit_id + ) + if input_registers.isError(): + raise RuntimeError() # no print, overflows log because read errors are not uncommon and handled with retries/backoff + + if len(input_registers.registers) < TOTAL_REG_COUNT: + raise RuntimeError(f"Knob Box Modbus: Expected {TOTAL_REG_COUNT} registers but got {len(input_registers.registers)}") + + # Unpack all 6 modbus registers received in packet. + registers = input_registers.registers + v_set = registers[IREG_V_SET_ADDR] + v_read = registers[IREG_V_READ_ADDR] + i_read = registers[IREG_I_READ_ADDR] + reset_counter = registers[IREG_3KV_RESET_COUNT_ADDR] + unlatched_signals = registers[DINPUT_UNLATCHED_SIGNALS_ADDR] + flags = registers[DINPUT_LATCHED_FLAGS_ADDR] + + # Unpack the unlatched signals + raw_hv_enable = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_HVENABLE)) + reset_state_1kV = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_RESET_STATE_1KV)) + arm_80kV = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_ARM80KV_ENABLE)) + ccs_power = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_CCSPOWER_ENABLE)) + arm_beams = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_ARMBEAMS_ENABLE)) + enable_3kV = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_3KV_ENABLE)) + nomop_flag = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_NOMOP)) + logic_alive_flag = int(bool(unlatched_signals & UNLATCHED_SIGNAL_MASK_LOGIC_ALIVE)) + + # HV enable for 3kV comes from the logic arduino output signal. + hv_enable = enable_3kV if unit_id == 4 else raw_hv_enable + + # Unpack the latched flags + timer_state_flag = int(bool(flags & LATCHED_FLAG_MASK_3KV_TIMER)) + armbeams_flag = int(bool(flags & LATCHED_FLAG_MASK_ARMBEAMS_SWITCH)) + ccspower_flag = int(bool(flags & LATCHED_FLAG_MASK_CCSPOWER_ALLOW)) + arm80kv_flag = int(bool(flags & LATCHED_FLAG_MASK_ARM80KV_SWITCH)) + vcomp_1k_flag = int(bool(flags & LATCHED_FLAG_MASK_1K_VCOMP)) + icomp_1k_flag = int(bool(flags & LATCHED_FLAG_MASK_1K_ICOMP)) + neg_vcomp_1k_flag = int(bool(flags & LATCHED_FLAG_MASK_NEG_1K_VCOMP)) + neg_icomp_1k_flag = int(bool(flags & LATCHED_FLAG_MASK_NEG_1K_ICOMP)) + vcomp_20k_flag = int(bool(flags & LATCHED_FLAG_MASK_20K_VCOMP)) + icomp_20k_flag = int(bool(flags & LATCHED_FLAG_MASK_20K_ICOMP)) + vcomp_3k_flag = int(bool(flags & LATCHED_FLAG_MASK_3K_VCOMP)) + icomp_3k_flag = int(bool(flags & LATCHED_FLAG_MASK_3K_ICOMP)) + + # Create a full data dict to update this unit's global data dict. + new_data = { + "set_voltage_V": float(v_set), + "actual_voltage_V": float(v_read), + "actual_current_mA": float(i_read) / 1000.0, # convert uA to mA + "3kv_reset_count": reset_counter, + "hv_enable": hv_enable, + "arm_80kV": arm_80kV, + "arm_beams": arm_beams, + "ccs_power": ccs_power, + "3kV_enable": enable_3kV, + "reset_state_1kV": reset_state_1kV, + "nomop_flag": nomop_flag, + "timer_state_3kV": timer_state_flag, + "armbeams_flag": armbeams_flag, + "ccspower_flag": ccspower_flag, + "arm80kv_flag": arm80kv_flag, + "vcomp_1k_flag": vcomp_1k_flag, + "icomp_1k_flag": icomp_1k_flag, + "neg_vcomp_1k_flag": neg_vcomp_1k_flag, + "neg_icomp_1k_flag": neg_icomp_1k_flag, + "vcomp_20k_flag": vcomp_20k_flag, + "icomp_20k_flag": icomp_20k_flag, + "vcomp_3k_flag": vcomp_3k_flag, + "icomp_3k_flag": icomp_3k_flag, + "logic_alive": logic_alive_flag + } + + # Update this unit's global data dict with the data lock. + with self.data_lock: + self.data[unit_id] = new_data + self.last_success[unit_id] = time.time() + + # Reset polling timing parameters with lock (successul read). + with self.poll_schedule_lock: + self._unit_poll_backoff_sec[unit_id] = 0.0 + self._next_unit_poll_time[unit_id] = 0.0 + + """ + 3kV Specific logging: all flags and signals except for 1kV reset state. + """ + if (unit_id == 4): + # Check for an increment of the 3kV reset counter + if self.reset_counter < reset_counter: + self.log(f"Knob Box: 3kV Bertan timer state counter incremented, counter = {reset_counter}", LogLevel.ERROR) + + # Check if 3kV forced off + if reset_counter > 0 and self.reset_counter == 0: + self.log(f"Knob Box: 3kV Bertan enable was forced off.", LogLevel.ERROR) + + # Update the stored reset counter + self.reset_counter = reset_counter + + # Check for edges on switch states + if self.switch_states[4] != arm_80kV: + if (self.switch_states[4] == 0 and arm_80kV == 1): + self.log(f"Knob Box: Arm 80kV switch turned ON", LogLevel.INFO) + else: + self.log(f"Knob Box: Arm 80kV switch turned OFF", LogLevel.INFO) + if self.switch_states[5] != arm_beams: + if (self.switch_states[5] == 0 and arm_beams == 1): + self.log(f"Knob Box: Arm beams switch turned ON", LogLevel.INFO) + else: + self.log(f"Knob Box: Arm beams switch turned OFF", LogLevel.INFO) + if self.switch_states[6] != ccs_power: + if (self.switch_states[6] == 0 and ccs_power == 1): + self.log(f"Knob Box: CCS power switch turned ON", LogLevel.INFO) + else: + self.log(f"Knob Box: CCS power switch turned OFF", LogLevel.INFO) + + # Check for edges on all latched flags and log them + new_flag_states = [ # just a list of new data to cleanly iterate through + (0, timer_state_flag, "3kV Timer State"), + (1, armbeams_flag, "Arm Beams"), + (2, ccspower_flag, "CCS Power Allow"), + (3, arm80kv_flag, "Arm 80kV"), + (4, vcomp_1k_flag, "1kV Voltage Comp"), + (5, icomp_1k_flag, "1kV Current Comp"), + (6, neg_vcomp_1k_flag, "Negative 1kV Voltage Comp"), + (7, neg_icomp_1k_flag, "Negative 1kV Current Comp"), + (8, vcomp_20k_flag, "20kV Voltage Comp"), + (9, icomp_20k_flag, "20kV Current Comp"), + (10, vcomp_3k_flag, "3kV Voltage Comp"), + (11, icomp_3k_flag, "3kV Current Comp"), + ] + for idx, new_val, flag_name in new_flag_states: + if self.latched_flags[idx] != new_val: + if self.latched_flags[idx] == 0 and new_val == 1: + # When any flag is tripped, it should be an ERROR. + self.log(f"Knob Box: {flag_name} flag tripped.", LogLevel.ERROR) + else: + # Log as INFO on falling edge. + # TODO unsure if this will cause log overflowing/if it is needed at all. + self.log(f"Knob Box: {flag_name} flag deasserted.", LogLevel.INFO) + + # Check for edges on all unlatched signals and log them. + new_signal_states = [ + (0, raw_hv_enable, "HV Enable"), + # (1kV reset is sent over by the +-1kV arduinos) + (2, arm_80kV, "Arm 80kV"), + (3, ccs_power, "CCS Power"), + (4, arm_beams, "Arm Beams"), + (5, enable_3kV, "3kV Enable"), + (6, nomop_flag, "Nomop"), + (7, logic_alive_flag, "Logic Comms") + ] + for idx, new_val, signal_name in new_signal_states: + if self.unlatched_signals[idx] != new_val: + if self.unlatched_signals[idx] == 0 and new_val == 1: + self.log(f"Knob Box: {signal_name} signal ON.", LogLevel.INFO) + + elif idx == 6: + # Nomop Signal: 1-->0 transition is an ERROR + self.log(f"Knob Box: Entered INTERLOCKS State.", LogLevel.ERROR) + elif idx == 7: + # Logic Alive Signal: 1-->0 transistion is an ERROR + self.log(f"Knob Box: Lost communication with the Logic Arduino.", LogLevel.ERROR) + else: + self.log(f"Knob Box: {signal_name} signal OFF.", LogLevel.INFO) + + # Arm Beams, CCS Power, and Arm 80kV are only recieved by the 3kv arduino. + self.switch_states[4] = 0 if arm_80kV == 0 else 1 + self.switch_states[5] = 0 if arm_beams == 0 else 1 + self.switch_states[6] = 0 if ccs_power == 0 else 1 + # Latched flags are only recieved by the 3kV arduino. + self.latched_flags = [ + timer_state_flag, + armbeams_flag, + ccspower_flag, + arm80kv_flag, + vcomp_1k_flag, + icomp_1k_flag, + neg_vcomp_1k_flag, + neg_icomp_1k_flag, + vcomp_20k_flag, + icomp_20k_flag, + vcomp_3k_flag, + icomp_3k_flag + ] + # Unlatched flags are only recieved by the 3kV arduino (exception: 1kV reset state). + reset_state = self.unlatched_signals[1] + self.unlatched_signals = [ + raw_hv_enable, + reset_state, # this needs to stay unchanged, only units 1 and 2 should update it + arm_80kV, + ccs_power, + arm_beams, + enable_3kV, + nomop_flag, + logic_alive_flag + ] + + """ + +-1kV Specific logging: just the reset state. + """ + if (unit_id in [1, 2]): + new_reset_state = reset_state_1kV + if self.unlatched_signals[1] != new_reset_state: + if self.unlatched_signals[1] == 0 and new_reset_state == 1: + if (unit_id == 1): + self.log(f"Knob Box: +1kV entered Overcurrent Reset Mode", LogLevel.ERROR) + else: + self.log(f"Knob Box: -1kV entered Overcurrent Reset Mode", LogLevel.ERROR) + else: + if (unit_id == 1): + self.log(f"Knob Box: +1kV exited Overcurrent Reset Mode", LogLevel.INFO) + else: + self.log(f"Knob Box: -1kV exited Overcurrent Reset Mode", LogLevel.INFO) + + # Just update the 1kv reset state + self.unlatched_signals[1] = new_reset_state + + + """ + All units: check for HV enable switch state edge and log. + """ + if self.switch_states[unit_id-1] != hv_enable: + unit = "+1kV" if unit_id == 1 else ("-1kV" if unit_id == 2 else ("20kV" if unit_id == 3 else "3kV")) + if (self.switch_states[unit_id-1] == 0 and hv_enable == 1): + self.log(f"Knob Box: {unit} HV enable turned ON", LogLevel.INFO) + else: + self.log(f"Knob Box: {unit} HV enable turned OFF", LogLevel.INFO) + + # Update HV enable switch state + self.switch_states[unit_id-1] = 0 if hv_enable == 0 else 1 + + return + + except Exception as e: + last_exception = e + if attempt < self.MAX_ATTEMPTS: + time.sleep(0.05) + else: + self.log(f"Knob Box: [unit {unit_id}] all {self.MAX_ATTEMPTS} read attempts failed", LogLevel.INFO) + + # All retries exhausted, raise the last exception + with self.poll_schedule_lock: + backoff = self._unit_poll_backoff_sec.get(unit_id, 0.0) + if backoff <= 0.0: + backoff = self._unit_poll_backoff_base_sec + else: + backoff = min(backoff * 2, self._unit_poll_backoff_max_sec) + self._unit_poll_backoff_sec[unit_id] = backoff + self._next_unit_poll_time[unit_id] = time.time() + backoff + raise last_exception + + def get_data_snapshot(self): + """ + Get a snapshot of the current data for all power supplies. + + Returns: + dict: A copy of the current data dictionary. + """ + self.flush_queued_logs() + with self.data_lock: + return {uid: values.copy() for uid, values in self.data.items()} + + def close(self): + """Compatibility alias used by subsystem shutdown/reconnect paths.""" + self.disconnect() + + def get_unit_connection_status(self, uid): + self.flush_queued_logs() + now = time.time() + with self.data_lock: + last_ok = self.last_success.get(uid, 0) + return (now - last_ok) < self.CONNECTION_TIMEOUT + + def any_unit_connected(self): + self.flush_queued_logs() + now = time.time() + with self.data_lock: + return any( + (now - self.last_success.get(uid, 0)) < self.CONNECTION_TIMEOUT + for uid in self.UNIT_IDS + ) + + def check_connection(self): + """Check if the Modbus client is connected and attempt to reconnect if not.""" + self.flush_queued_logs() + try: + if not self.connected: + self.log("Modbus client not connected. Attempting to reconnect...", LogLevel.WARNING) + self.connect() + return self.connected + except Exception as e: + self.log(f"Error checking connection: {str(e)}", LogLevel.ERROR) + self.connected = False + try: + self.client.close() + except Exception: + pass + self.connected = self.connect() + return self.connected + + def flush_queued_logs(self): + """Flush queued background-thread logs from the main thread only.""" + if threading.get_ident() != self._main_thread_id: + return + while True: + try: + queued_message, queued_level = self._background_log_queue.get_nowait() + except queue.Empty: + break + if self.logger: + self.logger.log(queued_message, queued_level) + else: + print(f"{queued_level.name}: {queued_message}") + + def log(self, message, level=LogLevel.INFO): + if threading.get_ident() == self._main_thread_id: + self.flush_queued_logs() + if self.logger: + self.logger.log(message, level) + else: + print(f"{level.name}: {message}") + return + + # Background thread: enqueue log for main-thread flush to avoid unsafe UI logger access. + self._background_log_queue.put((message, level)) diff --git a/instrumentctl/knob_box/pymodbus_tester.py b/instrumentctl/knob_box/pymodbus_tester.py new file mode 100644 index 00000000..944d6183 --- /dev/null +++ b/instrumentctl/knob_box/pymodbus_tester.py @@ -0,0 +1,70 @@ +from pymodbus.client import ModbusSerialClient +import time +import logging +logging.basicConfig() +logging.getLogger("pymodbus").setLevel(logging.DEBUG) +logging.getLogger("pymodbus.transaction").setLevel(logging.DEBUG) + +""" +This is a simple test script to read input registers from a Modbus slave device using pymodbus. +It is used to test the success rate of register reads while varying BAUD, TIMEOUT, and PACKET SIZE. +""" + +PORT = "COM13" # Change if needed +BAUDRATE = 9600 +SLAVE_IDS = [1,2,3,4] +TIMEOUT = 0.3 +REGISTER_COUNT = 6 + +client = ModbusSerialClient( + port=PORT, + baudrate=BAUDRATE, + bytesize=8, + parity='N', + stopbits=1, + timeout=TIMEOUT +) + +if not client.connect(): + print("Failed to connect to Modbus device") + exit(1) + +print("Connected") + +try: + slave_id = SLAVE_IDS[0] + print(f"\n--- Polling Slave {slave_id} ---") + + input_regs_ok = 0 + total_reads = 10 + + for i in range(total_reads): + # ---- Read Input Registers ---- + rr = client.read_input_registers( + address=0, + count=REGISTER_COUNT, + slave=slave_id + ) + + if rr is None: + print("No response") + elif rr.isError(): + print("Error response:", rr) + else: + input_regs_ok += 1 + raw = rr.encode() # bytes + print("Raw bytes:", raw.hex()) + + time.sleep(0.1) + + input_regs_pct = (input_regs_ok / total_reads) * 100.0 + + print("\n--- Read Success Summary ---") + print(f"Input registers: {input_regs_ok}/{total_reads} ({input_regs_pct:.1f}%)") + +except KeyboardInterrupt: + print("\nStopped by user") + +finally: + client.close() + print("Connection closed") diff --git a/main.py b/main.py index 602d0fd2..893a0e43 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ 'TempControllers', 'Interlocks', 'ProcessMonitors', + 'KnobBox', 'BeamPulse', ] diff --git a/subsystem/__init__.py b/subsystem/__init__.py index a505a958..e1cb470f 100644 --- a/subsystem/__init__.py +++ b/subsystem/__init__.py @@ -7,6 +7,7 @@ from .beam_extraction.beam_extraction import BeamExtractionSubsystem from .beam_pulse.beam_pulse import BeamPulseSubsystem from .deflection_monitor.deflection_monitor import DeflectionMonitorSubsystem +from .beam_energy.beam_energy import BeamEnergySubsystem __all__ = [ 'VTRXSubsystem', @@ -17,5 +18,6 @@ 'VisualizationGasControlSubsystem', 'BeamExtractionSubsystem', 'BeamPulseSubsystem', - 'DeflectionMonitorSubsystem' + 'DeflectionMonitorSubsystem', + 'BeamEnergySubsystem' ] \ No newline at end of file diff --git a/subsystem/beam_energy/README.md b/subsystem/beam_energy/README.md new file mode 100644 index 00000000..f80c675e --- /dev/null +++ b/subsystem/beam_energy/README.md @@ -0,0 +1,177 @@ +# Beam Energy Subsystem + +## Purpose + +`beam_energy.py` implements the dashboard subsystem used to monitor and supervise the beam energy power supplies. + +The subsystem sits between the Tkinter GUI and the Knob Box hardware controller. It is responsible for: + +- Building the beam-energy UI. +- Managing communication with the Knob Box via Modbus (RS-485). +- Displaying live voltage, current, and output states for each supply. +- Reflecting interlock, arming, and system status conditions. +- Handling connection monitoring and automatic reconnection. + +## Hardware Relationships + +The subsystem interfaces with the **Knob Box**, which acts as the hardware control and monitoring layer for multiple high-voltage supplies. + +### Power Supplies Monitored + +- +1 kV Matsusada +- –1 kV Matsusada +- +3 kV Bertan +- +20 kV Bertan +- +80 kV Glassman (interlock only, not directly controlled) + +The Knob Box: + +- Provides **voltage/current telemetry** for each supply. +- Handles **fast interlocks and protection logic** (e.g., overcurrent shutdowns). +- Exposes status via **Modbus registers** to the dashboard. +- Can **force shutdowns** (e.g., 3 kV forced-off condition). +- Monitors system-level faults and can trigger beam shutdown within ~1 ms per spec. + +## High-Level Behavior + +At runtime the subsystem has three main jobs: + +1. Build and maintain the GUI for all beam energy supplies. +2. Maintain a live connection to the Knob Box controller. +3. Continuously poll and update system state for display. + +## UI Structure + +The UI consists of: + +### Power Supply Panels (4 total) + +Each supply has a vertical panel showing: + +- Communication status indicator. +- Output status (ENABLED / DISABLED). +- Set voltage (from Knob Box). +- Measured voltage. +- Measured current. + +Additional indicators: + +- Matsusada supplies: + - Overcurrent/reset indicator. +- 3 kV Bertan: + - Forced-off indicator. + +### System Status Panel + +Displays global system state: + +- Arm Beams (Armed / Unarmed) +- CCS Power (On / Off) +- 80 kV Interlock (Armed / Unarmed) +- Logic Communications (Connected / Disconnected) +- Interlocks (Fault / OK) + +## Knob Box Communication + +### Initialization + +`initialize_knob_box_modbus()`: + +- Creates a `KnobBoxModbus` instance using the configured COM port. +- Establishes RS-485 communication. +- Starts a background polling thread on success. + +### Polling + +A background thread (`polling_loop`) runs every ~200 ms: + +- Calls `poll_all()` on the Knob Box controller. +- Updates internal data buffers. +- Detects communication failures. + +### Reconnection Strategy + +- Automatic reconnect attempts are scheduled on failure. +- Backoff timing is respected via controller state. +- Reconnect runs in a background thread to avoid UI blocking. + +## Data Flow + +### Source of Truth + +All live data comes from the Knob Box: + +- Voltages +- Currents +- Output states +- Interlock and system flags + +### Update Cycle + +`update_readings()` runs every 500 ms: + +- Processes reconnect requests. +- Checks controller health. +- Updates all GUI elements: + - Voltage/current displays + - Output states + - Indicator colors +- Falls back to default (“--”) values if disconnected. + +## Status Indicators + +Color conventions: + +- **Blue** → Communication active +- **Red** → Fault / disconnected / disabled +- **Green** → Enabled / healthy state +- **Yellow** → Warning (e.g., overcurrent reset) +- **White** → Neutral / inactive + +## Protection & Interlocks + +Protection logic is primarily enforced in hardware (Knob Box), not in this subsystem. + +The subsystem reflects: + +- Overcurrent conditions (Matsusada reset flags) +- Forced shutdowns (3 kV supply) +- Global interlock status +- Beam arm state + +Per system design: + +- The Knob Box can shut down beam-related supplies within ~1 ms on fault. +- The dashboard is responsible for **visibility**, not first-line protection. + +## Key Methods To Read First + +- `__init__()` +- `setup_ui()` +- `initialize_knob_box_modbus()` +- `polling_loop()` +- `update_readings()` +- `update_output_status()` +- `update_connection_status()` +- `update_indicators_panel()` + +## Relationship To Knob Box + +`beam_energy.py` is the **monitoring and visualization layer**. + +It decides: + +- How system state is displayed. +- How connection health is handled. +- When to attempt reconnection. + +The Knob Box: + +- Performs **real-time control and protection**. +- Interfaces directly with high-voltage supplies. +- Enforces interlocks and safety-critical behavior. + +In short: + +- `beam_energy.py` shows what is happening. +- The Knob Box decides what is allowed to happen. \ No newline at end of file diff --git a/subsystem/beam_energy/beam_energy.py b/subsystem/beam_energy/beam_energy.py new file mode 100644 index 00000000..f86f9e25 --- /dev/null +++ b/subsystem/beam_energy/beam_energy.py @@ -0,0 +1,641 @@ +import tkinter as tk +from tkinter import ttk +import threading +import time +from instrumentctl.knob_box.knob_box_modbus import KnobBoxModbus +from utils import LogLevel +import tkinter.messagebox as messagebox + + + +class BeamEnergySubsystem: + """ + Manages the beam energy system with four main power supplies: + - +80kV Glassman (interlock only) + - +1kV Matsusada + - -1kV Matsusada + - +3kV Bertran + - +20kV Bertran + """ + + displayFont = "Arial" + + def __init__(self, parent_frame, com_ports, logger=None): + """ + Initialize the Beam Energy subsystem interface. + + Args: + parent_frame: The tkinter frame where this subsystem will be displayed + logger: Logger instance for system messages + """ + self.parent_frame = parent_frame + self.com_ports = com_ports + self.logger = logger + + self.knob_box_controller = None + self.knob_box_connected = False + self.knob_box_connected_at = None + + # Main power supply configurations + self.power_supplies = [ + {"name": "+1kV Matsusada PS", "type": "matsusada", "voltage": 1000}, + {"name": "-1kV Matsusada PS", "type": "matsusada", "voltage": -1000}, + {"name": "+20kV Bertran PS", "type": "bertran", "voltage": 20000}, + {"name": "+3kV Bertran PS", "type": "bertran", "voltage": 3000}, + ] + + # Global data storing each power supply's latest readings + self.set_voltages = [tk.StringVar(value="-- V") for _ in range(len(self.power_supplies))] + self.actual_voltages = [tk.StringVar(value="-- V") for _ in range(len(self.power_supplies))] + self.actual_currents = [tk.StringVar(value="-- mA") for _ in range(len(self.power_supplies))] + self.output_status = [tk.StringVar(value="DISABLED") for _ in range(len(self.power_supplies))] + self.connection_status_colors = [tk.StringVar(value="red") for _ in range(len(self.power_supplies) )] + self.reset_status_colors = [tk.StringVar(value="white") for _ in range(2)] + self.forced_off_color = tk.StringVar(value="white") # Only for 3kV Bertran + + # Indicator Panel -> not power supply specific + self.glassman_interlock_var = tk.StringVar(value="UNARMED") + self.arm_beams_var = tk.StringVar(value="UNARMED") + self.ccs_power_var = tk.StringVar(value="OFF") + self.logic_comms_color = tk.StringVar(value="red") # red=Disconnected, blue=Connected + self.interlocks_color = tk.StringVar(value="red") # red=Fault, green=All Good + + self.overcurrent_flags = [False for _ in self.power_supplies] + + self.ui_elements = [] # To hold references to UI elements for updates + + self.data_lock = threading.Lock() + self.stop_polling = threading.Event() + self.poll_thread = None + self.reconnect_in_progress = threading.Event() + self.reconnect_requested = threading.Event() + + self.power_supply_instances = [] # List of KnobBoxPowerSupply instances + self.setup_ui() + # self.initialize_power_supplies() + self.initialize_knob_box_modbus() + self.update_readings() + + def setup_ui(self): + """Create the user interface with four vertical boxes for power supplies.""" + # Main container frame + main_frame = ttk.Frame(self.parent_frame, padding="2") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Initialize ui_elements list, one for each power supply + self.ui_elements = [None] * len(self.power_supplies) + + # Power supplies container frame + ps_container = ttk.Frame(main_frame) + ps_container.pack(fill=tk.BOTH, expand=True) + + # Create four vertical boxes arranged horizontally + self.ps_frames = [] + + for i, ps_config in enumerate(self.power_supplies): # Exclude Glassman + # Individual power supply frame + ps_frame = ttk.LabelFrame( + ps_container, + text=ps_config["name"], + padding="5", + labelanchor="n" # Center the title at the top + ) + ps_frame.grid(row=0, column=i, sticky="nsew", padx=3, pady=3) + + # Configure grid weights for responsive layout + ps_container.grid_columnconfigure(i, weight=1) + + self.ps_frames.append(ps_frame) + self.create_power_supply_displays(ps_frame, ps_config, i) + + # Configure main grid + ps_container.grid_rowconfigure(0, weight=1) + + # Right panel for status indicators + right_panel = ttk.Frame(ps_container) + right_panel.grid(row=0, column=len(self.ps_frames)+1, sticky="ns", padx=(10,0)) + self.create_indicators(right_panel) + + def create_indicator_circle(self, parent, color="gray"): + """Helper function, used to create indicators for system status panel.""" + canvas = tk.Canvas(parent, width=16, height=16, highlightthickness=0) + oval = canvas.create_oval(2, 2, 14, 14, fill=color, outline="") + return canvas, oval + + def create_indicators(self, parent_frame): + """ + Create a vertical list of indicators on the right side of power supply displays: + Arms Beams Status (Armed/Unarmed) + CCS Power Status (On/Off) + +80kV Interlock Status (Active/Bypassed) + Logic Comms (Connected/Disconnected) + Interlocks: All Good/Fault + """ + panel = ttk.LabelFrame(parent_frame, text="System Status", padding=5) + panel.pack(fill=tk.Y, anchor=tk.N) + + def add_row(label_text, var=None, color_var=None): + row = ttk.Frame(panel) + row.pack(fill=tk.X, pady=2) + + ttk.Label(row, text=label_text, font=("Segoe UI", 9)).pack(side=tk.LEFT) + + if var: + ttk.Label(row, textvariable=var, font=("Segoe UI", 9, "bold")).pack(side=tk.RIGHT) + + if color_var: + canvas, oval = self.create_indicator_circle(row) + canvas.pack(side=tk.RIGHT, padx=4) + + def update_circle(*args): + canvas.itemconfig(oval, fill=color_var.get()) + + color_var.trace_add("write", update_circle) + + # Initialize with current value + canvas.itemconfig(oval, fill=color_var.get()) + + add_row("Arm Beams:", self.arm_beams_var) + add_row("CCS Power:", self.ccs_power_var) + add_row("Arm 80kV:", self.glassman_interlock_var) + add_row("Logic Comms:", color_var=self.logic_comms_color) + add_row("Interlocks:", color_var=self.interlocks_color) + + def create_power_supply_displays(self, frame, ps_config, index): + """ + Create read-only displays for individual power supply. + + Args: + frame: Frame to contain the displays + ps_config: Power supply configuration dict + index: Index of the power supply, 1 through 4 + """ + # Connection status indicator (at top left) + top_row_frame = ttk.Frame(frame) + top_row_frame.pack(fill=tk.X, pady=(0, 5)) + connection_label = ttk.Label(top_row_frame, text="Comms:", font=("Segoe UI", 8)) + connection_label.pack(side=tk.LEFT) + connection_canvas, connection_oval = self.create_indicator_circle( + top_row_frame, color=self.connection_status_colors[index].get() + ) + connection_canvas.pack(side=tk.LEFT, padx=4) + + def update_connection_circle(*args): + connection_canvas.itemconfig(connection_oval, fill=self.connection_status_colors[index].get()) + + self.connection_status_colors[index].trace_add("write", update_connection_circle) + connection_canvas.itemconfig(connection_oval, fill=self.connection_status_colors[index].get()) + + # Matsusada reset status indicator (at top right) + if index < 2: + reset_canvas, reset_oval = self.create_indicator_circle( + top_row_frame, color=self.reset_status_colors[index].get() + ) + reset_canvas.pack(side=tk.RIGHT, padx=4) + reset_label = ttk.Label(top_row_frame, text="Overcurrent:", font=("Segoe UI", 8)) + reset_label.pack(side=tk.RIGHT) + + def update_reset_circle(*args): + reset_canvas.itemconfig(reset_oval, fill=self.reset_status_colors[index].get()) + + self.reset_status_colors[index].trace_add("write", update_reset_circle) + reset_canvas.itemconfig(reset_oval, fill=self.reset_status_colors[index].get()) + + # 3kV Bertan "Forced Off" indicator (at top right) + if index == 3: + forced_off_canvas, forced_off_oval = self.create_indicator_circle( + top_row_frame, color=self.forced_off_color.get() + ) + forced_off_canvas.pack(side=tk.RIGHT, padx=4) + forced_off_label = ttk.Label(top_row_frame, text="Forced Off:", font=("Segoe UI", 8)) + forced_off_label.pack(side=tk.RIGHT) + + def update_forced_off_circle(*args): + forced_off_canvas.itemconfig(forced_off_oval, fill=self.forced_off_color.get()) + + self.forced_off_color.trace_add("write", update_forced_off_circle) + forced_off_canvas.itemconfig(forced_off_oval, fill=self.forced_off_color.get()) + + # Output status indicator + status_frame = ttk.Frame(frame) + status_frame.pack(fill=tk.X, pady=(0, 8)) + + # Create a centered layout with consistent spacing + output_label = ttk.Label(status_frame, text="Output:", font=("Segoe UI", 8)) + output_label.pack(anchor=tk.CENTER) + + status_label = ttk.Label( + status_frame, + textvariable=self.output_status[index], + foreground="red", + font=(self.displayFont, 9, "bold"), + background="white", + relief="sunken", + width=15, + anchor=tk.CENTER + ) + status_label.pack(anchor=tk.CENTER, pady=(2, 0)) + + # Set voltage display + setpoint_frame = ttk.Frame(frame) + setpoint_frame.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(setpoint_frame, text="Set Voltage:", font=("Segoe UI", 8)).pack(anchor=tk.W) + setpoint_display = ttk.Label( + setpoint_frame, + textvariable=self.set_voltages[index], + font=(self.displayFont, 12, "bold"), + background="lightgray", + relief="sunken", + width=10, + anchor=tk.CENTER + ) + setpoint_display.pack(fill=tk.X, pady=(1, 0)) + + # Actual voltage display + voltage_frame = ttk.Frame(frame) + voltage_frame.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(voltage_frame, text="Actual Voltage:", font=("Segoe UI", 8)).pack(anchor=tk.W) + voltage_display = ttk.Label( + voltage_frame, + textvariable=self.actual_voltages[index], + font=(self.displayFont, 12, "bold"), + background="white", + relief="sunken", + width=10, + anchor=tk.CENTER + ) + voltage_display.pack(fill=tk.X, pady=(1, 0)) + + # Actual current display + current_frame = ttk.Frame(frame) + current_frame.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(current_frame, text="Actual Current:", font=("Segoe UI", 8)).pack(anchor=tk.W) + current_display = ttk.Label( + current_frame, + textvariable=self.actual_currents[index], + font=(self.displayFont, 12, "bold"), + background="white", + relief="sunken", + width=10, + anchor=tk.CENTER + ) + current_display.pack(fill=tk.X, pady=(1, 0)) + + # Store references for later use + if not hasattr(self, 'ui_elements'): + self.ui_elements = [] + + self.ui_elements[index] = { + 'connection_label': connection_label, # label and display variables used for updating colors + 'status_label': status_label, + 'setpoint_display': setpoint_display, + 'voltage_display': voltage_display, + 'current_display': current_display + } + + def initialize_knob_box_modbus(self): + """ + Initialize the hardware communication with KnobBox power supplies using Modbus protocol. + Starts polling thread for data collection. + Returns True if successful, False otherwise. + """ + port = self.com_ports.get('KnobBox', None) + if not port: + return False + + controller = self.knob_box_controller + if controller and getattr(controller, "port", None) != port: + controller.disconnect() + time.sleep(.2) + controller = None + + if controller is None: + controller = KnobBoxModbus(port=port, logger=self.logger) + self.knob_box_controller = controller + + if time.time() < getattr(controller, "_next_connect_time", 0.0): + return False + + try: + self.log(f"Attempting to connect to KnobBox Modbus controller on port {port}...", LogLevel.DEBUG) + if controller.connect(): # Initializes connection with RS-485 in KnobBoxModbus class + self.log(f"KnobBox Modbus controller CONNECTED on port {port}", LogLevel.DEBUG) + self.knob_box_connected = True + self.knob_box_connected_at = time.time() + self.start_polling_thread() # Start background thread to poll data + return True + else: + self.log(f"Failed to connect to KnobBox Modbus controller on port {port}", LogLevel.ERROR) + self.knob_box_connected = False + self.knob_box_connected_at = None + return False + except Exception as e: + self.log(f"Exception thrown when trying to connect to KnobBox on port {port}: {str(e)}", LogLevel.ERROR) + self.knob_box_connected = False + self.knob_box_connected_at = None + return False + + def attempt_knob_box_reconnect(self): + """Attempt to reconnect to the KnobBox Modbus controller.""" + if self.knob_box_controller: + self.knob_box_controller.disconnect() + time.sleep(.2) # Brief pause before reconnecting + return self.initialize_knob_box_modbus() + + def update_output_status(self, index, status): + """Update output status indicators.""" + if index < len(self.ui_elements): + if status: + self.output_status[index].set("ENABLED") + self.ui_elements[index]['status_label'].config(foreground="green") + else: + self.output_status[index].set("DISABLED") + self.ui_elements[index]['status_label'].config(foreground="red") + + def update_reset_status(self, index, reset_state): + if index < 2: # Only Matsusada units have reset status + if reset_state: + self.reset_status_colors[index].set("yellow") + else: + self.reset_status_colors[index].set("white") + + def update_forced_off_status(self, index, timer_state_3k): + if index == 3: # Only 3kV Bertran has forced off status + if timer_state_3k: + self.forced_off_color.set("red") + else: + self.forced_off_color.set("white") + + def update_connection_status(self, index, connected): + """Update connection status indicators.""" + if index < len(self.ui_elements): + if connected: + self.connection_status_colors[index].set("blue") + else: + self.connection_status_colors[index].set("red") + + def update_indicators_panel(self, index, arm_beams, ccs_power, arm_80kv, logic_comms, interlocks): + """Update system status indicators.""" + if index < len(self.ui_elements): + self.arm_beams_var.set("ARMED" if arm_beams else "UNARMED") + self.ccs_power_var.set("ON" if ccs_power else "OFF") + self.glassman_interlock_var.set("ARMED" if arm_80kv else "UNARMED") + self.logic_comms_color.set("blue" if logic_comms else "red") + self.interlocks_color.set("red" if interlocks else "green") + + def start_polling_thread(self): + """Start a background thread to poll power supply data periodically.""" + if self.poll_thread and self.poll_thread.is_alive(): + return # Polling thread already running + + self.stop_polling.clear() + self.poll_thread = threading.Thread(target=self.polling_loop, daemon=True) + self.poll_thread.start() + + def polling_loop(self): + """Background thread function to poll power supply data.""" + while not self.stop_polling.is_set(): + try: + if self.knob_box_connected and self.knob_box_controller: + self.knob_box_controller.poll_all() + elif not self.reconnect_in_progress.is_set(): + self._schedule_reconnect() + except Exception: + # Disconnect and schedule a reconnect if any polling error. + self.knob_box_connected = False + self.knob_box_connected_at = None + if not self.reconnect_in_progress.is_set(): + self._schedule_reconnect() + time.sleep(.2) # Polling interval + + def _safe_reconnect(self): + """Run reconnect in a background thread to keep the UI responsive.""" + def _worker(): + try: + self.attempt_knob_box_reconnect() + finally: + self.reconnect_in_progress.clear() + + threading.Thread(target=_worker, daemon=True).start() + + def _get_reconnect_wait_time(self): + """Return remaining seconds until the next reconnect is allowed by controller backoff.""" + controller = self.knob_box_controller + if not controller: + return 0.0 + + next_connect_time = getattr(controller, "_next_connect_time", 0.0) or 0.0 + return max(0.0, next_connect_time - time.time()) + + def _process_reconnect_request(self): + """ + Main-thread reconnect dispatcher; safe place to start reconnect workers. + """ + if not self.reconnect_requested.is_set(): + return False + + wait_time = self._get_reconnect_wait_time() + if wait_time > 0.0: + return False + + with self.data_lock: + if self.reconnect_in_progress.is_set(): + return False + self.reconnect_requested.clear() + self.reconnect_in_progress.set() + + self._safe_reconnect() + return True + + def _schedule_reconnect(self): + """Thread-safe reconnect request; actual dispatch runs on the Tk main loop.""" + if self.reconnect_in_progress.is_set(): + return False + self.reconnect_requested.set() + return True + + def update_readings(self): + """ + Update voltage and current readings from hardware. + This method should be called periodically to refresh displays. + """ + # Drain reconnect requests on the Tk main thread. + self._process_reconnect_request() + + # Update Knob Box data + try: + if self.knob_box_connected and self.knob_box_controller: + knob_box = self.knob_box_controller + any_connected = knob_box.any_unit_connected() + if not any_connected: + # Allow a short grace period after connect before forcing reconnect. + if self.knob_box_connected_at and (time.time() - self.knob_box_connected_at) < knob_box.CONNECTION_TIMEOUT: + for index, _ in enumerate(self.power_supplies): + self.set_default_values(index) + self.after_id = self.parent_frame.after(500, self.update_readings) + return + + self.knob_box_connected = False + self.knob_box_connected_at = None + for index, _ in enumerate(self.power_supplies): + self.set_default_values(index) + self._schedule_reconnect() + self._process_reconnect_request() + # Schedule next update and exit early + self.log( + "KnobBox controller unresponsive, using default values.", + LogLevel.DEBUG + ) + self.after_id = self.parent_frame.after(500, self.update_readings) + return + else: + # KnobBox not connected, set all to default + for index, _ in enumerate(self.power_supplies): + self.set_default_values(index) + self._schedule_reconnect() + self._process_reconnect_request() + # Schedule next update and exit early + self.log( + f"KnobBox controller not connected, using default values.", + LogLevel.DEBUG + ) + self.after_id = self.parent_frame.after(500, self.update_readings) + return + + # Pull data snapshot from KnobBox controller + data_snapshot = knob_box.get_data_snapshot() + for index, _ in enumerate(self.power_supplies): + + # Unit IDs start at one. We may want to create a mapping later when we have the final values + unit_id = index + 1 + comms = knob_box.get_unit_connection_status(unit_id) + if not comms: + self.set_default_values(index) + continue + + data = data_snapshot.get(unit_id, None) + + if not data: + self.set_default_values(index) + continue + + v_set = data.get('set_voltage_V', None) + v_read = data.get('actual_voltage_V', None) + i_read = data.get('actual_current_mA', None) + hv_enable = data.get('hv_enable', False) + arm_beams = data.get('arm_beams', False) + ccs_power = data.get('ccs_power', False) + arm_80kV = data.get('arm_80kV', False) + reset_state = data.get('reset_state_1kV', False) + nomop_flag = data.get('nomop_flag', False) + logic_alive = data.get('logic_alive', False) + reset_counter_3kv = data.get('3kv_reset_count', 0) + # TODO rest of flags for interlocks? + + # self.update_connection_status(index, True) + + # Update display values if data is valid + if v_set is not None: + if unit_id == 2: # insert minus sign for -1kV Matsusada + self.set_voltages[index].set(f"-{v_set:.1f} V") + else: + self.set_voltages[index].set(f"{v_set:.1f} V") + else: + self.set_voltages[index].set("-- V") + + if v_read is not None: + if unit_id == 2: # insert minus sign for -1kV Matsusada + self.actual_voltages[index].set(f"-{v_read:.1f} V") + else: + self.actual_voltages[index].set(f"{v_read:.1f} V") + else: + self.actual_voltages[index].set("-- V") + + if i_read is not None: + self.actual_currents[index].set(f"{i_read:.3f} mA") + else: + self.actual_currents[index].set("-- mA") + + # Update indicators based on dataFafter + interlocks = not nomop_flag # 1 for Nom Op, 0 for interlocks active + self.update_indicators_panel(index, arm_beams, ccs_power, arm_80kV, logic_alive, interlocks) + self.update_output_status(index, hv_enable) + self.update_reset_status(index, reset_state) + self.update_connection_status(index, comms) + self.update_forced_off_status(index, reset_counter_3kv > 0) + + except Exception as e: + self.log(f"Error updating readings: {str(e)}", LogLevel.ERROR) + for index, _ in enumerate(self.power_supplies): + self.set_default_values(index) + self._schedule_reconnect() + self._process_reconnect_request() + + + # Schedule next update after 500 ms + self.after_id = self.parent_frame.after(500, self.update_readings) + + def cancel_updates(self): + """Cancel scheduled updates when closing the application.""" + if hasattr(self, 'after_id'): + self.parent_frame.after_cancel(self.after_id) + + def set_default_values(self, index): + """Set display values to default '--'.""" + self.set_voltages[index].set("-- V") + self.actual_voltages[index].set("-- V") + self.actual_currents[index].set("-- A") + self.update_connection_status(index, False) + self.update_output_status(index, False) + self.update_reset_status(index, False) + self.update_indicators_panel(index, arm_beams=False, ccs_power=False, arm_80kv=False, logic_comms=False, interlocks=True) + + def update_com_port(self, new_com_ports): + """Update COM port assignments and reinitialize power supplies.""" + new_port = new_com_ports.get('KnobBox', None) + if not new_port: + return False + + if new_port == self.com_ports.get('KnobBox', None): + return True # No change + + self.com_ports = new_com_ports + + # Close existing connections + self.close_com_ports() + + # Reinitialize with new ports + self.initialize_knob_box_modbus() + + def close_com_ports(self): + # Close any open COM port connections + if self.knob_box_controller: + self.knob_box_controller.disconnect() + self.knob_box_controller = None + self.knob_box_connected = False + self.knob_box_connected_at = None + + # Stop polling thread + self._stop_polling_thread() + + def _stop_polling_thread(self): + """Stop and join the polling thread if it is running.""" + self.stop_polling.set() + if self.poll_thread and self.poll_thread.is_alive(): + self.poll_thread.join(timeout=2) + self.poll_thread = None + + def close(self): + """Cancel Dashboard updates and close COM ports.""" + self.cancel_updates() + self.close_com_ports() + + def log(self, message, level=LogLevel.INFO): + """Log a message with the specified level if a logger is configured.""" + if self.logger: + self.logger.log(message, level) + else: + print(f"{level.name}: {message}") \ No newline at end of file diff --git a/subsystem/beam_pulse/beam_pulse.py b/subsystem/beam_pulse/beam_pulse.py index 9017baf0..478dcb6e 100644 --- a/subsystem/beam_pulse/beam_pulse.py +++ b/subsystem/beam_pulse/beam_pulse.py @@ -175,11 +175,51 @@ def _channel_name(self, ch: int) -> str: def setup_ui(self): """Create the user interface with tabbed layout.""" + scroll_outer = ttk.Frame(self.parent_frame) + scroll_outer.pack(fill=tk.BOTH, expand=True) + + canvas = tk.Canvas(scroll_outer, highlightthickness=0) + vsb = ttk.Scrollbar(scroll_outer, orient=tk.VERTICAL, command=canvas.yview) + canvas.configure(yscrollcommand=vsb.set) + + ui_root = ttk.Frame(canvas) + win_id = canvas.create_window((0, 0), window=ui_root, anchor="nw") + + def _on_inner_configure(_event=None): + canvas.configure(scrollregion=canvas.bbox("all")) + + def _on_canvas_configure(event): + try: + canvas.itemconfig(win_id, width=event.width) + except tk.TclError: + pass + + ui_root.bind("", _on_inner_configure) + canvas.bind("", _on_canvas_configure) + + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + vsb.pack(side=tk.RIGHT, fill=tk.Y) + + def _on_mousewheel(event): + if event.delta: + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + elif getattr(event, "num", None) == 4: + canvas.yview_scroll(-1, "units") + elif getattr(event, "num", None) == 5: + canvas.yview_scroll(1, "units") + + canvas.bind("", lambda _e: canvas.focus_set()) + canvas.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + + self._bp_ui_root = ui_root + # Top status bar (BCON connection + safety) self._build_status_bar() # Notebook with three tabs - self.notebook = ttk.Notebook(self.parent_frame) + self.notebook = ttk.Notebook(ui_root) self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Tab 1: Manual Separate Control @@ -235,7 +275,7 @@ def _on_host_destroy(self, event) -> None: def _build_status_bar(self): """Build the top status bar with connection, interlock, arm info.""" - bar = ttk.Frame(self.parent_frame) + bar = ttk.Frame(self._bp_ui_root) bar.pack(fill=tk.X, padx=5, pady=(5, 0)) # BCON connection indicator @@ -254,7 +294,7 @@ def _build_status_bar(self): self.connect_btn.pack(side=tk.RIGHT, padx=4) # System settings row (watchdog / telemetry) - sys_frame = ttk.Frame(self.parent_frame) + sys_frame = ttk.Frame(self._bp_ui_root) 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) diff --git a/subsystem/oil_system/oil_system.py b/subsystem/oil_system/oil_system.py index a1d69693..5212637d 100644 --- a/subsystem/oil_system/oil_system.py +++ b/subsystem/oil_system/oil_system.py @@ -16,11 +16,11 @@ def __init__(self, parent, logger=None): def setup_gui(self): - """Creates and packs UI elements inside the given parent (horizontally).""" + """Creates and packs UI elements inside the given parent (stacked vertically).""" self.frame = tk.Frame(self.parent) self.frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) self.info_frame = tk.Frame(self.frame) - self.info_frame.pack(fill=tk.X, expand=True) + self.info_frame.pack(fill=tk.BOTH, expand=True, anchor="nw") self.create_sensor_frame("Temperature", f"{self.temperature:.1f}°C", "temp_label") self.create_sensor_frame("Pressure", f"{self.pressure:.1f} PSI", "pressure_label") @@ -30,9 +30,9 @@ def setup_gui(self): def create_sensor_frame(self, title, default_text, label_attr): - """Creates a horizontally aligned sensor frame.""" - frame = tk.Frame(self.info_frame, padx=20) - frame.pack(side=tk.LEFT, fill=tk.Y, expand=True) + """Creates one sensor row (title + value), stacked vertically.""" + frame = tk.Frame(self.info_frame) + frame.pack(side=tk.TOP, fill=tk.X, anchor="w", pady=(0, 6)) tk.Label(frame, text=title, font=("Helvetica", 10, "bold")).pack() label = tk.Label(frame, text=default_text, font=('Helvetica', 10), bg = "#d3d3d3", fg = "black", padx = 5, pady = 2)