Skip to content

Commit 6e5d18d

Browse files
committed
Add optional port filter based on usb info.
1 parent 883cb7b commit 6e5d18d

File tree

4 files changed

+285
-14
lines changed

4 files changed

+285
-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

+78-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,71 @@ 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.replace(port_info.device_path, '').strip()
109+
text = text.removeprefix('-').removeprefix(':').strip()
110+
if text:
111+
self.port_filter_name = re.escape(text)
112+
113+
def port_by_path(self, device_path):
114+
for port in self.ports:
115+
if port.device_path == device_path:
116+
return port
117+
return None
118+
119+
def get_matching_port(self):
120+
for port in self.ports:
121+
if self.port_matches(port):
122+
return port
123+
return None
124+
125+
def choose_filtered_port(self):
126+
if not self.has_port_filter():
127+
return self.port
128+
129+
self.refresh()
130+
port_info = self.port_by_path(self.port)
131+
if port_info and self.port_matches(port_info):
132+
return port_info.device_path # prefer last used port when suitable
133+
134+
port_info = self.get_matching_port()
135+
if port_info:
136+
return port_info.device_path
137+
138+
return None
139+
69140

70141
class SerialConfig(SerialConfigBase):
71142
def _default_ports(self):
@@ -92,17 +163,22 @@ class SerialTransport(RawFdTransport):
92163

93164
def connect(self):
94165
config = self.config
95-
self.device_path = config.port
96166
try:
97167
#: Save a reference
98168
self.protocol.transport = self
99169

100170
#: Make the wrapper
101171
self._protocol = RawFdProtocol(self, self.protocol)
102172

173+
port = self.config.choose_filtered_port()
174+
if not port:
175+
raise Exception("{} | Could not find suitable port".format(config.port))
176+
self.config.port = port # might be updated if there is a filter
177+
self.device_path = config.port
178+
103179
self.connection = SerialPort(
104180
self._protocol,
105-
config.port,
181+
port,
106182
reactor,
107183
baudrate=config.baudrate,
108184
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/transport/test_serial_config.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Copyright (c) 2024, Karlis Senko
3+
4+
Distributed under the terms of the GPL v3 License.
5+
6+
The full license is in the file LICENSE, distributed with this software.
7+
8+
Created on Dec 9, 2024
9+
10+
@author: karliss
11+
"""
12+
import pytest
13+
from inkcut.device.transports.serialport.plugin import SerialConfigBase, SerialPortInfo
14+
15+
16+
def test_serial_filter():
17+
info = SerialPortInfo(device_path='port1')
18+
config = SerialConfigBase()
19+
20+
assert not config.has_port_filter()
21+
assert config.port_matches(info)
22+
23+
config = SerialConfigBase(port_filter_name='ort')
24+
25+
assert config.has_port_filter()
26+
assert not config.port_matches(info)
27+
28+
assert config.port_matches(SerialPortInfo(description='Port1', usb_vid=1, usb_pid=1))
29+
assert not config.port_matches(SerialPortInfo(description='P_rt1', usb_vid=1, usb_pid=1))
30+
31+
config = SerialConfigBase(port_filter_vid=5)
32+
assert config.port_matches(SerialPortInfo(description='Port1', usb_vid=5, usb_pid=1))
33+
assert config.port_matches(SerialPortInfo(usb_vid=5, usb_pid=1))
34+
assert not config.port_matches(SerialPortInfo(description='Port1', usb_vid=1, usb_pid=1))
35+
36+
config = SerialConfigBase(port_filter_pid=10)
37+
assert config.port_matches(SerialPortInfo(description='Port1', usb_vid=5, usb_pid=10))
38+
assert config.port_matches(SerialPortInfo(usb_vid=4, usb_pid=10))
39+
assert not config.port_matches(SerialPortInfo(description='Port1', usb_vid=2, usb_pid=2))
40+
41+
config = SerialConfigBase(port_filter_name='Port1', port_filter_vid=0x1234, port_filter_pid=0x45ac)
42+
assert config.port_matches(SerialPortInfo(description='Port1', usb_vid=0x1234, usb_pid=0x45ac))
43+
assert not config.port_matches(SerialPortInfo(description='Port1', usb_vid=0x1234, usb_pid=0x45ad))
44+
assert not config.port_matches(SerialPortInfo(description='Port1', usb_vid=0x1235, usb_pid=0x45ac))
45+
assert not config.port_matches(SerialPortInfo(description='Port2', usb_vid=0x1234, usb_pid=0x45ac))
46+
47+
config = SerialConfigBase(port_filter_name='P.*1')
48+
assert config.port_matches(SerialPortInfo(description='Port1', usb_vid=1, usb_pid=1))
49+
assert config.port_matches(SerialPortInfo(description='Pooooooort1', usb_vid=1, usb_pid=1))
50+
assert not config.port_matches(SerialPortInfo(description='P..', usb_vid=1, usb_pid=1))
51+
52+
config = SerialConfigBase(port_filter_name='^Port1$')
53+
assert config.port_matches(SerialPortInfo(description='Port1', usb_vid=1, usb_pid=1))
54+
assert not config.port_matches(SerialPortInfo(description='Port12', usb_vid=1, usb_pid=1))
55+
56+
57+
def test_make_filter():
58+
info = SerialPortInfo(device_path='port1', description='Port1 some plotter', usb_vid=123, usb_pid=321)
59+
config = SerialConfigBase()
60+
61+
config.make_filter(info)
62+
assert config.has_port_filter()
63+
assert config.port_matches(info)
64+
65+
info_2 = SerialPortInfo(device_path='port1', description='Port1 other plotter', usb_vid=123, usb_pid=321)
66+
assert not config.port_matches(info_2)
67+
68+
info_2 = SerialPortInfo(device_path='port1', description='Port1 some plotter', usb_vid=124, usb_pid=321)
69+
assert not config.port_matches(info_2)
70+
71+
info_2 = SerialPortInfo(device_path='port1', description='Port1 some plotter', usb_vid=123, usb_pid=421)
72+
assert not config.port_matches(info_2)
73+
74+
config.clear_filter()
75+
assert not config.has_port_filter()
76+
77+
info = SerialPortInfo(device_path='port1', description=r"a+.*?(){},{^$|\\", usb_vid=123, usb_pid=321)
78+
config = SerialConfigBase()
79+
config.make_filter(info)
80+
assert config.port_matches(info)
81+
assert not config.port_matches(SerialPortInfo(description='a', usb_vid=123, usb_pid=321))

0 commit comments

Comments
 (0)