grc: Add edge (connection) properties

This allows connections (graph edges) to have properties associated with
them. In order to do so, the edge types must have parameters associated
with them in the *.domain.yml file.

For edges that have properties/parameters associated with them,
a property dialog can be summoned by right-clicking a connector arrow.

Signed-off-by: Martin Braun <martin.braun@ettus.com>
This commit is contained in:
Martin Braun 2023-10-10 15:21:03 +02:00 committed by Jeff Long
parent 829e21f0fd
commit 486e7a5edb
9 changed files with 195 additions and 25 deletions

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
"""
import collections
from .base import Element
from .Constants import ALIASES_OF
@ -13,15 +14,23 @@ from .utils.descriptors import lazy_property
class Connection(Element):
"""
Stores information about a connection between two block ports. This class
knows:
- Where the source and sink ports are (on which blocks)
- The domain (message, stream, ...)
- Which parameters are associated with this connection
"""
is_connection = True
documentation = {'': ''}
def __init__(self, parent, source, sink):
"""
Make a new connection given the parent and 2 ports.
Args:
flow_graph: the parent of this element
parent: the parent of this element (a flow graph)
source: a port (any direction)
sink: a port (any direction)
@throws Error cannot make connection
@ -41,6 +50,16 @@ class Connection(Element):
self.source_port = source
self.sink_port = sink
# Unlike the blocks, connection parameters are defined in the connection
# domain definition files, as all connections within the same domain
# share the same properties.
param_factory = self.parent_platform.make_param
conn_parameters = self.parent_platform.connection_params.get(self.type, {})
self.params = collections.OrderedDict(
(data['id'], param_factory(parent=self, **data))
for data in conn_parameters
)
def __str__(self):
return 'Connection (\n\t{}\n\t\t{}\n\t{}\n\t\t{}\n)'.format(
self.source_block, self.source_port, self.sink_block, self.sink_port,
@ -57,6 +76,10 @@ class Connection(Element):
def __iter__(self):
return iter((self.source_port, self.sink_port))
def children(self):
""" This includes the connection parameters """
return self.params.values()
@lazy_property
def source_block(self):
return self.source_port.parent_block
@ -79,6 +102,18 @@ class Connection(Element):
"""
return self.source_block.enabled and self.sink_block.enabled
@property
def label(self):
""" Returns a label for dialogs """
src_domain, sink_domain = [
self.parent_platform.domains[d].name for d in self.type]
return f'Connection ({src_domain}{sink_domain})'
@property
def namespace_templates(self):
"""Returns everything we want to have available in the template rendering"""
return {key: param.template_arg for key, param in self.params.items()}
def validate(self):
"""
Validate the connections.
@ -113,9 +148,33 @@ class Connection(Element):
Export this connection's info.
Returns:
a nested data odict
A tuple with connection info, and parameters.
"""
return (
# See if we need to use file format version 2:
if self.params:
return {
'src_blk_id': self.source_block.name,
'src_port_id': self.source_port.key,
'snk_blk_id': self.sink_block.name,
'snk_port_id': self.sink_port.key,
'params': collections.OrderedDict(sorted(
(param_id, param.value)
for param_id, param in self.params.items())),
}
# If there's no reason to do otherwise, we can export info as
# FLOW_GRAPH_FILE_FORMAT_VERSION 1 format:
return [
self.source_block.name, self.source_port.key,
self.sink_block.name, self.sink_port.key
)
self.sink_block.name, self.sink_port.key,
]
def import_data(self, params):
"""
Import connection parameters.
"""
for key, value in params.items():
try:
self.params[key].set_value(value)
except KeyError:
continue

View File

@ -26,11 +26,12 @@ CACHE_FILE = os.path.expanduser('~/.cache/grc_gnuradio/cache_v2.json')
BLOCK_DESCRIPTION_FILE_FORMAT_VERSION = 1
# File format versions:
# 0: undefined / legacy
# 1: non-numeric message port keys (label is used instead)
# This constant is the max known version. If a version higher than this shows
# up, we assume we can't handle it.
FLOW_GRAPH_FILE_FORMAT_VERSION = 1
# 0: undefined / legacy
# 1: non-numeric message port keys (label is used instead)
# 2: connection info is stored as dictionary
FLOW_GRAPH_FILE_FORMAT_VERSION = 2
# Param tabs
DEFAULT_PARAM_TAB = "General"

View File

@ -334,7 +334,7 @@ class FlowGraph(Element):
block = None
return block
def connect(self, porta, portb):
def connect(self, porta, portb, params=None):
"""
Create a connection between porta and portb.
@ -348,6 +348,8 @@ class FlowGraph(Element):
"""
connection = self.parent_platform.Connection(
parent=self, source=porta, sink=portb)
if params:
connection.import_data(params)
self.connections.add(connection)
return connection
@ -393,13 +395,31 @@ class FlowGraph(Element):
def block_order(b):
return not b.is_variable, b.name # todo: vars still first ?!?
def get_file_format_version(data):
"""Determine file format version based on available data"""
if any(isinstance(c, dict) for c in data['connections']):
return 2
return 1
def sort_connection_key(connection_info):
if isinstance(connection_info, dict):
return [
connection_info.get('src_blk_id'),
connection_info.get('src_port_id'),
connection_info.get('snk_blk_id'),
connection_info.get('snk_port_id'),
]
return connection_info
data = collections.OrderedDict()
data['options'] = self.options_block.export_data()
data['blocks'] = [b.export_data() for b in sorted(self.blocks, key=block_order)
if b is not self.options_block]
data['connections'] = sorted(c.export_data() for c in self.connections)
data['connections'] = sorted(
(c.export_data() for c in self.connections),
key=sort_connection_key)
data['metadata'] = {
'file_format': FLOW_GRAPH_FILE_FORMAT_VERSION,
'file_format': get_file_format_version(data),
'grc_version': self.parent_platform.config.version
}
return data
@ -470,7 +490,23 @@ class FlowGraph(Element):
_blocks = {block.name: block for block in self.blocks}
# TODO: Add better error handling if no connections exist in the flowgraph file.
for src_blk_id, src_port_id, snk_blk_id, snk_port_id in data.get('connections', []):
for connection_info in data.get('connections', []):
# First unpack the connection info, which can be in different formats.
# FLOW_GRAPH_FILE_FORMAT_VERSION 1: Connection info is a 4-tuple
if isinstance(connection_info, (list, tuple)) and len(connection_info) == 4:
src_blk_id, src_port_id, snk_blk_id, snk_port_id = connection_info
conn_params = {}
# FLOW_GRAPH_FILE_FORMAT_VERSION 2: Connection info is a dictionary
elif isinstance(connection_info, dict):
src_blk_id = connection_info.get('src_blk_id')
src_port_id = connection_info.get('src_port_id')
snk_blk_id = connection_info.get('snk_blk_id')
snk_port_id = connection_info.get('snk_port_id')
conn_params = connection_info.get('params', {})
else:
Messages.send_error_load(f'Invalid connection format detected!')
had_connect_errors = True
continue
try:
source_block = _blocks[src_blk_id]
sink_block = _blocks[snk_blk_id]
@ -486,7 +522,7 @@ class FlowGraph(Element):
sink_port = verify_and_get_port(
snk_port_id, sink_block, 'sink')
self.connect(source_port, sink_port)
self.connect(source_port, sink_port, conn_params)
except (KeyError, LookupError) as e:
Messages.send_error_load(

View File

@ -184,6 +184,13 @@ class TopBlockGenerator(object):
return output
def _blocks(self):
"""
Returns a list of tuples: (block, block_make)
'block' contains a reference to the block object.
'block_make' contains the pre-rendered string for the 'make' part of the
block.
"""
fg = self._flow_graph
parameters = fg.get_parameters()
@ -319,7 +326,10 @@ class TopBlockGenerator(object):
template = templates[con.type]
if con.source_port.dtype != 'bus':
code = template.render(
make_port_sig=make_port_sig, source=con.source_port, sink=con.sink_port)
make_port_sig=make_port_sig,
source=con.source_port,
sink=con.sink_port,
**con.namespace_templates)
rendered.append(code)
else:
# Bus ports need to iterate over the underlying connections and then render
@ -337,7 +347,10 @@ class TopBlockGenerator(object):
connection = fg.parent_platform.Connection(
parent=self, source=hidden_porta, sink=hidden_portb)
code = template.render(
make_port_sig=make_port_sig, source=hidden_porta, sink=hidden_portb)
make_port_sig=make_port_sig,
source=hidden_porta,
sink=hidden_portb,
**con.namespace_templates)
rendered.append(code)
return rendered

View File

@ -47,6 +47,7 @@ class Platform(Element):
self.domains = {}
self.connection_templates = {}
self.cpp_connection_templates = {}
self.connection_params = {}
self._block_categories = {}
self._auto_hier_block_generate_chain = set()
@ -279,6 +280,7 @@ class Platform(Element):
'connect', '')
self.cpp_connection_templates[connection_id] = connection.get(
'cpp_connect', '')
self.connection_params[connection_id] = connection.get('parameters', {})
def load_category_tree_description(self, data, file_path):
"""Parse category tree file and add it to list"""
@ -358,8 +360,10 @@ class Platform(Element):
data = flow_graph.export_data()
try:
data['connections'] = [yaml.ListFlowing(
i) for i in data['connections']]
data['connections'] = [
yaml.ListFlowing(conn) if isinstance(conn, (list, tuple)) else conn
for conn in data['connections']
]
except KeyError:
pass

View File

@ -1,9 +1,11 @@
from .utils import Spec, expand
from .block import PARAM_SCHEME
DOMAIN_CONNECTION = expand(
type=Spec(types=list, required=True, item_scheme=None),
connect=str,
cpp_connect=str,
parameters=Spec(types=list, required=False, item_scheme=PARAM_SCHEME),
)
DOMAIN_SCHEME = expand(

View File

@ -21,6 +21,8 @@ from .MainWindow import MainWindow
from .PropsDialog import PropsDialog
from ..core import Messages
from ..core.Connection import Connection
from ..core.blocks import Block
log = logging.getLogger(__name__)
@ -551,7 +553,8 @@ class Application(Gtk.Application):
##################################################
elif action == Actions.BLOCK_PARAM_MODIFY:
selected_block = args[0] if args[0] else flow_graph.selected_block
if selected_block:
selected_conn = args[0] if args[0] else flow_graph.selected_connection
if selected_block and isinstance(selected_block, Block):
self.dialog = PropsDialog(self.main_window, selected_block)
response = Gtk.ResponseType.APPLY
while response == Gtk.ResponseType.APPLY: # rerun the dialog if Apply was hit
@ -571,6 +574,26 @@ class Application(Gtk.Application):
Actions.ELEMENT_SELECT()
self.dialog.destroy()
self.dialog = None
elif selected_conn and isinstance(selected_conn, Connection):
self.dialog = PropsDialog(self.main_window, selected_conn)
response = Gtk.ResponseType.APPLY
while response == Gtk.ResponseType.APPLY: # rerun the dialog if Apply was hit
response = self.dialog.run()
if response in (Gtk.ResponseType.APPLY, Gtk.ResponseType.ACCEPT):
page.state_cache.save_new_state(
flow_graph.export_data())
# Following line forces a complete update of io ports
flow_graph_update()
page.saved = False
if response in (Gtk.ResponseType.REJECT, Gtk.ResponseType.ACCEPT):
curr_state = page.state_cache.get_current_state()
flow_graph.import_data(curr_state)
flow_graph_update()
if response == Gtk.ResponseType.APPLY:
# null action, that updates the main window
Actions.ELEMENT_SELECT()
self.dialog.destroy()
self.dialog = None
elif action == Actions.EXTERNAL_UPDATE:
page.state_cache.save_new_state(flow_graph.export_data())
flow_graph_update()
@ -809,11 +832,18 @@ class Application(Gtk.Application):
selected_blocks = list(flow_graph.selected_blocks())
selected_block = selected_blocks[0] if selected_blocks else None
# See if a connection has modifiable parameters or grey out the entry
# in the menu
selected_connections = list(flow_graph.selected_connections())
selected_connection = selected_connections[0] \
if len(selected_connections) == 1 \
else None
selected_conn_has_params = selected_connection and bool(len(selected_connection.params))
# update general buttons
Actions.ERRORS_WINDOW_DISPLAY.set_enabled(not flow_graph.is_valid())
Actions.ELEMENT_DELETE.set_enabled(bool(flow_graph.selected_elements))
Actions.BLOCK_PARAM_MODIFY.set_enabled(bool(selected_block))
Actions.BLOCK_PARAM_MODIFY.set_enabled(bool(selected_block) or bool(selected_conn_has_params))
Actions.BLOCK_ROTATE_CCW.set_enabled(bool(selected_blocks))
Actions.BLOCK_ROTATE_CW.set_enabled(bool(selected_blocks))
# update alignment options

View File

@ -42,6 +42,8 @@ class PropsDialog(Gtk.Dialog):
(Constants.MIN_DIALOG_WIDTH, Constants.MIN_DIALOG_HEIGHT)
))
# Careful: 'block' can also be a connection! The naming is because
# property dialogs for connections were added much later.
self._block = block
self._hash = 0
self._config = parent.config
@ -212,7 +214,9 @@ class PropsDialog(Gtk.Dialog):
pos = buf.get_end_iter()
# Add link to wiki page for this block, at the top, as long as it's not an OOT block
if self._block.category and self._block.category[0] == "Core":
if self._block.is_connection:
self._docs_link.set_markup('Connection')
elif self._block.category and self._block.category[0] == "Core":
note = "Wiki Page for this Block: "
prefix = self._config.wiki_block_docs_url_prefix
suffix = self._block.label.replace(" ", "_")
@ -236,11 +240,13 @@ class PropsDialog(Gtk.Dialog):
buf.insert(pos, '\n')
# if given the current parameters an exact match can be made
block_constructor = self._block.templates.render(
'make').rsplit('.', 2)[-1]
block_class = block_constructor.partition('(')[0].strip()
if block_class in docstrings:
docstrings = {block_class: docstrings[block_class]}
block_templates = getattr(self._block, 'templates', None)
if block_templates:
block_constructor = block_templates.render(
'make').rsplit('.', 2)[-1]
block_class = block_constructor.partition('(')[0].strip()
if block_class in docstrings:
docstrings = {block_class: docstrings[block_class]}
# show docstring(s) extracted from python sources
for cls_name, docstring in docstrings.items():

View File

@ -679,6 +679,15 @@ class FlowGraph(CoreFlowgraph, Drawable):
"""
return (e for e in self.selected_elements.copy() if e.is_block)
def selected_connections(self):
"""
Get a group of selected connections.
Returns:
sub set of connections in this flow graph
"""
return (e for e in self.selected_elements.copy() if e.is_connection)
@property
def selected_block(self):
"""
@ -689,6 +698,16 @@ class FlowGraph(CoreFlowgraph, Drawable):
"""
return next(self.selected_blocks(), None)
@property
def selected_connection(self):
"""
Get the selected connection
Returns:
a connection or None
"""
return next(self.selected_connections(), None)
def get_selected_elements(self):
"""
Get the group of selected elements.