Skip to content

Commit dded7d8

Browse files
Merge pull request #984 from YektaY/pva_nttable
ENH: PyDMNTTable Widget
2 parents 5be83f3 + ebc7a6e commit dded7d8

File tree

13 files changed

+498
-29
lines changed

13 files changed

+498
-29
lines changed

docs/source/data_plugins/p4p_plugin.rst

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ P4P is the only option (and will be chosen automatically if this variable is not
2828
in the future.
2929

3030
Supported Types
31-
---------------
31+
===============
3232

3333
Currently this data plugin supports all `normative types`_. The values and control variables are pulled out of
3434
the data received and sent through the existing PyDM signals to be read by widgets via the channels they are
@@ -39,6 +39,29 @@ currently possible in this version of the plugin. For example, defining a group
3939
result in the named fields being sent to the widgets. Full support for structured data is planned to be supported
4040
as part of a future release.
4141

42+
NTTables
43+
--------
44+
45+
The plugin accepts NTTables. It will convert NTTables in python dictionaries which are then passed to the pydm widgets.
46+
Not all widgets will accept a dictionary (or the whole NTTable) as an input.
47+
A specified section of the NTTable can be passed to a those pydm widgets which do not accept dictionaries.
48+
If the PV is passing an NTTable and the user wants to pass only a specific subfield of the NTTable this can be achieved via appending a ``/``
49+
followed by the key or name of the column header of the subfield of the NTTable.
50+
For example::
51+
52+
pva://MTEST/subfield
53+
54+
multiple layers of subfields also works::
55+
56+
pva://MTEST/sub-field/subfield_of_a_subfield
57+
58+
Note: currenty subfields can only be used to read a subset of data from an NTTables
59+
and can't be used to write to the subfield. A follow up release will add an ability to
60+
write to the subfield.
61+
62+
Image decompression
63+
-------------------
64+
4265
Image decompression is performed when image data is specified using an ``NTNDArray`` with the ``codec`` field set.
4366
The decompression algorithm to apply will be determined by what the ``codec`` field is set to. In order
4467
for decompression to happen, the python package for the associated codec must be installed in the environment

docs/source/widgets/nt_table.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#######################
2+
PyDMNTTable
3+
#######################
4+
5+
.. autoclass:: pydm.widgets.nt_table.PythonTableModel
6+
:members:
7+
:inherited-members:
8+
:show-inheritance:
9+
10+
.. autoclass:: pydm.widgets.nt_table.PyDMNTTable
11+
:members:
12+
:inherited-members:
13+
:show-inheritance:

pydm/data_plugins/__init__.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
from qtpy.QtWidgets import QApplication
1515

1616
from .. import config
17-
from ..utilities import (import_module_by_filename, log_failures,
18-
protocol_and_address)
17+
from ..utilities import (import_module_by_filename, log_failures, parsed_address)
1918
from .plugin import PyDMPlugin
2019

2120
logger = logging.getLogger(__name__)
@@ -77,15 +76,20 @@ def plugin_for_address(address: str) -> Optional[PyDMPlugin]:
7776
Find the correct PyDMPlugin for a channel
7877
"""
7978
# Check for a configured protocol
80-
protocol, addr = protocol_and_address(address)
79+
try:
80+
protocol = parsed_address(address).scheme
81+
except AttributeError:
82+
protocol = None
83+
8184
# Use default protocol
8285
if protocol is None and config.DEFAULT_PROTOCOL is not None:
8386
logger.debug("Using default protocol %s for %s",
8487
config.DEFAULT_PROTOCOL, address)
8588
# If no protocol was specified, and the default protocol
8689
# environment variable is specified, try to use that instead.
8790
protocol = config.DEFAULT_PROTOCOL
88-
# Load proper plugin module
91+
92+
# Load proper plugin module
8993
if protocol:
9094
initialize_plugins_if_needed()
9195
try:
@@ -98,6 +102,7 @@ def plugin_for_address(address: str) -> Optional[PyDMPlugin]:
98102
"will receive no data. To specify a default protocol, "
99103
"set the PYDM_DEFAULT_PROTOCOL environment variable."
100104
"".format(addr=address))
105+
101106
return None
102107

103108

pydm/data_plugins/epics_plugins/p4p_plugin_component.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import numpy as np
3-
3+
import collections
44
from p4p.client.thread import Context, Disconnected
55
from p4p.wrapper import Value
66
from .pva_codec import decompress
@@ -19,7 +19,6 @@ def __init__(self, channel: PyDMChannel, address: str,
1919
protocol: Optional[str] = None, parent: Optional[QObject] = None):
2020
"""
2121
Manages the connection to a channel using the P4P library. A given channel can have multiple listeners.
22-
2322
Parameters
2423
----------
2524
channel : PyDMChannel
@@ -33,7 +32,7 @@ def __init__(self, channel: PyDMChannel, address: str,
3332
"""
3433
super().__init__(channel, address, protocol, parent)
3534
self._connected = False
36-
self.monitor = P4PPlugin.context.monitor(name=address,
35+
self.monitor = P4PPlugin.context.monitor(name=self.address,
3736
cb=self.send_new_value,
3837
notify_disconnect=True)
3938
self.add_listener(channel)
@@ -49,6 +48,7 @@ def __init__(self, channel: PyDMChannel, address: str,
4948
self._upper_warning_limit = None
5049
self._lower_warning_limit = None
5150
self._timestamp = None
51+
self.nttable_data_location = PyDMPlugin.get_subfield(channel)
5252

5353
def clear_cache(self) -> None:
5454
""" Clear out all the stored values of this connection. """
@@ -65,6 +65,8 @@ def clear_cache(self) -> None:
6565
self._lower_warning_limit = None
6666
self._timestamp = None
6767

68+
self.nttable_data_location = None
69+
6870
def send_new_value(self, value: Value) -> None:
6971
""" Callback invoked whenever a new value is received by our monitor. Emits signals based on values changed. """
7072
if isinstance(value, Disconnected):
@@ -79,9 +81,45 @@ def send_new_value(self, value: Value) -> None:
7981
self.write_access_signal.emit(True)
8082

8183
self._value = value
84+
has_value_changed_yet = False
8285
for changed_value in value.changedSet():
83-
if changed_value == 'value':
84-
new_value = value.value
86+
if changed_value == 'value' or changed_value.split('.')[0] == 'value':
87+
# NTTable has a changedSet item for each column that has changed
88+
# Since we want to send an update on any table change, let's track
89+
# if the value item has been updated yet
90+
if has_value_changed_yet:
91+
continue
92+
else:
93+
has_value_changed_yet = True
94+
95+
if 'NTTable' in value.getID():
96+
new_value = value.value.todict()
97+
else:
98+
new_value = value.value
99+
100+
if self.nttable_data_location:
101+
msg = f"Invalid channel... {self.nttable_data_location}"
102+
103+
for value in self.nttable_data_location:
104+
if isinstance(new_value, collections.Container) and not isinstance(new_value, str):
105+
106+
if type(value) == str:
107+
try:
108+
new_value = new_value[value]
109+
continue
110+
except TypeError:
111+
logger.debug('Type Error when attempting to use the given key, code will next attempt to convert the key to an int')
112+
except KeyError:
113+
logger.exception(msg)
114+
115+
try:
116+
new_value = new_value[int(value)]
117+
except ValueError:
118+
logger.exception(msg, exc_info=True)
119+
else:
120+
logger.exception(msg, exc_info=True)
121+
raise ValueError(msg)
122+
85123
if new_value is not None:
86124
if isinstance(new_value, np.ndarray):
87125
if 'NTNDArray' in value.getID():
@@ -95,6 +133,8 @@ def send_new_value(self, value: Value) -> None:
95133
self.new_value_signal[int].emit(new_value)
96134
elif isinstance(new_value, str):
97135
self.new_value_signal[str].emit(new_value)
136+
elif isinstance(new_value, dict):
137+
self.new_value_signal[dict].emit(new_value)
98138
else:
99139
raise ValueError(f'No matching signal for value: {new_value} with type: {type(new_value)}')
100140
# Sometimes unchanged control variables appear to be returned with value changes, so checking against
@@ -149,7 +189,6 @@ def put_value(self, value):
149189
def add_listener(self, channel: PyDMChannel):
150190
"""
151191
Adds a listener to this connection, connecting the appropriate signals/slots to the input PyDMChannel.
152-
153192
Parameters
154193
----------
155194
channel : PyDMChannel
@@ -183,6 +222,10 @@ def add_listener(self, channel: PyDMChannel):
183222
channel.value_signal[np.ndarray].connect(self.put_value, Qt.QueuedConnection)
184223
except KeyError:
185224
pass
225+
try:
226+
channel.value_signal[dict].connect(self.put_value, Qt.QueuedConnection)
227+
except KeyError:
228+
pass
186229

187230
def close(self):
188231
""" Closes out this connection. """

pydm/data_plugins/plugin.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
from numpy import ndarray
66
from typing import Optional, Callable
77

8-
from ..utilities.remove_protocol import protocol_and_address
8+
from ..utilities.remove_protocol import parsed_address
99
from qtpy.QtCore import Signal, QObject, Qt
1010
from qtpy.QtWidgets import QApplication
1111
from .. import config
12-
12+
import re
1313

1414
class PyDMConnection(QObject):
15-
new_value_signal = Signal([float], [int], [str], [ndarray], [bool])
15+
new_value_signal = Signal([float], [int], [str], [bool], [object])
1616
connection_state_signal = Signal(bool)
1717
new_severity_signal = Signal(int)
1818
write_access_signal = Signal(bool)
@@ -55,14 +55,14 @@ def add_listener(self, channel):
5555
except TypeError:
5656
pass
5757
try:
58-
self.new_value_signal[ndarray].connect(channel.value_slot, Qt.QueuedConnection)
58+
self.new_value_signal[bool].connect(channel.value_slot, Qt.QueuedConnection)
5959
except TypeError:
6060
pass
6161
try:
62-
self.new_value_signal[bool].connect(channel.value_slot, Qt.QueuedConnection)
62+
self.new_value_signal[object].connect(channel.value_slot, Qt.QueuedConnection)
6363
except TypeError:
6464
pass
65-
65+
6666
if channel.severity_slot is not None:
6767
self.new_severity_signal.connect(channel.severity_slot, Qt.QueuedConnection)
6868

@@ -134,14 +134,14 @@ def remove_listener(self, channel, destroying: Optional[bool] = False) -> None:
134134
except TypeError:
135135
pass
136136
try:
137-
self.new_value_signal[ndarray].disconnect(channel.value_slot)
137+
self.new_value_signal[bool].disconnect(channel.value_slot)
138138
except TypeError:
139139
pass
140140
try:
141-
self.new_value_signal[bool].disconnect(channel.value_slot)
141+
self.new_value_signal[object].disconnect(channel.value_slot)
142142
except TypeError:
143143
pass
144-
144+
145145
if self._should_disconnect(channel.severity_slot, destroying):
146146
try:
147147
self.new_severity_signal.disconnect(channel.severity_slot)
@@ -250,19 +250,52 @@ def __init__(self):
250250
self.channels = weakref.WeakSet()
251251
self.lock = threading.Lock()
252252

253+
@staticmethod
254+
def get_full_address(channel):
255+
parsed_addr = parsed_address(channel.address)
256+
257+
if parsed_addr:
258+
full_addr = parsed_addr.netloc + parsed_addr.path
259+
else:
260+
full_addr = None
261+
262+
return full_addr
263+
253264
@staticmethod
254265
def get_address(channel):
255-
return protocol_and_address(channel.address)[1]
266+
parsed_addr = parsed_address(channel.address)
267+
addr = parsed_addr.netloc
268+
protocol = parsed_addr.scheme
269+
270+
if protocol == 'calc' or protocol == 'loc':
271+
addr = parsed_addr.netloc + '?' + parsed_addr.query
272+
273+
return addr
274+
275+
@staticmethod
276+
def get_subfield(channel):
277+
parsed_addr = parsed_address(channel.address)
278+
279+
if parsed_addr:
280+
subfield = parsed_addr.path
281+
282+
if subfield != '':
283+
subfield = subfield[1:].split('/')
284+
else:
285+
subfield = None
286+
287+
return subfield
256288

257289
@staticmethod
258290
def get_connection_id(channel):
259-
return PyDMPlugin.get_address(channel)
291+
return PyDMPlugin.get_full_address(channel)
260292

261293
def add_connection(self, channel):
262294
from pydm.utilities import is_qt_designer
263295
with self.lock:
264296
connection_id = self.get_connection_id(channel)
265297
address = self.get_address(channel)
298+
266299
# If this channel is already connected to this plugin lets ignore
267300
if channel in self.channels:
268301
return

pydm/tests/test_data_plugins_import.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@ def test_plugin_directory_loading(qapp, caplog):
3939
os.remove(os.path.join(cur_dir, 'plugin_foo.py'))
4040

4141

42-
def test_plugin_for_address(test_plugin):
42+
def test_plugin_for_address(test_plugin, monkeypatch):
4343
# Get by protocol
4444
assert isinstance(plugin_for_address('tst://tst:this'),
4545
test_plugin)
4646
assert plugin_for_address('tst:this') is None
4747
# Default protocol
48-
config.DEFAULT_PROTOCOL = 'tst'
48+
monkeypatch.setattr(config, 'DEFAULT_PROTOCOL', 'tst')
4949
assert isinstance(plugin_for_address('tst:this'),
5050
test_plugin)
5151

pydm/tests/utilities/test_remove_protocol.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ...utilities.remove_protocol import remove_protocol
2-
2+
from ...utilities.remove_protocol import protocol_and_address
3+
from ...utilities.remove_protocol import parsed_address
34

45
def test_remove_protocol():
56
out = remove_protocol('foo://bar')
@@ -10,3 +11,22 @@ def test_remove_protocol():
1011

1112
out = remove_protocol('foo://bar://foo2')
1213
assert (out == 'bar://foo2')
14+
15+
def test_protocol_and_address():
16+
out = protocol_and_address('foo://bar')
17+
assert (out == ('foo', 'bar'))
18+
19+
out = protocol_and_address('foo:/bar')
20+
assert (out == (None, 'foo:/bar'))
21+
22+
def test_parsed_address():
23+
out = parsed_address(1)
24+
assert (out == None)
25+
26+
out = parsed_address('foo:/bar')
27+
assert (out == None)
28+
29+
out = parsed_address('foo://bar')
30+
assert (out == ('foo', 'bar', '', '', '', ''))
31+
32+

pydm/utilities/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from . import colors, macro, shortcuts
1818
from .connection import close_widget_connections, establish_widget_connections
1919
from .iconfont import IconFont
20-
from .remove_protocol import protocol_and_address, remove_protocol
20+
from .remove_protocol import protocol_and_address, remove_protocol, parsed_address
2121
from .units import convert, find_unit_options, find_unittype
2222

2323
logger = logging.getLogger(__name__)

0 commit comments

Comments
 (0)