from pyqtgraph import DateAxisItem
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QAction,
    QDialog,
    QLabel,
    QLineEdit,
    QPushButton,
    QHBoxLayout,
    QListWidget,
    QListWidgetItem,
    QMessageBox,
    QFormLayout,
    QAbstractItemView,
    QDial,
    QMenuBar,
    QGroupBox,
    QFileDialog,
    QTableWidget,
    QTableWidgetItem,
    QDesktopWidget,
    QComboBox,
    QDoubleSpinBox,  # Added for PID controls
)
import sys
import threading
import datetime
import time
import re
import serial
import serial.tools.list_ports  # Add this import statement
import numpy as np
import math
import json
from PyQt5 import QtWidgets, QtCore
import pyqtgraph as pg

# Import the official Lake Shore drivers
try:
    from lakeshore.model_224 import Model224
    from lakeshore.model_372 import Model372
except ImportError:
    # If the lakeshore package is not installed, define None
    Model224 = None
    Model372 = None

# ============================ Device Classes ============================= #

# ------ CTC100 Class ------ #


class CTC100:
    """
    Class to interact with the CTC100 Programmable Temperature Controller.
    """

    def __init__(self, address):
        """
        Initialize CTC100 device.

        :param address: Serial port address (e.g., 'COM3').
        """
        self.address = address
        self.device = serial.Serial(address, baudrate=115200, timeout=1)
        self.input_channels = []
        self.output_channels = []
        self.list_channels()  # Populate input and output channels

    def get_input_channels(self):
        return self.input_channels

    def get_output_channels(self):
        return self.output_channels

    def close(self):
        self.device.close()

    def write(self, command):
        self.device.write((command + "\n").encode())  # \n terminates commands
        t1 = time.time()
        response = b''
        while True:
            response += self.device.read(self.device.in_waiting or 1)
            if b'\r\n' in response:
                break
            t2 = time.time()
            if (t2 - t1) > 0.2:
                break
        return response

    def get_variable(self, var):
        var = var.replace(" ", "")
        return self.write("{}?".format(var))

    def set_variable(self, var, val):
        var = var.replace(" ", "")
        val = "({})".format(val)
        return self.write("{} = {}".format(var, val))

    def read(self, channel):
        if not isinstance(channel, str):
            channel = str(channel).strip()
        response = self.get_variable("{}.value".format(channel))

        # Extract the response using a regex
        match = re.search(
            r"[-+]?\d*\.\d+([eE][-+]?\d+)?", response.decode("utf-8")
        )

        if match is not None:
            return float(match.group())
        else:
            raise RuntimeError(f"Unable to read from channel {channel}")

    def read_all_channels(self):
        """
        Read temperature values from all specified channels.

        :return: Dictionary with channel names as keys and readings as values.
        """
        readings = {}
        for channel in self.input_channels:
            try:
                temp = self.read(channel)
                readings[channel] = temp
            except Exception as e:
                print(
                    f"Error reading channel {channel} on CTC100 ({self.address}): {e}"
                )
                readings[channel] = None
        return readings

    def enableHeater(self, channel):
        self.set_variable(f"{channel}.Heater.On", 'On')

    def setHeaterOutput(self, channel, output_value):
        """
        Set the heater output percentage (0-100%) for a specific output channel.
        """
        self.set_variable(f"{channel}.Heater.Range", output_value)

    def set_heater_input(self, out_channel, in_channel):
        self.set_variable(
            var=f'{out_channel}.PID.Input', val=f'"{in_channel}"')

    def set_setpoint(self, channel, value):
        """
        Set the temperature setpoint for a specific control channel.
        """
        self.set_variable(f"{channel}.setpoint", value)

    def set_pid_parameters(self, channel, p=None, i=None, d=None):
        """
        Set PID parameters for a specific control channel.

        :param channel: Channel name.
        :param p: Proportional gain.
        :param i: Integral gain.
        :param d: Derivative gain.
        """
        if p is not None:
            self.set_variable(f"{channel}.PID.P", p)
        if i is not None:
            self.set_variable(f"{channel}.PID.I", i)
        if d is not None:
            self.set_variable(f"{channel}.PID.D", d)

    def list_channels(self):
        """
        Query the device to list input and output channels.

        :return: None
        """
        # Query for input channel names
        try:
            response = self.write('getInput.names')
            decoded_response = response.decode().strip()
            decoded_response = decoded_response.replace('getInput.names=', '')
            input_channel_names = decoded_response.split(',')
            self.input_channels = [name.strip()
                                   for name in input_channel_names if name.strip()]

            # Query for output channel names
            response = self.write('getOutput.names')
            decoded_response = response.decode().strip()
            decoded_response = decoded_response.replace('getOutput.names=', '')
            output_channel_names = decoded_response.split(',')
            self.output_channels = [
                name.strip() for name in output_channel_names if name.strip()]

        except Exception as e:
            print(f"Error listing channels on CTC100 ({self.address}): {e}")
            # If any error occurs, default to predefined channels for compatibility
            self.input_channels = ['In1', 'In2', 'In3', 'In4']
            self.output_channels = ['Out1', 'Out2', 'Out3', 'Out4']
            # Alternatively, you can set them to empty lists or handle as needed


# ------ Simulated CTC100 Class ------ #
class SimulatedCTC100:
    """
    Simulated CTC100 device for testing without hardware.
    """

    def __init__(self, address):
        """
        Initialize simulated CTC100 device.

        :param address: Device identifier.
        """
        self.address = address  # Added to simulate address
        # Simulate some input and output channels
        self.input_channels = ['In1', 'In2', 'In3', 'In4']
        self.output_channels = ['Out1', 'Out2', 'Out3', 'Out4']
        self.output_to_input_link = {}
        self.temperatures = {channel: 300.0 for channel in self.input_channels}
        self.output_values = {channel: 0.0 for channel in self.output_channels}

        # Initialize PID parameters and setpoints
        self.setpoints = {channel: 300.0 for channel in self.input_channels}
        self.pid_parameters = {channel: {'P': 1.0, 'I': 0.1, 'D': 0.01}
                               for channel in self.input_channels}
        self.integral = {channel: 0.0 for channel in self.input_channels}
        self.previous_error = {channel: 0.0 for channel in self.input_channels}

        print(
            f"Simulated CTC100 initialized at {address} with input channels {self.input_channels} and output channels {self.output_channels}"
        )

    def get_input_channels(self):
        return self.input_channels

    def get_output_channels(self):
        return self.output_channels

    def read_all_channels(self):
        """
        Simulate reading temperature values from all channels.

        :return: Dictionary with channel names as keys and readings as values.
        """
        readings = {}
        for output_channel in self.output_channels:
            input_channel = self.output_to_input_link.get(output_channel)
            if input_channel:
                # Use the associated input channel for PID control
                setpoint = self.setpoints.get(input_channel, 300)
                current_temp = self.temperatures[input_channel]
                pid = self.pid_parameters.get(
                    input_channel, {'P': 1.0, 'I': 0.1, 'D': 0.01})

                # Simple PID simulation
                error = setpoint - current_temp
                self.integral[input_channel] += error
                derivative = error - self.previous_error.get(input_channel, 0)
                output_value = (pid['P'] * error +
                                pid['I'] * self.integral[input_channel] +
                                pid['D'] * derivative)

                # Update heater output value based on PID output
                self.output_values[output_channel] = output_value

                self.previous_error[input_channel] = error

            readings[input_channel] = self.temperatures[input_channel]
        return readings

    def read(self, channel):
        # Simulate temperature reading
        if channel in self.input_channels:
            temp = self.temperatures[channel]
            return temp
        else:
            raise RuntimeError(
                f"Channel {channel} not found in simulated device.")

    def enableHeater(self, channel):
        pass  # No action needed for simulation

    def setHeaterOutput(self, channel, output_value):
        self.output_values[channel] = float(output_value)

    def set_setpoint(self, channel, value):
        self.setpoints[channel] = value

    def set_pid_parameters(self, channel, p=None, i=None, d=None):
        pid = self.pid_parameters.setdefault(
            channel, {'P': 1.0, 'I': 0.1, 'D': 0.01})
        if p is not None:
            pid['P'] = p
        if i is not None:
            pid['I'] = i
        if d is not None:
            pid['D'] = d

    def list_channels(self):
        pass  # Already initialized in __init__ for simulated device
    
    def link_output_to_input(self, output_channel, input_channel):
        self.output_to_input_link[output_channel] = input_channel
    
    def set_heater_input(self, out_channel, in_channel):
        """
        Simulate linking a heater output channel to an input channel for PID control.
        """
        self.output_to_input_link[out_channel] = in_channel

    def set_pid_parameters(self, out_channel, p=None, i=None, d=None):
        """
        Simulate setting PID parameters for an output channel.
        """
        pid = self.pid_parameters.setdefault(
            out_channel, {'P': 1.0, 'I': 0.1, 'D': 0.01})
        if p is not None:
            pid['P'] = p
        if i is not None:
            pid['I'] = i
        if d is not None:
            pid['D'] = d

    def set_setpoint(self, out_channel, value):
        """
        Simulate setting the temperature setpoint for an output channel.
        """
        self.setpoints[out_channel] = value


