# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import time
from typing import Optional
from PySide6 import QtWidgets
import requests
from ansys.aedt.toolkits.common.ui.logger_handler import logger
from ansys.aedt.toolkits.common.ui.models import general_settings
from ansys.aedt.toolkits.common.utils import ToolkitThreadStatus
MSG_TK_RUNNING = "Please wait, toolkit running"
[docs]
class FrontendGeneric:
"""This class provides a generic frontend for controlling the toolkit."""
def __init__(self):
logger.info("Frontend initialization...")
self.ui = None
url = general_settings.backend_url
port = general_settings.backend_port
self.url = f"http://{url}:{port}"
self.logger = logger
# Load toolkit icon
self.images_path = os.path.join(os.path.dirname(__file__), "images")
[docs]
@staticmethod
def poll_url(url: str, timeout: int = 10):
"""Perform GET requests on URL.
Continuously perform GET requests to the specified URL until a valid response is received.
Parameters
----------
url : str
URL to poll.
timeout : int, optional
Time out in seconds. The default is 10 seconds.
Returns
-------
tuple
A 2-tuple containing a string and a boolean.
The boolean states if the GET requests succeeded.
The string represents the response or exception content.
"""
logger.debug(f"Poll url '{url}'")
count = 0
response_content = None
response_success = False
try:
while not response_success and count < timeout:
time.sleep(0.1)
response = requests.get(url)
response_success = response.ok
count += 1
except requests.exceptions.RequestException as e:
response_content = f"Backend error occurred. Exception {str(e)}"
else:
response_content = response.json()
return response_success, response_content
[docs]
def check_connection(self):
"""Check the backend connection.
Returns
-------
bool
``True`` when successful, ``False`` when failed.
"""
url = self.url + "/health"
response_success, response_content = self.poll_url(url)
if response_success:
logger.debug(response_content)
else:
logger.error(response_content)
return response_success
[docs]
def backend_busy(self):
"""
Check if the backend is currently busy.
Returns
-------
bool
``True`` if the backend is busy, ``False`` otherwise.
"""
try:
response = requests.get(self.url + "/status")
res = response.ok and response.json() == ToolkitThreadStatus.BUSY.value
return res
except requests.exceptions.RequestException:
logger.error("Get backend status failed")
return False
[docs]
def wait_thread(self, timeout: int = 10):
"""
Wait thread until backend is idle.
Parameters
----------
timeout : int, optional
Time out in seconds. The default is 10 seconds.
Returns
-------
bool
``True`` when the backend is idle, ``False`` otherwise.
"""
try:
response = requests.get(self.url + "/wait_thread", json=timeout)
if response.ok:
return True
else:
return False
except requests.exceptions.RequestException:
logger.error("Wait thread failed.")
return False
[docs]
def installed_versions(self):
"""
Get the installed versions of AEDT.
Returns
-------
list or False
A list of installed AEDT versions if successful, ``False`` otherwise.
"""
try:
response = requests.get(self.url + "/installed_versions")
if response.ok:
versions = response.json()
return versions
except requests.exceptions.RequestException:
msg = "Get AEDT installed versions failed"
self.log_and_update_progress(msg, log_level="error")
return False
[docs]
def get_properties(self):
"""
Get properties from the backend.
Returns
-------
dict or False
A dictionary of properties if successful, ``False`` otherwise.
"""
try:
response = requests.get(self.url + "/properties")
if response.ok:
data = response.json()
if data:
logger.debug("Properties from backend updated successfully")
return data
else:
logger.debug("Backend properties empty")
return False
except requests.exceptions.RequestException:
self.ui.update_logger("Get properties failed")
[docs]
def set_properties(self, data):
"""
Set properties in the backend.
Parameters
----------
data : dict
Dictionary of properties to set.
"""
try:
response = requests.put(self.url + "/properties", json=data)
if response.ok:
return response.json()
else:
return False
except requests.exceptions.RequestException:
msg = "Set properties failed"
self.log_and_update_progress(msg, log_level="error")
[docs]
def find_process_ids(self, version, non_graphical):
"""
Find AEDT sessions based on the selected version and graphical mode.
Parameters
----------
version : str
AEDT version.
non_graphical : bool
Flag indicating graphical or non-graphical mode.
Returns
-------
list or False
A list of found AEDT sessions if successful, ``False`` otherwise.
"""
try:
be_properties = self.get_properties()
be_properties["aedt_version"] = version
be_properties["non_graphical"] = non_graphical
self.set_properties(be_properties)
response = requests.get(self.url + "/aedt_sessions")
sessions = []
if response.ok:
sessions = response.json()
return sessions
except requests.exceptions.RequestException:
logger.error(f"Find AEDT sessions failed")
return False
[docs]
def launch_aedt(self, selected_version, selected_process, non_graphical=False):
"""Launch AEDT.
Parameters
----------
selected_version : str
The selected AEDT version.
selected_process : str
The selected AEDT process.
non_graphical : bool, optional
Flag indicating whether to run AEDT in non-graphical mode. The default is False.
"""
response = requests.get(self.url + "/status")
res_busy = response.ok and response.json() == ToolkitThreadStatus.BUSY.value
res_idle = response.ok and response.json() == ToolkitThreadStatus.IDLE.value
if res_busy:
msg = MSG_TK_RUNNING
self.log_and_update_progress(msg, log_level="debug")
elif res_idle:
self.ui.update_progress(0)
response = requests.get(self.url + "/health")
if response.ok and response.json() == "Toolkit is not connected to AEDT.":
be_properties = self.get_properties()
if be_properties["selected_process"] == 0:
be_properties["aedt_version"] = selected_version
be_properties["non_graphical"] = non_graphical
if selected_process != "New Session":
be_properties["non_graphical"] = False
text_splitted = selected_process.split(" ")
if len(text_splitted) == 4:
be_properties["use_grpc"] = True
be_properties["selected_process"] = int(text_splitted[3])
else:
be_properties["use_grpc"] = False
be_properties["selected_process"] = int(text_splitted[1])
self.set_properties(be_properties)
response = requests.post(self.url + "/launch_aedt")
if response.status_code == 200:
msg = "Launching AEDT"
self.log_and_update_progress(msg, log_level="debug", progress=50)
else:
msg = f"Failed backend call: {self.url}"
self.log_and_update_progress(msg, log_level="error", progress=100)
else:
msg = response.json()
self.log_and_update_progress(msg, log_level="debug", progress=100)
else:
msg = response.json()
self.log_and_update_progress(msg, log_level="debug", progress=100)
[docs]
def open_project(self, selected_project):
"""Open an AEDT project.
Parameters
----------
selected_project : str
The path to the selected AEDT project.
"""
response = requests.get(self.url + "/status")
res_busy = response.ok and response.json() == ToolkitThreadStatus.BUSY.value
res_idle = response.ok and response.json() == ToolkitThreadStatus.IDLE.value
if res_busy:
msg = MSG_TK_RUNNING
self.log_and_update_progress(msg, log_level="debug")
elif res_idle:
self.ui.update_progress(0)
response = requests.get(self.url + "/health")
if response.ok and response.json() == "Toolkit is not connected to AEDT.":
response = requests.post(self.url + "/open_project", data=selected_project)
if response.status_code == 200:
msg = "Project opened"
self.log_and_update_progress(msg, log_level="debug")
else:
msg = f"Failed backend call: {self.url} + '/'open_project"
self.log_and_update_progress(msg, log_level="error", progress=100)
else:
msg = response.json()
self.log_and_update_progress(msg, log_level="debug", progress=100)
else:
msg = response.json()
self.log_and_update_progress(msg, log_level="debug", progress=100)
[docs]
def get_aedt_model(
self,
project_selected,
design_selected,
air_objects=True,
encode=True,
obj_list=None,
export_path=None,
export_as_single_objects=True,
):
"""Get AEDT model.
Parameters
----------
project_selected : str
Project name.
design_selected : str
Design name.
air_objects : bool, optional
Define if air and vacuum objects will be exported.
encode : bool, optional
Whether to encode the file. The default is ``True``.
obj_list : list, optional
List of objects to export. The default is ``None``, in which case
every model object except 3D, vacuum, and air objects are exported.
export_path : str, optional
Full path of the exported OBJ file.
The default is ``None``, in which case the file is exported in the working directory.
export_as_single_objects : bool, optional
Whether to export the model as a single object. The default is ``True``.
If ``False``, the model is exported as a list of objects for each object.
Returns
-------
bool
``True`` when successful, ``False`` when failed.
"""
# Set active project and design
be_properties = self.get_properties()
if project_selected == "No Project" or design_selected == "No Design":
logger.error("Wrong project or design")
return False
else:
for project in be_properties["project_list"]:
if self.get_project_name(project) == project_selected:
be_properties["active_project"] = project
if project_selected in list(be_properties["design_list"].keys()):
designs = be_properties["design_list"][project_selected]
for design in designs:
if design_selected == design:
be_properties["active_design"] = design
break
break
self.set_properties(be_properties)
response = requests.get(
self.url + "/get_aedt_model",
json={
"air_objects": air_objects,
"encode": encode,
"obj_list": obj_list,
"export_path": export_path,
"export_as_single_objects": export_as_single_objects,
},
)
if response.ok:
msg = "Geometry created."
logger.info(msg)
return response.json()
else:
msg = f"Failed backend call: {self.url}"
logger.error(msg)
return False
[docs]
def get_aedt_data(self):
"""Get a list of AEDT projects.
Returns
-------
list
A list of AEDT project names. Returns ["No Project"] if no projects are available.
"""
be_properties = self.get_properties()
project_list = []
if be_properties["active_project"]:
if be_properties["project_list"]:
for project in be_properties["project_list"]:
active_project_name = os.path.splitext(os.path.basename(project))[0]
project_list.append(active_project_name)
else:
project_list.append("No Project")
return project_list
[docs]
@staticmethod
def get_project_name(project_path):
"""Get project name from project path.
Returns
-------
str
Project name
"""
return os.path.splitext(os.path.basename(project_path))[0]
[docs]
def update_design_names(self, active_project=None):
"""Update design names based on the active project.
Parameters
----------
active_project : str, optional
The active AEDT project. If not provided, the current active project will be used.
Returns
-------
list
A list of design names.
"""
be_properties = self.get_properties()
if not active_project:
if be_properties["active_project"] == "No Project":
return ["No Design"]
active_project = os.path.splitext(os.path.basename(be_properties["active_project"]))[0]
if not active_project:
active_project = "No Project"
be_properties["active_project"] = active_project
else:
be_properties["active_project"] = active_project
design_list = be_properties["design_list"].get(active_project)
if not design_list:
design_list = ["No Design"]
be_properties["active_design"] = "No Design"
else:
be_properties["active_design"] = design_list[0]
self.set_properties(be_properties)
return design_list
[docs]
def save_project(self):
"""Save the current AEDT project.
Opens a file dialog to select a location to save the AEDT project. The project is saved
with a '.aedt' extension.
Note:
This method relies on backend communication to save the project.
Returns
-------
None
"""
dialog = QtWidgets.QFileDialog()
dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)
dialog.setFileMode(QtWidgets.QFileDialog.FileMode.AnyFile)
dialog.setOption(QtWidgets.QFileDialog.Option.DontConfirmOverwrite, True)
file_name, _ = dialog.getSaveFileName(
self,
"Save new aedt file",
"",
"Aedt Files (*.aedt)",
)
if file_name:
response = requests.get(self.url + "/status")
res_busy = response.ok and response.json() == ToolkitThreadStatus.BUSY.value
res_idle = response.ok and response.json() == ToolkitThreadStatus.IDLE.value
if res_busy:
msg = MSG_TK_RUNNING
self.log_and_update_progress(msg, log_level="debug")
elif res_idle:
response = requests.post(self.url + "/save_project", json=file_name)
if response.ok:
msg = "Saving project: {}".format(file_name)
self.log_and_update_progress(msg, log_level="debug")
else:
msg = f"Failed backend call: {self.url}"
self.log_and_update_progress(msg, log_level="error", progress=100)
[docs]
def release_only(self):
"""Release the AEDT desktop without closing projects."""
response = requests.get(self.url + "/status")
if response.ok and response.json() == ToolkitThreadStatus.BUSY.value:
self.log_and_update_progress(MSG_TK_RUNNING, log_level="debug")
else:
properties = {"close_projects": False, "close_on_exit": False}
if self.close():
requests.post(self.url + "/close_aedt", json=properties)
[docs]
def release_and_close(self):
"""Release and close the AEDT desktop."""
response = requests.get(self.url + "/status")
if response.ok and response.json() == ToolkitThreadStatus.BUSY.value:
self.log_and_update_progress(MSG_TK_RUNNING, log_level="debug")
elif response.ok and response.json() == ToolkitThreadStatus.IDLE.value:
properties = {"close_projects": True, "close_on_exit": True}
if self.close():
requests.post(self.url + "/close_aedt", json=properties)
[docs]
def on_cancel_clicked(self):
"""Handle cancel button click."""
self.close()
[docs]
def closeEvent(self, event):
"""Handle the close event of the application window."""
close = QtWidgets.QMessageBox.question(
self, "QUIT", "Confirm quit?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No
)
if close == QtWidgets.QMessageBox.Yes:
logger.info("Closing toolkit")
success = self.check_connection()
if success:
self.release_only()
event.accept()
else:
event.ignore()
[docs]
def log_and_update_progress(self, msg, log_level: str = "debug", progress: Optional[int] = None):
"""Log a message and update the progress bar.
This method logs the given message at the specified log level, and updates the progress
bar to the given progress percentage if provided.
Parameters
----------
msg : str
The log message.
log_level : str, optional
The log level (debug, info, warning, error, critical). The default is "debug".
progress : int, optional
The progress percentage. If provided, it updates the progress bar.
"""
# toolkit logging
log_levels = {
"debug": logger.debug,
"info": logger.info,
"warning": logger.warning,
"error": logger.error,
"critical": logger.critical,
}
log_func = log_levels.get(log_level, "debug")
log_func(msg)
# UI logging
self.ui.update_logger(msg)
# Update progress bar if needed
if progress is not None:
self.ui.update_progress(progress)