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.
@@ -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,3 @@
1
+ [console_scripts]
2
+ camcontrol = camlock.cli:main
3
+ camlock = camlock.cli:main
@@ -0,0 +1 @@
1
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ camlock
@@ -0,0 +1,7 @@
1
+ from .device import DeviceInfo
2
+ from .discovery import find_devices
3
+ from .serial_manager import SerialManager
4
+
5
+ __all__ = ["DeviceInfo", "SerialManager", "find_devices"]
6
+ __version__ = "0.1.0"
7
+
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+