Skip to content

Commit 5c0566a

Browse files
committed
added RemoteID panel
1 parent 598a0b9 commit 5c0566a

File tree

5 files changed

+260
-37
lines changed

5 files changed

+260
-37
lines changed
+216
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#
2+
# Copyright (C) 2023 UAVCAN Development Team <dronecan.org>
3+
#
4+
# This software is distributed under the terms of the MIT License.
5+
#
6+
# Author: Andrew Tridgell
7+
#
8+
9+
import dronecan
10+
from functools import partial
11+
from PyQt5.QtWidgets import QVBoxLayout, QWidget, QLabel, QDialog, \
12+
QPlainTextEdit, QPushButton, QLineEdit, QFileDialog, QComboBox, QHBoxLayout
13+
from PyQt5.QtCore import QTimer, Qt
14+
from logging import getLogger
15+
from ..widgets import make_icon_button, get_icon, get_monospace_font, directory_selection
16+
import random
17+
import base64
18+
import struct
19+
20+
__all__ = 'PANEL_NAME', 'spawn', 'get_icon'
21+
22+
PANEL_NAME = 'RemoteID Panel'
23+
24+
logger = getLogger(__name__)
25+
26+
_singleton = None
27+
28+
SECURE_COMMAND_GET_REMOTEID_SESSION_KEY = dronecan.dronecan.remoteid.SecureCommand.Request().SECURE_COMMAND_GET_REMOTEID_SESSION_KEY
29+
SECURE_COMMAND_SET_REMOTEID_CONFIG = dronecan.dronecan.remoteid.SecureCommand.Request().SECURE_COMMAND_SET_REMOTEID_CONFIG
30+
31+
class RemoteIDPanel(QDialog):
32+
DEFAULT_INTERVAL = 0.1
33+
34+
def __init__(self, parent, node):
35+
super(RemoteIDPanel, self).__init__(parent)
36+
self.setWindowTitle('RemoteID Management Panel')
37+
self.setAttribute(Qt.WA_DeleteOnClose) # This is required to stop background timers!
38+
39+
self.timeout = 5
40+
41+
self._node = node
42+
self.session_key = None
43+
self.sequence = random.randint(0, 0xFFFFFFFF)
44+
45+
layout = QVBoxLayout()
46+
47+
self.key_selection = directory_selection.DirectorySelectionWidget(self, 'Secret key file')
48+
self.command = QLineEdit(self)
49+
self.send = QPushButton('Send', self)
50+
self.send.clicked.connect(self.on_send)
51+
52+
self.node_select = QComboBox()
53+
54+
self.state = QLineEdit()
55+
self.state.setText("")
56+
self.state.setReadOnly(True)
57+
58+
layout.addLayout(self.labelWidget('Node', self.node_select))
59+
layout.addWidget(self.key_selection)
60+
layout.addLayout(self.labelWidget('Command:', self.command))
61+
layout.addLayout(self.labelWidget('Status:', self.state))
62+
layout.addWidget(self.send)
63+
64+
self.setLayout(layout)
65+
self.resize(400, 200)
66+
QTimer.singleShot(250, self.update_nodes)
67+
68+
69+
def labelWidget(self, label, widget):
70+
'''a widget with a label'''
71+
hlayout = QHBoxLayout()
72+
hlayout.addWidget(QLabel(label, self))
73+
hlayout.addWidget(widget)
74+
return hlayout
75+
76+
def on_send(self):
77+
'''callback for send button'''
78+
priv_key = self.key_selection.get_selection()
79+
if priv_key is None:
80+
self.status_update("Need to select private key")
81+
return
82+
self.status_update("Requesting session key")
83+
self.request_session_key()
84+
85+
def status_update(self, text):
86+
'''update status line'''
87+
self.state.setText(text)
88+
89+
def update_nodes(self):
90+
'''update list of available nodes'''
91+
QTimer.singleShot(250, self.update_nodes)
92+
from ..widgets.node_monitor import app_node_monitor
93+
if app_node_monitor is None:
94+
return
95+
node_list = []
96+
for nid in app_node_monitor._registry.keys():
97+
r = app_node_monitor._registry[nid]
98+
if r.info is not None:
99+
node_list.append("%u: %s" % (nid, r.info.name.decode()))
100+
else:
101+
node_list.append("%u" % nid)
102+
node_list = sorted(node_list)
103+
current_node = sorted([self.node_select.itemText(i) for i in range(self.node_select.count())])
104+
for n in node_list:
105+
if not n in current_node:
106+
self.node_select.addItem(n)
107+
108+
def get_session_key_response(self, reply):
109+
'''handle session key response'''
110+
if not reply:
111+
self.status_update("timed out")
112+
return
113+
self.session_key = bytearray(reply.response.data)
114+
self.status_update("Got session key")
115+
self.send_config_change()
116+
117+
def get_private_key(self):
118+
'''get private key, return 32 byte key or None'''
119+
priv_key_file = self.key_selection.get_selection()
120+
if priv_key_file is None:
121+
self.status_update("Please select private key file")
122+
return None
123+
try:
124+
d = open(priv_key_file,'r').read()
125+
except Exception as ex:
126+
print(ex)
127+
return None
128+
ktype = "PRIVATE_KEYV1:"
129+
if not d.startswith(ktype):
130+
return None
131+
return base64.b64decode(d[len(ktype):])
132+
133+
def make_signature(self, seq, command, data):
134+
'''make a signature'''
135+
import monocypher
136+
private_key = self.get_private_key()
137+
d = struct.pack("<II", seq, command)
138+
d += data
139+
if command != SECURE_COMMAND_GET_REMOTEID_SESSION_KEY:
140+
if self.session_key is None:
141+
raise Exception("No session key")
142+
d += self.session_key
143+
return monocypher.signature_sign(private_key, d)
144+
145+
def get_target_node(self):
146+
'''get the target node'''
147+
return int(self.node_select.currentText().split(':')[0])
148+
149+
def request_session_key(self):
150+
'''request a session key'''
151+
sig = self.make_signature(self.sequence, SECURE_COMMAND_GET_REMOTEID_SESSION_KEY, bytes())
152+
self._node.request(dronecan.dronecan.remoteid.SecureCommand.Request(
153+
sequence=self.sequence,
154+
operation=SECURE_COMMAND_GET_REMOTEID_SESSION_KEY,
155+
sig_length=len(sig),
156+
data=sig,
157+
timeout=self.timeout),
158+
self.get_target_node(),
159+
self.get_session_key_response)
160+
self.sequence = (self.sequence+1) % (1<<32)
161+
print("Requested session key")
162+
163+
def config_change_response(self, reply):
164+
if not reply:
165+
self.status_update("timed out")
166+
return
167+
result_map = {
168+
0: "ACCEPTED",
169+
1: "TEMPORARILY_REJECTED",
170+
2: "DENIED",
171+
3: "UNSUPPORTED",
172+
4: "FAILED" }
173+
result = result_map.get(reply.response.result, "invalid")
174+
self.status_update("Got change response: %s" % result)
175+
176+
def send_config_change(self):
177+
'''send remoteid config change'''
178+
req = self.command.text().encode('utf-8')
179+
sig = self.make_signature(self.sequence, SECURE_COMMAND_SET_REMOTEID_CONFIG, req)
180+
self._node.request(dronecan.dronecan.remoteid.SecureCommand.Request(
181+
sequence=self.sequence,
182+
operation=SECURE_COMMAND_SET_REMOTEID_CONFIG,
183+
sig_length=len(sig),
184+
data=req+sig,
185+
timeout=self.timeout),
186+
self.get_target_node(),
187+
self.config_change_response)
188+
self.sequence = (self.sequence+1) % (1<<32)
189+
self.status_update("Requested config change")
190+
191+
def __del__(self):
192+
global _singleton
193+
_singleton = None
194+
195+
def closeEvent(self, event):
196+
global _singleton
197+
_singleton = None
198+
super(RemoteIDPanel, self).closeEvent(event)
199+
200+
201+
def spawn(parent, node):
202+
global _singleton
203+
if _singleton is None:
204+
try:
205+
_singleton = RemoteIDPanel(parent, node)
206+
except Exception as ex:
207+
print(ex)
208+
209+
_singleton.show()
210+
_singleton.raise_()
211+
_singleton.activateWindow()
212+
213+
return _singleton
214+
215+
216+
get_icon = partial(get_icon, 'asterisk')

dronecan_gui_tool/panels/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from . import RTK_panel
1515
from . import serial_panel
1616
from . import stats_panel
17+
from . import RemoteID_panel
1718

1819
class PanelDescriptor:
1920
def __init__(self, module):
@@ -39,5 +40,6 @@ def safe_spawn(self, parent, node):
3940
PanelDescriptor(actuator_panel),
4041
PanelDescriptor(RTK_panel),
4142
PanelDescriptor(serial_panel),
42-
PanelDescriptor(stats_panel)
43+
PanelDescriptor(stats_panel),
44+
PanelDescriptor(RemoteID_panel)
4345
], key=lambda x: x.name)

dronecan_gui_tool/setup_window.py

+2-36
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import time
1212
import threading
1313
import copy
14-
from .widgets import show_error, get_monospace_font
14+
from .widgets import show_error, get_monospace_font, directory_selection
1515
from PyQt5.QtWidgets import QComboBox, QCompleter, QDialog, QDirModel, QFileDialog, QGroupBox, QHBoxLayout, QLabel, \
1616
QLineEdit, QPushButton, QSpinBox, QVBoxLayout, QGridLayout, QCheckBox
1717
from qtwidgets import PasswordEdit
@@ -150,40 +150,6 @@ def get_list(self):
150150
return copy.copy(self._ifaces)
151151

