Adding motor class and PEPing up
This commit is contained in:
@@ -69,11 +69,19 @@ class GigaFrostCameraMixin(CustomDetectorMixin):
|
||||
source_port = 3000 + j
|
||||
if j < 4:
|
||||
extend_header_table(
|
||||
udp_header_table, self.parent.macSouth.get(), self.parent.ipSouth.get(), dest_port, source_port
|
||||
udp_header_table,
|
||||
self.parent.macSouth.get(),
|
||||
self.parent.ipSouth.get(),
|
||||
dest_port,
|
||||
source_port
|
||||
)
|
||||
else:
|
||||
extend_header_table(
|
||||
udp_header_table, self.parent.macNorth.get(), self.parent.ipNorth.get(), dest_port, source_port
|
||||
udp_header_table,
|
||||
self.parent.macNorth.get(),
|
||||
self.parent.ipNorth.get(),
|
||||
dest_port,
|
||||
source_port
|
||||
)
|
||||
|
||||
return udp_header_table
|
||||
@@ -230,17 +238,25 @@ class GigaFrostCamera(PSIDetectorBase):
|
||||
cmdWriteService = Component(EpicsSignal, "WRITE_SRV.PROC", put_complete=True, kind=Kind.omitted)
|
||||
|
||||
# Standard camera configs
|
||||
cfgExposure = Component(EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgFramerate = Component(EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgRoiX = Component(EpicsSignal, "ROIX", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgRoiY = Component(EpicsSignal, "ROIY", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgScanId = Component(EpicsSignal, "SCAN_ID", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgCntNum = Component(EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgCorrMode = Component(EpicsSignal, "CORR_MODE", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgExposure = Component(
|
||||
EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgFramerate = Component(
|
||||
EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgRoiX = Component(
|
||||
EpicsSignal, "ROIX", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgRoiY = Component(
|
||||
EpicsSignal, "ROIY", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgScanId = Component(
|
||||
EpicsSignal, "SCAN_ID", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgCntNum = Component(
|
||||
EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
cfgCorrMode = Component(
|
||||
EpicsSignal, "CORR_MODE", put_complete=True, auto_monitor=True, kind=Kind.config)
|
||||
|
||||
# Software signals
|
||||
cmdSoftEnable = Component(EpicsSignal, "SOFT_ENABLE", put_complete=True)
|
||||
cmdSoftTrigger = Component(EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted)
|
||||
cmdSoftTrigger = Component(
|
||||
EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted)
|
||||
cmdSoftExposure = Component(EpicsSignal, "SOFT_EXP", put_complete=True)
|
||||
|
||||
# Trigger configuration PVs
|
||||
@@ -252,7 +268,11 @@ class GigaFrostCamera(PSIDetectorBase):
|
||||
kind=Kind.config,
|
||||
)
|
||||
cfgCntEndBit = Component(
|
||||
EpicsSignal, "CNT_ENDBIT_RBV", write_pv="CNT_ENDBIT", put_complete=True, kind=Kind.config
|
||||
EpicsSignal,
|
||||
"CNT_ENDBIT_RBV",
|
||||
write_pv="CNT_ENDBIT",
|
||||
put_complete=True,
|
||||
kind=Kind.config
|
||||
)
|
||||
# Enable modes
|
||||
cfgTrigEnableExt = Component(
|
||||
@@ -373,10 +393,6 @@ class GigaFrostCamera(PSIDetectorBase):
|
||||
name,
|
||||
auto_soft_enable=False,
|
||||
backend_url=const.BE999_DAFL_CLIENT,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
):
|
||||
# Ugly hack to pass values before on_init()
|
||||
@@ -388,10 +404,6 @@ class GigaFrostCamera(PSIDetectorBase):
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -414,7 +426,7 @@ class GigaFrostCamera(PSIDetectorBase):
|
||||
status = DeviceStatus(self)
|
||||
sleep_time = self.cfgExposure.value*self.cfgCntNum.value*0.001+0.050
|
||||
sleep(sleep_time)
|
||||
logger.info(f"[GF2] Slept for: {sleep_time} seconds")
|
||||
logger.info("[%s] Slept for %f seconds", self.name, sleep_time)
|
||||
status.set_finished()
|
||||
return status
|
||||
|
||||
@@ -658,13 +670,10 @@ class GigaFrostCamera(PSIDetectorBase):
|
||||
mode_external = self.cfgTrigEnableExt.get()
|
||||
mode_auto = self.cfgTrigEnableAuto.get()
|
||||
if mode_soft and not mode_auto:
|
||||
if mode_external:
|
||||
return "soft+ext"
|
||||
else:
|
||||
return "soft"
|
||||
elif mode_auto and not mode_soft and not mode_external:
|
||||
return "soft+ext" if mode_external else "soft"
|
||||
if mode_auto and not mode_soft and not mode_external:
|
||||
return "always"
|
||||
elif mode_external and not mode_soft and not mode_auto:
|
||||
if mode_external and not mode_soft and not mode_auto:
|
||||
return "external"
|
||||
|
||||
return None
|
||||
|
||||
@@ -29,13 +29,6 @@ class GigaFrostClientMixin(CustomDetectorMixin):
|
||||
|
||||
This class will be called by the custom_prepare_cls attribute of the detector class.
|
||||
"""
|
||||
def on_init(self) -> None:
|
||||
"""Initialize the camera, set channel values
|
||||
|
||||
on_init is automatically called during __init__ of the sub devices.
|
||||
"""
|
||||
return super().on_init()
|
||||
|
||||
def on_stage(self) -> None:
|
||||
"""
|
||||
Specify actions to be executed during stage in preparation for a scan.
|
||||
@@ -120,6 +113,7 @@ class GigaFrostClient(PSIDetectorBase):
|
||||
cam = Component(GigaFrostCamera, prefix="X02DA-CAM-GF2:", name="cam")
|
||||
daq = Component(StdDaqClient, name="daq")
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
@@ -130,31 +124,25 @@ class GigaFrostClient(PSIDetectorBase):
|
||||
daq_ws_url = "ws://localhost:8080",
|
||||
daq_rest_url = "http://localhost:5000",
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.__class__.__dict__["cam"].kwargs['backend_url'] = backend_url
|
||||
self.__class__.__dict__["cam"].kwargs['auto_soft_enable'] = auto_soft_enable
|
||||
self.__class__.__dict__["daq"].kwargs['ws_url'] = daq_ws_url
|
||||
self.__class__.__dict__["daq"].kwargs['rest_url'] = daq_rest_url
|
||||
#self.__class__.__dict__["daq"].__class__.__dict__["config"].kwargs['rest_url'] = daq_rest_url
|
||||
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def configure(self, d: dict=None):
|
||||
"""Configure the next scan with the GigaFRoST camera and standard DAQ backend
|
||||
"""Configure the next scan with the GigaFRoST camera and standard DAQ backend.
|
||||
It also makes some simple checks for consistent configuration, but otherwise
|
||||
status feedback is missing on both sides.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -163,30 +151,22 @@ class GigaFrostClient(PSIDetectorBase):
|
||||
for an unlimited number of images (limited by the ringbuffer size and
|
||||
backend speed). (default = 10000)
|
||||
nimages : int, optional
|
||||
Number of images to be taken during each scan. Set to -1 for an
|
||||
unlimited number of images (limited by the ringbuffer size and
|
||||
backend speed). (default = 10)
|
||||
Number of images to be taken during each trigger (i.e. burst).
|
||||
Maximum is 16777215 images. (default = 10)
|
||||
exposure : float, optional
|
||||
Exposure time [ms]. (default = 0.2)
|
||||
Exposure time, max 40 ms. [ms]. (default = 0.2)
|
||||
period : float, optional
|
||||
Exposure period [ms], ignored in soft trigger mode. (default = 1.0)
|
||||
pixel_width : int, optional
|
||||
Image size in the x-direction [pixels] (default = 2016)
|
||||
Image size in the x-direction, must be multiple of 48 [pixels] (default = 2016)
|
||||
pixel_height : int, optional
|
||||
Image size in the y-direction [pixels] (default = 2016)
|
||||
Image size in the y-direction, must be multiple of 16 [pixels] (default = 2016)
|
||||
scanid : int, optional
|
||||
Scan identification number to be associated with the scan data
|
||||
(default = 0)
|
||||
Scan identification number to be associated with the scan data.
|
||||
ToDo: This should be retrieved from the BEC. (default = 0)
|
||||
correction_mode : int, optional
|
||||
The correction to be applied to the imaging data. The following
|
||||
modes are available (default = 5):
|
||||
|
||||
* 0: Bypass. No corrections are applied to the data.
|
||||
* 1: Send correction factor A instead of pixel values
|
||||
* 2: Send correction factor B instead of pixel values
|
||||
* 3: Send correction factor C instead of pixel values
|
||||
* 4: Invert pixel values, but do not apply any linearity correction
|
||||
* 5: Apply the full linearity correction
|
||||
"""
|
||||
# Unstage camera (reconfiguration will anyway stop camera)
|
||||
super().unstage()
|
||||
|
||||
@@ -102,7 +102,7 @@ class StdDaqPreview(Device):
|
||||
self._stop_polling = True
|
||||
return super().unstage()
|
||||
|
||||
def stop(self, success=False):
|
||||
def stop(self, *, success=False):
|
||||
"""Stop a running preview"""
|
||||
self.unstage()
|
||||
|
||||
@@ -119,7 +119,13 @@ class StdDaqPreview(Device):
|
||||
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
meta, data = self._socket.recv_multipart(flags=zmq.NOBLOCK)
|
||||
r = self._socket.recv_multipart(flags=zmq.NOBLOCK)
|
||||
if len(r)==2:
|
||||
meta, data = r
|
||||
else:
|
||||
sleep(0.1)
|
||||
continue
|
||||
|
||||
t_curr = time()
|
||||
t_elapsed = t_curr - t_last
|
||||
if t_elapsed > self.throttle.get():
|
||||
@@ -127,7 +133,8 @@ class StdDaqPreview(Device):
|
||||
if header["type"]=="uint16":
|
||||
image = np.frombuffer(data, dtype=np.uint16)
|
||||
if image.size != np.prod(header['shape']):
|
||||
raise ValueError(f"Unexpected array size of {image.size} for header: {header}")
|
||||
err = f"Unexpected array size of {image.size} for header: {header}"
|
||||
raise ValueError(err)
|
||||
image = image.reshape(header['shape'])
|
||||
|
||||
# Update image and update subscribers
|
||||
@@ -202,7 +209,9 @@ class StdDaqPreviewMixin(CustomDetectorMixin):
|
||||
self.parent.image.put(image, force=True)
|
||||
self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=image)
|
||||
t_last=t_curr
|
||||
logger.info(f"[{self.parent.name}]\tUpdated frame {header['frame']}\tMean: {np.mean(image)}")
|
||||
name = self.parent.name
|
||||
nfo = f"[{name}]\tFrameNo: {header['frame']}\tMean: {np.mean(image)}"
|
||||
logger.info(nfo)
|
||||
except ValueError:
|
||||
# Happens when ZMQ partially delivers the multipart message
|
||||
pass
|
||||
|
||||
@@ -52,7 +52,7 @@ class StdDaqRestClient(Device):
|
||||
cfg_start_udp_port = Component(Signal, kind=Kind.config)
|
||||
cfg_writer_user_id = Component(Signal, kind=Kind.config)
|
||||
cfg_submodule_info = Component(Signal, kind=Kind.config)
|
||||
cfg_max_number_of_forwarders_spawned = Component(Signal, kind=Kind.config)
|
||||
cfg_max_number_of_forwarders = Component(Signal, kind=Kind.config)
|
||||
cfg_use_all_forwarders = Component(Signal, kind=Kind.config)
|
||||
cfg_module_sync_queue_size = Component(Signal, kind=Kind.config)
|
||||
cfg_module_positions = Component(Signal, kind=Kind.config)
|
||||
@@ -89,7 +89,7 @@ class StdDaqRestClient(Device):
|
||||
self.cfg_start_udp_port.set(cfg['start_udp_port']).wait()
|
||||
self.cfg_writer_user_id.set(cfg['writer_user_id']).wait()
|
||||
#self.cfg_submodule_info.set(cfg['submodule_info']).wait()
|
||||
self.cfg_max_number_of_forwarders_spawned.set(cfg['max_number_of_forwarders_spawned']).wait()
|
||||
self.cfg_max_number_of_forwarders.set(cfg['max_number_of_forwarders']).wait()
|
||||
self.cfg_use_all_forwarders.set(cfg['use_all_forwarders']).wait()
|
||||
self.cfg_module_sync_queue_size.set(cfg['module_sync_queue_size']).wait()
|
||||
#self.cfg_module_positions.set(cfg['module_positions']).wait()
|
||||
@@ -109,7 +109,7 @@ class StdDaqRestClient(Device):
|
||||
'writer_user_id': int(self.cfg_writer_user_id.get()),
|
||||
'log_level': "debug",
|
||||
'submodule_info': {},
|
||||
'max_number_of_forwarders_spawned': int(self.cfg_max_number_of_forwarders_spawned.get()),
|
||||
'max_number_of_forwarders': int(self.cfg_max_number_of_forwarders.get()),
|
||||
'use_all_forwarders': bool(self.cfg_use_all_forwarders.get()),
|
||||
'module_sync_queue_size': int(self.cfg_module_sync_queue_size.get()),
|
||||
'module_positions': {}
|
||||
@@ -165,7 +165,7 @@ class StdDaqRestClient(Device):
|
||||
self.cfg_pixel_width.set(pixel_width).wait()
|
||||
|
||||
self.write_daq_config()
|
||||
logger.info(f"[{self.name}] Reconfigured the StdDAQ")
|
||||
logger.info("[%s] Reconfigured the StdDAQ", self.name)
|
||||
# No feedback on restart, we just sleep
|
||||
sleep(3)
|
||||
|
||||
@@ -192,7 +192,7 @@ class StdDaqRestClient(Device):
|
||||
self.read_daq_config()
|
||||
return super().unstage()
|
||||
|
||||
def stop(self, success=False):
|
||||
def stop(self, *, success=False):
|
||||
"""Stop op: Read the current configuration from the DAQ
|
||||
"""
|
||||
self.unstage()
|
||||
|
||||
@@ -85,7 +85,8 @@ class StdDaqClient(Device):
|
||||
num_retry += 1
|
||||
sleep(3)
|
||||
if num_retry==5:
|
||||
raise ConnectionRefusedError("The standard DAQ websocket interface refused connection 5 times.")
|
||||
raise ConnectionRefusedError(
|
||||
"The stdDAQ websocket interface refused connection 5 times.")
|
||||
|
||||
def __del__(self):
|
||||
"""Try to close the socket"""
|
||||
@@ -174,7 +175,7 @@ class StdDaqClient(Device):
|
||||
pass
|
||||
return super().unstage()
|
||||
|
||||
def stop(self, success=False):
|
||||
def stop(self, *, success=False):
|
||||
""" Stop a running acquisition
|
||||
|
||||
WARN: This will also close the connection!!!
|
||||
|
||||
149
tomcat_bec/devices/psimotor.py
Normal file
149
tomcat_bec/devices/psimotor.py
Normal file
@@ -0,0 +1,149 @@
|
||||
""" Extension class for EpicsMotor
|
||||
|
||||
|
||||
This module extends the basic EpicsMotor with additional functionality. It
|
||||
exposes additional parameters of the EPICS MotorRecord and provides a more
|
||||
detailed interface for motors using the new ECMC-based motion systems at PSI.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
from ophyd import Component, EpicsMotor, EpicsSignal, EpicsSignalRO, Kind
|
||||
from ophyd.status import MoveStatus
|
||||
|
||||
|
||||
class SpmgStates:
|
||||
""" Enum for the EPICS MotorRecord's SPMG state"""
|
||||
# pylint: disable=too-few-public-methods
|
||||
STOP = 0
|
||||
PAUSE = 1
|
||||
MOVE= 2
|
||||
GO = 3
|
||||
|
||||
|
||||
class EpicsMotorMR(EpicsMotor):
|
||||
""" Extended EPICS Motor class
|
||||
|
||||
Special motor class that exposes additional motor record functionality.
|
||||
It extends EpicsMotor base class to provide some simple status checks
|
||||
before movement.
|
||||
"""
|
||||
|
||||
SUB_PROGRESS = "progress"
|
||||
motor_deadband = Component(
|
||||
EpicsSignalRO, ".RDBD", auto_monitor=True, kind=Kind.config)
|
||||
motor_mode = Component(
|
||||
EpicsSignal, ".SPMG", auto_monitor=True, put_complete=True, kind=Kind.omitted)
|
||||
|
||||
_start_position = None
|
||||
_target_position = None
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.subscribe(self._progress_update, run=False)
|
||||
|
||||
def move(self, position, wait=True, **kwargs) -> MoveStatus:
|
||||
""" Extended move function with a few sanity checks
|
||||
|
||||
Note that the default EpicsMotor only supports the 'GO' movement mode.
|
||||
"""
|
||||
# Reset SPMG before move
|
||||
spmg = self.motor_mode.get()
|
||||
if spmg != SpmgStates.GO:
|
||||
self.motor_mode.put(SpmgStates.GO).wait()
|
||||
#Warni if trying to move beyond an active limit
|
||||
if self.high_limit_switch and position > self.position:
|
||||
warnings.warn("Attempting to move above active HLS", RuntimeWarning)
|
||||
if self.low_limit_switch and position < self.position:
|
||||
warnings.warn("Attempting to move below active LLS", RuntimeWarning)
|
||||
|
||||
self._start_position = self.position
|
||||
self._target_position = position
|
||||
|
||||
return super().move(position, wait, **kwargs)
|
||||
|
||||
def _progress_update(self, value, **kwargs) -> None:
|
||||
"""Progress update on the current movement"""
|
||||
if (self._start_position is None) or (self._target_position is None) or (not self.moving):
|
||||
self._run_subs(sub_type=self.SUB_PROGRESS, value=1, max_value=1, done=1)
|
||||
return
|
||||
|
||||
progress = abs(
|
||||
(value - self._start_position) / (self._target_position - self._start_position)
|
||||
)
|
||||
self._run_subs(
|
||||
sub_type=self.SUB_PROGRESS, value=progress, max_value=1, done=self.moving)
|
||||
|
||||
|
||||
class EpicsMotorEC(EpicsMotorMR):
|
||||
""" Detailed ECMC EPICS motor class
|
||||
|
||||
Special motor class to provide additional functionality for ECMC based motors.
|
||||
It exposes additional diagnostic fields and includes basic error management.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ['reset']
|
||||
enable_readback = Component(EpicsSignalRO, "-EnaAct", auto_monitor=True, kind=Kind.normal)
|
||||
enable = Component(
|
||||
EpicsSignal, "-EnaCmd-RB", write_pv="-EnaCmd", auto_monitor=True, kind=Kind.normal)
|
||||
homed = Component(EpicsSignalRO, "-Homed", auto_monitor=True, kind=Kind.normal)
|
||||
velocity_readback = Component(EpicsSignalRO, "-VelAct", auto_monitor=True, kind=Kind.normal)
|
||||
position_readback = Component(EpicsSignalRO, "-PosAct", auto_monitor=True, kind=Kind.normal)
|
||||
position_error = Component(EpicsSignalRO, "-PosErr", auto_monitor=True, kind=Kind.normal)
|
||||
#high_interlock = Component(EpicsSignalRO, "-SumIlockFwd", auto_monitor=True, kind=Kind.normal)
|
||||
#low_interlock = Component(EpicsSignalRO, "-SumIlockBwd", auto_monitor=True, kind=Kind.normal)
|
||||
|
||||
ecmc_status = Component(EpicsSignalRO, "-Status", auto_monitor=True, kind=Kind.normal)
|
||||
error = Component(EpicsSignalRO, "-ErrId", auto_monitor=True, kind=Kind.normal)
|
||||
error_msg = Component(EpicsSignalRO, "-MsgTxt", auto_monitor=True, kind=Kind.normal)
|
||||
error_reset = Component(EpicsSignal, "-ErrRst", put_complete=True, kind=Kind.omitted)
|
||||
|
||||
def move(self, position, wait=True, **kwargs) -> MoveStatus:
|
||||
""" Extended move function with a few sanity checks
|
||||
|
||||
Note that the default EpicsMotor only supports the 'GO' movement mode.
|
||||
"""
|
||||
# Reset SPMG before move
|
||||
error = self.error.get()
|
||||
if error:
|
||||
raise RuntimeError(f"Motor is in error state with message: '{self.error_msg.get()}'")
|
||||
|
||||
return super().move(position, wait, **kwargs)
|
||||
|
||||
def reset(self):
|
||||
""" Resets an ECMC axis
|
||||
|
||||
Recovers an ECMC axis from a previous error. Note that this does not
|
||||
solve the cause of the error, that you'll have to do yourself.
|
||||
|
||||
Common error causes:
|
||||
-------------------------
|
||||
- MAX_POSITION_LAG_EXCEEDED : The PID tuning is wrong.
|
||||
- MAX_VELOCITY_EXCEEDED : Either the PID is wrong or the motor is sticking-slipping
|
||||
- BOTH_LIMITS_ACTIVE : The motors are probably not connected
|
||||
"""
|
||||
# Reset the error
|
||||
self.error_reset.set(1, settle_time=0.1).wait()
|
||||
# Check if it disappeared
|
||||
if self.error.get():
|
||||
raise RuntimeError(f"Failed to reset axis error: '{self.error_msg.get()}'")
|
||||
Reference in New Issue
Block a user