# ------ LakeShore224Device Class ------ #
class LakeShore224Device:
    """
    Class to interact with the Lake Shore 224 Temperature Monitor using the official driver.
    """

    def __init__(self, port):
        try:
            if Model224 is not None:
                self.device = Model224(com_port=port)
            else:
                raise ImportError("Lake Shore Model224 driver not available.")
            self.port = port
            self.address = port  # Added to store the address
            self.input_channels = []
            self.list_channels()
            print(
                f"Connected to Lake Shore 224 on {port} with channels {self.input_channels}")
        except Exception as e:
            raise e

    def get_input_channels(self):
        return self.input_channels

    def get_output_channels(self):
        return []  # Lake Shore 224 has no output channels

    def get_temperature(self, channel):
        try:
            temp = self.device.get_kelvin_reading(channel)
            return temp
        except Exception as e:
            print(
                f"Error reading temperature from Lake Shore 224 (Channel {channel}): {e}"
            )
            return None

    def read_all_channels(self):
        readings = {}
        for channel in self.input_channels:
            temp = self.get_temperature(channel)
            readings[channel] = temp
        return readings

    def list_channels(self):
        """
        List available input channels on the Lake Shore 224.

        Channel names are 'A', 'B', 'C1' - 'C5', 'D1' - 'D5'.
        """
        self.input_channels = ['A', 'B'] + \
            [f'C{i}' for i in range(1, 6)] + [f'D{i}' for i in range(1, 6)]


# ------ Simulated LakeShore224Device ------ #
class SimulatedLakeShore224Device:
    """
    Simulated Lake Shore 224 device for testing without hardware.
    """

    def __init__(self, port):
        self.port = port
        self.address = port  # Added to simulate address
        # Simulate channels 'A', 'B', 'C1' - 'C5', 'D1' - 'D5'
        self.input_channels = ['A', 'B'] + \
            [f'C{i}' for i in range(1, 6)] + [f'D{i}' for i in range(1, 6)]
        self.temperatures = {channel: 77.0 for channel in self.input_channels}
        print(
            f"Simulated Lake Shore 224 initialized at {port} with channels {self.input_channels}"
        )

    def get_input_channels(self):
        return self.input_channels

    def get_output_channels(self):
        return []  # No outputs in simulated Lake Shore 224

    def get_temperature(self, channel):
        # Simulate temperature fluctuations
        fluctuation = np.random.normal(scale=0.05)
        self.temperatures[channel] += fluctuation
        return self.temperatures[channel]

    def read_all_channels(self):
        readings = {}
        for channel in self.input_channels:
            temp = self.get_temperature(channel)
            readings[channel] = temp
        return readings

    def list_channels(self):
        pass  # Already initialized in __init__ for simulated device


# ------ LakeShore372Device Class ------ #
class LakeShore372Device:
    """
    Class to interact with the Lake Shore 372 Temperature Controller using the official driver.
    """

    def __init__(self, port):
        try:
            if Model372 is not None:
                self.device = Model372(com_port=port)
            else:
                raise ImportError("Lake Shore Model372 driver not available.")
            self.port = port
            self.address = port  # Added to store the address
            self.input_channels = []
            self.output_channels = []
            self.list_channels()
            print(
                f"Connected to Lake Shore 372 on {port} with input channels {self.input_channels} and output channels {self.output_channels}")
        except Exception as e:
            raise e

    def get_input_channels(self):
        return self.input_channels

    def get_output_channels(self):
        return self.output_channels

    def get_temperature(self, channel):
        try:
            if channel == 'A':
                temp = self.device.get_kelvin_reading(channel)
            else:
                temp = self.device.get_kelvin_reading(int(channel))
            return temp
        except Exception as e:
            print(
                f"Error reading temperature from Lake Shore 372 (Channel {channel}): {e}"
            )
            return None

    def read_all_channels(self):
        readings = {}
        for channel in self.input_channels:
            temp = self.get_temperature(channel)
            readings[channel] = temp
        return readings

    def set_heater_output(self, heater_number=1, heat_percent=0.0):
        try:
            # Setting heater output percentage according to the official driver methods
            heater_number = int(heater_number)
            if heater_number == 1:
                self.device.set_heater_output_percentage(heat_percent)
            elif heater_number == 2:
                self.device.set_analog_heater_output(heat_percent)
            return True
        except Exception as e:
            print(f"Error setting heater output on Lake Shore 372: {e}")
            return False

    def set_setpoint(self, value):
        try:
            self.device.set_setpoint_kelvin(value)
        except Exception as e:
            print(f"Error setting setpoint on Lake Shore 372: {e}")

    def set_pid_parameters(self, p=None, i=None, d=None):
        try:
            current_p, current_i, current_d = self.device.get_pid()
            p = p if p is not None else current_p
            i = i if i is not None else current_i
            d = d if d is not None else current_d
            self.device.set_pid(p, i, d)
        except Exception as e:
            print(f"Error setting PID parameters on Lake Shore 372: {e}")

    def list_channels(self):
        """
        List available input and output channels on the Lake Shore 372.

        Input channels are '1' - '16', or 'A'.
        Output channels are '1' (Warm-up heater), '2' (Analog output).
        """
        self.input_channels = [str(i) for i in range(1, 17)] + ['A']
        self.output_channels = ['1', '2']


# ------ Simulated LakeShore372Device ------ #
class SimulatedLakeShore372Device:
    """
    Simulated Lake Shore 372 device for testing without hardware.
    """

    def __init__(self, port):
        self.port = port
        self.address = port  # Added to simulate address
        # Simulate input channels '1'-'16' and 'A'
        self.input_channels = [str(i) for i in range(1, 17)] + ['A']
        self.output_channels = ['1', '2']  # Output 1 and Output 2
        self.temperatures = {channel: 4.2 for channel in self.input_channels}
        self.output_values = {channel: 0.0 for channel in self.output_channels}

        # Initialize PID parameters and setpoints
        self.setpoint = 4.2
        self.pid_parameters = {'P': 1.0, 'I': 0.1, 'D': 0.01}
        self.integral = 0.0
        self.previous_error = 0.0

        print(
            f"Simulated Lake Shore 372 initialized at {port} with input channels {self.input_channels} and output channels {self.output_channels}"
        )

    def get_input_channels(self):
        return self.input_channels

    def get_output_channels(self):
        return self.output_channels

    def get_temperature(self, channel):
        # Simulate temperature fluctuations
        error = self.setpoint - self.temperatures[channel]
        self.integral += error
        derivative = error - self.previous_error
        output = (self.pid_parameters['P'] * error +
                  self.pid_parameters['I'] * self.integral +
                  self.pid_parameters['D'] * derivative)

        # Update temperature based on PID output
        heater_effect = sum(self.output_values.values()) / \
            max(len(self.output_values), 1)
        self.temperatures[channel] += (heater_effect * output * 0.01)

        # Apply some random fluctuation
        fluctuation = np.random.normal(scale=0.02)
        self.temperatures[channel] += fluctuation

        self.previous_error = error

        return self.temperatures[channel]

    def read_all_channels(self):
        readings = {}
        for channel in self.input_channels:
            temp = self.get_temperature(channel)
            readings[channel] = temp
        return readings

    def set_heater_output(self, heater_number='1', heat_percent=0.0):
        self.output_values[heater_number] = heat_percent
        return True

    def set_setpoint(self, value):
        self.setpoint = value

    def set_pid_parameters(self, p=None, i=None, d=None):
        if p is not None:
            self.pid_parameters['P'] = p
        if i is not None:
            self.pid_parameters['I'] = i
        if d is not None:
            self.pid_parameters['D'] = d

    def list_channels(self):
        pass  # Already initialized in __init__ for simulated device

# ============================ GUI Application ============================= #


class ClickablePlotWidget(pg.PlotWidget):
    clicked = pyqtSignal(object)  # Custom signal

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.clicked.emit(event)
        super().mousePressEvent(event)


class DeviceSetupDialog(QtWidgets.QDialog):
    """
    Dialog to assign serial ports to devices and connect them.
    """

    def __init__(self, parent):
        super().__init__(parent)
        self.setWindowTitle("Device Setup")
        # Access the existing device configs
        self.device_configs = parent.device_configs

        self.initUI()

    def initUI(self):
        layout = QtWidgets.QVBoxLayout()

        # Device list
        self.table = QtWidgets.QTableWidget()
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(
            ['Device Type', 'Address/Port', 'Channels'])
        self.table.horizontalHeader().setStretchLastSection(True)
        layout.addWidget(self.table)

        # Populate the table with existing device configurations
        self.populate_table()

        # Buttons to add and remove devices
        btn_layout = QtWidgets.QHBoxLayout()
        add_device_btn = QtWidgets.QPushButton("Add Device")
        remove_device_btn = QtWidgets.QPushButton("Remove Device")
        rescan_button = QtWidgets.QPushButton("Rescan Ports")
        btn_layout.addWidget(add_device_btn)
        btn_layout.addWidget(remove_device_btn)
        btn_layout.addWidget(rescan_button)
        layout.addLayout(btn_layout)

        add_device_btn.clicked.connect(self.add_device)
        remove_device_btn.clicked.connect(self.remove_device)
        rescan_button.clicked.connect(self.refresh_address_combos)

        # OK and Cancel buttons
        ok_cancel_layout = QtWidgets.QHBoxLayout()
        ok_btn = QtWidgets.QPushButton("OK")
        cancel_btn = QtWidgets.QPushButton("Cancel")
        ok_cancel_layout.addWidget(ok_btn)
        ok_cancel_layout.addWidget(cancel_btn)
        layout.addLayout(ok_cancel_layout)

        ok_btn.clicked.connect(self.accept)
        cancel_btn.clicked.connect(self.reject)

        self.setLayout(layout)

        # Install event filter to detect when the dialog is shown
        self.installEventFilter(self)

    def eventFilter(self, obj, event):
        if event.type() == QtCore.QEvent.Show:
            # Refresh the address combos when the dialog is shown
            self.refresh_address_combos()
        return super().eventFilter(obj, event)

    def get_available_serial_ports(self):
        ports = serial.tools.list_ports.comports()
        return [port.device for port in ports]

    def refresh_address_combos(self):
        available_ports = self.get_available_serial_ports()
        for row in range(self.table.rowCount()):
            address_combo = self.table.cellWidget(row, 1)
            if isinstance(address_combo, QComboBox):
                current_address = address_combo.currentText()
                address_combo.clear()
                address_combo.addItems(available_ports)
                # Re-set the current address if it was previously set
                if current_address in available_ports:
                    address_combo.setCurrentText(current_address)
                else:
                    address_combo.addItem(current_address)
                    address_combo.setCurrentText(current_address)

    def populate_table(self):
        self.table.setRowCount(len(self.device_configs))
        for row, config in enumerate(self.device_configs):
            # Device Type
            device_type_combo = QComboBox()
            device_type_combo.addItems(
                ["CTC100", "LakeShore224", "LakeShore372"])
            device_type_combo.setCurrentText(config['device_type'])
            self.table.setCellWidget(row, 0, device_type_combo)

            # Address/Port
            address_combo = QComboBox()
            address_combo.setEditable(True)  # Allow manual entry if needed
            available_ports = self.get_available_serial_ports()
            address_combo.addItems(available_ports)
            # Set the current address if it's in the list, else add it to the combo
            if config['address'] in available_ports:
                address_combo.setCurrentText(config['address'])
            else:
                address_combo.addItem(config['address'])
                address_combo.setCurrentText(config['address'])
            self.table.setCellWidget(row, 1, address_combo)

            # Channels
            channels_item = QtWidgets.QTableWidgetItem(
                ', '.join(config.get('channels', [])))
            self.table.setItem(row, 2, channels_item)

    def add_device(self):
        row = self.table.rowCount()
        self.table.insertRow(row)

        # Device Type
        device_type_combo = QComboBox()
        device_type_combo.addItems(["CTC100", "LakeShore224", "LakeShore372"])
        self.table.setCellWidget(row, 0, device_type_combo)

        # Address/Port
        address_combo = QComboBox()
        address_combo.setEditable(True)  # Allow manual entry if needed
        available_ports = self.get_available_serial_ports()
        address_combo.addItems(available_ports)
        self.table.setCellWidget(row, 1, address_combo)

        # Channels
        channels_item = QtWidgets.QTableWidgetItem("")
        self.table.setItem(row, 2, channels_item)

    def remove_device(self):
        selected_rows = set()
        for item in self.table.selectedItems():
            selected_rows.add(item.row())
        for row in sorted(selected_rows, reverse=True):
            self.table.removeRow(row)

    def get_device_configs(self):
        device_configs = []
        for row in range(self.table.rowCount()):
            device_type_combo = self.table.cellWidget(row, 0)
            device_type = device_type_combo.currentText()
            address_combo = self.table.cellWidget(row, 1)
            channels_item = self.table.item(row, 2)

            address = address_combo.currentText() if address_combo else ''
            channels = channels_item.text() if channels_item else ''
            # Convert channels to a list
            channels_list = [channel.strip()
                             for channel in channels.split(',') if channel.strip()]

            device_configs.append({
                'device_type': device_type,
                'address': address,
                'channels': channels_list
            })
        return device_configs


class PlotWindow(QtWidgets.QMainWindow):
    closed = pyqtSignal()  # Custom signal

    def __init__(self, thermometer_id, app, title, color='b'):
        super().__init__()
        self.setWindowTitle(title)
        self.app = app  # Reference to the main application
        self.thermometer_id = thermometer_id
        self.color = color  # Store the color

        # Central Widget
        central_widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout()
        central_widget.setLayout(layout)
        self.setCentralWidget(central_widget)

        # Add the plot to the layout with DateAxisItem for datetime x-axis
        self.full_screen_plot = pg.PlotWidget(
            axisItems={'bottom': pg.DateAxisItem()})
        layout.addWidget(self.full_screen_plot)

        # Initialize the plot data with the specified color
        self.curve = self.full_screen_plot.plot([], [], pen=self.color)

    def closeEvent(self, event):
        self.closed.emit()
        super().closeEvent(event)

    def update_plot(self):
        data = self.app.data_buffers.get(self.thermometer_id, {})
        times = data.get('times', [])
        values = data.get('values', [])
        if values:
            # Convert to Unix timestamps
            x = [time.timestamp() for time in times]
            y = values
            self.curve.setData(x, y)
            self.full_screen_plot.setYRange(min(y), max(y))
            # Optionally set X range to show the latest data
            self.full_screen_plot.setXRange(min(x), max(x))


class ConfigurationDialog(QtWidgets.QDialog):
    def __init__(self, parent, ctc100_devices, ls224_devices, ls372_devices, config=None):
        super().__init__(parent)
        self.setWindowTitle("Configure Tabs and Thermometers")
        self.ctc100_devices = ctc100_devices
        self.ls224_devices = ls224_devices
        self.ls372_devices = ls372_devices

        self.config = config if config is not None else []  # Configuration data
        self.initUI()

    def initUI(self):
        layout = QtWidgets.QVBoxLayout()

        # Tabs List
        self.tabs_list = QtWidgets.QListWidget()
        layout.addWidget(self.tabs_list)

        # Buttons to add, edit, delete tabs
        btn_layout = QtWidgets.QHBoxLayout()
        add_tab_btn = QtWidgets.QPushButton("Add Tab")
        edit_tab_btn = QtWidgets.QPushButton("Edit Tab")
        delete_tab_btn = QtWidgets.QPushButton("Delete Tab")
        configure_channels_btn = QtWidgets.QPushButton("Configure Channels")
        btn_layout.addWidget(add_tab_btn)
        btn_layout.addWidget(edit_tab_btn)
        btn_layout.addWidget(delete_tab_btn)
        btn_layout.addWidget(configure_channels_btn)
        layout.addLayout(btn_layout)

        add_tab_btn.clicked.connect(self.add_tab)
        edit_tab_btn.clicked.connect(self.edit_tab)
        delete_tab_btn.clicked.connect(self.delete_tab)
        configure_channels_btn.clicked.connect(self.configure_channels)

        # OK and Cancel buttons
        ok_cancel_layout = QtWidgets.QHBoxLayout()
        ok_btn = QtWidgets.QPushButton("OK")
        cancel_btn = QtWidgets.QPushButton("Cancel")
        ok_cancel_layout.addWidget(ok_btn)
        ok_cancel_layout.addWidget(cancel_btn)
        layout.addLayout(ok_cancel_layout)

        ok_btn.clicked.connect(self.accept)
        cancel_btn.clicked.connect(self.reject)

        self.setLayout(layout)

        self.update_tabs_list()

    def update_tabs_list(self):
        self.tabs_list.clear()
        for tab in self.config:
            self.tabs_list.addItem(tab['tab_name'])

    def add_tab(self):
        tab_config_dialog = TabConfigDialog(
            self, self.ctc100_devices, self.ls224_devices, self.ls372_devices)
        if tab_config_dialog.exec_():
            new_tab_config = tab_config_dialog.get_config()
            self.config.append(new_tab_config)
            self.update_tabs_list()

    def edit_tab(self):
        selected_items = self.tabs_list.selectedItems()
        if selected_items:
            index = self.tabs_list.row(selected_items[0])
            tab_config = self.config[index]
            tab_config_dialog = TabConfigDialog(
                self, self.ctc100_devices, self.ls224_devices, self.ls372_devices, tab_config)
            if tab_config_dialog.exec_():
                updated_tab_config = tab_config_dialog.get_config()
                self.config[index] = updated_tab_config
                self.update_tabs_list()
        else:
            QtWidgets.QMessageBox.warning(self, "Edit Tab", "No tab selected.")

    def delete_tab(self):
        selected_items = self.tabs_list.selectedItems()
        if selected_items:
            index = self.tabs_list.row(selected_items[0])
            del self.config[index]
            self.update_tabs_list()
        else:
            QtWidgets.QMessageBox.warning(
                self, "Delete Tab", "No tab selected.")

    def configure_channels(self):
        channel_config_dialog = ChannelConfigurationDialog(
            self, self.ctc100_devices, self.ls224_devices, self.ls372_devices, self.parent().channel_names)
        if channel_config_dialog.exec_():
            # Update channel names from the dialog
            self.parent().channel_names = channel_config_dialog.get_channel_names()

    def get_config(self):
        return self.config


class TabConfigDialog(QtWidgets.QDialog):
    def __init__(self, parent, ctc100_devices, ls224_devices, ls372_devices, tab_config=None):
        super().__init__(parent)
        self.setWindowTitle("Tab Configuration")
        self.ctc100_devices = ctc100_devices
        self.ls224_devices = ls224_devices
        self.ls372_devices = ls372_devices
        self.tab_config = tab_config or {'tab_name': '', 'thermometers': []}
        self.initUI()

    def initUI(self):
        layout = QtWidgets.QVBoxLayout()

        # Tab name
        form_layout = QtWidgets.QFormLayout()
        self.tab_name_edit = QtWidgets.QLineEdit(self.tab_config['tab_name'])
        form_layout.addRow("Tab Name:", self.tab_name_edit)
        layout.addLayout(form_layout)

        # Thermometer selection
        self.thermometer_list = QtWidgets.QListWidget()
        self.thermometer_list.setSelectionMode(
            QtWidgets.QAbstractItemView.MultiSelection)
        layout.addWidget(QtWidgets.QLabel("Select Thermometers and Outputs:"))
        layout.addWidget(self.thermometer_list)

        # Populate thermometer list
        self.populate_thermometer_list()

        # Pre-select existing thermometers, if editing
        selected_ids = [self.get_thermometer_id(
            t) for t in self.tab_config['thermometers']]
        for index in range(self.thermometer_list.count()):
            item = self.thermometer_list.item(index)
            thermometer_id = item.data(QtCore.Qt.UserRole)
            if thermometer_id in selected_ids:
                item.setSelected(True)

        # OK and Cancel buttons
        ok_cancel_layout = QtWidgets.QHBoxLayout()
        ok_btn = QtWidgets.QPushButton("OK")
        cancel_btn = QtWidgets.QPushButton("Cancel")
        ok_cancel_layout.addWidget(ok_btn)
        ok_cancel_layout.addWidget(cancel_btn)
        layout.addLayout(ok_cancel_layout)

        ok_btn.clicked.connect(self.accept)
        cancel_btn.clicked.connect(self.reject)

        self.setLayout(layout)

    def populate_thermometer_list(self):
        self.thermometer_list.clear()
        # Add CTC100 devices
        for i, device in enumerate(self.ctc100_devices):
            # Input channels
            for channel in device.get_input_channels():
                thermometer_id = f"CTC100_{i}_Ch{channel}"
                # Use custom name if available
                channel_name = self.parent().parent().channel_names.get(
                    thermometer_id, channel)
                item_text = f"CTC100 #{i} Input {channel_name}"
                item = QtWidgets.QListWidgetItem(item_text)
                item.setData(QtCore.Qt.UserRole, thermometer_id)
                self.thermometer_list.addItem(item)
            # Output channels
            for channel in device.get_output_channels():
                thermometer_id = f"CTC100_{i}_Out{channel}"
                channel_name = self.parent().parent().channel_names.get(
                    thermometer_id, channel)
                item_text = f"CTC100 #{i} Output {channel_name}"
                item = QtWidgets.QListWidgetItem(item_text)
                item.setData(QtCore.Qt.UserRole, thermometer_id)
                self.thermometer_list.addItem(item)

        # Add LakeShore224 devices
        for i, device in enumerate(self.ls224_devices):
            for channel in device.get_input_channels():
                thermometer_id = f"LakeShore224_{i}_Ch{channel}"
                channel_name = self.parent().parent().channel_names.get(
                    thermometer_id, channel)
                item_text = f"LakeShore224 #{i} Input {channel_name}"
                item = QtWidgets.QListWidgetItem(item_text)
                item.setData(QtCore.Qt.UserRole, thermometer_id)
                self.thermometer_list.addItem(item)
            # LakeShore224 has no outputs

        # Add LakeShore372 devices
        for i, device in enumerate(self.ls372_devices):
            # Input channels
            for channel in device.get_input_channels():
                thermometer_id = f"LakeShore372_{i}_Ch{channel}"
                channel_name = self.parent().parent().channel_names.get(
                    thermometer_id, channel)
                item_text = f"LakeShore372 #{i} Input {channel_name}"
                item = QtWidgets.QListWidgetItem(item_text)
                item.setData(QtCore.Qt.UserRole, thermometer_id)
                self.thermometer_list.addItem(item)
            # Output channels
            for channel in device.get_output_channels():
                thermometer_id = f"LakeShore372_{i}_Out{channel}"
                channel_name = self.parent().parent().channel_names.get(
                    thermometer_id, channel)
                item_text = f"LakeShore372 #{i} Output {channel_name}"
                item = QtWidgets.QListWidgetItem(item_text)
                item.setData(QtCore.Qt.UserRole, thermometer_id)
                self.thermometer_list.addItem(item)

    def get_thermometer_id(self, thermo):
        device_type = thermo['device']
        device_index = thermo['device_index']
        channel = thermo['channel']
        io_type = thermo.get('io_type', 'Input')
        if io_type == 'Input':
            return f"{device_type}_{device_index}_Ch{channel}"
        else:
            return f"{device_type}_{device_index}_Out{channel}"

    def get_config(self):
        tab_name = self.tab_name_edit.text()
        selected_items = self.thermometer_list.selectedItems()
        thermometers = []
        for item in selected_items:
            thermometer_id = item.data(QtCore.Qt.UserRole)
            device_type, device_index_str, channel_info = thermometer_id.split(
                '_')
            device_index = int(device_index_str)
            if channel_info.startswith('Ch'):
                io_type = 'Input'
                channel = channel_info[2:]  # Keep as string
            elif channel_info.startswith('Out'):
                io_type = 'Output'
                channel = channel_info[3:]  # Keep as string
            else:
                continue  # unknown channel info
            thermometers.append({
                'device': device_type,
                'device_index': int(device_index),
                'channel': channel,
                'io_type': io_type,
            })
        return {'tab_name': tab_name, 'thermometers': thermometers}


