camcontrol 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.
- camcontrol-0.1.0/PKG-INFO +42 -0
- camcontrol-0.1.0/README.md +34 -0
- camcontrol-0.1.0/camcontrol.egg-info/PKG-INFO +42 -0
- camcontrol-0.1.0/camcontrol.egg-info/SOURCES.txt +16 -0
- camcontrol-0.1.0/camcontrol.egg-info/dependency_links.txt +1 -0
- camcontrol-0.1.0/camcontrol.egg-info/entry_points.txt +3 -0
- camcontrol-0.1.0/camcontrol.egg-info/requires.txt +1 -0
- camcontrol-0.1.0/camcontrol.egg-info/top_level.txt +1 -0
- camcontrol-0.1.0/camlock/__init__.py +7 -0
- camcontrol-0.1.0/camlock/cli.py +311 -0
- camcontrol-0.1.0/camlock/device.py +24 -0
- camcontrol-0.1.0/camlock/discovery.py +30 -0
- camcontrol-0.1.0/camlock/discovery_windows.py +113 -0
- camcontrol-0.1.0/camlock/exceptions.py +15 -0
- camcontrol-0.1.0/camlock/interactive.py +71 -0
- camcontrol-0.1.0/camlock/serial_manager.py +293 -0
- camcontrol-0.1.0/pyproject.toml +18 -0
- camcontrol-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camcontrol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python CLI + library for Camlock USB-serial access control devices (CH340).
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pyserial>=3.5
|
|
8
|
+
|
|
9
|
+
# camcontrol
|
|
10
|
+
|
|
11
|
+
Windows-first Python CLI + library for communicating with Camlock USB serial devices (CH340), including PICO Hub-style line commands.
|
|
12
|
+
|
|
13
|
+
## Install (editable)
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI usage
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
camcontrol list
|
|
23
|
+
camcontrol connect
|
|
24
|
+
camcontrol send STATE
|
|
25
|
+
camcontrol send UNLOCK
|
|
26
|
+
camcontrol send TEMP
|
|
27
|
+
camcontrol temp
|
|
28
|
+
camcontrol interactive
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Optional multi-channel flag (reserved for ACS200-style devices):
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
camcontrol send STATE --port 3
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
- Commands are sent as complete lines terminated by `\n` (never character-by-character).
|
|
40
|
+
- Responses are read as line-based text; multi-line responses are collected until a blank line or timeout.
|
|
41
|
+
- Serial speed is fixed at **115200 baud**.
|
|
42
|
+
- Package name on PyPI is `camcontrol`. The import/package name is currently `camlock`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# camcontrol
|
|
2
|
+
|
|
3
|
+
Windows-first Python CLI + library for communicating with Camlock USB serial devices (CH340), including PICO Hub-style line commands.
|
|
4
|
+
|
|
5
|
+
## Install (editable)
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI usage
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
camcontrol list
|
|
15
|
+
camcontrol connect
|
|
16
|
+
camcontrol send STATE
|
|
17
|
+
camcontrol send UNLOCK
|
|
18
|
+
camcontrol send TEMP
|
|
19
|
+
camcontrol temp
|
|
20
|
+
camcontrol interactive
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Optional multi-channel flag (reserved for ACS200-style devices):
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
camcontrol send STATE --port 3
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Notes
|
|
30
|
+
|
|
31
|
+
- Commands are sent as complete lines terminated by `\n` (never character-by-character).
|
|
32
|
+
- Responses are read as line-based text; multi-line responses are collected until a blank line or timeout.
|
|
33
|
+
- Serial speed is fixed at **115200 baud**.
|
|
34
|
+
- Package name on PyPI is `camcontrol`. The import/package name is currently `camlock`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camcontrol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python CLI + library for Camlock USB-serial access control devices (CH340).
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pyserial>=3.5
|
|
8
|
+
|
|
9
|
+
# camcontrol
|
|
10
|
+
|
|
11
|
+
Windows-first Python CLI + library for communicating with Camlock USB serial devices (CH340), including PICO Hub-style line commands.
|
|
12
|
+
|
|
13
|
+
## Install (editable)
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI usage
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
camcontrol list
|
|
23
|
+
camcontrol connect
|
|
24
|
+
camcontrol send STATE
|
|
25
|
+
camcontrol send UNLOCK
|
|
26
|
+
camcontrol send TEMP
|
|
27
|
+
camcontrol temp
|
|
28
|
+
camcontrol interactive
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Optional multi-channel flag (reserved for ACS200-style devices):
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
camcontrol send STATE --port 3
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
- Commands are sent as complete lines terminated by `\n` (never character-by-character).
|
|
40
|
+
- Responses are read as line-based text; multi-line responses are collected until a blank line or timeout.
|
|
41
|
+
- Serial speed is fixed at **115200 baud**.
|
|
42
|
+
- Package name on PyPI is `camcontrol`. The import/package name is currently `camlock`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
camcontrol.egg-info/PKG-INFO
|
|
4
|
+
camcontrol.egg-info/SOURCES.txt
|
|
5
|
+
camcontrol.egg-info/dependency_links.txt
|
|
6
|
+
camcontrol.egg-info/entry_points.txt
|
|
7
|
+
camcontrol.egg-info/requires.txt
|
|
8
|
+
camcontrol.egg-info/top_level.txt
|
|
9
|
+
camlock/__init__.py
|
|
10
|
+
camlock/cli.py
|
|
11
|
+
camlock/device.py
|
|
12
|
+
camlock/discovery.py
|
|
13
|
+
camlock/discovery_windows.py
|
|
14
|
+
camlock/exceptions.py
|
|
15
|
+
camlock/interactive.py
|
|
16
|
+
camlock/serial_manager.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyserial>=3.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
camlock
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from .discovery import find_devices, pick_default_device
|
|
10
|
+
from .exceptions import CamlockError, ConnectionError, DiscoveryError, ProtocolError
|
|
11
|
+
from .interactive import run_interactive
|
|
12
|
+
from .serial_manager import SerialConfig, SerialManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _print_devices(devices) -> None:
|
|
16
|
+
if not devices:
|
|
17
|
+
print("No serial ports found.")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
print("PORT CH340 VID:PID DESCRIPTION")
|
|
21
|
+
for d in devices:
|
|
22
|
+
vp = d.vid_pid or "-"
|
|
23
|
+
ch = "yes" if d.is_ch340_like else "no"
|
|
24
|
+
desc = d.description or d.product or "-"
|
|
25
|
+
print(f"{d.device:<6} {ch:<5} {vp:<8} {desc}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_com_port(explicit: Optional[str]) -> str:
|
|
29
|
+
if explicit:
|
|
30
|
+
return explicit
|
|
31
|
+
|
|
32
|
+
devices = find_devices()
|
|
33
|
+
if not devices:
|
|
34
|
+
raise DiscoveryError("No serial ports found.")
|
|
35
|
+
|
|
36
|
+
chosen = pick_default_device(devices)
|
|
37
|
+
if chosen is None:
|
|
38
|
+
print("Multiple candidate devices found; specify one with --com.")
|
|
39
|
+
_print_devices(devices)
|
|
40
|
+
raise DiscoveryError("No default device could be selected.")
|
|
41
|
+
|
|
42
|
+
return chosen.device
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_manager(args) -> SerialManager:
|
|
46
|
+
port = _resolve_com_port(args.com)
|
|
47
|
+
cfg = SerialConfig(
|
|
48
|
+
port=port,
|
|
49
|
+
read_timeout_s=args.read_timeout,
|
|
50
|
+
write_timeout_s=args.write_timeout,
|
|
51
|
+
)
|
|
52
|
+
return SerialManager(cfg)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_list(_args) -> int:
|
|
56
|
+
devices = find_devices()
|
|
57
|
+
_print_devices(devices)
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_help(args) -> int:
|
|
62
|
+
parser = build_parser(_program_name())
|
|
63
|
+
if not args.topic:
|
|
64
|
+
parser.print_help()
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
topic = args.topic[0]
|
|
68
|
+
for action in parser._actions:
|
|
69
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
70
|
+
sub = action.choices.get(topic)
|
|
71
|
+
if sub is None:
|
|
72
|
+
print(f"Unknown help topic: {topic}", file=sys.stderr)
|
|
73
|
+
return 2
|
|
74
|
+
print(sub.format_help())
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
parser.print_help()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_connect(args) -> int:
|
|
82
|
+
manager = _build_manager(args)
|
|
83
|
+
with manager:
|
|
84
|
+
print(f"Connected to {manager.port} @ {manager.baudrate} baud.")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalize_command(command: str, *, raw: bool) -> str:
|
|
89
|
+
cmd = command.strip()
|
|
90
|
+
if not raw:
|
|
91
|
+
cmd = cmd.upper()
|
|
92
|
+
return cmd
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_send(args) -> int:
|
|
96
|
+
manager = _build_manager(args)
|
|
97
|
+
command = _normalize_command(" ".join(args.command), raw=args.raw)
|
|
98
|
+
with manager:
|
|
99
|
+
lines = manager.send_and_read_response(
|
|
100
|
+
command,
|
|
101
|
+
acs_port=args.port,
|
|
102
|
+
total_timeout_s=args.total_timeout,
|
|
103
|
+
idle_timeout_s=args.idle_timeout,
|
|
104
|
+
clear_input=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not lines:
|
|
108
|
+
print("(no response)")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
for line in lines:
|
|
112
|
+
print(line)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_temp(args) -> int:
|
|
117
|
+
args.command = ["TEMP"]
|
|
118
|
+
return cmd_send(args)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_interactive(args) -> int:
|
|
122
|
+
manager = _build_manager(args)
|
|
123
|
+
with manager:
|
|
124
|
+
return run_interactive(manager, acs_port=args.port)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _program_name() -> str:
|
|
128
|
+
base = os.path.basename(sys.argv[0] or "camlock")
|
|
129
|
+
name, _ext = os.path.splitext(base)
|
|
130
|
+
return name or "camlock"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_parser(prog: str) -> argparse.ArgumentParser:
|
|
134
|
+
p = argparse.ArgumentParser(
|
|
135
|
+
prog=prog,
|
|
136
|
+
description="Camlock USB-serial CLI (fixed 115200 baud).",
|
|
137
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
138
|
+
epilog=(
|
|
139
|
+
"Examples:\n"
|
|
140
|
+
f" {prog} list\n"
|
|
141
|
+
f" {prog} --com COM15 connect\n"
|
|
142
|
+
f" {prog} --com COM15 send STATE\n"
|
|
143
|
+
f" {prog} --com COM15 send HOLD ON\n"
|
|
144
|
+
f" {prog} --com COM15 temp\n"
|
|
145
|
+
f" {prog} --com COM15 interactive\n"
|
|
146
|
+
"\n"
|
|
147
|
+
"Shortcuts:\n"
|
|
148
|
+
f" {prog} COM15 TEMP (same as: --com COM15 send TEMP)\n"
|
|
149
|
+
f" {prog} COM15 HOLD ON (same as: --com COM15 send HOLD ON)\n"
|
|
150
|
+
"\n"
|
|
151
|
+
"Notes:\n"
|
|
152
|
+
" - Commands are sent as complete lines terminated with \\n.\n"
|
|
153
|
+
" - Interactive mode sends only after ENTER (never per-character).\n"
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
p.add_argument(
|
|
157
|
+
"--com",
|
|
158
|
+
help="Serial port (e.g. COM3). If omitted, auto-selects a likely CH340 port.",
|
|
159
|
+
)
|
|
160
|
+
p.add_argument(
|
|
161
|
+
"--read-timeout",
|
|
162
|
+
type=float,
|
|
163
|
+
default=0.2,
|
|
164
|
+
help="Per-read timeout in seconds (default: 0.2).",
|
|
165
|
+
)
|
|
166
|
+
p.add_argument(
|
|
167
|
+
"--write-timeout",
|
|
168
|
+
type=float,
|
|
169
|
+
default=1.0,
|
|
170
|
+
help="Write timeout in seconds (default: 1.0).",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
174
|
+
|
|
175
|
+
sp = sub.add_parser("help", help="Show help for a command.")
|
|
176
|
+
sp.add_argument("topic", nargs="*", help="Command to show help for (e.g. send).")
|
|
177
|
+
sp.set_defaults(func=cmd_help)
|
|
178
|
+
|
|
179
|
+
sp = sub.add_parser("list", help="List available serial ports.")
|
|
180
|
+
sp.set_defaults(func=cmd_list)
|
|
181
|
+
|
|
182
|
+
sp = sub.add_parser("connect", help="Open and close a serial connection (smoke test).")
|
|
183
|
+
sp.set_defaults(func=cmd_connect)
|
|
184
|
+
|
|
185
|
+
sp = sub.add_parser("send", help="Send a command and print the response.")
|
|
186
|
+
sp.add_argument(
|
|
187
|
+
"command",
|
|
188
|
+
nargs="+",
|
|
189
|
+
help="Command to send (e.g. STATE, UNLOCK, TEMP, HOLD ON).",
|
|
190
|
+
)
|
|
191
|
+
sp.add_argument(
|
|
192
|
+
"--raw",
|
|
193
|
+
action="store_true",
|
|
194
|
+
help="Send exactly as provided (do not auto-uppercase).",
|
|
195
|
+
)
|
|
196
|
+
sp.add_argument(
|
|
197
|
+
"--port",
|
|
198
|
+
type=int,
|
|
199
|
+
help="Reserved for multi-channel devices (e.g. ACS200).",
|
|
200
|
+
)
|
|
201
|
+
sp.add_argument(
|
|
202
|
+
"--total-timeout",
|
|
203
|
+
type=float,
|
|
204
|
+
default=2.0,
|
|
205
|
+
help="Max time to wait for the full response (default: 2.0).",
|
|
206
|
+
)
|
|
207
|
+
sp.add_argument(
|
|
208
|
+
"--idle-timeout",
|
|
209
|
+
type=float,
|
|
210
|
+
default=0.35,
|
|
211
|
+
help="Stop after this much response silence (default: 0.35).",
|
|
212
|
+
)
|
|
213
|
+
sp.set_defaults(func=cmd_send)
|
|
214
|
+
|
|
215
|
+
sp = sub.add_parser("temp", help="Shortcut for: send TEMP.")
|
|
216
|
+
sp.add_argument(
|
|
217
|
+
"--raw",
|
|
218
|
+
action="store_true",
|
|
219
|
+
help="Send exactly as provided (do not auto-uppercase).",
|
|
220
|
+
)
|
|
221
|
+
sp.add_argument(
|
|
222
|
+
"--port",
|
|
223
|
+
type=int,
|
|
224
|
+
help="Reserved for multi-channel devices (e.g. ACS200).",
|
|
225
|
+
)
|
|
226
|
+
sp.add_argument(
|
|
227
|
+
"--total-timeout",
|
|
228
|
+
type=float,
|
|
229
|
+
default=2.0,
|
|
230
|
+
help="Max time to wait for the full response (default: 2.0).",
|
|
231
|
+
)
|
|
232
|
+
sp.add_argument(
|
|
233
|
+
"--idle-timeout",
|
|
234
|
+
type=float,
|
|
235
|
+
default=0.35,
|
|
236
|
+
help="Stop after this much response silence (default: 0.35).",
|
|
237
|
+
)
|
|
238
|
+
sp.set_defaults(func=cmd_temp)
|
|
239
|
+
|
|
240
|
+
sp = sub.add_parser("interactive", help="Interactive mode (line-based; sends only after ENTER).")
|
|
241
|
+
sp.add_argument(
|
|
242
|
+
"--port",
|
|
243
|
+
type=int,
|
|
244
|
+
help="Reserved for multi-channel devices (e.g. ACS200).",
|
|
245
|
+
)
|
|
246
|
+
sp.set_defaults(func=cmd_interactive)
|
|
247
|
+
|
|
248
|
+
return p
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_SUBCOMMANDS = {"help", "list", "connect", "send", "temp", "interactive"}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _preprocess_argv(argv: List[str]) -> List[str]:
|
|
255
|
+
if not argv:
|
|
256
|
+
return argv
|
|
257
|
+
|
|
258
|
+
out = list(argv)
|
|
259
|
+
|
|
260
|
+
# Allow: camlock COM15 <...>
|
|
261
|
+
if re.fullmatch(r"COM\d+", out[0], flags=re.IGNORECASE):
|
|
262
|
+
out = ["--com", out[0], *out[1:]]
|
|
263
|
+
|
|
264
|
+
# Allow: camlock [--com COM15] TEMP|STATE|HOLD ON (default to send)
|
|
265
|
+
# Find first positional token after known options and their values.
|
|
266
|
+
options_with_values = {"--com", "--read-timeout", "--write-timeout"}
|
|
267
|
+
i = 0
|
|
268
|
+
insert_at: Optional[int] = None
|
|
269
|
+
while i < len(out):
|
|
270
|
+
t = out[i]
|
|
271
|
+
if t in options_with_values:
|
|
272
|
+
i += 2
|
|
273
|
+
continue
|
|
274
|
+
if t.startswith("-"):
|
|
275
|
+
i += 1
|
|
276
|
+
continue
|
|
277
|
+
insert_at = i
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if insert_at is not None:
|
|
281
|
+
token = out[insert_at]
|
|
282
|
+
is_subcommand = token.lower() in _SUBCOMMANDS and token == token.lower()
|
|
283
|
+
if not is_subcommand:
|
|
284
|
+
out = out[:insert_at] + ["send"] + out[insert_at:]
|
|
285
|
+
|
|
286
|
+
# Allow: camlock help (common muscle-memory)
|
|
287
|
+
if out and out[0].lower() == "help":
|
|
288
|
+
out = ["help", *out[1:]]
|
|
289
|
+
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
294
|
+
parser = build_parser(_program_name())
|
|
295
|
+
args = parser.parse_args(
|
|
296
|
+
_preprocess_argv(list(argv)) if argv is not None else _preprocess_argv(sys.argv[1:])
|
|
297
|
+
)
|
|
298
|
+
try:
|
|
299
|
+
return int(args.func(args))
|
|
300
|
+
except (DiscoveryError, ConnectionError, ProtocolError) as exc:
|
|
301
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
302
|
+
return 2
|
|
303
|
+
except CamlockError as exc:
|
|
304
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
305
|
+
return 2
|
|
306
|
+
except KeyboardInterrupt:
|
|
307
|
+
return 130
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__": # pragma: no cover
|
|
311
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class DeviceInfo:
|
|
9
|
+
device: str # e.g. "COM3"
|
|
10
|
+
description: str = ""
|
|
11
|
+
hwid: str = ""
|
|
12
|
+
manufacturer: str = ""
|
|
13
|
+
product: str = ""
|
|
14
|
+
serial_number: str = ""
|
|
15
|
+
vid: Optional[int] = None
|
|
16
|
+
pid: Optional[int] = None
|
|
17
|
+
is_ch340_like: bool = False
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def vid_pid(self) -> str:
|
|
21
|
+
if self.vid is None or self.pid is None:
|
|
22
|
+
return ""
|
|
23
|
+
return f"{self.vid:04X}:{self.pid:04X}"
|
|
24
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from .device import DeviceInfo
|
|
7
|
+
from .exceptions import DiscoveryError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_devices() -> List[DeviceInfo]:
|
|
11
|
+
"""
|
|
12
|
+
Windows-only in Phase 1.
|
|
13
|
+
|
|
14
|
+
Kept as a thin dispatcher so Linux/macOS backends can be added later without
|
|
15
|
+
leaking platform-specific logic into the rest of the package.
|
|
16
|
+
"""
|
|
17
|
+
if sys.platform != "win32":
|
|
18
|
+
raise DiscoveryError("Device discovery is currently supported on Windows only.")
|
|
19
|
+
from .discovery_windows import find_devices as impl
|
|
20
|
+
|
|
21
|
+
return impl()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pick_default_device(devices: List[DeviceInfo]) -> Optional[DeviceInfo]:
|
|
25
|
+
if sys.platform != "win32":
|
|
26
|
+
return None
|
|
27
|
+
from .discovery_windows import pick_default_device as impl
|
|
28
|
+
|
|
29
|
+
return impl(devices)
|
|
30
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from .device import DeviceInfo
|
|
6
|
+
from .exceptions import DiscoveryError
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from serial.tools import list_ports
|
|
10
|
+
except Exception as exc: # pragma: no cover
|
|
11
|
+
list_ports = None # type: ignore[assignment]
|
|
12
|
+
_IMPORT_ERROR = exc
|
|
13
|
+
else:
|
|
14
|
+
_IMPORT_ERROR = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_CH340_VIDS = {0x1A86}
|
|
18
|
+
_CH340_PIDS = {
|
|
19
|
+
0x7523, # CH340/CH341
|
|
20
|
+
0x5523, # CH341 variant
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_ch340_like(
|
|
25
|
+
*,
|
|
26
|
+
description: str,
|
|
27
|
+
hwid: str,
|
|
28
|
+
manufacturer: str,
|
|
29
|
+
vid: Optional[int],
|
|
30
|
+
pid: Optional[int],
|
|
31
|
+
) -> bool:
|
|
32
|
+
text = f"{description} {hwid} {manufacturer}".upper()
|
|
33
|
+
if "CH340" in text or "CH341" in text:
|
|
34
|
+
return True
|
|
35
|
+
if vid is not None and vid in _CH340_VIDS:
|
|
36
|
+
return True
|
|
37
|
+
if vid is not None and pid is not None and vid in _CH340_VIDS and pid in _CH340_PIDS:
|
|
38
|
+
return True
|
|
39
|
+
if "VID:PID=1A86" in text:
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_devices() -> List[DeviceInfo]:
|
|
45
|
+
if list_ports is None: # pragma: no cover
|
|
46
|
+
raise DiscoveryError(
|
|
47
|
+
f"pyserial is required for discovery but could not be imported: {_IMPORT_ERROR}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
devices: List[DeviceInfo] = []
|
|
51
|
+
try:
|
|
52
|
+
ports = list(list_ports.comports())
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise DiscoveryError(f"Failed to enumerate serial ports: {exc}") from exc
|
|
55
|
+
|
|
56
|
+
for p in ports:
|
|
57
|
+
vid = getattr(p, "vid", None)
|
|
58
|
+
pid = getattr(p, "pid", None)
|
|
59
|
+
manufacturer = getattr(p, "manufacturer", "") or ""
|
|
60
|
+
product = getattr(p, "product", "") or ""
|
|
61
|
+
serial_number = getattr(p, "serial_number", "") or ""
|
|
62
|
+
description = getattr(p, "description", "") or ""
|
|
63
|
+
hwid = getattr(p, "hwid", "") or ""
|
|
64
|
+
|
|
65
|
+
devices.append(
|
|
66
|
+
DeviceInfo(
|
|
67
|
+
device=getattr(p, "device", "") or "",
|
|
68
|
+
description=description,
|
|
69
|
+
hwid=hwid,
|
|
70
|
+
manufacturer=manufacturer,
|
|
71
|
+
product=product,
|
|
72
|
+
serial_number=serial_number,
|
|
73
|
+
vid=vid,
|
|
74
|
+
pid=pid,
|
|
75
|
+
is_ch340_like=_is_ch340_like(
|
|
76
|
+
description=description,
|
|
77
|
+
hwid=hwid,
|
|
78
|
+
manufacturer=manufacturer,
|
|
79
|
+
vid=vid,
|
|
80
|
+
pid=pid,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return devices
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pick_default_device(devices: List[DeviceInfo]) -> Optional[DeviceInfo]:
|
|
89
|
+
"""
|
|
90
|
+
Choose a sensible default:
|
|
91
|
+
- Prefer CH340-like ports.
|
|
92
|
+
- Otherwise, if only one port exists, return it.
|
|
93
|
+
"""
|
|
94
|
+
ch340 = [d for d in devices if d.is_ch340_like]
|
|
95
|
+
if len(ch340) == 1:
|
|
96
|
+
return ch340[0]
|
|
97
|
+
if len(ch340) > 1:
|
|
98
|
+
def key(d: DeviceInfo) -> tuple:
|
|
99
|
+
name = d.device.upper()
|
|
100
|
+
if name.startswith("COM"):
|
|
101
|
+
try:
|
|
102
|
+
return (0, int(name[3:]))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return (1, name)
|
|
105
|
+
return (2, name)
|
|
106
|
+
|
|
107
|
+
return sorted(ch340, key=key)[0]
|
|
108
|
+
|
|
109
|
+
if len(devices) == 1:
|
|
110
|
+
return devices[0]
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CamlockError(Exception):
|
|
2
|
+
"""Base exception for camlock."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DiscoveryError(CamlockError):
|
|
6
|
+
"""Raised when device discovery fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConnectionError(CamlockError):
|
|
10
|
+
"""Raised when serial connection cannot be established or is lost."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProtocolError(CamlockError):
|
|
14
|
+
"""Raised for malformed commands/responses."""
|
|
15
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .serial_manager import SerialManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_interactive(
|
|
12
|
+
manager: SerialManager,
|
|
13
|
+
*,
|
|
14
|
+
acs_port: Optional[int] = None,
|
|
15
|
+
) -> int:
|
|
16
|
+
"""
|
|
17
|
+
Interactive REPL:
|
|
18
|
+
- Reads user input line-by-line (only sends after ENTER).
|
|
19
|
+
- Displays device responses in (near) real-time from a background reader.
|
|
20
|
+
"""
|
|
21
|
+
stop = threading.Event()
|
|
22
|
+
|
|
23
|
+
def on_disconnect(exc: BaseException) -> None:
|
|
24
|
+
if stop.is_set():
|
|
25
|
+
return
|
|
26
|
+
print(f"\n[disconnected] {exc}")
|
|
27
|
+
print("[reconnecting] attempting to reopen the port...")
|
|
28
|
+
while not stop.is_set():
|
|
29
|
+
try:
|
|
30
|
+
manager.reopen(delay_s=0.5)
|
|
31
|
+
manager.start_reader(on_disconnect=on_disconnect)
|
|
32
|
+
print("[reconnected]")
|
|
33
|
+
return
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"[reconnect failed] {e}")
|
|
36
|
+
time.sleep(1.0)
|
|
37
|
+
|
|
38
|
+
rx = manager.start_reader(on_disconnect=on_disconnect)
|
|
39
|
+
|
|
40
|
+
def printer() -> None:
|
|
41
|
+
while not stop.is_set():
|
|
42
|
+
try:
|
|
43
|
+
line = rx.get(timeout=0.2)
|
|
44
|
+
except queue.Empty:
|
|
45
|
+
continue
|
|
46
|
+
print(line)
|
|
47
|
+
|
|
48
|
+
t = threading.Thread(target=printer, name="camlock-interactive-printer", daemon=True)
|
|
49
|
+
t.start()
|
|
50
|
+
|
|
51
|
+
print("Interactive mode. Type commands and press ENTER. Ctrl+C or 'exit' to quit.")
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
try:
|
|
55
|
+
user = input("camlock> ")
|
|
56
|
+
except EOFError:
|
|
57
|
+
break
|
|
58
|
+
cmd = user.strip()
|
|
59
|
+
if not cmd:
|
|
60
|
+
continue
|
|
61
|
+
if cmd.lower() in {"exit", "quit"}:
|
|
62
|
+
break
|
|
63
|
+
manager.send_line(cmd, acs_port=acs_port, clear_input=False)
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
pass
|
|
66
|
+
finally:
|
|
67
|
+
stop.set()
|
|
68
|
+
manager.stop_reader()
|
|
69
|
+
|
|
70
|
+
return 0
|
|
71
|
+
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Callable, List, Optional
|
|
9
|
+
|
|
10
|
+
import serial
|
|
11
|
+
|
|
12
|
+
from .exceptions import ConnectionError, ProtocolError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_port_name(port: str) -> str:
|
|
16
|
+
port = port.strip()
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
up = port.upper()
|
|
19
|
+
if up.startswith("COM"):
|
|
20
|
+
# COM10+ sometimes requires the Win32 device prefix.
|
|
21
|
+
try:
|
|
22
|
+
n = int(up[3:])
|
|
23
|
+
except ValueError:
|
|
24
|
+
return port
|
|
25
|
+
if n >= 10 and not port.startswith("\\\\.\\"):
|
|
26
|
+
return f"\\\\.\\{up}"
|
|
27
|
+
return up
|
|
28
|
+
return port
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_command_line(command: str, *, acs_port: Optional[int] = None) -> str:
|
|
32
|
+
cmd = command.strip()
|
|
33
|
+
if not cmd:
|
|
34
|
+
raise ProtocolError("Command cannot be empty.")
|
|
35
|
+
if "\n" in cmd or "\r" in cmd:
|
|
36
|
+
raise ProtocolError("Command must be a single line (no embedded newlines).")
|
|
37
|
+
if acs_port is not None:
|
|
38
|
+
if acs_port < 1:
|
|
39
|
+
raise ProtocolError("--port must be >= 1.")
|
|
40
|
+
# Reserved format for multi-channel devices. Protocol may vary by device.
|
|
41
|
+
cmd = f"{cmd} {acs_port}"
|
|
42
|
+
return f"{cmd}\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SerialConfig:
|
|
47
|
+
port: str
|
|
48
|
+
read_timeout_s: float = 0.2
|
|
49
|
+
write_timeout_s: float = 1.0
|
|
50
|
+
encoding: str = "utf-8"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SerialManager:
|
|
54
|
+
"""
|
|
55
|
+
Robust line-oriented serial manager.
|
|
56
|
+
|
|
57
|
+
- Writes are always full-line (single write call, terminated with '\\n').
|
|
58
|
+
- Reads use line-based decoding and support multi-line responses.
|
|
59
|
+
- Optional background reader thread for interactive mode.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: SerialConfig):
|
|
63
|
+
self._config = SerialConfig(
|
|
64
|
+
port=_normalize_port_name(config.port),
|
|
65
|
+
read_timeout_s=config.read_timeout_s,
|
|
66
|
+
write_timeout_s=config.write_timeout_s,
|
|
67
|
+
encoding=config.encoding,
|
|
68
|
+
)
|
|
69
|
+
self._baudrate = 115200
|
|
70
|
+
self._ser: Optional[serial.Serial] = None
|
|
71
|
+
self._io_lock = threading.RLock()
|
|
72
|
+
|
|
73
|
+
self._rx_queue: "queue.Queue[str]" = queue.Queue()
|
|
74
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
75
|
+
self._reader_stop = threading.Event()
|
|
76
|
+
self._reader_error: Optional[BaseException] = None
|
|
77
|
+
self._on_disconnect: Optional[Callable[[BaseException], None]] = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def port(self) -> str:
|
|
81
|
+
return self._config.port
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def baudrate(self) -> int:
|
|
85
|
+
return self._baudrate
|
|
86
|
+
|
|
87
|
+
def is_open(self) -> bool:
|
|
88
|
+
s = self._ser
|
|
89
|
+
return bool(s and s.is_open)
|
|
90
|
+
|
|
91
|
+
def open(self) -> None:
|
|
92
|
+
with self._io_lock:
|
|
93
|
+
if self.is_open():
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
self._ser = serial.Serial(
|
|
97
|
+
port=self._config.port,
|
|
98
|
+
baudrate=self._baudrate,
|
|
99
|
+
timeout=self._config.read_timeout_s,
|
|
100
|
+
write_timeout=self._config.write_timeout_s,
|
|
101
|
+
)
|
|
102
|
+
except (serial.SerialException, OSError) as exc:
|
|
103
|
+
raise ConnectionError(f"Failed to open {self._config.port}: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
self.stop_reader()
|
|
107
|
+
with self._io_lock:
|
|
108
|
+
if self._ser is None:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
self._ser.close()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
self._ser = None
|
|
115
|
+
|
|
116
|
+
def reopen(self, *, delay_s: float = 0.25) -> None:
|
|
117
|
+
self.close()
|
|
118
|
+
if delay_s > 0:
|
|
119
|
+
time.sleep(delay_s)
|
|
120
|
+
self.open()
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> "SerialManager":
|
|
123
|
+
self.open()
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
|
|
127
|
+
self.close()
|
|
128
|
+
|
|
129
|
+
def start_reader(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
on_disconnect: Optional[Callable[[BaseException], None]] = None,
|
|
133
|
+
) -> "queue.Queue[str]":
|
|
134
|
+
"""
|
|
135
|
+
Start a background reader thread that pushes received lines to a queue.
|
|
136
|
+
"""
|
|
137
|
+
if self._reader_thread and self._reader_thread.is_alive():
|
|
138
|
+
return self._rx_queue
|
|
139
|
+
|
|
140
|
+
self._on_disconnect = on_disconnect
|
|
141
|
+
self._reader_error = None
|
|
142
|
+
self._reader_stop.clear()
|
|
143
|
+
|
|
144
|
+
def run() -> None:
|
|
145
|
+
while not self._reader_stop.is_set():
|
|
146
|
+
try:
|
|
147
|
+
line = self._readline_once()
|
|
148
|
+
except BaseException as exc: # includes SerialException/OSError
|
|
149
|
+
self._reader_error = exc
|
|
150
|
+
if self._on_disconnect:
|
|
151
|
+
try:
|
|
152
|
+
self._on_disconnect(exc)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if line is None:
|
|
158
|
+
continue
|
|
159
|
+
if line == "":
|
|
160
|
+
continue
|
|
161
|
+
self._rx_queue.put(line)
|
|
162
|
+
|
|
163
|
+
self._reader_thread = threading.Thread(
|
|
164
|
+
target=run, name="camlock-serial-reader", daemon=True
|
|
165
|
+
)
|
|
166
|
+
self._reader_thread.start()
|
|
167
|
+
return self._rx_queue
|
|
168
|
+
|
|
169
|
+
def stop_reader(self) -> None:
|
|
170
|
+
t = self._reader_thread
|
|
171
|
+
if not t:
|
|
172
|
+
return
|
|
173
|
+
self._reader_stop.set()
|
|
174
|
+
t.join(timeout=1.0)
|
|
175
|
+
self._reader_thread = None
|
|
176
|
+
|
|
177
|
+
def get_reader_error(self) -> Optional[BaseException]:
|
|
178
|
+
return self._reader_error
|
|
179
|
+
|
|
180
|
+
def send_line(
|
|
181
|
+
self,
|
|
182
|
+
command: str,
|
|
183
|
+
*,
|
|
184
|
+
acs_port: Optional[int] = None,
|
|
185
|
+
clear_input: bool = True,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Send exactly one full-line command (single write call, with trailing '\\n').
|
|
189
|
+
"""
|
|
190
|
+
line = _build_command_line(command, acs_port=acs_port)
|
|
191
|
+
data = line.encode(self._config.encoding)
|
|
192
|
+
|
|
193
|
+
with self._io_lock:
|
|
194
|
+
if not self.is_open():
|
|
195
|
+
raise ConnectionError("Serial port is not open.")
|
|
196
|
+
assert self._ser is not None
|
|
197
|
+
try:
|
|
198
|
+
if clear_input:
|
|
199
|
+
self._ser.reset_input_buffer()
|
|
200
|
+
written = self._ser.write(data)
|
|
201
|
+
self._ser.flush()
|
|
202
|
+
except (serial.SerialException, OSError) as exc:
|
|
203
|
+
raise ConnectionError(f"Failed to write to {self._config.port}: {exc}") from exc
|
|
204
|
+
|
|
205
|
+
if written != len(data):
|
|
206
|
+
raise ConnectionError(
|
|
207
|
+
f"Partial write to {self._config.port}: {written}/{len(data)} bytes."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def send_and_read_response(
|
|
211
|
+
self,
|
|
212
|
+
command: str,
|
|
213
|
+
*,
|
|
214
|
+
acs_port: Optional[int] = None,
|
|
215
|
+
total_timeout_s: float = 2.0,
|
|
216
|
+
idle_timeout_s: float = 0.35,
|
|
217
|
+
clear_input: bool = True,
|
|
218
|
+
) -> List[str]:
|
|
219
|
+
"""
|
|
220
|
+
Send a command, then collect response lines.
|
|
221
|
+
|
|
222
|
+
Collection stops when:
|
|
223
|
+
- A blank line is received (common end-of-response marker), OR
|
|
224
|
+
- No new line arrives for `idle_timeout_s` after at least one line, OR
|
|
225
|
+
- `total_timeout_s` elapses.
|
|
226
|
+
"""
|
|
227
|
+
self.send_line(command, acs_port=acs_port, clear_input=clear_input)
|
|
228
|
+
return self.read_response_lines(
|
|
229
|
+
total_timeout_s=total_timeout_s, idle_timeout_s=idle_timeout_s
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def read_response_lines(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
total_timeout_s: float = 2.0,
|
|
236
|
+
idle_timeout_s: float = 0.35,
|
|
237
|
+
) -> List[str]:
|
|
238
|
+
if total_timeout_s <= 0:
|
|
239
|
+
raise ValueError("total_timeout_s must be > 0")
|
|
240
|
+
if idle_timeout_s <= 0:
|
|
241
|
+
raise ValueError("idle_timeout_s must be > 0")
|
|
242
|
+
|
|
243
|
+
deadline = time.monotonic() + total_timeout_s
|
|
244
|
+
last_line_at: Optional[float] = None
|
|
245
|
+
lines: List[str] = []
|
|
246
|
+
|
|
247
|
+
while time.monotonic() < deadline:
|
|
248
|
+
line = self._readline_once()
|
|
249
|
+
if line is None:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
# Blank line terminator (common in PICO Hub tooling).
|
|
253
|
+
if line == "":
|
|
254
|
+
if lines:
|
|
255
|
+
break
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
lines.append(line)
|
|
259
|
+
now = time.monotonic()
|
|
260
|
+
last_line_at = now
|
|
261
|
+
|
|
262
|
+
# If we got at least one line and then go idle, stop.
|
|
263
|
+
while time.monotonic() < deadline:
|
|
264
|
+
if last_line_at is not None and (time.monotonic() - last_line_at) >= idle_timeout_s:
|
|
265
|
+
return lines
|
|
266
|
+
nxt = self._readline_once()
|
|
267
|
+
if nxt is None:
|
|
268
|
+
continue
|
|
269
|
+
if nxt == "":
|
|
270
|
+
return lines
|
|
271
|
+
lines.append(nxt)
|
|
272
|
+
last_line_at = time.monotonic()
|
|
273
|
+
|
|
274
|
+
return lines
|
|
275
|
+
|
|
276
|
+
def _readline_once(self) -> Optional[str]:
|
|
277
|
+
with self._io_lock:
|
|
278
|
+
if not self.is_open():
|
|
279
|
+
raise ConnectionError("Serial port is not open.")
|
|
280
|
+
assert self._ser is not None
|
|
281
|
+
try:
|
|
282
|
+
raw = self._ser.readline()
|
|
283
|
+
except (serial.SerialException, OSError) as exc:
|
|
284
|
+
raise ConnectionError(f"Failed to read from {self._config.port}: {exc}") from exc
|
|
285
|
+
|
|
286
|
+
if raw is None or raw == b"":
|
|
287
|
+
return None # timeout
|
|
288
|
+
|
|
289
|
+
# Normalize CRLF/LF and decode.
|
|
290
|
+
raw = raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
291
|
+
text = raw.decode(self._config.encoding, errors="replace")
|
|
292
|
+
text = text.rstrip("\n")
|
|
293
|
+
return text
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "camcontrol"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python CLI + library for Camlock USB-serial access control devices (CH340)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = ["pyserial>=3.5"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
camcontrol = "camlock.cli:main"
|
|
15
|
+
camlock = "camlock.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools]
|
|
18
|
+
packages = ["camlock"]
|