152152

153-
class DirectorySelectionWidget(QGroupBox):
154-
def __init__(self, parent, dsdl_path=None):
155-
super(DirectorySelectionWidget, self).__init__('Location of custom DSDL definitions [optional]', parent)
156-
self._dir_selection = dsdl_path
157-
dir_textbox = QLineEdit(self)
158-
dir_textbox.setText(self._dir_selection)
159-
160-
dir_text_completer = QCompleter(self)
161-
dir_text_completer.setCaseSensitivity(Qt.CaseSensitive)
162-
dir_text_completer.setModel(QDirModel(self))
163-
dir_textbox.setCompleter(dir_text_completer)
164-
165-
def on_edit():
166-
self._dir_selection = str(dir_textbox.text())
167-
168-
dir_textbox.textChanged.connect(on_edit)
169-
170-
dir_browser = QPushButton('Browse', self)
171-
172-
def on_browse():
173-
self._dir_selection = str(QFileDialog.getExistingDirectory(self, 'Select Directory'))
174-
dir_textbox.setText(self._dir_selection)
175-
176-
dir_browser.clicked.connect(on_browse)
177-
178-
layout = QHBoxLayout(self)
179-
layout.addWidget(dir_textbox)
180-
layout.addWidget(dir_browser)
181-
self.setLayout(layout)
182-
183-
def get_selection(self):
184-
return self._dir_selection
185-
186-
187153
def run_setup_window(icon, dsdl_path=None):
188154
win = QDialog()
189155
win.setWindowTitle('Application Setup')
@@ -235,7 +201,7 @@ def run_setup_window(icon, dsdl_path=None):
235201

236202
signing_key = PasswordEdit(win)
237203

238-
dir_selection = DirectorySelectionWidget(win, dsdl_path)
204+
dir_selection = directory_selection.DirectorySelectionWidget(win, dsdl_path, directory_only=True)
239205

240206
ok = QPushButton('OK', win)
241207

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from PyQt5.QtWidgets import QGroupBox, QLineEdit, QCompleter, QPushButton, QDirModel, QHBoxLayout, QFileDialog
2+
from PyQt5.QtCore import Qt
3+
4+
class DirectorySelectionWidget(QGroupBox):
5+
def __init__(self, parent, label, path=None, directory_only=False):
6+
super(DirectorySelectionWidget, self).__init__(label, parent)
7+
self._selection = path
8+
dir_textbox = QLineEdit(self)
9+
dir_textbox.setText(self._selection)
10+
11+
dir_text_completer = QCompleter(self)
12+
dir_text_completer.setCaseSensitivity(Qt.CaseSensitive)
13+
dir_text_completer.setModel(QDirModel(self))
14+
dir_textbox.setCompleter(dir_text_completer)
15+
16+
def on_edit():
17+
self._selection = str(dir_textbox.text())
18+
19+
dir_textbox.textChanged.connect(on_edit)
20+
21+
dir_browser = QPushButton('Browse', self)
22+
23+
def on_browse():
24+
if directory_only:
25+
self._selection = str(QFileDialog.getExistingDirectory(self, 'Select Directory'))
26+
else:
27+
self._selection = QFileDialog.getOpenFileName(self, 'Select File')[0]
28+
dir_textbox.setText(self._selection)
29+
30+
dir_browser.clicked.connect(on_browse)
31+
32+
layout = QHBoxLayout(self)
33+
layout.addWidget(dir_textbox)
34+
layout.addWidget(dir_browser)
35+
self.setLayout(layout)
36+
37+
def get_selection(self):
38+
return self._selection

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
'qtconsole>=4.2.0',
4848
'pyyaml>=5.1',
4949
'easywebdav>=1.2',
50+
'pymonocypher',
5051
'numpy',
5152
'pyqt5',
5253
'traitlets',

0 commit comments

Comments
 (0)