diff --git a/config_template.json b/config_template.json index b4f5d527..85e9c9bf 100644 --- a/config_template.json +++ b/config_template.json @@ -25,37 +25,34 @@ "pos": "right", "args": { "ttlInfo": { - "TTL_test_0": "ttl0", - "TTL_test_1": "ttl1" - }, - "dacInfo": { - "DAC_test_0": { - "device": "zotino0", - "channel": 0 - }, - "DAC_test_1": { - "device": "zotino0", - "channel": 1 - } + "TTL_test_0": "jb_0", + "TTL_test_1": "jb_1", + "TTL_test_2": "jb_2", + "TTL_test_3": "jb_3", + "TTL_test_4": "jb_4", + "TTL_test_5": "jb_5", + "TTL_test_6": "jb_6", + "TTL_test_7": "jb_7", + "TTL_test_8": "jc_0", + "TTL_test_9": "jc_1", + "TTL_test_10": "jc_2", + "TTL_test_11": "jc_3", + "TTL_test_12": "jc_4", + "TTL_test_13": "jc_5", + "TTL_test_14": "jc_6", + "TTL_test_15": "jc_7", + "TTL_test_16": "jd_0", + "TTL_test_17": "jd_1", + "TTL_test_18": "jd_2", + "TTL_test_19": "jd_3", + "TTL_test_20": "jd_4", + "TTL_test_21": "jd_5", + "TTL_test_22": "jd_6", + "TTL_test_23": "jd_7" }, + "dacInfo": { }, "ddsInfo": { - "numColumns": 2, - "DDS_test_0": { - "device": "urukul0", - "channel": 0, - "frequencyInfo": { - "min": 1, - "max": 100, - "unit": "MHz" - } - }, - "DDS_test_1": { - "device": "urukul0", - "channel": 3, - "amplitudeInfo": { - "step": 0.1 - } - } + "numColumns": 0 } } }, @@ -64,20 +61,6 @@ "cls": "DataViewerApp", "pos": "floating", "channel": ["MONITOR"] - }, - "stage": { - "module": "iquip.apps.stage", - "cls": "StageControllerApp", - "pos": "right", - "args": { - "stages": { - "stage_name": { - "index": [0, 0], - "target": ["localhost", 1234, "target_name"] - } - }, - "period": 0.5 - } } }, "constant": { diff --git a/iquip/apps/config_builder.py b/iquip/apps/config_builder.py new file mode 100644 index 00000000..fbe9ea11 --- /dev/null +++ b/iquip/apps/config_builder.py @@ -0,0 +1,269 @@ +"""App module for editting the configuration attributes and submitting the configuration json file.""" + +# pylint: disable=unused-import +import json +import logging +from typing import Any, Dict, Optional, Tuple +import dataclasses + +import requests +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal, pyqtSlot +from PyQt5.QtWidgets import ( + QGroupBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, + QTabWidget, QVBoxLayout, QWidget, +) + +import qiwis +from iquip.protocols import ConfigurationInfo +from iquip.apps.thread import ConfigurationInfoThread +from iquip.apps.builder import ( + _BaseEntry, + _StringEntry, +) +logger = logging.getLogger(__name__) + +class ConfigBuilderFrame(QWidget): + """Frame for showing the configuration attributes and requesting to submit it. + + Attributes: + argsListWidget: The list widget with the configuration attributes. + reloadArgsButton: The button for reloading the configuration attributes. + submitButton: The button for submitting the configuration. + """ + + def __init__( + self, + configurationName: str, + configurationClsName: str, + parent: Optional[QWidget] = None + ): + """Extended. + + Args: + configurationName: The configuration name, the name field of + protocols.ConfigurationInfo. + configurationClsName: The class name of the configuration. + """ + super().__init__(parent=parent) + # widgets + clsBox = QGroupBox("Config", self) + clsBox.setToolTip(configurationName) + QHBoxLayout(clsBox).addWidget(QLabel(configurationClsName, self)) + self.argsListWidget = QListWidget(self) + tabWidget = QTabWidget(self) + tabWidget.addTab(self.argsListWidget, "Attributes") + self.reloadArgsButton = QPushButton("Reload", self) + self.submitButton = QPushButton("Submit", self) + # layout + buttonLayout = QHBoxLayout() + buttonLayout.addWidget(self.reloadArgsButton) + buttonLayout.addWidget(self.submitButton) + layout = QVBoxLayout(self) + layout.addWidget(clsBox) + layout.addWidget(tabWidget) + layout.addLayout(buttonLayout) + +class _ConfigurationSubmitThread(QThread): + """QThread for submitting the configuration. + + Signals: + submitted(str): The configuration is submitted. The str is name of submitted configuration. + + Attributes: + configurationPath: The file name of the configuration file. + configurationClsName: The file name of the configuration. + configurationArgs: The attributes of the configuration. + ip: The proxy server IP address. + port: The proxy server PORT number. + """ + + submitted = pyqtSignal(str) + + def __init__( + self, + configurationPath: str, + configurationClsName: str, + configurationArgs: Dict[str, Any], + ip: str, + port: int, + parent: Optional[QObject] = None + ): # pylint: disable=too-many-arguments + """Extended. + + Args: + See the attributes section. + """ + super().__init__(parent=parent) + self.configurationPath = configurationPath + self.configurationClsName = configurationClsName + self.configurationArgs = configurationArgs + self.ip = ip + self.port = port + + def run(self): + """Overridden. + + Submits the configuration to the proxy server. + + Whenever the configuration is submitted well regardless of whether it runs successfully or not, + the server returns the run identifier. + """ + try: + params = { + "file": self.configurationPath, + "cls": self.configurationClsName, + "args": json.dumps(self.configurationArgs) + } + except TypeError: + logger.exception("Failed to convert the build arguments to a JSON string.") + return + try: + response = requests.get(f"http://{self.ip}:{self.port}/configuration/submit/", + params=params, + timeout=10) + response.raise_for_status() + except requests.exceptions.RequestException: + logger.exception("Failed to submit the configuration.") + return + self.submitted.emit(self.configurationClsName) + +class ConfigBuilderApp(qiwis.BaseApp): + """App for editting the build arguments and submitting the configuration. + + There are four types of build arguments. + StringValue: Set to a string. + + Attributes: + proxy_id: The proxy server IP address. + proxy_port: The proxy server PORT number. + builderFrame: The frame that shows the build arguments and requests to submit it. + configurationPath: The path of the configuration file. + configurationClsName: The class name of the configuration. + configurationSubmitThread: The most recently executed _ConfigurationSubmitThread instance. + configurationInfoThread: The most recently executed ConfigurationInfoThread instance. + """ + + def __init__( + self, + name: str, + configurationPath: str, + configurationClsName: str, + configurationInfo: Dict[str, Any], + parent: Optional[QObject] = None + ): # pylint: disable=too-many-arguments + """Extended. + + Args: + configurationPath, configurationClsName: See the attributes section in BuilderApp. + configurationInfo: The configuration information, a dictionary of + protocols ConfigurationInfo. + """ + super().__init__(name, parent=parent) + self.proxy_ip = self.constants.proxy_ip # pylint: disable=no-member + self.proxy_port = self.constants.proxy_port # pylint: disable=no-member + self.configurationPath = configurationPath + self.configurationClsName = configurationClsName + self.configurationSubmitThread: Optional[_ConfigurationSubmitThread] = None + self.configurationInfoThread: Optional[ConfigurationInfoThread] = None + self.builderFrame = ConfigBuilderFrame(name, configurationClsName) + self.initArgsEntry(ConfigurationInfo(**configurationInfo)) + # connect signals to slots + self.builderFrame.reloadArgsButton.clicked.connect(self.reloadArgs) + self.builderFrame.submitButton.clicked.connect(self.submit) + + def initArgsEntry(self, configurationInfo: ConfigurationInfo): + """Initializes the configuration entry. + + Args: + configurationInfo: The configuration information. + """ + for argName, argInfo in dataclasses.asdict(configurationInfo).items(): + widget = _StringEntry(argName, {"default" : argInfo}) + listWidget = (self.builderFrame.argsListWidget) + item = QListWidgetItem(listWidget) + item.setSizeHint(widget.sizeHint()) + listWidget.addItem(item) + listWidget.setItemWidget(item, widget) + + @pyqtSlot() + def reloadArgs(self): + """Reloads the configuration. + + Once the reloadArgsButton is clicked, this is called. + """ + self.configurationInfoThread = ConfigurationInfoThread( + self.configurationPath, + self.proxy_ip, + self.proxy_port, + self + ) + self.configurationInfoThread.fetched.connect(self.onReloaded, type=Qt.QueuedConnection) + self.configurationInfoThread.finished.connect(self.configurationInfoThread.deleteLater) + self.configurationInfoThread.start() + + @pyqtSlot(dict) + def onReloaded(self, configurationInfos: Dict[str, ConfigurationInfo]): + """Clears the original configuration attributes entry and re-initializes them. + + Args: + See thread.ConfigurationInfoThread.fetched signal. + """ + configurationInfo = configurationInfos[self.configurationClsName] + for _ in range(self.builderFrame.argsListWidget.count()): + item = self.builderFrame.argsListWidget.takeItem(0) + del item + self.initArgsEntry(configurationInfo) + + def argumentsFromListWidget(self, listWidget: QListWidget) -> Dict[str, Any]: + """Gets configuration attributes from the given list widget and returns them. + + Args: + listWidget: The QListWidget containing _BaseEntry instances. + + Returns: + A dictionary of arguments. + Each key is the argument name and its value is the argument value. + """ + args = {} + for row in range(listWidget.count()): + item = listWidget.item(row) + widget = listWidget.itemWidget(item) + args[widget.name] = widget.value() + return args + + @pyqtSlot() + def submit(self): + """Submits the configuration with the attributes. + + Once the submitButton is clicked, this is called. + """ + try: + configurationArgs = self.argumentsFromListWidget(self.builderFrame.argsListWidget) + except ValueError: + logger.exception("The submission is rejected because of an invalid argument.") + return + self.configurationSubmitThread = _ConfigurationSubmitThread( + self.configurationPath, + self.configurationClsName, + configurationArgs, + self.proxy_ip, + self.proxy_port, + self + ) + self.configurationSubmitThread.submitted.connect(self.onSubmitted, type=Qt.QueuedConnection) + self.configurationSubmitThread.finished.connect(self.configurationSubmitThread.deleteLater) + self.configurationSubmitThread.start() + + def onSubmitted(self, rid: int): + """Sends the rid to the logger after submitted. + + This is the slot for _ConfigurationSubmitThread.submitted. + + Args: + rid: The run identifier of the submitted configuration. + """ + logger.info("RID: %d", rid) + + def frames(self) -> Tuple[Tuple[str, ConfigBuilderFrame]]: + """Overridden.""" + return (("", self.builderFrame),) diff --git a/iquip/apps/config_editor.py b/iquip/apps/config_editor.py new file mode 100644 index 00000000..3b679d3e --- /dev/null +++ b/iquip/apps/config_editor.py @@ -0,0 +1,294 @@ +"""App module for showing the configuration list and opening an configuration.""" + +import posixpath +import logging +from typing import Dict, List, Optional, Tuple, Union + +import requests +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSlot, pyqtSignal +from PyQt5.QtWidgets import ( + QInputDialog, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget +) + +import qiwis +from iquip.protocols import ConfigurationInfo +from iquip.apps.thread import ConfigurationInfoThread + +logger = logging.getLogger(__name__) + + +class RemoteEditorFrame(QWidget): + """Frame for showing the configuration list and opening an configuration. + + Attributes: + fileTree: The tree widget for showing the file structure. + reloadButton: The button for reloading the fileTree. + openButton: The button for opening the selected configuration file. + """ + + def __init__(self, parent: Optional[QWidget] = None): + """Extended.""" + super().__init__(parent=parent) + # widgets + self.fileTree = QTreeWidget(self) + self.fileTree.header().setVisible(False) + self.reloadButton = QPushButton("Reload", self) + self.openButton = QPushButton("Open", self) + # layout + layout = QVBoxLayout(self) + layout.addWidget(self.reloadButton) + layout.addWidget(self.fileTree) + layout.addWidget(self.openButton) + self.setLayout(layout) + + +class _FileFinderThread(QThread): + """QThread for finding the file list from the proxy server. + + Signals: + fetched(configurationList, widget): The file list is fetched. + + Attributes: + path: The path of the directory to search for configuration files. + widget: The widget corresponding to the path. + ip: The proxy server IP address. + port: The proxy server PORT number. + """ + + fetched = pyqtSignal(list, object) + + def __init__( + self, + path: str, + widget: Union[QTreeWidget, QTreeWidgetItem], + ip: str, + port: int, + parent: Optional[QObject] = None + ): # pylint: disable=too-many-arguments + """Extended. + + Args: + See the attributes section. + """ + super().__init__(parent=parent) + self.path = path + self.widget = widget + self.ip = ip + self.port = port + + def run(self): + """Overridden. + + Fetches the file list from the proxy server. + + Searches for only files in path, not in deeper path and adds them into the widget. + After finished, the fetched signal is emitted. + """ + try: + response = requests.get(f"http://{self.ip}:{self.port}/ls_config/", + params={"directory": self.path}, + timeout=10) + response.raise_for_status() + configurationList = response.json() + except requests.exceptions.RequestException: + logger.exception("Failed to fetch the file list.") + return + self.fetched.emit(configurationList, self.widget) + + +class RemoteEditorApp(qiwis.BaseApp): + """App for showing the configuration json file list and opening an configuration. + + Attributes: + proxy_id: The proxy server IP address. + proxy_port: The proxy server PORT number. + selectedConfigurationPath: The currently selected configuration path. + explorerFrame: The frame that shows the file tree. + fileFinderThread: The most recently executed _FileFinderThread instance. + ConfigurationInfoThread: The most recently executed ConfigurationInfoThread instance. + """ + + def __init__(self, name: str, parent: Optional[QObject] = None): + """Extended.""" + super().__init__(name, parent=parent) + self.proxy_ip = self.constants.proxy_ip # pylint: disable=no-member + self.proxy_port = self.constants.proxy_port # pylint: disable=no-member + self.selectedConfigurationPath: Optional[str] = None + self.fileFinderThread: Optional[_FileFinderThread] = None + self.configurationInfoThread: Optional[ConfigurationInfoThread] = None + self.explorerFrame = RemoteEditorFrame() + self.loadFileTree() + # connect signals to slots + self.explorerFrame.fileTree.itemExpanded.connect(self.lazyLoadFile) + self.explorerFrame.fileTree.itemDoubleClicked.connect(self.fetchConfigurationInfo) + self.explorerFrame.reloadButton.clicked.connect(self.loadFileTree) + self.explorerFrame.openButton.clicked.connect(self.openButtonClicked) + + @pyqtSlot() + def loadFileTree(self): + """Loads the configuration file structure in self.explorerFrame.fileTree.""" + self.explorerFrame.fileTree.clear() + self.fileFinderThread = _FileFinderThread( + ".", + self.explorerFrame.fileTree, + self.proxy_ip, + self.proxy_port, + self + ) + self.fileFinderThread.fetched.connect(self._addFile, type=Qt.QueuedConnection) + self.fileFinderThread.finished.connect(self.fileFinderThread.deleteLater) + self.fileFinderThread.start() + + @pyqtSlot(QTreeWidgetItem) + def lazyLoadFile(self, configurationFileItem: QTreeWidgetItem): + """Loads the configuration file in the directory. + + This will be called when a directory item is expanded, + so it makes loading files lazy. + + Args: + configurationFileItem: The expanded file item. + """ + if ( + configurationFileItem.childCount() != 1 or + configurationFileItem.child(0).columnCount() != 0 + ): + return + # Remove the empty item of an unloaded directory. + configurationFileItem.takeChild(0) + configurationPath = self.fullPath(configurationFileItem) + self.fileFinderThread = _FileFinderThread( + configurationPath, + configurationFileItem, + self.proxy_ip, + self.proxy_port, + self + ) + self.fileFinderThread.fetched.connect(self._addFile, type=Qt.QueuedConnection) + self.fileFinderThread.finished.connect(self.fileFinderThread.deleteLater) + self.fileFinderThread.start() + + @pyqtSlot(list, object) + def _addFile(self, configurationList: List[str], widget: Union[QTreeWidget, QTreeWidgetItem]): + """Adds the files into the children of the widget. + + A file or directory which starts with "_" will be ignored, e.g. __pycache__/. + + Args: + configurationList: The list of files under the widget path. + widget: See _FileFinderThread class. + """ + for configurationFile in configurationList: + if configurationFile.startswith("_"): + continue + if configurationFile.endswith("/"): + configurationFileItem = QTreeWidgetItem(widget) + configurationFileItem.setText(0, configurationFile[:-1]) + # Make an empty item for indicating that it is a directory. + QTreeWidgetItem(configurationFileItem) + elif configurationFile.endswith((".json",)): + configurationFileItem = QTreeWidgetItem(widget) + configurationFileItem.setText(0, configurationFile) + + @pyqtSlot() + def openButtonClicked(self): + """Called when the openButton is clicked. + + If no item is selected, nothing happens. + """ + item = self.explorerFrame.fileTree.currentItem() + if item is not None: # item is selected + self.fetchConfigurationInfo(item) + + + @pyqtSlot(QTreeWidgetItem) + def fetchConfigurationInfo(self, item: QTreeWidgetItem): + """Fetches the given configuration info. + + After fetched, self.selectConfigurationCls() is called to select an configuration class. + + Once an configuration item is double-clicked or the openButton is clicked, this is called. + If the given item is a directory, nothing happens. + """ + if item.childCount(): # item is a directory + return + self.selectedConfigurationPath = self.fullPath(item) + self.configurationInfoThread = ConfigurationInfoThread( + self.selectedConfigurationPath, + self.proxy_ip, + self.proxy_port, + self + ) + self.configurationInfoThread.fetched.connect(self.selectConfigurationCls, + type=Qt.QueuedConnection) + self.configurationInfoThread.finished.connect(self.configurationInfoThread.deleteLater) + self.configurationInfoThread.start() + + @pyqtSlot(dict) + def selectConfigurationCls(self, configurationInfos: Dict[str, ConfigurationInfo]): + """Selects an configuration class to be opened as a builder. + + After selected, self.openBuilder() is called to open a builder. + + If there is only one class, it is selected automatically without showing a QInputDialog. + If no class is selected, nothing happens. + + Args: + See thread.ConfigurationInfoThread.fetched signal. + """ + if len(configurationInfos) > 1: + cls, ok = QInputDialog().getItem( + None, "Select an configuration class", + "Configuration class: ", + configurationInfos, + editable=False + ) + if not ok: + return + else: + cls = next(iter(configurationInfos)) + self.openBuilder(cls, configurationInfos[cls]) + + def openBuilder( + self, + configurationClsName: str, + configurationInfo: ConfigurationInfo + ): + """Opens the configuration builder with its information. + + The configuration is guaranteed to be the correct configuration file. + + Args: + configurationClsName: The class name of the configuration. + configurationInfo: The configuration information. See protocols.ConfigurationInfo. + """ + self.qiwiscall.createApp( + name=f"builder - {self.selectedConfigurationPath}:{configurationClsName}", + info=qiwis.AppInfo( + module="iquip.apps.config_builder", + cls="ConfigBuilderApp", + pos="center", + args={ + "configurationPath": self.selectedConfigurationPath, + "configurationClsName": configurationClsName, + "configurationInfo": configurationInfo + }, + trust=True + ) + ) + + def fullPath(self, configurationFileItem: QTreeWidgetItem) -> str: + """Finds the full path of the file item and returns it. + + Args: + configurationFileItem: The file item to get its full path. + """ + paths = [configurationFileItem.text(0)] + while configurationFileItem.parent(): + configurationFileItem = configurationFileItem.parent() + paths.append(configurationFileItem.text(0)) + return posixpath.join(*reversed(paths)) + + def frames(self) -> Tuple[Tuple[str, RemoteEditorFrame]]: + """Overridden.""" + return (("", self.explorerFrame),) diff --git a/iquip/apps/thread.py b/iquip/apps/thread.py index 27041f78..13ffad20 100644 --- a/iquip/apps/thread.py +++ b/iquip/apps/thread.py @@ -6,7 +6,7 @@ import requests from PyQt5.QtCore import QObject, QThread, pyqtSignal -from iquip.protocols import ExperimentInfo +from iquip.protocols import ExperimentInfo, ConfigurationInfo logger = logging.getLogger(__name__) @@ -71,3 +71,64 @@ def run(self): self.fetched.emit(experimentInfos) else: logger.info("The selected item is not an experiment file.") + +class ConfigurationInfoThread(QThread): + """QThread for obtaining the experiment information from the proxy server. + + Signals: + fetched(experimentInfos): Experiments infomation of the given experiment path is fetched. + The experimentInfos is a dictionary with the experiments class name. + Each value is the ExperimentInfo instance of the experiment class. + + Attributes: + experimentPath: The path of the experiment file. + ip: The proxy server IP address. + port: The proxy server PORT number. + """ + + fetched = pyqtSignal(dict) + + def __init__( + self, + configurationPath: str, + ip: str, + port: int, + parent: Optional[QObject] = None + ): + """Extended. + + Args: + See the attributes section. + """ + super().__init__(parent=parent) + self.configurationPath = configurationPath + self.ip = ip + self.port = port + + def run(self): + """Overridden. + + Fetches the experiment information from the proxy server. + + If the path is a directory, 500 Server error occurs. + If the path is a non-experiment file, the server returns an empty dictionary. + + The experiment information is an instance of protocols.ExperimentInfo. + After finished, the fetched signal is emitted. + """ + try: + response = requests.get(f"http://{self.ip}:{self.port}/configuration/info/", + params={"file": self.configurationPath}, + timeout=10) + response.raise_for_status() + data = response.json() + except requests.exceptions.RequestException: + logger.exception("Failed to fetch the experiment information.") + return + if data: + configurationInfos: Dict[str, ConfigurationInfo] = {} + for cls, info in data.items(): + configurationInfos[cls] = ConfigurationInfo(**info) + self.fetched.emit(configurationInfos) + else: + logger.info("The selected item is not an experiment file.") diff --git a/iquip/protocols.py b/iquip/protocols.py index ec4a0f1f..81407202 100644 --- a/iquip/protocols.py +++ b/iquip/protocols.py @@ -16,6 +16,47 @@ class ExperimentInfo: name: str arginfo: Dict[str, Any] +@dataclasses.dataclass +class ConfigurationInfo: + """lolenc Configuartion Information. + + Fields: + common_path: The common absolute path for the project. + ip: The IP address of the board. + port: The port number of the board. + xilinx_include_path: The relative path to the Xilinx include directory. + bsp_src_path: The relative path to the BSP source directory. All files + in this directory will be compiled and linked with actual application file. + bsp_include_path: The relative path to the BSP include directory. Header files + in this directory will be used as a header files in the bsp_src_path. + bsp_lib_path: The relative path to the BSP library directory. Compiled bsp source + files will be made as a library file in this directory. + startup_path: The relative path to the startup file. This file will be used as a + startup file in the linker file. (*.S) + linker_path: The relative path to the linker file. (*.ld) + compile_driver: Option for the compile driver.(True or False) + device_config: The absolute path of device configuration file. (*.cpp) + device_db: The absolute path of device database file. (*.json) + log_path: The absolute path of path to the log file. + """ + common_path: str + ip: str + port: str + xilinx_include_path: str + bsp_src_path: str + bsp_include_path: str + bsp_lib_path: str + startup_path: str + linker_path: str + compile_driver: str + device_config: str + device_db: str + log_path: str + + def __str__(self) -> str: + """Overridden.""" + return str(dataclasses.asdict(self)) + @dataclasses.dataclass class SubmittedExperimentInfo: # pylint: disable=too-many-instance-attributes