Skip to content

Commit b7057ab

Browse files
authored
Add optional port filter based on usb info. (#45)
1 parent 883cb7b commit b7057ab

File tree

5 files changed

+314
-14
lines changed

5 files changed

+314
-14
lines changed

inkcut/device/transports/qtserialport/plugin.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,14 @@ class QtSerialTransport(DeviceTransport):
8585

8686
def open_serial_port(self, config):
8787
try:
88+
port_name = self.config.choose_filtered_port()
89+
if not port_name:
90+
raise Exception("{} | Could not find suitable port".format(config.port))
91+
self.config.port = port_name # might be updated if there is a filter
92+
93+
8894
serial_port = QSerialPort()
89-
serial_port.setPortName(config.port)
95+
serial_port.setPortName(port_name)
9096
#Setting the AllDirections flag is supported on all platforms. Windows supports only this mode.
9197
serial_port.setBaudRate(config.baudrate, QSerialPort.Direction.AllDirections)
9298
serial_port.setParity(config.map_parity())

inkcut/device/transports/serialport/plugin.py

+88-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from twisted.internet.protocol import Protocol, connectionDone
2121
from twisted.internet.serialport import SerialPort
2222
from serial.tools.list_ports import comports
23+
import re
2324

2425
from inkcut.device.transports.raw.plugin import RawFdTransport, RawFdProtocol
2526

@@ -39,6 +40,11 @@ class SerialConfigBase(Model):
3940

4041
#: Serial port config
4142
port = Str().tag(config=True)
43+
44+
port_filter_name = Str().tag(config=True)
45+
port_filter_vid = Int().tag(config=True)
46+
port_filter_pid = Int().tag(config=True)
47+
4248
baudrate = Int(9600).tag(config=True)
4349
bytesize = Enum(serial.EIGHTBITS, serial.SEVENBITS, serial.SIXBITS,
4450
serial.FIVEBITS).tag(config=True)
@@ -66,6 +72,81 @@ def _default_port(self):
6672
def refresh(self):
6773
self.ports = self._default_ports()
6874

75+
def has_port_filter(self):
76+
return self.port_filter_name != "" or self.port_filter_vid > 0 or self.port_filter_pid > 0
77+
78+
def port_matches(self, port: SerialPortInfo):
79+
if self.port_filter_name:
80+
if not re.search(self.port_filter_name, port.description):
81+
return False
82+
if self.port_filter_vid > 0 and port.usb_vid != self.port_filter_vid:
83+
return False
84+
if self.port_filter_pid > 0 and port.usb_pid != self.port_filter_pid:
85+
return False
86+
return True
87+
88+
def clear_filter(self):
89+
self.port_filter_name = ""
90+
self.port_filter_vid = 0
91+
self.port_filter_pid = 0
92+
93+
def make_filter(self, port_info: SerialPortInfo):
94+
self.clear_filter()
95+
if not port_info:
96+
return
97+
if port_info.usb_vid > 0:
98+
# usb devices should have both vid and pid
99+
self.port_filter_vid = port_info.usb_vid
100+
self.port_filter_pid = port_info.usb_pid
101+
102+
# For now assume that only usb devices will have a meaningful description
103+
#
104+
# Physical serial ports can't know what's connected to them, but they are
105+
# also more likely to have stable device path so there is less need for filter.
106+
if port_info.description:
107+
text = port_info.description
108+
text = text.strip(' -:()')
109+
parts = text.split(port_info.device_path)
110+
best = ""
111+
for part in parts:
112+
if len(part) > len(best):
113+
best = part
114+
115+
best = best.strip(' -:()')
116+
117+
if best:
118+
self.port_filter_name = re.escape(best)
119+
if not re.search(self.port_filter_name, port_info.description):
120+
log.warn("Failed to create port description filter for '{}'".format(port_info.description))
121+
self.port_filter_name = ""
122+
123+
def port_by_path(self, device_path):
124+
for port in self.ports:
125+
if port.device_path == device_path:
126+
return port
127+
return None
128+
129+
def get_matching_port(self):
130+
for port in self.ports:
131+
if self.port_matches(port):
132+
return port
133+
return None
134+
135+
def choose_filtered_port(self):
136+
if not self.has_port_filter():
137+
return self.port
138+
139+
self.refresh()
140+
port_info = self.port_by_path(self.port)
141+
if port_info and self.port_matches(port_info):
142+
return port_info.device_path # prefer last used port when suitable
143+
144+
port_info = self.get_matching_port()
145+
if port_info:
146+
return port_info.device_path
147+
148+
return None
149+
69150

70151
class SerialConfig(SerialConfigBase):
71152
def _default_ports(self):
@@ -92,17 +173,22 @@ class SerialTransport(RawFdTransport):
92173

93174
def connect(self):
94175
config = self.config
95-
self.device_path = config.port
96176
try:
97177
#: Save a reference
98178
self.protocol.transport = self
99179

100180
#: Make the wrapper
101181
self._protocol = RawFdProtocol(self, self.protocol)
102182

183+
port = self.config.choose_filtered_port()
184+
if not port:
185+
raise Exception("{} | Could not find suitable port".format(config.port))
186+
self.config.port = port # might be updated if there is a filter
187+
self.device_path = config.port
188+
103189
self.connection = SerialPort(
104190
self._protocol,
105-
config.port,
191+
port,
106192
reactor,
107193
baudrate=config.baudrate,
108194
bytesize=config.bytesize,

inkcut/device/transports/serialport/settings.enaml

+119-11
Original file line numberDiff line numberDiff line change
@@ -12,45 +12,153 @@ Created on Jul 12, 2015
1212
"""
1313
import textwrap
1414
from inkcut.core.utils import load_icon
15-
from enaml.layout.api import hbox, align, spacer
15+
from enaml.layout.api import hbox, vbox, align, spacer
1616
from enaml.qt.QtWidgets import QApplication
17-
from enaml.widgets.api import Container, Form, Label, ObjectCombo, SpinBox, CheckBox, PushButton
17+
from enaml.widgets.api import Container, Form, Label, ObjectCombo, SpinBox, CheckBox, PushButton, Menu, Action, Field
18+
from enaml.validator import IntValidator
19+
from inkcut.core.api import log
20+
from enaml.stdlib.message_box import question
1821
import serial
1922

23+
enamldef HexPair(Field):
24+
attr number = 0
25+
mask = "HHHH"
26+
validator = IntValidator(minimum=0, maximum=0x888ffff, base=16)
27+
text << "{:0>4x}".format(number)
28+
text :: self.number = int(change['value'], 16)
29+
max_length = 4
30+
2031
enamldef SerialPortSettingsView(Container):
2132
attr model
2233
attr exclusive = False
34+
attr show_all_ports = False
2335
padding = 0
2436

2537
alias dsrdtr
2638
func selected_port(port, ports):
27-
matches = [p for p in ports if p.device_path == port]
39+
matches = [p for p in ports if p.device_path == port and model.port_matches(p)]
2840
return matches[0] if matches else None
2941

42+
func get_ports(candidates):
43+
if show_all_ports:
44+
return candidates
45+
else:
46+
return [port for port in candidates if model.port_matches(port)]
47+
48+
func port_string(port):
49+
if model.port_matches(port):
50+
return str(port)
51+
return "{}{}".format(QApplication.translate("serialport", "(not compatible): "), (str(port)))
52+
53+
func filter_update_confirm(result):
54+
if result and result.action == 'accept':
55+
return True
56+
return False
57+
58+
func refresh_filter_clear():
59+
filter_clear.enabled = model.has_port_filter()
60+
3061
Form:
3162
Label:
3263
text = QApplication.translate("serialport", "Port")
3364
Container:
3465
padding = 0
3566
constraints = [
36-
hbox(cb, pb),
37-
align('v_center', cb, pb)
67+
vbox(
68+
hbox(cb, pb),
69+
hbox(filter_label, filter_descr, filter_label_vid, filter_vid,
70+
filter_label_pid, filter_pid, clear_group),
71+
),
72+
align('v_center', cb, pb),
73+
align('v_center', filter_label, filter_label_vid, filter_label_pid, filter_descr),
74+
(filter_vid.width == 50) | 'medium',
75+
(filter_pid.width == 50) | 'medium',
76+
filter_vid.width == filter_pid.width
3877
]
3978
ObjectCombo: cb:
40-
items << model.ports
79+
items << get_ports(model.ports)
4180
selected << selected_port(model.port, model.ports)
81+
to_string = port_string
4282
selected ::
4383
port = change['value']
4484
if port:
45-
model.port = port.device_path
85+
if not model.port_matches(port):
86+
if filter_update_confirm(question(
87+
self, '', QApplication.translate("serialport",
88+
"Port does not match the filter, do you want to update filter?"))):
89+
model.clear_filter()
90+
model.make_filter(port)
91+
model.port = port.device_path
92+
refresh_filter_clear()
93+
else:
94+
model.port = ""
95+
cb.selected = None
96+
cb._refresh_proxy(dict(type='update'))
97+
else:
98+
old_port = change['oldvalue']
99+
if not model.has_port_filter() and\
100+
(old_port and old_port.device_path != port.device_path):
101+
model.make_filter(port)
102+
model.port = port.device_path
46103
tool_tip = textwrap.dedent("""
47104
List of serial ports detected by the system. If nothing is here you
48105
must install the device driver for your machine.
49106
""").strip()
50-
PushButton: pb:
51-
text = QApplication.translate("serialport", "Refresh")
52-
icon = load_icon("arrow_refresh")
53-
clicked :: model.refresh()
107+
Container: pb:
108+
padding = 0
109+
constraints = [
110+
hbox(pb2, pb3, spacing=0),
111+
pb2.height == pb3.height,
112+
pb3.width == pb3.height
113+
]
114+
hug_width = 'medium'
115+
PushButton: pb2:
116+
text = QApplication.translate("serialport", "Refresh")
117+
icon = load_icon("arrow_refresh")
118+
clicked :: model.refresh()
119+
PushButton: pb3:
120+
Menu:
121+
Action:
122+
text = QApplication.translate("serialport", "Show all")
123+
checkable = True
124+
checked := show_all_ports
125+
checked :: model.refresh()
126+
Label: filter_label:
127+
text = QApplication.translate("serialport", "Filter: ")
128+
Field: filter_descr:
129+
text := model.port_filter_name
130+
text :: refresh_filter_clear()
131+
Label: filter_label_vid:
132+
text = QApplication.translate("serialport", "VID")
133+
HexPair: filter_vid:
134+
number := model.port_filter_vid
135+
number :: refresh_filter_clear()
136+
resist_width = 'weak'
137+
Label: filter_label_pid:
138+
text = QApplication.translate("serialport", "PID")
139+
HexPair: filter_pid:
140+
number := model.port_filter_pid
141+
number :: refresh_filter_clear()
142+
resist_width = 'weak'
143+
Container: clear_group:
144+
padding = 0
145+
constraints = [
146+
hbox(filter_clear, pb4, spacing=0),
147+
pb4.height == filter_clear.height,
148+
pb4.width == pb4.height
149+
]
150+
PushButton: filter_clear:
151+
text = QApplication.translate("serialport", "Clear")
152+
icon = load_icon("cancel")
153+
clicked ::
154+
model.clear_filter()
155+
model.refresh()
156+
refresh_filter_clear()
157+
PushButton: pb4:
158+
Menu:
159+
Action:
160+
text = QApplication.translate("serialport", "Make filter")
161+
triggered :: model.make_filter(cb.selected)
54162
Label:
55163
text = QApplication.translate("serialport", "Baudrate")
56164
SpinBox:

tests/test_app.py

+5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ def close():
3030
@pytest.mark.skipif(not is_pyqtgraph_available,
3131
reason='pyqtgraph is not available')
3232
def test_app():
33+
# main app will fail to start if there already is a reactor
34+
import sys
35+
if "twisted.internet.reactor" in sys.modules:
36+
sys.modules.pop("twisted.internet.reactor")
37+
3338
# Must close programatically
3439
Timer(10, lambda: deferred_call(close)).start()
3540
from inkcut.app import main

0 commit comments

Comments
 (0)