com2tty 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
com2tty-0.1.0/PKG-INFO ADDED
@@ -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,117 @@
1
+ # com2tty
2
+
3
+ `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.
4
+
5
+ It does this using a low-latency, firewall-resilient **process-pipe bridge** over standard input/output redirection. It requires **no network or firewall configuration**.
6
+
7
+ ## Architecture Overview
8
+
9
+ 1. The Windows host process opens the physical COM port (using `pyserial`).
10
+ 2. It spawns the WSL Python bridge helper in the background, redirecting its stdin/stdout.
11
+ 3. Inside WSL, the bridge helper opens a pseudo-terminal (PTY) and creates a symbolic link to the PTY's slave file.
12
+ 4. Data is piped bidirectionally between the physical Windows COM port and the WSL virtual device.
13
+
14
+ ```
15
+ [Windows COM Port] <--> [com2tty host] <--> (stdin/stdout pipe) <--> [wsl bridge] <--> [PTY Slave /tmp/ttyUSB0]
16
+ ```
17
+
18
+ ## Requirements
19
+
20
+ - **Windows Host**: Python 3.8+ and `pyserial` (installed automatically).
21
+ - **WSL Guest**: Python 3.x (uses standard library modules only, no dependencies required).
22
+
23
+ ## Installation
24
+
25
+ Install the package on the Windows host by running the following command in the project root:
26
+
27
+ ```cmd
28
+ pip install .
29
+ ```
30
+
31
+ For development, you can install it in editable mode:
32
+
33
+ ```cmd
34
+ pip install -e .
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ Run `com2tty` from any Windows terminal (PowerShell or Command Prompt).
40
+
41
+ ```cmd
42
+ com2tty <COM_PORT> [options]
43
+ ```
44
+
45
+ ### Examples
46
+
47
+ Bridge **COM3** to the default WSL path `/tmp/ttyUSB0` at 115200 baud:
48
+ ```cmd
49
+ com2tty COM3 --baud 115200
50
+ ```
51
+
52
+ Bridge **COM5** to a custom WSL device path `/tmp/my_device`:
53
+ ```cmd
54
+ com2tty COM5 --baud 9600 -w /tmp/my_device
55
+ ```
56
+
57
+ ### Options
58
+
59
+ ```
60
+ positional arguments:
61
+ port Windows COM port to connect to (e.g. COM3).
62
+
63
+ options:
64
+ -h, --help show this help message and exit
65
+ -b BAUD, --baud BAUD Baud rate for the serial port (default: 9600).
66
+ -w WSL_TTY, --wsl-tty WSL_TTY
67
+ Target symlink path inside WSL (default: /tmp/ttyUSB0).
68
+ --bytesize {5,6,7,8} Serial byte size (default: 8).
69
+ --parity {N,E,O,S,M} Serial parity: None, Even, Odd, Space, Mark (default: N).
70
+ --stopbits {1,1.5,2} Serial stop bits: 1, 1.5, or 2 (default: 1).
71
+ --xonxoff Enable software flow control (XON/XOFF).
72
+ --rtscts Enable hardware flow control (RTS/CTS).
73
+ --dsrdtr Enable hardware flow control (DSR/DTR).
74
+ -d, --debug Enable debug logging output.
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Configuring `/dev/ttyUSB0` in WSL (Highly Recommended)
80
+
81
+ 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`.
82
+
83
+ To work around this cleanly without requiring root permissions or passwordless `sudo` at runtime:
84
+
85
+ 1. Run `com2tty` with the default path (or any `/tmp/` path):
86
+ ```cmd
87
+ com2tty COM3 --wsl-tty /tmp/ttyUSB0
88
+ ```
89
+ 2. In your WSL terminal, run the following command **once** to create a permanent symlink pointing from `/dev/` to the stable `/tmp/` path:
90
+ ```bash
91
+ sudo ln -sf /tmp/ttyUSB0 /dev/ttyUSB0
92
+ ```
93
+ 3. Now, any WSL application (such as `minicom`, `screen`, `esp-idf`, or Python scripts) can read and write to `/dev/ttyUSB0`.
94
+
95
+ 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.
96
+
97
+ ---
98
+
99
+ ## Troubleshooting
100
+
101
+ ### WSL Bridge warns about Permission Denied
102
+ If you specify `-w /dev/ttyUSB0` directly and see:
103
+ `[WSL] Warning: Permission denied creating symlink at /dev/ttyUSB0.`
104
+ This is expected behavior. The script will automatically fall back to `/tmp/ttyUSB0` and output instructions on how to link them.
105
+
106
+ ### Serial Port Busy / Access Denied
107
+ 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.
108
+
109
+ ### Debugging
110
+ Run `com2tty` with the `-d` or `--debug` flag to view detailed logs and data transfer statistics:
111
+ ```cmd
112
+ com2tty COM3 --debug
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "com2tty"
7
+ version = "0.1.0"
8
+ description = "A Windows COM port to WSL ttyUSB forwarder"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "yichengs", email = "yichengs.tw+com2tty@gmail.com" }
12
+ ]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: Microsoft :: Windows",
17
+ "Operating System :: POSIX :: Linux",
18
+ ]
19
+ requires-python = ">=3.8"
20
+ dependencies = [
21
+ "pyserial>=3.5",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Yi-Cheng-Wang/com2tty"
26
+
27
+ [project.scripts]
28
+ com2tty = "com2tty.cli:main"
29
+
30
+ [tool.setuptools]
31
+ package-dir = {"" = "src"}
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
com2tty-0.1.0/setup.py ADDED
@@ -0,0 +1,4 @@
1
+ from setuptools import setup
2
+
3
+ if __name__ == "__main__":
4
+ setup()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from com2tty.cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ main()
@@ -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()
@@ -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()
@@ -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,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ src/com2tty/__init__.py
6
+ src/com2tty/__main__.py
7
+ src/com2tty/bridge.py
8
+ src/com2tty/cli.py
9
+ src/com2tty/host.py
10
+ src/com2tty.egg-info/PKG-INFO
11
+ src/com2tty.egg-info/SOURCES.txt
12
+ src/com2tty.egg-info/dependency_links.txt
13
+ src/com2tty.egg-info/entry_points.txt
14
+ src/com2tty.egg-info/requires.txt
15
+ src/com2tty.egg-info/top_level.txt
16
+ tests/test_bridge_script.py
17
+ tests/test_cli.py
18
+ tests/test_host.py
19
+ tests/test_main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ com2tty = com2tty.cli:main
@@ -0,0 +1 @@
1
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ com2tty
@@ -0,0 +1,156 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
7
+
8
+ from com2tty.bridge import main, cleanup_symlink
9
+
10
+ class TestBridgeScript(unittest.TestCase):
11
+
12
+ @patch("os.path.exists")
13
+ @patch("os.unlink", create=True)
14
+ @patch("sys.stderr.write")
15
+ def test_cleanup_symlink(self, mock_write, mock_unlink, mock_exists):
16
+ mock_exists.return_value = True
17
+ cleanup_symlink("/tmp/tty")
18
+ mock_unlink.assert_called_once_with("/tmp/tty")
19
+
20
+ # Test exception path
21
+ mock_unlink.side_effect = Exception("unlink failed")
22
+ cleanup_symlink("/tmp/tty")
23
+ mock_write.assert_called()
24
+
25
+ @patch("sys.argv", ["bridge.py", "--symlink", "/dev/ttyUSB0"])
26
+ @patch("os.openpty", create=True)
27
+ @patch("os.ttyname", create=True)
28
+ @patch("os.path.lexists")
29
+ @patch("os.symlink", create=True)
30
+ @patch("select.select")
31
+ @patch("os.read")
32
+ @patch("os.write")
33
+ @patch("os.unlink", create=True)
34
+ @patch("os.close")
35
+ def test_bridge_main_normal(self, mock_close, mock_unlink, mock_write, mock_read, mock_select, mock_symlink, mock_lexists, mock_ttyname, mock_openpty):
36
+ mock_openpty.return_value = (3, 4)
37
+ mock_ttyname.return_value = "/dev/pts/1"
38
+ mock_lexists.return_value = False
39
+
40
+ def fake_select(*args, **kwargs):
41
+ if getattr(fake_select, "calls", 0) == 0:
42
+ fake_select.calls = 1
43
+ return ([0], [], [])
44
+ elif fake_select.calls == 1:
45
+ fake_select.calls = 2
46
+ return ([3], [], [])
47
+ else:
48
+ raise KeyboardInterrupt()
49
+
50
+ mock_select.side_effect = fake_select
51
+
52
+ mock_read.side_effect = [b"stdin_data", b"pty_data"]
53
+
54
+ main()
55
+
56
+ mock_symlink.assert_called_with("/dev/pts/1", "/dev/ttyUSB0")
57
+ mock_write.assert_any_call(3, b"stdin_data")
58
+ mock_write.assert_any_call(1, b"pty_data")
59
+ mock_close.assert_any_call(4)
60
+ mock_close.assert_any_call(3)
61
+
62
+ @patch("sys.argv", ["bridge.py", "--symlink", "/dev/ttyUSB0"])
63
+ @patch("os.openpty", create=True)
64
+ @patch("os.ttyname", create=True)
65
+ @patch("os.path.lexists")
66
+ @patch("os.symlink", create=True)
67
+ @patch("select.select")
68
+ @patch("os.read")
69
+ @patch("os.close")
70
+ @patch("os.unlink", create=True)
71
+ def test_bridge_main_permission_fallback(self, mock_unlink, mock_close, mock_read, mock_select, mock_symlink, mock_lexists, mock_ttyname, mock_openpty):
72
+ mock_openpty.return_value = (3, 4)
73
+ mock_ttyname.return_value = "/dev/pts/1"
74
+
75
+ # First call is for target_path (return False), second call is for fallback_path (return True) to trigger unlink
76
+ mock_lexists.side_effect = [False, True]
77
+
78
+ def fake_symlink(src, dst):
79
+ if dst == "/dev/ttyUSB0":
80
+ raise PermissionError("Access denied")
81
+ return None
82
+
83
+ mock_symlink.side_effect = fake_symlink
84
+
85
+ mock_select.return_value = ([0], [], [])
86
+ mock_read.return_value = b""
87
+
88
+ main()
89
+
90
+ mock_unlink.assert_any_call("/tmp/ttyUSB0")
91
+ mock_symlink.assert_any_call("/dev/pts/1", "/tmp/ttyUSB0")
92
+
93
+ @patch("sys.argv", ["bridge.py", "--symlink", "/tmp/tty"])
94
+ @patch("os.openpty", create=True)
95
+ @patch("os.ttyname", create=True)
96
+ @patch("os.path.lexists")
97
+ @patch("os.symlink", create=True)
98
+ @patch("select.select")
99
+ @patch("os.read")
100
+ @patch("os.close")
101
+ @patch("os.unlink", create=True)
102
+ def test_bridge_main_eio_and_eof(self, mock_unlink, mock_close, mock_read, mock_select, mock_symlink, mock_lexists, mock_ttyname, mock_openpty):
103
+ mock_openpty.return_value = (3, 4)
104
+ mock_ttyname.return_value = "/dev/pts/1"
105
+
106
+ # Trigger EOF on master_fd loop
107
+ def fake_select(*args, **kwargs):
108
+ return ([3], [], [])
109
+
110
+ mock_select.side_effect = fake_select
111
+
112
+ eio_err = OSError()
113
+ eio_err.errno = 5
114
+
115
+ # Read returns EIO first, then EOF
116
+ mock_read.side_effect = [eio_err, b""]
117
+
118
+ # Make close throw exception to hit lines 120-121, 125-126
119
+ mock_close.side_effect = Exception("Mocked close error")
120
+
121
+ main()
122
+
123
+ @patch("sys.argv", ["bridge.py", "--symlink", "/tmp/tty"])
124
+ @patch("os.openpty", create=True)
125
+ @patch("os.ttyname", create=True)
126
+ @patch("os.path.lexists")
127
+ @patch("os.symlink", create=True)
128
+ @patch("select.select")
129
+ @patch("os.read")
130
+ @patch("os.close")
131
+ @patch("os.unlink", create=True)
132
+ def test_bridge_main_generic_oserror(self, mock_unlink, mock_close, mock_read, mock_select, mock_symlink, mock_lexists, mock_ttyname, mock_openpty):
133
+ mock_openpty.return_value = (3, 4)
134
+ mock_ttyname.return_value = "/dev/pts/1"
135
+
136
+ def fake_select(*args, **kwargs):
137
+ return ([3], [], [])
138
+
139
+ mock_select.side_effect = fake_select
140
+
141
+ gen_err = OSError("generic")
142
+ gen_err.errno = 99
143
+
144
+ # Read returns generic OSError to trigger 'raise e'
145
+ mock_read.side_effect = [gen_err]
146
+
147
+ main()
148
+
149
+ @patch("sys.argv", ["bridge.py", "--symlink", "/tmp/tty"])
150
+ @patch("os.openpty", create=True)
151
+ def test_bridge_main_fatal_exception(self, mock_openpty):
152
+ mock_openpty.side_effect = Exception("Fatal OS Error")
153
+ main()
154
+
155
+ if __name__ == "__main__":
156
+ unittest.main()
@@ -0,0 +1,46 @@
1
+ import unittest
2
+ from unittest.mock import patch
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
7
+
8
+ from com2tty.cli import main
9
+
10
+ class TestCli(unittest.TestCase):
11
+
12
+ @patch("com2tty.cli.run_bridge")
13
+ @patch("sys.argv", ["com2tty", "COM1", "-b", "115200", "-w", "/dev/ttyUSB1", "--debug"])
14
+ def test_cli_parsing(self, mock_run_bridge):
15
+ main()
16
+
17
+ mock_run_bridge.assert_called_once_with(
18
+ port="COM1",
19
+ baud=115200,
20
+ wsl_tty="/dev/ttyUSB1",
21
+ bytesize=8,
22
+ parity="N",
23
+ stopbits=1,
24
+ xonxoff=False,
25
+ rtscts=False,
26
+ dsrdtr=False
27
+ )
28
+
29
+ @patch("com2tty.cli.run_bridge")
30
+ @patch("sys.exit")
31
+ @patch("sys.argv", ["com2tty", "COM2"])
32
+ def test_cli_keyboard_interrupt(self, mock_exit, mock_run_bridge):
33
+ mock_run_bridge.side_effect = KeyboardInterrupt()
34
+ main()
35
+ mock_exit.assert_called_once_with(0)
36
+
37
+ @patch("com2tty.cli.run_bridge")
38
+ @patch("sys.exit")
39
+ @patch("sys.argv", ["com2tty", "COM2", "--debug"])
40
+ def test_cli_fatal_error(self, mock_exit, mock_run_bridge):
41
+ mock_run_bridge.side_effect = Exception("Fatal runtime error")
42
+ main()
43
+ mock_exit.assert_called_once_with(1)
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
@@ -0,0 +1,192 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+ import serial
4
+ import sys
5
+ import os
6
+ import threading
7
+
8
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
9
+
10
+ from com2tty.host import (
11
+ get_wsl_path,
12
+ get_serial_settings,
13
+ read_wsl_stdout,
14
+ read_com_port,
15
+ read_wsl_stderr,
16
+ run_bridge
17
+ )
18
+
19
+ class TestCom2TtyHost(unittest.TestCase):
20
+
21
+ def test_wsl_path_conversion(self):
22
+ with patch("subprocess.run") as mock_run:
23
+ mock_run.side_effect = Exception("wslpath tool not found")
24
+ path = get_wsl_path(r"C:\Users\Username\some\file.py")
25
+ self.assertEqual(path, "/mnt/c/Users/Username/some/file.py")
26
+
27
+ with patch("subprocess.run") as mock_run:
28
+ mock_res = MagicMock()
29
+ mock_res.stdout = "/mnt/d/success\n"
30
+ mock_run.return_value = mock_res
31
+ path = get_wsl_path(r"D:\success")
32
+ self.assertEqual(path, "/mnt/d/success")
33
+
34
+ def test_serial_settings_mapping(self):
35
+ bs, par, sb = get_serial_settings(8, "N", 1)
36
+ self.assertEqual(bs, serial.EIGHTBITS)
37
+ self.assertEqual(par, serial.PARITY_NONE)
38
+ self.assertEqual(sb, serial.STOPBITS_ONE)
39
+
40
+ bs, par, sb = get_serial_settings(7, "E", 2)
41
+ self.assertEqual(bs, serial.SEVENBITS)
42
+ self.assertEqual(par, serial.PARITY_EVEN)
43
+ self.assertEqual(sb, serial.STOPBITS_TWO)
44
+
45
+ bs, par, sb = get_serial_settings(6, "O", 1.5)
46
+ self.assertEqual(bs, serial.SIXBITS)
47
+ self.assertEqual(par, serial.PARITY_ODD)
48
+ self.assertEqual(sb, serial.STOPBITS_ONE_POINT_FIVE)
49
+
50
+ def test_read_wsl_stdout_normal(self):
51
+ proc = MagicMock()
52
+ ser = MagicMock()
53
+ shutdown_event = threading.Event()
54
+
55
+ proc.stdout.read.side_effect = [b"hello", b""] # Data then EOF
56
+
57
+ read_wsl_stdout(proc, ser, shutdown_event)
58
+
59
+ ser.write.assert_called_with(b"hello")
60
+ self.assertTrue(shutdown_event.is_set())
61
+
62
+ def test_read_wsl_stdout_exception(self):
63
+ proc = MagicMock()
64
+ ser = MagicMock()
65
+ shutdown_event = threading.Event()
66
+
67
+ proc.stdout.read.side_effect = Exception("Read Error")
68
+
69
+ read_wsl_stdout(proc, ser, shutdown_event)
70
+
71
+ self.assertTrue(shutdown_event.is_set())
72
+
73
+ def test_read_com_port_normal(self):
74
+ proc = MagicMock()
75
+ ser = MagicMock()
76
+ shutdown_event = threading.Event()
77
+
78
+ def fake_read(*args, **kwargs):
79
+ if not getattr(fake_read, "called", False):
80
+ fake_read.called = True
81
+ return b"com_data"
82
+ shutdown_event.set() # Stop the loop gracefully
83
+ return b""
84
+
85
+ ser.read.side_effect = fake_read
86
+
87
+ read_com_port(ser, proc, shutdown_event)
88
+
89
+ proc.stdin.write.assert_called_with(b"com_data")
90
+ self.assertTrue(shutdown_event.is_set())
91
+
92
+ def test_read_com_port_exception(self):
93
+ proc = MagicMock()
94
+ ser = MagicMock()
95
+ shutdown_event = threading.Event()
96
+
97
+ ser.read.side_effect = Exception("COM Error")
98
+
99
+ read_com_port(ser, proc, shutdown_event)
100
+
101
+ self.assertTrue(shutdown_event.is_set())
102
+
103
+ def test_read_wsl_stderr_normal(self):
104
+ proc = MagicMock()
105
+ shutdown_event = threading.Event()
106
+
107
+ proc.stderr.readline.side_effect = [b"error_log\n", b""] # Data then EOF
108
+
109
+ read_wsl_stderr(proc, shutdown_event)
110
+ self.assertTrue(shutdown_event.is_set() is False)
111
+
112
+ def test_read_wsl_stderr_exception(self):
113
+ proc = MagicMock()
114
+ shutdown_event = threading.Event()
115
+
116
+ proc.stderr.readline.side_effect = Exception("Stderr Error")
117
+
118
+ read_wsl_stderr(proc, shutdown_event)
119
+ self.assertTrue(shutdown_event.is_set() is False)
120
+
121
+ @patch("com2tty.host.get_wsl_path")
122
+ @patch("serial.Serial")
123
+ @patch("subprocess.Popen")
124
+ @patch("os.path.exists")
125
+ @patch("threading.Thread")
126
+ @patch("time.sleep")
127
+ def test_run_bridge(self, mock_sleep, mock_thread, mock_exists, mock_popen, mock_serial, mock_wsl_path):
128
+ mock_exists.return_value = True
129
+ mock_wsl_path.return_value = "/wsl/bridge.py"
130
+
131
+ mock_proc = MagicMock()
132
+ # Immediately pretend it exited to break loop
133
+ mock_proc.poll.return_value = 0
134
+ mock_popen.return_value = mock_proc
135
+
136
+ run_bridge("COM1", 9600, "/tmp/tty", 8, "N", 1, False, False, False)
137
+
138
+ mock_proc.terminate.assert_not_called()
139
+
140
+ @patch("com2tty.host.get_wsl_path")
141
+ @patch("serial.Serial")
142
+ @patch("subprocess.Popen")
143
+ @patch("os.path.exists")
144
+ @patch("time.sleep")
145
+ def test_run_bridge_keyboard_interrupt(self, mock_sleep, mock_exists, mock_popen, mock_serial, mock_wsl_path):
146
+ mock_exists.return_value = True
147
+ mock_wsl_path.return_value = "/wsl/bridge.py"
148
+
149
+ mock_proc = MagicMock()
150
+ mock_proc.poll.return_value = None
151
+ mock_popen.return_value = mock_proc
152
+
153
+ mock_sleep.side_effect = KeyboardInterrupt() # Simulate Ctrl+C
154
+
155
+ run_bridge("COM1", 9600, "/tmp/tty", 8, "N", 1, False, False, False)
156
+
157
+ mock_serial.return_value.close.assert_called()
158
+ mock_proc.terminate.assert_called()
159
+
160
+ @patch("com2tty.host.get_wsl_path")
161
+ @patch("serial.Serial")
162
+ @patch("subprocess.Popen")
163
+ @patch("os.path.exists")
164
+ @patch("time.sleep")
165
+ def test_run_bridge_cleanup_exceptions(self, mock_sleep, mock_exists, mock_popen, mock_serial, mock_wsl_path):
166
+ mock_exists.return_value = True
167
+ mock_wsl_path.return_value = "/wsl/bridge.py"
168
+
169
+ mock_proc = MagicMock()
170
+ mock_proc.poll.return_value = None
171
+ mock_proc.wait.side_effect = __import__("subprocess").TimeoutExpired(cmd="wsl", timeout=3.0)
172
+ mock_popen.return_value = mock_proc
173
+
174
+ mock_sleep.side_effect = KeyboardInterrupt()
175
+
176
+ mock_ser = MagicMock()
177
+ mock_ser.close.side_effect = Exception("Close error")
178
+ mock_serial.return_value = mock_ser
179
+
180
+ run_bridge("COM1", 9600, "/tmp/tty", 8, "N", 1, False, False, False)
181
+
182
+ mock_proc.kill.assert_called()
183
+
184
+ @patch("serial.Serial")
185
+ @patch("os.path.exists")
186
+ def test_run_bridge_file_not_found(self, mock_exists, mock_serial):
187
+ mock_exists.return_value = False
188
+ with self.assertRaises(FileNotFoundError):
189
+ run_bridge("COM1", 9600, "/tmp/tty", 8, "N", 1, False, False, False)
190
+
191
+ if __name__ == "__main__":
192
+ unittest.main()
@@ -0,0 +1,27 @@
1
+ import unittest
2
+ from unittest.mock import patch
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
7
+
8
+ class TestMainEntryPoint(unittest.TestCase):
9
+
10
+ @patch("com2tty.cli.main")
11
+ def test_main_execution(self, mock_main):
12
+ import com2tty.__main__
13
+ # Because the import triggers the __name__ == "__main__" block natively
14
+ # only if we use runpy or execute it directly, we just call the block manually
15
+ # to ensure coverage tool registers it.
16
+ with patch.object(com2tty.__main__, "__name__", "__main__"):
17
+ # Trigger the module execution
18
+ try:
19
+ with open(com2tty.__main__.__file__) as f:
20
+ exec(f.read(), com2tty.__main__.__dict__)
21
+ except SystemExit:
22
+ pass
23
+
24
+ mock_main.assert_called()
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()