Files
motorDriverTests/setup/caproto.py

309 lines
11 KiB
Python

# *****************************************************************************
# NICOS, the Networked Instrument Control System of the MLZ
# Copyright (c) 2009-2025 by the NICOS contributors (see AUTHORS)
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Matt Clarke <matt.clarke@ess.eu>
# Stefan Mathis <stefan.mathis@psi.ch>
#
# *****************************************************************************
#
# This file has been taken from NICOS, the original file can be found in:
# https://github.com/mlz-ictrl/nicos/blob/master/nicos/devices/epics/pva/caproto.py
from functools import partial
import numpy as np
from caproto import CaprotoTimeoutError, ChannelType
from caproto.sync.client import read, write
from caproto.threading.client import Context
FTYPE_TO_TYPE = {
ChannelType.STRING: str,
ChannelType.INT: int,
ChannelType.FLOAT: float,
ChannelType.ENUM: int,
ChannelType.CHAR: bytes,
ChannelType.LONG: int,
ChannelType.DOUBLE: float,
ChannelType.TIME_STRING: str,
ChannelType.TIME_INT: int,
ChannelType.TIME_FLOAT: float,
ChannelType.TIME_ENUM: int,
ChannelType.TIME_CHAR: bytes,
ChannelType.TIME_LONG: int,
ChannelType.TIME_DOUBLE: float,
ChannelType.CTRL_STRING: str,
ChannelType.CTRL_INT: int,
ChannelType.CTRL_FLOAT: float,
ChannelType.CTRL_ENUM: int,
ChannelType.CTRL_CHAR: bytes,
ChannelType.CTRL_LONG: int,
ChannelType.CTRL_DOUBLE: float,
}
STATUS_TO_MESSAGE = {
0: 'NO_ALARM',
1: 'READ',
2: 'WRITE',
3: 'HIHI',
4: 'HIGH',
5: 'LOLO',
6: 'LOW',
7: 'STATE',
8: 'COS',
9: 'COMM',
10: 'TIMED',
11: 'HWLIMIT',
12: 'CALC',
13: 'SCAN',
14: 'LINK',
15: 'SOFT',
16: 'BAD_SUB',
17: 'UDF',
18: 'DISABLE',
19: 'SIMM',
20: 'READ_ACCESS',
21: 'WRITE_ACCESS',
}
# Same context can be shared across all devices.
_Context = Context()
def caget(name, timeout=3.0):
""" Returns the PV's current value in its raw form via CA.
:param name: the PV name
:param timeout: the EPICS timeout
:return: the PV's raw value
"""
response = read(name, timeout=timeout)
return response.data[0] if len(response.data) == 1 else response.data
def caput(name, value, wait=False, timeout=3.0):
""" Sets a PV's value via CA.
:param name: the PV name
:param value: the value to set
:param wait: whether to wait for completion
:param timeout: the EPICS timeout
"""
write(name, value, notify=wait, timeout=timeout)
class CaprotoWrapper:
""" Class that wraps the caproto module that provides EPICS Channel Access
(CA) support.
"""
def __init__(self, timeout=3.0):
self._pvs = {}
self._choices = {}
self._callbacks = set()
self._timeout = timeout
def connect_pv(self, pvname):
if pvname in self._pvs:
return
value = self._create_pv(pvname)
# Do some prep work for enum types
if hasattr(value.metadata, 'enum_strings'):
self._choices[pvname] = self.get_value_choices(pvname)
def _create_pv(self, pvname, connection_callback=None):
try:
pv, *_ = _Context.get_pvs(
pvname,
connection_state_callback=connection_callback,
timeout=self._timeout
)
self._pvs[pvname] = pv
# Do a read to force a connection
return pv.read(timeout=self._timeout, data_type='control')
except CaprotoTimeoutError:
raise TimeoutError(
f'could not connect to PV {pvname}') from None
def get_pv_value(self, pvname, as_string=False):
try:
response = self._pvs[pvname].read(timeout=self._timeout,
data_type='control')
return self._convert_value(pvname, response, as_string)
except CaprotoTimeoutError:
raise TimeoutError(f'getting {pvname} timed out') from None
def _convert_value(self, pvname, response, as_string=False):
# By default, the response data is always a list to cover all possible
# readback values from EPICS (lists, strings, chars, numbers). The last
# two cases need to be treated in a special way. They can be identified
# by the list length being 1.
if len(response.data) == 1:
value = response.data[0]
if pvname in self._choices:
return self._choices[pvname][value] if as_string else value
elif isinstance(value, bytes):
return value.decode()
# If an empty string is returned, the data has a single entry
# (the NULL terminator)
if as_string:
return bytes(response.data).rstrip(b'\x00').decode(
encoding='utf-8', errors='ignore')
return value
# Strings are read with their NULL terminator, hence it needs to be
# stripped before decoding
if as_string:
return bytes(response.data).rstrip(b'\x00').decode(
encoding='utf-8', errors='ignore')
if isinstance(response.data, np.ndarray):
val_type = FTYPE_TO_TYPE[self._pvs[pvname].channel.native_data_type]
if val_type == bytes or as_string:
return response.data.tobytes().decode()
return response.data
def put_pv_value(self, pvname, value, wait=False):
if pvname in self._choices:
value = self._choices[pvname].index(value)
try:
self._pvs[pvname].write(value, wait=wait, timeout=self._timeout)
except CaprotoTimeoutError:
raise TimeoutError(f'setting {pvname} timed out') from None
def put_pv_value_blocking(self, pvname, value, block_timeout=60):
if pvname in self._choices:
value = self._choices[pvname].index(value)
try:
self._pvs[pvname].write(value, wait=True, timeout=block_timeout)
except CaprotoTimeoutError:
raise TimeoutError(f'setting {pvname} timed out') from None
def get_pv_type(self, pvname):
data_type = self._pvs[pvname].channel.native_data_type
return FTYPE_TO_TYPE.get(data_type)
def get_alarm_status(self, pvname):
values = self.get_control_values(pvname)
return self._extract_alarm_info(values)
def get_units(self, pvname, default=''):
values = self.get_control_values(pvname)
return self._get_units(values, default)
def _get_units(self, values, default):
if hasattr(values, 'units'):
return values.units.decode()
return default
def get_limits(self, pvname, default_low=-1e308, default_high=1e308):
values = self.get_control_values(pvname)
if hasattr(values, 'lower_ctrl_limit'):
default_low = values.lower_ctrl_limit
default_high = values.upper_ctrl_limit
return default_low, default_high
def get_control_values(self, pvname):
try:
result = self._pvs[pvname].read(timeout=self._timeout,
data_type='control')
return result.metadata
except CaprotoTimeoutError:
raise TimeoutError(
f'getting control values for {pvname} timed out') from None
def get_value_choices(self, pvname):
# Only works for enum types like MBBI and MBBO
value = self.get_control_values(pvname)
return self._extract_choices(value)
def _extract_choices(self, value):
if hasattr(value, 'enum_strings'):
return [x.decode() for x in value.enum_strings]
return []
def subscribe(self, pvname, pvparam, change_callback,
connection_callback=None, as_string=False):
"""
Create a monitor subscription to the specified PV.
:param pvname: The PV name.
:param pvparam: The associated NICOS parameter
(e.g. readpv, writepv, etc.).
:param change_callback: The function to call when the value changes.
:param connection_callback: The function to call when the connection
status changes.
:param as_string: Whether to return the value as a string.
:return: the subscription object.
"""
conn_callback = self._create_connection_callback(pvname, pvparam,
connection_callback)
self._create_pv(pvname, conn_callback)
value_callback = self._create_value_callback(pvname, pvparam,
change_callback, as_string)
sub = self._pvs[pvname].subscribe(data_type='control')
sub.add_callback(value_callback)
return sub
def _create_value_callback(self, pvname, pvparam, change_callback,
as_string):
callback = partial(self._callback, pvname, pvparam, change_callback,
as_string)
self._store_callback(callback)
return callback
def _create_connection_callback(self, pvname, pvparam, connection_callback):
callback = partial(self._conn_callback, pvname, pvparam,
connection_callback)
self._store_callback(callback)
return callback
def _store_callback(self, callback):
# Must keep a reference to callbacks to avoid garbage collection!
self._callbacks.add(callback)
def _callback(self, pvname, pvparam, change_callback, as_string, sub,
response):
value = self._convert_value(pvname, response, as_string)
units = self._get_units(response.metadata, '')
severity, message = self._extract_alarm_info(response)
change_callback(pvname, pvparam, value, units, severity, message)
def _conn_callback(self, pvname, pvparam, connection_callback, pv, state):
connection_callback(pvname, pvparam, state == 'connected')
def _extract_alarm_info(self, values):
# The EPICS 'severity' matches to the NICOS `status` and the message has
# a short description of the alarm details.
if hasattr(values, 'severity'):
message = STATUS_TO_MESSAGE[values.status]
return values.severity, '' if message == 'NO_ALARM' else message
return values.severity, 'alarm information unavailable'
def close_subscription(self, subscription):
subscription.clear()