Skip to content
Snippets Groups Projects
Commit 0b5b48ed authored by René Schöne's avatar René Schöne
Browse files

integrate protobuf messages, add auto-scroll log.

parent 92976ad5
No related branches found
No related tags found
No related merge requests found
# Dashboard for ros3rag use case
[Java impl repo](https://git-st.inf.tu-dresden.de/jastadd/ros3rag) | [Paper repo](https://git-st.inf.tu-dresden.de/stgroup/publications/2021/ragconnect-followup) | [Paper issue](https://git-st.inf.tu-dresden.de/stgroup/publications/kanban/2021/-/issues/3)
## Running
- Install requirements, best using virtual environment
- Run `python main.py`
- Open <http://127.0.0.1:8050/>
// cgv_connector.proto
// this file contains the messages that are exchanged between the cgv framework and the st ROS interface
syntax = "proto3";
option java_package = "de.tudresden.inf.st.ceti";
option java_multiple_files = true;
message Object {
// Position is object-center related
message Position {
float x = 1; // in m
float y = 2; // in m
float z = 3; // height in m
}
// 3D description of the object
message Size {
float length = 1; // in m
float width = 2; // in m
float height = 3; // in m
}
message Orientation {
float x = 1; // normalized quaternion
float y = 2;
float z = 3;
float w = 4;
}
message Color {
float r = 1; // 0..1
float g = 2; // 0..1
float b = 3; // 0..1
}
enum Type {
UNKNOWN = 0;
BOX = 1;
BIN = 2;
ARM = 3;
}
string id = 1;
Type type = 2;
Position pos = 3;
Size size = 4;
Orientation orientation = 5;
Color color = 6;
}
// the scene is stored within the ROS side and sent to the CGV framework
message Scene {
repeated Object objects = 1;
}
// the selection is done by the CGV framework and sent to ROS
message Selection {
string id = 1; // the id corresponds to an id of an Object in a Scene
}
// vvv from rs vvv.
// Merged message to contain both pick and place in one
message MergedSelection {
string idRobot = 1; // the id corresponds to and id of the robot Object that should execute this operation
string idPick = 2; // the id corresponds to an id of the Object in a Scene to be picked
string idPlace = 3; // the id corresponds to an id of the Object in a Scene where the picked object shall be placed
}
// Reachability of objects, as reported by MoveIt
message Reachability {
message ObjectReachability {
string idObject = 1; // the id of the object to reach
bool reachable = 2; // whether the object can be reached
}
string idRobot = 1; // the id of the robot arm
repeated ObjectReachability objects = 2; // all objects reachable
}
This diff is collapsed.
{ "objects": [
{ "id": "tablePillar1","pos": { "x": 0.77,"y": 0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "tablePillar2","pos": { "x": -0.77,"y": -0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "tablePillar3","pos": { "x": -0.77,"y": 0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "tablePillar4","pos": { "x": 0.77,"y": -0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "table","pos": { "z": 0.7 },"size": { "length": 1.6,"width": 1.6,"height": 0.1 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "binBlue","type": "BIN","pos": { "x": -0.34,"y": 0.49,"z": 0.8325 },"size": { "length": 0.21,"width": 0.3,"height": 0.165 },"orientation": { "w": 1 },"color": { "b": 1 } },
{ "id": "binRed","type": "BIN","pos": { "x": 0.06,"y": 0.49,"z": 0.8325 },"size": { "length": 0.21,"width": 0.3,"height": 0.165 },"orientation": { "w": 1 },"color": { "r": 1 } },
{ "id": "binGreen","type": "BIN","pos": { "x": 0.46,"y": 0.49,"z": 0.8325 },"size": { "length": 0.21,"width": 0.3,"height": 0.165 },"orientation": { "w": 1 },"color": { "g": 1 } },
{ "id": "objectRed1","type": "BOX","pos": { "x": 0.5,"y": -0.1,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "r": 1 } },
{ "id": "objectRed2","type": "BOX","pos": { "x": 0.25,"y": -0.2,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "w": 1 },"color": { "r": 1 } },
{ "id": "objectRed3","type": "BOX","pos": { "x": -0.4,"z": 0.819 },"size": { "length": 0.031,"width": 0.031,"height": 0.138 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "r": 1 } },
{ "id": "objectGreen1","type": "BOX","pos": { "x": 0.45,"y": -0.3,"z": 0.8105 },"size": {"length":0.031,"width":0.062,"height":0.121},"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "g": 1 } },
{ "id": "objectGreen2","type": "BOX","pos": { "x": -0.45,"y": -0.2,"z": 0.8105 },"size": {"length":0.031,"width":0.031,"height":0.138},"orientation": { "z": 1,"w": 0.92388 },"color": { "g": 1 } },
{ "id": "objectGreen3","type": "BOX","pos": { "x": 0.1,"y": -0.3,"z": 0.819 },"size": {"length":0.031,"width":0.062,"height":0.121},"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "g": 1 } },
{ "id": "objectBlue1","type": "BOX","pos": { "x": 0.25,"y": -0.4,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "b": 1 } },
{ "id": "objectBlue2","type": "BOX","pos": { "x": -0.3,"y": -0.3,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "b": 1 } },
{ "id": "objectBlue3","type": "BOX","pos": { "x": 0.4,"z": 0.819 },"size": { "length": 0.031,"width": 0.031,"height": 0.138 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "b": 1 } },
{ "id": "arm","type": "ARM","pos": { "z": 0.75 },"size": { },"orientation": { "w": 1 },"color": { "r": 1.00,"g": 1.00,"b": 1.00 } }
] }
{ "objects": [
{ "id": "tablePillar1","pos": { "x": 0.77,"y": 0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "tablePillar2","pos": { "x": -0.77,"y": -0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "tablePillar3","pos": { "x": -0.77,"y": 0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "tablePillar4","pos": { "x": 0.77,"y": -0.77,"z": 0.325 },"size": { "length": 0.06,"width": 0.06,"height": 0.65 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "table","pos": { "z": 0.7 },"size": { "length": 1.6,"width": 1.6,"height": 0.1 },"orientation": { "w": 1 },"color": { "r": 255,"g": 222,"b": 173 } },
{ "id": "binBlue","type": "BIN","pos": { "x": -0.34,"y": 0.49,"z": 0.8325 },"size": { "length": 0.21,"width": 0.3,"height": 0.165 },"orientation": { "w": 1 },"color": { "b": 1 } },
{ "id": "binRed","type": "BIN","pos": { "x": 0.06,"y": 0.49,"z": 0.8325 },"size": { "length": 0.21,"width": 0.3,"height": 0.165 },"orientation": { "w": 1 },"color": { "r": 1 } },
{ "id": "binGreen","type": "BIN","pos": { "x": 0.46,"y": 0.49,"z": 0.8325 },"size": { "length": 0.21,"width": 0.3,"height": 0.165 },"orientation": { "w": 1 },"color": { "g": 1 } },
{ "id": "objectRed1","type": "BOX","pos": { "x": 0.5,"y": -0.1,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "r": 1 } },
{ "id": "objectRed2","type": "BOX","pos": { "x": 0.25,"y": -0.2,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "w": 1 },"color": { "r": 1 } },
{ "id": "objectRed3","type": "BOX","pos": { "x": -0.4,"z": 0.819 },"size": { "length": 0.031,"width": 0.031,"height": 0.138 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "r": 1 } },
{ "id": "objectGreen1","type": "BOX","pos": { "x": 0.45,"y": -0.3,"z": 0.8105 },"size": {"length":0.031,"width":0.062,"height":0.121},"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "g": 1 } },
{ "id": "objectGreen2","type": "BOX","pos": { "x": -0.45,"y": -0.2,"z": 0.8105 },"size": {"length":0.031,"width":0.031,"height":0.138},"orientation": { "z": 1,"w": 0.92388 },"color": { "g": 1 } },
{ "id": "objectGreen3","type": "BOX","pos": { "x": 0.1,"y": -0.3,"z": 0.819 },"size": {"length":0.031,"width":0.062,"height":0.121},"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "g": 1 } },
{ "id": "objectBlue1","type": "BOX","pos": { "x": 0.25,"y": -0.4,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "b": 1 } },
{ "id": "objectBlue2","type": "BOX","pos": { "x": -0.3,"y": -0.3,"z": 0.8105 },"size": { "length": 0.031,"width": 0.062,"height": 0.121 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "b": 1 } },
{ "id": "objectBlue3","type": "BOX","pos": { "x": 0.4,"z": 0.819 },"size": { "length": 0.031,"width": 0.031,"height": 0.138 },"orientation": { "z": 0.382683,"w": 0.92388 },"color": { "b": 1 } },
{ "id": "arm1","type": "ARM","pos": { "z": 0.75 },"size": { },"orientation": { "w": 1 },"color": { "r": 1.00,"g": 1.00,"b": 1.00 } },
{ "id": "arm2","type": "ARM","pos": { "x": 1, "z": 0.75 },"size": { },"orientation": { "w": 1 },"color": { "r": 1.00,"g": 1.00,"b": 1.00 } }
] }
{
"idRobot": "ARM1",
"objects": [
{ "idObject": "objectRed1", "reachable": true },
{ "idObject": "objectRed2", "reachable": true },
{ "idObject": "objectRed3", "reachable": false },
{ "idObject": "binRed", "reachable": true },
{ "idObject": "binBlue", "reachable": true },
{ "idObject": "binGreen", "reachable": false }
]
}
{
"idRobot": "ARM2",
"objects": [
{ "idObject": "objectRed1", "reachable": false },
{ "idObject": "objectRed2", "reachable": false },
{ "idObject": "objectRed3", "reachable": false },
{ "idObject": "binRed", "reachable": false },
{ "idObject": "binBlue", "reachable": true },
{ "idObject": "binGreen", "reachable": true }
]
}
......@@ -8,8 +8,11 @@ import dash
import dash_core_components as dcc
import dash_html_components as html
import paho.mqtt.client as mqtt
import visdcc
from dash.dependencies import Input, Output, State
from google.protobuf import json_format
import cgv_connector_pb2
import utils
external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
......@@ -39,34 +42,77 @@ commands = {
'send-place-b-demo-objRed-red': ('place-b/demo/move/objectRed1/red', '1'),
}
# button-id: (topic, textarea-content-id, protobuf-object)
complex_commands = {
'send-place-a-update':
('place-a/scene/update', 'place-a-input-json', cgv_connector_pb2.Scene()),
'send-place-b-update':
('place-b/scene/update', 'place-b-input-json', cgv_connector_pb2.Scene()),
'send-place-b-reachability-1-update':
('place-b/reachability/arm1', 'place-b-reachability-1-json', cgv_connector_pb2.Reachability()),
'send-place-b-reachability-2-update':
('place-b/reachability/arm2', 'place-b-reachability-2-json', cgv_connector_pb2.Reachability()),
}
bytes_topics = [
'place-a/scene/update',
'place-b/scene/update',
'place-b/reachability/arm1',
'place-b/reachability/arm2',
'place-b/commands',
]
button_style_normal = {"marginRight": "15px"}
button_style_exit = {**button_style_normal, "backgroundColor": "red", "color": "white"}
textarea_style_normal = {'width': '100%', 'height': '200px', 'resize': 'vertical'}
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = html.Div([
html.Div([
html.Div([
html.Div([ # First Row
html.Div([ # Column for place a
dcc.Textarea(
id='place-a-input-json',
placeholder='place-a-input-json',
value='{}',
style={'width': '100%'}
style=textarea_style_normal
),
html.Button('Send to place-a/scene/update', id='send-place-a-update', disabled=True),
html.Button('Send to place-a/scene/update', id='send-place-a-update'),
html.Div(id='hidden-div-scene-a', style={'display': 'none'})
], className="six columns"),
html.Div([
html.Div([ # Column for place b
dcc.Textarea(
id='place-b-input-json',
placeholder='place-b-input-json',
value='{}',
style={'width': '100%'}
style=textarea_style_normal
),
html.Button('Send to place-b/scene/update', id='send-place-b-update', disabled=True),
html.Button('Send to place-b/scene/update', id='send-place-b-update'),
html.Div(id='hidden-div-scene-b', style={'display': 'none'}),
html.Div([ # Row for reachability
html.Div([ # Column for reachability arm 1
dcc.Textarea(
id='place-b-reachability-1-json',
placeholder='place-b-reachability-1-json',
value='{}',
style=textarea_style_normal
),
html.Button('Send to place-b/reachability/arm1', id='send-place-b-reachability-1-update'),
], className="six columns"),
html.Div([ # Column for reachability arm 2
dcc.Textarea(
id='place-b-reachability-2-json',
placeholder='place-b-reachability-2-json',
value='{}',
style=textarea_style_normal
),
html.Button('Send to place-b/reachability/arm2', id='send-place-b-reachability-2-update'),
], className="six columns"),
], className='row', style={'marginTop': '15px'}),
], className="six columns"),
], className='row'),
dcc.Markdown("---"),
html.Div([
html.Div([
# dcc.Markdown("---"),
html.Div([ # Row for commands
html.Div([ # Column for commands of place a
html.H3("Commands Place A"),
html.Button('Model', id='send-place-a-model', style=button_style_normal),
html.Button('Model (Details)', id='send-place-a-model-details', style=button_style_normal),
......@@ -74,7 +120,7 @@ app.layout = html.Div([
html.Button('obj-Red -> Red', id='send-place-a-demo-objRed-red', style=button_style_normal),
html.Button('obj-Red -> Blue', id='send-place-a-demo-objRed-blue', style=button_style_normal),
], className="six columns"),
html.Div([
html.Div([ # Column for commands of place b
html.H3("Commands Place B"),
html.Button('Model', id='send-place-b-model', style=button_style_normal),
html.Button('Model (Details)', id='send-place-b-model-details', style=button_style_normal),
......@@ -82,42 +128,146 @@ app.layout = html.Div([
html.Button('obj-Red -> Red', id='send-place-b-demo-objRed-red', style=button_style_normal),
], className="six columns"),
], className='row'),
dcc.Markdown("---"),
# dcc.Markdown("---"),
html.H3("MQTT Log"),
dcc.Textarea(
id='mqtt-log',
value="",
readOnly=True,
style={'width': '100%', 'height': '200px', 'fontFamily': 'Consolas, monospace'}
style={**textarea_style_normal, 'fontFamily': 'Consolas, monospace'}
),
dcc.Checklist(
id="should-scroll-mqtt-log",
options=[{"label": "Auto-Scroll", "value": "Auto-Scroll"}],
value=["Auto-Scroll"],
labelStyle={"display": "inline-block"},
),
dcc.Markdown("---"),
html.Div([
# html.Div([
html.P("Topic"),
dcc.Input(id='manual-mqtt-topic'),
html.P("Message"),
dcc.Input(id='manual-mqtt-message'),
# dcc.Textarea(id='manual-mqtt-message', style={'width': '50%', 'font-family': 'Consolas, monospace'}),
html.Button('Send', id='manual-mqtt-send')
# ], className="six columns"),
], className='row'),
html.P("Topic"),
dcc.Input(id='manual-mqtt-topic'),
html.P("Message"),
dcc.Input(id='manual-mqtt-message'),
# dcc.Textarea(id='manual-mqtt-message', style={'width': '50%', 'font-family': 'Consolas, monospace'}),
html.Button('Send', id='manual-mqtt-send')
], className='row', style=dict(display='flex')),
# -- Invisible elements --
dcc.Location(id='url', refresh=False),
dcc.Interval(
id='every-1-second',
interval=1000, # in milliseconds
n_intervals=0
),
visdcc.Run_js(id='javascriptLog', run=""),
html.Div(id='hidden-div', style={'display': 'none'})
])
@app.callback(
Output('place-a-input-json', 'value'),
Output('place-b-input-json', 'value'),
Output('place-b-reachability-1-json', 'value'),
Output('place-b-reachability-2-json', 'value'),
Input('url', 'pathname')
)
def set_initial_json_content(_pathname):
"""
Set initial JSON content from files in config/ directory
:param _pathname: Unused value of page URL
:return: list of contents
"""
with open('config/config-scene-a.json') as fdr:
json_content_a = fdr.read()
with open('config/config-scene-b.json') as fdr:
json_content_b = fdr.read()
with open('config/dummy-reachability-b-arm1.json') as fdr:
json_content_b_reachability_1 = fdr.read()
with open('config/dummy-reachability-b-arm2.json') as fdr:
json_content_b_reachability_2 = fdr.read()
return json_content_a, json_content_b, json_content_b_reachability_1, json_content_b_reachability_2
def send_json_for_object_to_topic(json_content: str, obj, topic: str):
"""
Use JSON to populate protobuf object and send its serialization to the given topic
:param json_content: Content to initialize the object represented in JSON
:param obj: The object to send
:param topic: The MQTT topic to send the serialization
:return: None
"""
json_format.Parse(json_content, obj)
mqttc.publish(topic=topic, payload=obj.SerializeToString())
# @app.callback(
# Output('hidden-div-scene-a', 'children'),
# Input('send-place-a-update', 'n_clicks'),
# State('place-a-input-json', 'value')
# )
# def send_scene_a(n_clicks, json_content):
# """
# Send scene in place a
# :param n_clicks: button.clicks
# :param json_content: state of place-a-input-json
# :return: no_update
# """
# if n_clicks:
# scene = cgv_connector_pb2.Scene()
# send_json_for_object_to_topic(json_content, scene, 'place-a/scene/update')
# return dash.no_update
#
#
# @app.callback(
# Output('hidden-div-scene-b', 'children'),
# Input('send-place-b-update', 'n_clicks'),
# State('place-b-input-json', 'value')
# )
# def send_scene_a(n_clicks, json_content):
# """
# Send scene in place b
# :param n_clicks: button.clicks
# :param json_content:state of place-b-input-json
# :return:no_update
# """
# if n_clicks:
# scene = cgv_connector_pb2.Scene()
# send_json_for_object_to_topic(json_content, scene, 'place-b/scene/update')
# return dash.no_update
@app.callback(
Output('hidden-div-scene-b', 'children'),
[Input(button_id, 'n_clicks') for button_id in complex_commands],
[State(value[1], 'value') for value in complex_commands.values()]
)
def send_complex(*_):
ctx = dash.callback_context
if not ctx.triggered:
button_id = None
else:
button_id = ctx.triggered[0]['prop_id'].split('.')[0]
if button_id:
topic, state_id, protobuf_obj = complex_commands[button_id]
json_content = ctx.states[state_id + ".value"]
send_json_for_object_to_topic(json_content, protobuf_obj, topic)
return dash.no_update
@app.callback(
Output('mqtt-log', 'value'),
Output('javascriptLog', 'run'),
Input('every-1-second', 'n_intervals'),
State('mqtt-log', 'value'),
State('should-scroll-mqtt-log', 'value')
)
def append_to_mqtt_log(_, value):
def append_to_mqtt_log(_n_intervals, value, should_scroll):
"""
Periodically update mqtt log
:param _n_intervals: Unused value of intervals
:param value: current content of mqtt log
:param should_scroll: checkbox value whether to scroll to the end after update
:return: new content of mqtt log
"""
local_messages = []
while not message_queue.empty():
local_messages.append(message_queue.get_nowait())
......@@ -136,7 +286,13 @@ def append_to_mqtt_log(_, value):
# reformatted_lines.append(utils.format_log_msg(topic, max_topic.max_mqtt_topic_length,
# message, timestamp=timestamp))
# value = '\n'.join(reformatted_lines)
return value
else:
return dash.no_update
log_cmd = '''
var textarea = document.getElementById('mqtt-log');
textarea.scrollTop = textarea.scrollHeight;
''' if should_scroll else ""
return value, log_cmd
@app.callback(
......@@ -144,6 +300,11 @@ def append_to_mqtt_log(_, value):
[Input(button_id, 'n_clicks') for button_id in commands]
)
def button_clicked_to_add_to_mqtt_log(*_):
"""
Based on dict "commands", send message to topic
:param _: Unused "catch-all" variable for n_clicks of all buttons
:return: no_update
"""
ctx = dash.callback_context
if not ctx.triggered:
......@@ -163,28 +324,44 @@ def button_clicked_to_add_to_mqtt_log(*_):
State('manual-mqtt-message', 'value'),
)
def send_manual(n_clicks, topic, message):
"""
Manual sending of mqtt message
:param n_clicks: button.n_clicks
:param topic: value of input field manual-mqtt-topic
:param message: value of input field manual-mqtt-message
:return: no_update
"""
if n_clicks:
mqttc.publish(topic=topic, payload=message)
return dash.no_update
def on_mqtt_connect(_client, _userdata, _flags, _rc, _properties=None):
# Callback for mqtt client when connected
print('Connected at ' + datetime.datetime.now().isoformat())
ready_event.set()
def on_mqtt_message(_client, _userdata, message):
# Callback for mqtt client when message was received
max_mqtt_topic_length = max_topic.process_topic(message.topic)
try:
payload = message.payload.decode("utf-8")
except UnicodeDecodeError:
payload = "(unreadable bytes)"
if message.topic in bytes_topics:
payload = "(ignored bytes)"
else:
try:
payload = message.payload.decode("utf-8")
except UnicodeDecodeError:
payload = "(unreadable bytes)"
message_queue.put_nowait(utils.format_log_msg(message.topic,
max_mqtt_topic_length,
payload)) #.replace("\n", " ~ "))
payload)) # .replace("\n", " ~ "))
def publish_test_message():
"""
Publish test message to see that app has started and mqtt client has connected (both successfully)
:return: None
"""
time.sleep(2)
mqttc.publish(topic="init", payload=datetime.datetime.now().isoformat())
......
paho-mqtt~=1.5.1
dash~=1.20.0
visdcc~=0.0.40
protobuf~=3.15.8
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment