com2tty 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- com2tty/__init__.py +1 -0
- com2tty/__main__.py +4 -0
- com2tty/bridge.py +131 -0
- com2tty/cli.py +112 -0
- com2tty/host.py +180 -0
- com2tty-0.1.0.dist-info/METADATA +133 -0
- com2tty-0.1.0.dist-info/RECORD +11 -0
- com2tty-0.1.0.dist-info/WHEEL +5 -0
- com2tty-0.1.0.dist-info/entry_points.txt +2 -0
- com2tty-0.1.0.dist-info/licenses/LICENSE +21 -0
- com2tty-0.1.0.dist-info/top_level.txt +1 -0
com2tty/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
com2tty/__main__.py
ADDED
com2tty/bridge.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import select
|
|
4
|
+
import argparse
|
|
5
|
+
import signal
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
def cleanup_symlink(path):
|
|
9
|
+
if path and os.path.exists(path):
|
|
10
|
+
try:
|
|
11
|
+
os.unlink(path)
|
|
12
|
+
sys.stderr.write(f"Removed symlink: {path}\n")
|
|
13
|
+
sys.stderr.flush()
|
|
14
|
+
except Exception as e: # pragma: no cover
|
|
15
|
+
sys.stderr.write(f"Failed to remove symlink {path}: {e}\n")
|
|
16
|
+
sys.stderr.flush()
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
parser = argparse.ArgumentParser(description="com2tty WSL Bridge Helper")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"-s", "--symlink",
|
|
22
|
+
required=True,
|
|
23
|
+
help="Target symlink path for the pseudo-terminal device."
|
|
24
|
+
)
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
target_path = args.symlink
|
|
28
|
+
created_symlink = None
|
|
29
|
+
|
|
30
|
+
# We must keep both master and slave descriptors open.
|
|
31
|
+
# Keeping slave_fd open prevents EIO errors on the master side when
|
|
32
|
+
# WSL clients open and close the virtual serial port.
|
|
33
|
+
master_fd = None
|
|
34
|
+
slave_fd = None
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
master_fd, slave_fd = os.openpty()
|
|
38
|
+
slave_name = os.ttyname(slave_fd)
|
|
39
|
+
|
|
40
|
+
sys.stderr.write(f"Created pseudo-terminal: master_fd={master_fd}, slave={slave_name}\n")
|
|
41
|
+
sys.stderr.flush()
|
|
42
|
+
|
|
43
|
+
# Attempt to create the symlink at target path
|
|
44
|
+
try:
|
|
45
|
+
if os.path.lexists(target_path):
|
|
46
|
+
os.unlink(target_path)
|
|
47
|
+
os.symlink(slave_name, target_path)
|
|
48
|
+
created_symlink = target_path
|
|
49
|
+
sys.stderr.write(f"Successfully symlinked {target_path} -> {slave_name}\n")
|
|
50
|
+
sys.stderr.flush()
|
|
51
|
+
except PermissionError:
|
|
52
|
+
# Fallback to /tmp if write permission to dev is denied
|
|
53
|
+
basename = os.path.basename(target_path)
|
|
54
|
+
fallback_path = f"/tmp/{basename}"
|
|
55
|
+
sys.stderr.write(f"Warning: Permission denied creating symlink at {target_path}.\n")
|
|
56
|
+
sys.stderr.write(f"Attempting fallback to user-writable path: {fallback_path}...\n")
|
|
57
|
+
sys.stderr.flush()
|
|
58
|
+
|
|
59
|
+
if os.path.lexists(fallback_path):
|
|
60
|
+
os.unlink(fallback_path)
|
|
61
|
+
os.symlink(slave_name, fallback_path)
|
|
62
|
+
created_symlink = fallback_path
|
|
63
|
+
|
|
64
|
+
sys.stderr.write(f"Fallback successful: {fallback_path} -> {slave_name}\n")
|
|
65
|
+
sys.stderr.write("--------------------------------------------------\n")
|
|
66
|
+
sys.stderr.write(f"To use the desired device path '{target_path}', please run this command ONCE in WSL:\n")
|
|
67
|
+
sys.stderr.write(f" sudo ln -sf {fallback_path} {target_path}\n")
|
|
68
|
+
sys.stderr.write("--------------------------------------------------\n")
|
|
69
|
+
sys.stderr.flush()
|
|
70
|
+
|
|
71
|
+
# Select loop
|
|
72
|
+
# 0 is stdin, master_fd is the pseudo-terminal master
|
|
73
|
+
sys.stderr.write("WSL bridge enter main loop.\n")
|
|
74
|
+
sys.stderr.flush()
|
|
75
|
+
|
|
76
|
+
while True:
|
|
77
|
+
# select blocks until data is available on stdin or master_fd
|
|
78
|
+
r, w, x = select.select([0, master_fd], [], [])
|
|
79
|
+
|
|
80
|
+
if 0 in r:
|
|
81
|
+
data = os.read(0, 4096)
|
|
82
|
+
if not data:
|
|
83
|
+
sys.stderr.write("EOF on stdin. Exiting.\n")
|
|
84
|
+
sys.stderr.flush()
|
|
85
|
+
break
|
|
86
|
+
os.write(master_fd, data)
|
|
87
|
+
|
|
88
|
+
if master_fd in r:
|
|
89
|
+
# Read from the virtual serial port
|
|
90
|
+
try:
|
|
91
|
+
data = os.read(master_fd, 4096)
|
|
92
|
+
if not data:
|
|
93
|
+
# Should not happen typically while slave_fd is kept open,
|
|
94
|
+
# but handle it gracefully if it does.
|
|
95
|
+
sys.stderr.write("EOF on PTY master. Exiting.\n")
|
|
96
|
+
sys.stderr.flush()
|
|
97
|
+
break
|
|
98
|
+
os.write(1, data)
|
|
99
|
+
except OSError as e:
|
|
100
|
+
# In case of EIO (Input/output error) when slave closes,
|
|
101
|
+
# just ignore it and continue since we keep slave_fd open.
|
|
102
|
+
if e.errno == 5: # EIO
|
|
103
|
+
continue
|
|
104
|
+
else:
|
|
105
|
+
raise e
|
|
106
|
+
|
|
107
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
108
|
+
sys.stderr.write("WSL bridge interrupted by signal.\n")
|
|
109
|
+
sys.stderr.flush()
|
|
110
|
+
except Exception as e: # pragma: no cover
|
|
111
|
+
sys.stderr.write(f"WSL bridge error: {traceback.format_exc()}\n")
|
|
112
|
+
sys.stderr.flush()
|
|
113
|
+
finally:
|
|
114
|
+
# Clean up symlink and file descriptors
|
|
115
|
+
if created_symlink:
|
|
116
|
+
cleanup_symlink(created_symlink)
|
|
117
|
+
if slave_fd is not None:
|
|
118
|
+
try:
|
|
119
|
+
os.close(slave_fd)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
if master_fd is not None:
|
|
123
|
+
try:
|
|
124
|
+
os.close(master_fd)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
sys.stderr.write("WSL bridge shut down.\n")
|
|
128
|
+
sys.stderr.flush()
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__": # pragma: no cover
|
|
131
|
+
main()
|
com2tty/cli.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import logging
|
|
4
|
+
from com2tty.host import run_bridge
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
description="com2tty: Forward Windows COM ports to WSL virtual ttyUSB devices."
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"port",
|
|
13
|
+
help="Windows COM port to connect to (e.g. COM3 or COM1)."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"-b", "--baud",
|
|
18
|
+
type=int,
|
|
19
|
+
default=9600,
|
|
20
|
+
help="Baud rate for the serial port (default: 9600)."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-w", "--wsl-tty",
|
|
25
|
+
default="/tmp/ttyUSB0",
|
|
26
|
+
help="Target symlink path inside WSL (default: /tmp/ttyUSB0)."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--bytesize",
|
|
31
|
+
type=int,
|
|
32
|
+
choices=[5, 6, 7, 8],
|
|
33
|
+
default=8,
|
|
34
|
+
help="Serial byte size (default: 8)."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--parity",
|
|
39
|
+
choices=["N", "E", "O", "S", "M"],
|
|
40
|
+
default="N",
|
|
41
|
+
help="Serial parity: None, Even, Odd, Space, Mark (default: N)."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--stopbits",
|
|
46
|
+
type=float,
|
|
47
|
+
choices=[1, 1.5, 2],
|
|
48
|
+
default=1,
|
|
49
|
+
help="Serial stop bits: 1, 1.5, or 2 (default: 1)."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--xonxoff",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Enable software flow control (XON/XOFF)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--rtscts",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Enable hardware flow control (RTS/CTS)."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--dsrdtr",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Enable hardware flow control (DSR/DTR)."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"-d", "--debug",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="Enable debug logging output."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
args = parser.add_argument_group("advanced")
|
|
77
|
+
|
|
78
|
+
parsed_args = parser.parse_args()
|
|
79
|
+
|
|
80
|
+
# Configure logging
|
|
81
|
+
log_level = logging.DEBUG if parsed_args.debug else logging.INFO
|
|
82
|
+
logging.basicConfig(
|
|
83
|
+
level=log_level,
|
|
84
|
+
format="[%(asctime)s] %(levelname)s: %(message)s",
|
|
85
|
+
datefmt="%H:%M:%S",
|
|
86
|
+
stream=sys.stderr
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
run_bridge(
|
|
91
|
+
port=parsed_args.port,
|
|
92
|
+
baud=parsed_args.baud,
|
|
93
|
+
wsl_tty=parsed_args.wsl_tty,
|
|
94
|
+
bytesize=parsed_args.bytesize,
|
|
95
|
+
parity=parsed_args.parity,
|
|
96
|
+
stopbits=parsed_args.stopbits,
|
|
97
|
+
xonxoff=parsed_args.xonxoff,
|
|
98
|
+
rtscts=parsed_args.rtscts,
|
|
99
|
+
dsrdtr=parsed_args.dsrdtr
|
|
100
|
+
)
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
logging.info("Interrupted by user. Exiting.")
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logging.error(f"Fatal error: {e}")
|
|
106
|
+
if parsed_args.debug:
|
|
107
|
+
import traceback
|
|
108
|
+
traceback.print_exc()
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__": # pragma: no cover
|
|
112
|
+
main()
|
com2tty/host.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
import serial
|
|
8
|
+
|
|
9
|
+
def get_wsl_path(win_path):
|
|
10
|
+
cmd = ["wsl", "wslpath", "-u", win_path]
|
|
11
|
+
try:
|
|
12
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
13
|
+
return res.stdout.strip()
|
|
14
|
+
except Exception as e:
|
|
15
|
+
logging.debug(f"wslpath failed: {e}. Using fallback conversion.")
|
|
16
|
+
# Fallback to mounting convention /mnt/<drive>/...
|
|
17
|
+
drive = win_path[0].lower()
|
|
18
|
+
path = win_path[2:].replace("\\", "/")
|
|
19
|
+
return f"/mnt/{drive}{path}"
|
|
20
|
+
|
|
21
|
+
def get_serial_settings(bytesize, parity, stopbits):
|
|
22
|
+
bytesize_map = {
|
|
23
|
+
5: serial.FIVEBITS,
|
|
24
|
+
6: serial.SIXBITS,
|
|
25
|
+
7: serial.SEVENBITS,
|
|
26
|
+
8: serial.EIGHTBITS
|
|
27
|
+
}
|
|
28
|
+
parity_map = {
|
|
29
|
+
"N": serial.PARITY_NONE,
|
|
30
|
+
"E": serial.PARITY_EVEN,
|
|
31
|
+
"O": serial.PARITY_ODD,
|
|
32
|
+
"S": serial.PARITY_SPACE,
|
|
33
|
+
"M": serial.PARITY_MARK
|
|
34
|
+
}
|
|
35
|
+
stopbits_map = {
|
|
36
|
+
1: serial.STOPBITS_ONE,
|
|
37
|
+
1.5: serial.STOPBITS_ONE_POINT_FIVE,
|
|
38
|
+
2: serial.STOPBITS_TWO
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
bytesize_map.get(bytesize, serial.EIGHTBITS),
|
|
43
|
+
parity_map.get(parity, serial.PARITY_NONE),
|
|
44
|
+
stopbits_map.get(stopbits, serial.STOPBITS_ONE)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def read_wsl_stdout(proc, ser, shutdown_event):
|
|
48
|
+
logging.debug("WSL-to-COM thread started.")
|
|
49
|
+
try:
|
|
50
|
+
while not shutdown_event.is_set():
|
|
51
|
+
data = proc.stdout.read(1024)
|
|
52
|
+
if not data:
|
|
53
|
+
logging.info("WSL process stdout reached EOF (exited).")
|
|
54
|
+
break
|
|
55
|
+
logging.debug(f"WSL -> COM: {len(data)} bytes")
|
|
56
|
+
ser.write(data)
|
|
57
|
+
ser.flush()
|
|
58
|
+
except Exception as e:
|
|
59
|
+
if not shutdown_event.is_set():
|
|
60
|
+
logging.error(f"Error in WSL-to-COM thread: {e}")
|
|
61
|
+
finally:
|
|
62
|
+
shutdown_event.set()
|
|
63
|
+
|
|
64
|
+
def read_com_port(ser, proc, shutdown_event):
|
|
65
|
+
logging.debug("COM-to-WSL thread started.")
|
|
66
|
+
try:
|
|
67
|
+
while not shutdown_event.is_set():
|
|
68
|
+
# Short timeout allows periodic checking of shutdown_event
|
|
69
|
+
data = ser.read(1024)
|
|
70
|
+
if data:
|
|
71
|
+
logging.debug(f"COM -> WSL: {len(data)} bytes")
|
|
72
|
+
proc.stdin.write(data)
|
|
73
|
+
proc.stdin.flush()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
if not shutdown_event.is_set():
|
|
76
|
+
logging.error(f"Error in COM-to-WSL thread: {e}")
|
|
77
|
+
finally:
|
|
78
|
+
shutdown_event.set()
|
|
79
|
+
|
|
80
|
+
def read_wsl_stderr(proc, shutdown_event):
|
|
81
|
+
logging.debug("WSL stderr logging thread started.")
|
|
82
|
+
try:
|
|
83
|
+
while not shutdown_event.is_set():
|
|
84
|
+
line = proc.stderr.readline()
|
|
85
|
+
if not line:
|
|
86
|
+
break
|
|
87
|
+
line_str = line.decode("utf-8", errors="replace").strip()
|
|
88
|
+
if line_str:
|
|
89
|
+
logging.info(f"[WSL] {line_str}")
|
|
90
|
+
except Exception as e:
|
|
91
|
+
if not shutdown_event.is_set():
|
|
92
|
+
logging.debug(f"Error in WSL stderr thread: {e}")
|
|
93
|
+
|
|
94
|
+
def run_bridge(port, baud, wsl_tty, bytesize, parity, stopbits, xonxoff, rtscts, dsrdtr):
|
|
95
|
+
# Resolve serial settings
|
|
96
|
+
ser_bytesize, ser_parity, ser_stopbits = get_serial_settings(bytesize, parity, stopbits)
|
|
97
|
+
|
|
98
|
+
logging.info(f"Opening Windows serial port {port} at {baud} baud...")
|
|
99
|
+
ser = serial.Serial(
|
|
100
|
+
port=port,
|
|
101
|
+
baudrate=baud,
|
|
102
|
+
bytesize=ser_bytesize,
|
|
103
|
+
parity=ser_parity,
|
|
104
|
+
stopbits=ser_stopbits,
|
|
105
|
+
xonxoff=xonxoff,
|
|
106
|
+
rtscts=rtscts,
|
|
107
|
+
dsrdtr=dsrdtr,
|
|
108
|
+
timeout=0.2 # Enable timeout for shutdown check
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Locate WSL bridge.py script
|
|
112
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
113
|
+
bridge_script = os.path.join(current_dir, "bridge.py")
|
|
114
|
+
if not os.path.exists(bridge_script):
|
|
115
|
+
raise FileNotFoundError(f"WSL bridge script not found at: {bridge_script}")
|
|
116
|
+
|
|
117
|
+
wsl_bridge_path = get_wsl_path(bridge_script)
|
|
118
|
+
logging.info(f"WSL bridge script resolved to: {wsl_bridge_path}")
|
|
119
|
+
|
|
120
|
+
cmd = ["wsl", "python3", "-u", wsl_bridge_path, "--symlink", wsl_tty]
|
|
121
|
+
logging.info(f"Spawning WSL process: {' '.join(cmd)}")
|
|
122
|
+
|
|
123
|
+
# Use CREATE_NO_WINDOW to prevent wsl.exe from modifying the Windows console mode,
|
|
124
|
+
# which would otherwise disable Ctrl+C (ENABLE_PROCESSED_INPUT) for the Python CLI.
|
|
125
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
126
|
+
proc = subprocess.Popen(
|
|
127
|
+
cmd,
|
|
128
|
+
stdin=subprocess.PIPE,
|
|
129
|
+
stdout=subprocess.PIPE,
|
|
130
|
+
stderr=subprocess.PIPE,
|
|
131
|
+
bufsize=0,
|
|
132
|
+
creationflags=CREATE_NO_WINDOW
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
shutdown_event = threading.Event()
|
|
136
|
+
|
|
137
|
+
# Start thread routing
|
|
138
|
+
t_wsl_to_com = threading.Thread(target=read_wsl_stdout, args=(proc, ser, shutdown_event), daemon=True)
|
|
139
|
+
t_com_to_wsl = threading.Thread(target=read_com_port, args=(ser, proc, shutdown_event), daemon=True)
|
|
140
|
+
t_wsl_stderr = threading.Thread(target=read_wsl_stderr, args=(proc, shutdown_event), daemon=True)
|
|
141
|
+
|
|
142
|
+
t_wsl_to_com.start()
|
|
143
|
+
t_com_to_wsl.start()
|
|
144
|
+
t_wsl_stderr.start()
|
|
145
|
+
|
|
146
|
+
logging.info("Bridge is fully active. Press Ctrl+C to stop.")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
while not shutdown_event.is_set():
|
|
150
|
+
# Wait and check if the WSL process is still running
|
|
151
|
+
if proc.poll() is not None:
|
|
152
|
+
logging.info("WSL subprocess exited unexpectedly.")
|
|
153
|
+
break
|
|
154
|
+
time.sleep(0.5)
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
logging.info("Stopping bridge due to KeyboardInterrupt...")
|
|
157
|
+
finally:
|
|
158
|
+
shutdown_event.set()
|
|
159
|
+
|
|
160
|
+
logging.info("Cleaning up resources...")
|
|
161
|
+
# Wait for threads to exit to avoid pyserial close() deadlock on Windows
|
|
162
|
+
t_com_to_wsl.join(timeout=0.5)
|
|
163
|
+
|
|
164
|
+
# Close serial port first
|
|
165
|
+
try:
|
|
166
|
+
ser.close()
|
|
167
|
+
logging.info("Closed Windows serial port.")
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logging.debug(f"Error closing serial port: {e}")
|
|
170
|
+
|
|
171
|
+
# Terminate WSL subprocess
|
|
172
|
+
if proc.poll() is None:
|
|
173
|
+
logging.info("Terminating WSL process...")
|
|
174
|
+
proc.terminate()
|
|
175
|
+
try:
|
|
176
|
+
proc.wait(timeout=3.0)
|
|
177
|
+
except subprocess.TimeoutExpired:
|
|
178
|
+
logging.warning("WSL process did not exit. Killing it.")
|
|
179
|
+
proc.kill()
|
|
180
|
+
logging.info("Bridge stopped successfully.")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: com2tty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Windows COM port to WSL ttyUSB forwarder
|
|
5
|
+
Author-email: yichengs <yichengs.tw+com2tty@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/Yi-Cheng-Wang/com2tty
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: pyserial>=3.5
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# com2tty
|
|
18
|
+
|
|
19
|
+
`com2tty` is a Python package designed to forward serial communications from a Windows COM port into a WSL (Windows Subsystem for Linux) instance, presenting it as a virtual `ttyUSB` (or similar) serial device in WSL.
|
|
20
|
+
|
|
21
|
+
It does this using a low-latency, firewall-resilient **process-pipe bridge** over standard input/output redirection. It requires **no network or firewall configuration**.
|
|
22
|
+
|
|
23
|
+
## Architecture Overview
|
|
24
|
+
|
|
25
|
+
1. The Windows host process opens the physical COM port (using `pyserial`).
|
|
26
|
+
2. It spawns the WSL Python bridge helper in the background, redirecting its stdin/stdout.
|
|
27
|
+
3. Inside WSL, the bridge helper opens a pseudo-terminal (PTY) and creates a symbolic link to the PTY's slave file.
|
|
28
|
+
4. Data is piped bidirectionally between the physical Windows COM port and the WSL virtual device.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
[Windows COM Port] <--> [com2tty host] <--> (stdin/stdout pipe) <--> [wsl bridge] <--> [PTY Slave /tmp/ttyUSB0]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- **Windows Host**: Python 3.8+ and `pyserial` (installed automatically).
|
|
37
|
+
- **WSL Guest**: Python 3.x (uses standard library modules only, no dependencies required).
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Install the package on the Windows host by running the following command in the project root:
|
|
42
|
+
|
|
43
|
+
```cmd
|
|
44
|
+
pip install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For development, you can install it in editable mode:
|
|
48
|
+
|
|
49
|
+
```cmd
|
|
50
|
+
pip install -e .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Run `com2tty` from any Windows terminal (PowerShell or Command Prompt).
|
|
56
|
+
|
|
57
|
+
```cmd
|
|
58
|
+
com2tty <COM_PORT> [options]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Examples
|
|
62
|
+
|
|
63
|
+
Bridge **COM3** to the default WSL path `/tmp/ttyUSB0` at 115200 baud:
|
|
64
|
+
```cmd
|
|
65
|
+
com2tty COM3 --baud 115200
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Bridge **COM5** to a custom WSL device path `/tmp/my_device`:
|
|
69
|
+
```cmd
|
|
70
|
+
com2tty COM5 --baud 9600 -w /tmp/my_device
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Options
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
positional arguments:
|
|
77
|
+
port Windows COM port to connect to (e.g. COM3).
|
|
78
|
+
|
|
79
|
+
options:
|
|
80
|
+
-h, --help show this help message and exit
|
|
81
|
+
-b BAUD, --baud BAUD Baud rate for the serial port (default: 9600).
|
|
82
|
+
-w WSL_TTY, --wsl-tty WSL_TTY
|
|
83
|
+
Target symlink path inside WSL (default: /tmp/ttyUSB0).
|
|
84
|
+
--bytesize {5,6,7,8} Serial byte size (default: 8).
|
|
85
|
+
--parity {N,E,O,S,M} Serial parity: None, Even, Odd, Space, Mark (default: N).
|
|
86
|
+
--stopbits {1,1.5,2} Serial stop bits: 1, 1.5, or 2 (default: 1).
|
|
87
|
+
--xonxoff Enable software flow control (XON/XOFF).
|
|
88
|
+
--rtscts Enable hardware flow control (RTS/CTS).
|
|
89
|
+
--dsrdtr Enable hardware flow control (DSR/DTR).
|
|
90
|
+
-d, --debug Enable debug logging output.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Configuring `/dev/ttyUSB0` in WSL (Highly Recommended)
|
|
96
|
+
|
|
97
|
+
In Linux, the `/dev` directory is owned by `root`. Running `com2tty` as a normal Windows user means the WSL subprocess cannot write directly to `/dev/ttyUSB0`.
|
|
98
|
+
|
|
99
|
+
To work around this cleanly without requiring root permissions or passwordless `sudo` at runtime:
|
|
100
|
+
|
|
101
|
+
1. Run `com2tty` with the default path (or any `/tmp/` path):
|
|
102
|
+
```cmd
|
|
103
|
+
com2tty COM3 --wsl-tty /tmp/ttyUSB0
|
|
104
|
+
```
|
|
105
|
+
2. In your WSL terminal, run the following command **once** to create a permanent symlink pointing from `/dev/` to the stable `/tmp/` path:
|
|
106
|
+
```bash
|
|
107
|
+
sudo ln -sf /tmp/ttyUSB0 /dev/ttyUSB0
|
|
108
|
+
```
|
|
109
|
+
3. Now, any WSL application (such as `minicom`, `screen`, `esp-idf`, or Python scripts) can read and write to `/dev/ttyUSB0`.
|
|
110
|
+
|
|
111
|
+
Whenever `com2tty` starts up, it updates `/tmp/ttyUSB0` to point to the active pseudo-terminal (`/dev/pts/N`), and `/dev/ttyUSB0` resolves to the correct endpoint automatically.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Troubleshooting
|
|
116
|
+
|
|
117
|
+
### WSL Bridge warns about Permission Denied
|
|
118
|
+
If you specify `-w /dev/ttyUSB0` directly and see:
|
|
119
|
+
`[WSL] Warning: Permission denied creating symlink at /dev/ttyUSB0.`
|
|
120
|
+
This is expected behavior. The script will automatically fall back to `/tmp/ttyUSB0` and output instructions on how to link them.
|
|
121
|
+
|
|
122
|
+
### Serial Port Busy / Access Denied
|
|
123
|
+
Ensure that no other application on the Windows host (like PuTTY, Serial Monitor, or another instance of `com2tty`) is currently holding the COM port open.
|
|
124
|
+
|
|
125
|
+
### Debugging
|
|
126
|
+
Run `com2tty` with the `-d` or `--debug` flag to view detailed logs and data transfer statistics:
|
|
127
|
+
```cmd
|
|
128
|
+
com2tty COM3 --debug
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
com2tty/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
com2tty/__main__.py,sha256=c3sfBf9CsB__uxUmojI786ZCfgagUGxqoJeIo2fP_Ag,87
|
|
3
|
+
com2tty/bridge.py,sha256=nZaBv_ffVf2H0hw6ySUHiyHiF9q-SWlDAt5z4FobkYA,5067
|
|
4
|
+
com2tty/cli.py,sha256=uYXOIg5Vu_Y3m8qHJxRDvnCag9lzyhxjakBmB9iLARE,2883
|
|
5
|
+
com2tty/host.py,sha256=XTyThnAvfhSmob_uozvXsbe3c2ocJVYaSJy2BD3FhLE,6298
|
|
6
|
+
com2tty-0.1.0.dist-info/licenses/LICENSE,sha256=ZaKMWSm_-7TSseqHEwfSFtvxHtG1x8FGXg6eYPPmKU0,1066
|
|
7
|
+
com2tty-0.1.0.dist-info/METADATA,sha256=9L9TFJa0ra_tLhhxfjUiWDSqssW6E6wNlvwOtwDRQNs,4699
|
|
8
|
+
com2tty-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
com2tty-0.1.0.dist-info/entry_points.txt,sha256=9NgquNy1sScY5NcZD2N2jmVYL0A7Nqy8OP_OkFUqzWQ,45
|
|
10
|
+
com2tty-0.1.0.dist-info/top_level.txt,sha256=3fAwaVYRxXMqMp5XZwQFpMLGTHXuEJD4gfVJNb3Vcq8,8
|
|
11
|
+
com2tty-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 王羿程
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
com2tty
|