class ChannelConfigurationDialog(QtWidgets.QDialog):
    def __init__(self, parent, ctc100_devices, ls224_devices, ls372_devices, channel_names):
        super().__init__(parent)
        self.setWindowTitle("Channel Configuration")
        self.ctc100_devices = ctc100_devices
        self.ls224_devices = ls224_devices
        self.ls372_devices = ls372_devices
        self.channel_names = channel_names  # This is a dictionary

        self.initUI()

    def initUI(self):
        layout = QtWidgets.QVBoxLayout()

        # Create a table to list all channels and their names
        self.table = QtWidgets.QTableWidget()
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(
            ['Device', 'Channel', 'Custom Name'])
        layout.addWidget(self.table)

        # Fill the table with channels
        self.populate_table()

        # OK and Cancel buttons
        ok_cancel_layout = QtWidgets.QHBoxLayout()
        ok_btn = QtWidgets.QPushButton("OK")
        cancel_btn = QtWidgets.QPushButton("Cancel")
        ok_cancel_layout.addWidget(ok_btn)
        ok_cancel_layout.addWidget(cancel_btn)
        layout.addLayout(ok_cancel_layout)

        ok_btn.clicked.connect(self.accept)
        cancel_btn.clicked.connect(self.reject)

        self.setLayout(layout)

    def populate_table(self):
        # Collect all channels
        channels = []

        # CTC100 devices
        for i, device in enumerate(self.ctc100_devices):
            device_type = 'CTC100'
            device_index = i
            for channel in device.get_input_channels():
                channel_id = f"{device_type}_{device_index}_Ch{channel}"
                channels.append((device_type, device_index,
                                 'Input', channel, channel_id))
            for channel in device.get_output_channels():
                channel_id = f"{device_type}_{device_index}_Out{channel}"
                channels.append((device_type, device_index,
                                 'Output', channel, channel_id))

        # LS224 devices
        for i, device in enumerate(self.ls224_devices):
            device_type = 'LakeShore224'
            device_index = i
            for channel in device.get_input_channels():
                channel_id = f"{device_type}_{device_index}_Ch{channel}"
                channels.append((device_type, device_index,
                                 'Input', channel, channel_id))
            # LakeShore224 has no outputs

        # LS372 devices
        for i, device in enumerate(self.ls372_devices):
            device_type = 'LakeShore372'
            device_index = i
            for channel in device.get_input_channels():
                channel_id = f"{device_type}_{device_index}_Ch{channel}"
                channels.append((device_type, device_index,
                                 'Input', channel, channel_id))
            for channel in device.get_output_channels():
                channel_id = f"{device_type}_{device_index}_Out{channel}"
                channels.append((device_type, device_index,
                                 'Output', channel, channel_id))

        # Set table row count
        self.table.setRowCount(len(channels))

        for row, (device_type, device_index, io_type, channel, channel_id) in enumerate(channels):
            # Device column
            device_item = QtWidgets.QTableWidgetItem(
                f"{device_type} #{device_index}")
            device_item.setFlags(QtCore.Qt.ItemIsEnabled)
            self.table.setItem(row, 0, device_item)

            # Channel column
            channel_item = QtWidgets.QTableWidgetItem(f"{io_type} {channel}")
            channel_item.setFlags(QtCore.Qt.ItemIsEnabled)
            self.table.setItem(row, 1, channel_item)

            # Custom Name column
            custom_name = self.channel_names.get(channel_id, "")
            name_item = QtWidgets.QTableWidgetItem(custom_name)
            self.table.setItem(row, 2, name_item)

    def get_channel_names(self):
        # Update channel_names dictionary with the entries from the table
        channel_names = {}
        for row in range(self.table.rowCount()):
            device_item = self.table.item(row, 0)
            device_type_str = device_item.text()
            device_type, device_index_str = device_item.text().split(' #')
            device_index = int(device_index_str)
            channel_item = self.table.item(row, 1)
            io_type, channel_number = channel_item.text().split(' ', 1)
            channel_number = channel_number.strip()
            if io_type == 'Input':
                channel_id = f"{device_type}_{device_index}_Ch{channel_number}"
            else:
                channel_id = f"{device_type}_{device_index}_Out{channel_number}"
            name_item = self.table.item(row, 2)
            custom_name = name_item.text()
            if custom_name:
                channel_names[channel_id] = custom_name
        return channel_names


class TemperatureMonitorApp(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        # Devices will be initialized in the setup_devices method
        self.ctc100_devices = []
        self.ls224_devices = []
        self.ls372_devices = []

        self.device_configs = []  # To store device configurations

        # New: Mapping of (device_type, device_index) to device instances
        self.device_mapping = {}

        self.channel_names = {}  # Dictionary to hold custom channel names

        # Configuration data
        self.config = []  # This will be set via the configuration dialog

        # Data buffers and plot widgets
        self.max_data_points = 1000  # Max points to display in plots
        self.data_buffers = {}  # Key: thermometer ID, Value: list of readings
        self.plot_widgets = {}  # Key: thermometer ID, Value: plot widget

        # Assign unique colors to plots
        self.plot_colors = {}  # Key: thermometer ID, Value: color
        self.open_windows = {}  # Key: thermometer_id, Value: PlotWindow instance

        self.initUI()

    def initUI(self):
        self.setWindowTitle('Temperature Monitor')

        # Adjust window size based on screen resolution
        screen = QtWidgets.QApplication.primaryScreen()
        size = screen.availableGeometry()
        self.setGeometry(size)

        # Create Menu Bar
        self.createMenuBar()

        # Central Widget
        self.central_widget = QtWidgets.QWidget()
        self.setCentralWidget(self.central_widget)

        # Layout
        self.main_layout = QtWidgets.QVBoxLayout()
        self.central_widget.setLayout(self.main_layout)

        # Tab Widget
        self.tabs = QtWidgets.QTabWidget()
        self.main_layout.addWidget(self.tabs)

    def createMenuBar(self):
        # Ensure that the menu bar is created properly
        menubar = self.menuBar()

        # Force menu bar into the application window
        menubar.setNativeMenuBar(False)

        # Device Setup Menu
        device_menu = menubar.addMenu('Devices')

        # Setup Devices Action
        setup_devices_action = QAction('Assign Devices...', self)
        setup_devices_action.setShortcut('Ctrl+D')
        setup_devices_action.triggered.connect(self.open_device_setup_dialog)
        device_menu.addAction(setup_devices_action)

        # Configuration Menu
        config_menu = menubar.addMenu('Configuration')

        # Configure Tabs Action
        configure_action = QAction('Configure Tabs...', self)
        configure_action.setShortcut('Ctrl+T')
        configure_action.triggered.connect(self.open_configuration_dialog)
        config_menu.addAction(configure_action)

        # Save Configuration Action
        save_config_action = QAction('Save Configuration', self)
        save_config_action.setShortcut('Ctrl+Shift+S')
        save_config_action.triggered.connect(self.save_configuration)
        config_menu.addAction(save_config_action)

        # Load Configuration Action
        load_config_action = QAction('Load Configuration', self)
        load_config_action.setShortcut('Ctrl+Shift+O')
        load_config_action.triggered.connect(self.load_configuration)
        config_menu.addAction(load_config_action)

        # Add Save Data Action
        save_data_action = QAction('Save Data to CSV', self)
        save_data_action.setShortcut('Ctrl+S')
        save_data_action.triggered.connect(self.save_data_to_csv)
        menubar.addAction(save_data_action)

    def open_device_setup_dialog(self):
        device_setup_dialog = DeviceSetupDialog(self)
        if device_setup_dialog.exec_():
            self.device_configs = device_setup_dialog.get_device_configs()
            self.setup_devices()
            # After setting up devices, you can start updating temperatures
            self.start_threads()

    def setup_devices(self):
        # Clear existing devices
        self.ctc100_devices = []
        self.ls224_devices = []
        self.ls372_devices = []
        self.device_mapping = {}  # Clear the device mapping

        # Initialize device indices
        ctc100_index = 0
        ls224_index = 0
        ls372_index = 0

        # Initialize devices based on device_configs
        for config in self.device_configs:
            device_type = config['device_type']
            address = config['address']
            channels = config.get('channels', [])

            if device_type == 'CTC100':
                try:
                    ctc100 = CTC100(address)
                    if channels:
                        ctc100.input_channels = channels
                    self.ctc100_devices.append(ctc100)
                    device_index = ctc100_index
                    self.device_mapping[(device_type, device_index)] = ctc100
                    ctc100_index += 1
                    print(
                        f"Connected to CTC100 on {address} with input channels {ctc100.get_input_channels()} and output channels {ctc100.get_output_channels()}")
                except Exception as e:
                    print(f"Could not connect to CTC100 on {address}: {e}")
                    print(
                        f"Using simulated CTC100 device at address {address}.")
                    ctc100 = SimulatedCTC100(address)
                    if channels:
                        ctc100.input_channels = channels
                    self.ctc100_devices.append(ctc100)
                    device_index = ctc100_index
                    self.device_mapping[(device_type, device_index)] = ctc100
                    ctc100_index += 1

            elif device_type == 'LakeShore224':
                try:
                    ls224 = LakeShore224Device(address)
                    if channels:
                        ls224.input_channels = channels
                    self.ls224_devices.append(ls224)
                    device_index = ls224_index
                    self.device_mapping[(device_type, device_index)] = ls224
                    ls224_index += 1
                    print(
                        f"Connected to Lake Shore 224 on {address} with channels {ls224.get_input_channels()}")
                except Exception as e:
                    print(
                        f"Could not connect to Lake Shore 224 on {address}: {e}")
                    print(
                        f"Using simulated Lake Shore 224 device at address {address}.")
                    ls224 = SimulatedLakeShore224Device(address)
                    if channels:
                        ls224.input_channels = channels
                    self.ls224_devices.append(ls224)
                    device_index = ls224_index
                    self.device_mapping[(device_type, device_index)] = ls224
                    ls224_index += 1

            elif device_type == 'LakeShore372':
                try:
                    ls372 = LakeShore372Device(address)
                    if channels:
                        ls372.input_channels = channels
                    self.ls372_devices.append(ls372)
                    device_index = ls372_index
                    self.device_mapping[(device_type, device_index)] = ls372
                    ls372_index += 1
                    print(
                        f"Connected to Lake Shore 372 on {address} with input channels {ls372.get_input_channels()} and output channels {ls372.get_output_channels()}")
                except Exception as e:
                    print(
                        f"Could not connect to Lake Shore 372 on {address}: {e}")
                    print(
                        f"Using simulated Lake Shore 372 device at address {address}.")
                    ls372 = SimulatedLakeShore372Device(address)
                    if channels:
                        ls372.input_channels = channels
                    self.ls372_devices.append(ls372)
                    device_index = ls372_index
                    self.device_mapping[(device_type, device_index)] = ls372
                    ls372_index += 1

    def open_configuration_dialog(self):
        config_dialog = ConfigurationDialog(
            self, self.ctc100_devices, self.ls224_devices, self.ls372_devices, config=self.config)
        if config_dialog.exec_():
            # Update configuration and rebuild tabs
            self.config = config_dialog.get_config()
            self.build_tabs()

    def save_configuration(self):
        # Open file dialog to choose save location
        options = QFileDialog.Options()
        filename, _ = QFileDialog.getSaveFileName(
            self, "Save Configuration", "", "JSON Files (*.json);;All Files (*)", options=options)
        if filename:
            try:
                config_data = {
                    'config': self.config,
                    'channel_names': self.channel_names,
                    'device_configs': self.device_configs  # Save device configurations
                }
                with open(filename, 'w') as f:
                    json.dump(config_data, f)
                QtWidgets.QMessageBox.information(
                    self, "Save Configuration", f"Configuration saved successfully to {filename}")
            except Exception as e:
                QtWidgets.QMessageBox.critical(
                    self, "Save Configuration", f"An error occurred while saving configuration: {e}")

    def load_configuration(self):
        # Open file dialog to choose configuration file
        options = QFileDialog.Options()
        filename, _ = QFileDialog.getOpenFileName(
            self, "Load Configuration", "", "JSON Files (*.json);;All Files (*)", options=options)
        if filename:
            try:
                with open(filename, 'r') as f:
                    config_data = json.load(f)
                self.config = config_data.get('config', [])
                self.channel_names = config_data.get('channel_names', {})
                self.device_configs = config_data.get('device_configs', [])
                self.setup_devices()  # Initialize devices based on loaded configs
                self.build_tabs()
                QtWidgets.QMessageBox.information(
                    self, "Load Configuration", f"Configuration loaded successfully from {filename}")
            except Exception as e:
                QtWidgets.QMessageBox.critical(
                    self, "Load Configuration", f"An error occurred while loading configuration: {e}")

    def save_data_to_csv(self):
        if not self.data_buffers:
            QtWidgets.QMessageBox.warning(
                self, "Save Data", "No data to save.")
            return

        # Open file dialog to choose save location
        options = QFileDialog.Options()
        filename, _ = QFileDialog.getSaveFileName(
            self, "Save Data to CSV", "", "CSV Files (*.csv);;All Files (*)", options=options)
        if filename:
            try:
                import csv
                # Organize data into a dictionary with timestamps
                max_length = max(len(data['times'])
                                 for data in self.data_buffers.values())
                timestamps = list(range(max_length))

                # Prepare the header
                headers = ['Time (s)']
                for thermometer_id in self.data_buffers.keys():
                    # Use custom name if available
                    channel_name = self.channel_names.get(
                        thermometer_id, thermometer_id)
                    headers.append(channel_name)

                # Create data rows
                data_rows = []
                for i in range(max_length):
                    row = [timestamps[i]]
                    for thermometer_id in self.data_buffers.keys():
                        data = self.data_buffers[thermometer_id]
                        if i < len(data['values']):
                            row.append(data['values'][i])
                        else:
                            row.append('')
                    data_rows.append(row)

                # Write to CSV
                with open(filename, 'w', newline='') as csvfile:
                    csvwriter = csv.writer(csvfile)
                    csvwriter.writerow(headers)
                    csvwriter.writerows(data_rows)

                QtWidgets.QMessageBox.information(
                    self, "Save Data", f"Data saved successfully to {filename}")
            except Exception as e:
                QtWidgets.QMessageBox.critical(
                    self, "Save Data", f"An error occurred while saving data: {e}")
        else:
            # User canceled the file dialog
            return

    def build_tabs(self):
        # Clear existing tabs
        self.tabs.clear()
        self.data_buffers.clear()
        self.plot_widgets.clear()
        self.plot_colors.clear()  # Clear existing plot colors

        # Create a list of colors to cycle through
        # Red, Green, Blue, Cyan, Magenta, Yellow, White
        color_list = ['r', 'g', 'b', 'c', 'm', 'y', 'w']
        color_index = 0

        # Create tabs based on the configuration
        for tab_config in self.config:
            tab_name = tab_config['tab_name']
            thermometers = tab_config['thermometers']

            tab = QtWidgets.QWidget()
            tab_layout = QtWidgets.QHBoxLayout()
            tab.setLayout(tab_layout)
            self.tabs.addTab(tab, tab_name)

            # Separate the input and output channels
            input_channels = [t for t in thermometers if t.get(
                'io_type', 'Input') == 'Input']
            output_channels = [t for t in thermometers if t.get(
                'io_type', 'Input') == 'Output']

            # Left side (plots)
            plot_widget = QtWidgets.QWidget()
            plot_layout = QtWidgets.QGridLayout()
            plot_widget.setLayout(plot_layout)

            # For input channels, create plots
            num_plots = len(input_channels)
            if num_plots > 0:
                columns = int(math.ceil(math.sqrt(num_plots)))
                rows = int(math.ceil(num_plots / columns))
                plot_positions = [(i // columns, i % columns)
                                  for i in range(num_plots)]

                for index, (thermo, position) in enumerate(zip(input_channels, plot_positions)):
                    device_type = thermo['device']
                    device_index = thermo['device_index']
                    channel = thermo['channel']
                    io_type = thermo.get('io_type', 'Input')

                    # Retrieve device
                    device = None
                    if device_type == 'CTC100':
                        device = self.ctc100_devices[device_index]
                    elif device_type == 'LakeShore224':
                        device = self.ls224_devices[device_index]
                    elif device_type == 'LakeShore372':
                        device = self.ls372_devices[device_index]
                    else:
                        print(f"Unknown device type: {device_type}")
                        continue

                    address = device.address
                    thermometer_id = f"{device_type}_{device_index}_Ch{channel}"

                    # Use custom name if available
                    channel_name = self.channel_names.get(
                        thermometer_id, f"{device_type} {device_index} Channel {channel}")

                    # Replace the existing plot_w creation line with the following
                    plot_w = ClickablePlotWidget(title=channel_name, axisItems={
                        'bottom': pg.DateAxisItem()})
                    plot_w.setYRange(0, 400)  # Adjust as needed

                    # Add the plot to the grid layout
                    plot_layout.addWidget(plot_w, *position)

                    # Initialize data buffer and store plot widget
                    if thermometer_id not in self.data_buffers:
                        self.data_buffers[thermometer_id] = {
                            'times': [], 'values': []}
                    self.plot_widgets[thermometer_id] = plot_w

                    # Assign a color to this plot
                    color = color_list[color_index % len(color_list)]
                    self.plot_colors[thermometer_id] = color
                    color_index += 1

                    # Connect the plot's clicked signal to the callback
                    plot_w.clicked.connect(self.create_plot_click_callback(
                        thermometer_id, channel_name))

            # Add the plot widget to the tab layout
            tab_layout.addWidget(plot_widget, stretch=3)

            # For output channels, create controls
            if output_channels:
                # Right side (heaters)
                heater_widget = QtWidgets.QWidget()
                heater_layout = QtWidgets.QVBoxLayout()
                heater_widget.setLayout(heater_layout)

                # Heaters and PID controls
                for thermo in output_channels:
                    device_type = thermo['device']
                    device_index = thermo['device_index']
                    channel = thermo['channel']
                    output_id = f"{device_type}_{device_index}_Out{channel}"

                    # Use custom name if available
                    channel_name = self.channel_names.get(
                        output_id, f"{device_type} {device_index} Output Channel {channel}")

                    # Get the device
                    device = self.device_mapping.get(
                        (device_type, device_index))
                    if device is None:
                        continue  # Device not found; skip this heater control

                    heater_control_layout = QtWidgets.QVBoxLayout()
                    heater_label = QLabel(f"{channel_name}")
                    heater_label.setAlignment(Qt.AlignCenter)
                    heater_control_layout.addWidget(heater_label)

                    # Setpoint Control
                    setpoint_label = QLabel("Setpoint:")
                    setpoint_spinbox = QDoubleSpinBox()
                    setpoint_spinbox.setRange(0, 1000)  # Set appropriate range
                    setpoint_spinbox.setValue(300)  # Default value
                    if device_type == 'CTC100':
                        setpoint_spinbox.valueChanged.connect(
                            self.create_setpoint_callback(device_type, device_index, channel))
                    elif device_type == 'LakeShore372':
                        setpoint_spinbox.valueChanged.connect(
                            self.create_ls372_setpoint_callback(device_type, device_index))
                    heater_control_layout.addWidget(setpoint_label)
                    heater_control_layout.addWidget(setpoint_spinbox)

                    # PID parameter controls
                    pid_group_box = QGroupBox("PID Parameters")
                    pid_layout = QFormLayout()

                    p_label = QLabel("P:")
                    p_spinbox = QDoubleSpinBox()
                    p_spinbox.setRange(0, 1000)
                    p_spinbox.setValue(1.0)
                    if device_type == 'CTC100':
                        p_spinbox.valueChanged.connect(
                            self.create_pid_callback(device_type, device_index, channel, 'P'))
                    elif device_type == 'LakeShore372':
                        p_spinbox.valueChanged.connect(
                            self.create_ls372_pid_callback(device_type, device_index, 'P'))
                    pid_layout.addRow(p_label, p_spinbox)

                    i_label = QLabel("I:")
                    i_spinbox = QDoubleSpinBox()
                    i_spinbox.setRange(0, 1000)
                    i_spinbox.setValue(0.1)
                    if device_type == 'CTC100':
                        i_spinbox.valueChanged.connect(
                            self.create_pid_callback(device_type, device_index, channel, 'I'))
                    elif device_type == 'LakeShore372':
                        i_spinbox.valueChanged.connect(
                            self.create_ls372_pid_callback(device_type, device_index, 'I'))
                    pid_layout.addRow(i_label, i_spinbox)

                    d_label = QLabel("D:")
                    d_spinbox = QDoubleSpinBox()
                    d_spinbox.setRange(0, 1000)
                    d_spinbox.setValue(0.01)
                    if device_type == 'CTC100':
                        d_spinbox.valueChanged.connect(
                            self.create_pid_callback(device_type, device_index, channel, 'D'))
                    elif device_type == 'LakeShore372':
                        d_spinbox.valueChanged.connect(
                            self.create_ls372_pid_callback(device_type, device_index, 'D'))
                    pid_layout.addRow(d_label, d_spinbox)

                    pid_group_box.setLayout(pid_layout)
                    heater_control_layout.addWidget(pid_group_box)

                    # Heater Output Dial
                    heater_dial = QDial()
                    heater_dial.setMinimum(0)
                    heater_dial.setMaximum(100)
                    heater_dial.setNotchesVisible(True)
                    heater_dial.setValue(0)
                    heater_dial.setWrapping(False)
                    if device_type == 'CTC100':
                        heater_dial.valueChanged.connect(
                            self.create_output_dial_callback(device_type, device_index, channel))
                    elif device_type == 'LakeShore372':
                        heater_dial.valueChanged.connect(
                            self.create_ls372_output_dial_callback(device_type, device_index, channel))

                    heater_value_label = QLabel("0%")
                    heater_value_label.setAlignment(Qt.AlignCenter)

                    heater_control_layout.addWidget(heater_dial)
                    heater_control_layout.addWidget(heater_value_label)
                    # Store the heater value label
                    if not hasattr(device, 'heater_value_labels'):
                        device.heater_value_labels = {}
                    device.heater_value_labels[channel] = heater_value_label
                    heater_layout.addLayout(heater_control_layout)

                # Add the heater widget to the tab layout
                tab_layout.addWidget(heater_widget, stretch=1)
            else:
                # No output channels; expand plots to full width
                tab_layout.setStretch(0, 1)  # Ensure plots take full space

    def create_output_dial_callback(self, device_type, device_index, channel):
        def dial_changed(value):
            device = self.device_mapping.get((device_type, device_index))
            if device:
                device.enableHeater(channel)
                device.setHeaterOutput(channel, value)
                # Update the heater value label
                if hasattr(device, 'heater_value_labels') and channel in device.heater_value_labels:
                    device.heater_value_labels[channel].setText(f"{value}%")
            else:
                print(
                    f"Device {device_type} at index {device_index} not found.")
        return dial_changed

    def create_ls372_output_dial_callback(self, device_type, device_index, channel):
        def dial_changed(value):
            device = self.device_mapping.get((device_type, device_index))
            if device:
                device.set_heater_output(
                    heater_number=channel, heat_percent=value)
                # Update the heater value label
                if hasattr(device, 'heater_value_labels') and channel in device.heater_value_labels:
                    device.heater_value_labels[channel].setText(f"{value}%")
            else:
                print(
                    f"Device {device_type} at index {device_index} not found.")
        return dial_changed

    def create_setpoint_callback(self, device_type, device_index, channel):
        def setpoint_changed(value):
            device = self.device_mapping.get((device_type, device_index))
            if device:
                device.set_setpoint(channel, value)
                print(
                    f"Setpoint for {device_type} {device_index} Channel {channel} set to {value}")
            else:
                print(
                    f"Device {device_type} at index {device_index} not found.")
        return setpoint_changed

    def create_ls372_setpoint_callback(self, device_type, device_index):
        def setpoint_changed(value):
            device = self.device_mapping.get((device_type, device_index))
            if device:
                device.set_setpoint(value)
                print(
                    f"Setpoint for {device_type} {device_index} set to {value}")
            else:
                print(
                    f"Device {device_type} at index {device_index} not found.")
        return setpoint_changed

    def create_pid_callback(self, device_type, device_index, channel, parameter):
        def pid_parameter_changed(value):
            device = self.device_mapping.get((device_type, device_index))
            if device:
                # Initialize p, i, d as None
                p = i = d = None
                if parameter == 'P':
                    p = value
                elif parameter == 'I':
                    i = value
                elif parameter == 'D':
                    d = value
                device.set_pid_parameters(channel, p, i, d)
                print(
                    f"{parameter}-gain for {device_type} {device_index} Channel {channel} set to {value}")
            else:
                print(
                    f"Device {device_type} at index {device_index} not found.")
        return pid_parameter_changed

    def create_ls372_pid_callback(self, device_type, device_index, parameter):
        def pid_parameter_changed(value):
            device = self.device_mapping.get((device_type, device_index))
            if device:
                # Initialize p, i, d as None
                p = i = d = None
                if parameter == 'P':
                    p = value
                elif parameter == 'I':
                    i = value
                elif parameter == 'D':
                    d = value
                device.set_pid_parameters(p, i, d)
                print(
                    f"{parameter}-gain for {device_type} {device_index} set to {value}")
            else:
                print(
                    f"Device {device_type} at index {device_index} not found.")
        return pid_parameter_changed

    def create_plot_click_callback(self, thermometer_id, title):

        def plot_clicked(event):
            # Open the plot in full screen
            if thermometer_id in self.open_windows:
                # Bring the window to the front if it's already open
                self.open_windows[thermometer_id].raise_()
                self.open_windows[thermometer_id].activateWindow()
            else:
                # Get the color for this thermometer_id
                # Default to blue if not assigned
                color = self.plot_colors.get(thermometer_id, 'b')
                full_screen_window = PlotWindow(
                    thermometer_id, self, title, color)
                full_screen_window.show()

                # Store a reference to prevent garbage collection
                self.open_windows[thermometer_id] = full_screen_window

                # Clean up the reference when the window is closed
                full_screen_window.closed.connect(
                    lambda tid=thermometer_id: self.open_windows.pop(tid, None)
                )
        return plot_clicked

    def start_threads(self):
        # Thread for updating temperatures
        if hasattr(self, 'update_thread') and self.update_thread.is_alive():
            return  # Thread already running
        self.update_thread = threading.Thread(target=self.update_temperatures)
        self.update_thread.daemon = True
        self.update_thread.start()

    def update_temperatures(self):
        while True:
            if not self.config:
                # No configuration set; skip reading temperatures
                time.sleep(1)
                continue

            current_time = datetime.datetime.now()
            # Acquire data for all thermometers in the configuration
            for tab_config in self.config:
                thermometers = tab_config['thermometers']
                for thermo in thermometers:
                    if thermo.get('io_type', 'Input') != 'Input':
                        continue  # Skip output channels for temperature reading
                    device_type = thermo['device']
                    device_index = thermo['device_index']
                    channel = thermo['channel']
                    thermometer_id = f"{device_type}_{device_index}_Ch{channel}"

                    device = None
                    if device_type == 'CTC100':
                        device = self.ctc100_devices[device_index]
                    elif device_type == 'LakeShore224':
                        device = self.ls224_devices[device_index]
                    elif device_type == 'LakeShore372':
                        device = self.ls372_devices[device_index]
                    else:
                        print(f"Unknown device type: {device_type}")
                        continue  # Device not found; skip this channel

                    try:
                        if device_type == 'CTC100':
                            temp = device.read(channel)
                        elif device_type == 'LakeShore224':
                            temp = device.get_temperature(channel)
                        elif device_type == 'LakeShore372':
                            temp = device.get_temperature(channel)
                        else:
                            print(f"Unknown device type: {device_type}")
                            temp = None

                        if temp is not None:
                            data_buffer = self.data_buffers.setdefault(
                                thermometer_id, {'times': [], 'values': []})
                            current_time = datetime.datetime.now()
                            data_buffer['times'].append(current_time)
                            data_buffer['values'].append(temp)
                            if len(data_buffer['values']) > self.max_data_points:
                                data_buffer['times'].pop(0)
                                data_buffer['values'].pop(0)
                        else:
                            print(f"Error reading {thermometer_id}")
                    except Exception as e:
                        print(f"Error reading {thermometer_id}: {e}")

            # Update plots
            self.update_plots()

            time.sleep(1)  # Adjust the update interval as needed

    def update_plots(self):
        QtCore.QMetaObject.invokeMethod(
            self, "refresh_plots", QtCore.Qt.QueuedConnection)

    @QtCore.pyqtSlot()
    def refresh_plots(self):
        # Update all plots
        for thermometer_id, plot_widget in self.plot_widgets.items():
            data = self.data_buffers[thermometer_id]
            times = data['times']
            values = data['values']
            if values:
                # Convert to Unix timestamps
                x = [time.timestamp() for time in times]
                y = values
                plot_widget.clear()

                # Use the assigned color for this plot
                # Default to blue if not assigned
                color = self.plot_colors.get(thermometer_id, 'b')
                plot_widget.plot(x, y, pen=color)

                # Set X and Y ranges
                plot_widget.setXRange(min(x), max(x))
                plot_widget.setYRange(min(y), max(y))

        # Update any open PlotWindow instances
        for thermometer_id, plot_window in self.open_windows.items():
            plot_window.update_plot()

    def save_data_to_csv(self):
        if not self.data_buffers:
            QtWidgets.QMessageBox.warning(
                self, "Save Data", "No data to save.")
            return

        # Open file dialog to choose save location
        options = QFileDialog.Options()
        filename, _ = QFileDialog.getSaveFileName(
            self, "Save Data to CSV", "", "CSV Files (*.csv);;All Files (*)", options=options)
        if filename:
            try:
                import csv
                # Organize data into a dictionary with timestamps
                max_length = max(len(data)
                                 for data in self.data_buffers.values())
                timestamps = list(range(max_length))

                # Prepare the header
                headers = ['Timestamp']
                for thermometer_id in self.data_buffers.keys():
                    # Use custom name if available
                    channel_name = self.channel_names.get(
                        thermometer_id, thermometer_id)
                    headers.append(channel_name)

                # Collect all timestamps
                all_times = set()
                for data in self.data_buffers.values():
                    all_times.update(data['times'])
                all_times = sorted(all_times)

                # Create data rows
                data_rows = []
                for timestamp in all_times:
                    row = [timestamp.strftime('%Y-%m-%d %H:%M:%S')]
                    for thermometer_id in self.data_buffers.keys():
                        data = self.data_buffers[thermometer_id]
                        if timestamp in data['times']:
                            index = data['times'].index(timestamp)
                            value = data['values'][index]
                            row.append(value)
                        else:
                            row.append('')  # No data for this timestamp
                    data_rows.append(row)

                # Write to CSV
                with open(filename, 'w', newline='') as csvfile:
                    csvwriter = csv.writer(csvfile)
                    csvwriter.writerow(headers)
                    csvwriter.writerows(data_rows)

                QtWidgets.QMessageBox.information(
                    self, "Save Data", f"Data saved successfully to {filename}")
            except Exception as e:
                QtWidgets.QMessageBox.critical(
                    self, "Save Data", f"An error occurred while saving data: {e}")
        else:
            # User canceled the file dialog
            return

    # Include any other methods that were previously provided

# ============================ Main Execution ============================= #


def main():
    # Start the application
    app = QtWidgets.QApplication(sys.argv)
    # Set application name (optional)
    app.setApplicationName('Temperature Monitor')
    # Set organization name (optional)
    app.setOrganizationName('YourOrganization')

    monitor_app = TemperatureMonitorApp()
    monitor_app.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()


this code has some issues: it does not link properly the input thermometer to the heater. Also it has 4 heaters channel, while the CTC100 has 2 heaters channels and 4 AIO channels