embedded-lab-control 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
@@ -0,0 +1,338 @@
1
+ """
2
+ ser2net configuration generator.
3
+
4
+ Generates ser2net YAML configuration for exposing serial ports over TCP.
5
+ Supports ser2net 4.0+ YAML configuration format.
6
+ """
7
+
8
+ import errno
9
+ import os
10
+ import stat
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ PARITY_CHARS = {"none": "n", "even": "e", "odd": "o"}
17
+
18
+
19
+ @dataclass
20
+ class Ser2NetPort:
21
+ """Configuration for a single ser2net port."""
22
+
23
+ name: str
24
+ device: str # e.g., /dev/lab/sbc1-console
25
+ tcp_port: int
26
+ baud: int = 115200
27
+ databits: int = 8
28
+ parity: str = "none" # none, even, odd
29
+ stopbits: int = 1
30
+ local: bool = True # Only allow local connections
31
+ kickolduser: bool = False # Don't kick existing connections
32
+
33
+ def to_ser2net_dict(self) -> dict:
34
+ """Convert to ser2net YAML connection format."""
35
+ # ser2net serial params: <baud><parity><databits><stopbits>, e.g. 115200n81
36
+ parity_char = PARITY_CHARS.get(self.parity.lower(), "n")
37
+ conn_str = f"{self.baud}{parity_char}{self.databits}{self.stopbits}"
38
+
39
+ # Build options list
40
+ options = []
41
+ if self.local:
42
+ options.append("local")
43
+ if not self.kickolduser:
44
+ options.append("-kickolduser")
45
+
46
+ # Ensure device path is absolute
47
+ device = self.device
48
+ if not device.startswith("/"):
49
+ device = f"/dev/lab/{device}"
50
+
51
+ connection = {
52
+ "accepter": f"tcp,{self.tcp_port}",
53
+ "connector": f"serialdev,{device},{conn_str}",
54
+ "enable": "on",
55
+ }
56
+
57
+ if options:
58
+ connection["options"] = {opt: True for opt in options}
59
+
60
+ return connection
61
+
62
+
63
+ def generate_ser2net_config(
64
+ ports: list[Ser2NetPort],
65
+ include_header: bool = True,
66
+ ) -> str:
67
+ """
68
+ Generate ser2net YAML configuration from port list.
69
+
70
+ Args:
71
+ ports: List of Ser2NetPort configurations
72
+ include_header: Include YAML header and comments
73
+
74
+ Returns:
75
+ YAML string for /etc/ser2net.yaml
76
+ """
77
+ output_lines = []
78
+
79
+ if include_header:
80
+ output_lines.extend(
81
+ [
82
+ "%YAML 1.1",
83
+ "---",
84
+ "# Lab Controller ser2net configuration",
85
+ "# Generated by labctl",
86
+ "#",
87
+ "# Reload with: sudo systemctl restart ser2net",
88
+ "# Test with: nc localhost <port>",
89
+ "",
90
+ ]
91
+ )
92
+
93
+ # Add each connection
94
+ for port in ports:
95
+ parity_char = PARITY_CHARS.get(port.parity.lower(), "n")
96
+ conn_str = f"{port.baud}{parity_char}{port.databits}{port.stopbits}"
97
+
98
+ # Build options - local means only localhost connections
99
+ local_spec = "localhost," if port.local else ""
100
+
101
+ # Ensure device path is absolute (fix for bare device names)
102
+ device = port.device
103
+ if not device.startswith("/"):
104
+ device = f"/dev/lab/{device}"
105
+
106
+ output_lines.append(f"connection: &{port.name}")
107
+ output_lines.append(f" accepter: tcp,{local_spec}{port.tcp_port}")
108
+ output_lines.append(f" connector: serialdev,{device},{conn_str},local")
109
+ output_lines.append(" enable: on")
110
+ output_lines.append(" options:")
111
+ output_lines.append(
112
+ f" kickolduser: {'true' if port.kickolduser else 'false'}"
113
+ )
114
+ output_lines.append("")
115
+
116
+ return "\n".join(output_lines)
117
+
118
+
119
+ def load_port_mapping(mapping_file: Path) -> list[dict]:
120
+ """Load port mapping from YAML file."""
121
+ with open(mapping_file) as f:
122
+ data = yaml.safe_load(f)
123
+ return data.get("ports", [])
124
+
125
+
126
+ def generate_from_mapping(
127
+ mapping_file: Path,
128
+ base_tcp_port: int = 3000,
129
+ baud: int = 115200,
130
+ ) -> str:
131
+ """
132
+ Generate ser2net config from udev port mapping file.
133
+
134
+ Args:
135
+ mapping_file: Path to port-mapping.yaml
136
+ base_tcp_port: Starting TCP port number
137
+ baud: Default baud rate
138
+
139
+ Returns:
140
+ ser2net YAML configuration string
141
+ """
142
+ ports_data = load_port_mapping(mapping_file)
143
+
144
+ ports = []
145
+ for i, port_info in enumerate(ports_data):
146
+ name = port_info.get("name")
147
+ if not name:
148
+ continue
149
+
150
+ port = Ser2NetPort(
151
+ name=name,
152
+ device=f"/dev/lab/{name}",
153
+ tcp_port=base_tcp_port + i,
154
+ baud=baud,
155
+ )
156
+ ports.append(port)
157
+
158
+ return generate_ser2net_config(ports)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ # Example usage
163
+ ports = [
164
+ Ser2NetPort(
165
+ name="sbc1-console",
166
+ device="/dev/lab/sbc1-console",
167
+ tcp_port=3001,
168
+ baud=115200,
169
+ ),
170
+ Ser2NetPort(
171
+ name="sbc2-console",
172
+ device="/dev/lab/sbc2-console",
173
+ tcp_port=3002,
174
+ baud=115200,
175
+ ),
176
+ ]
177
+
178
+ print(generate_ser2net_config(ports))
179
+
180
+
181
+ def write_ser2net_config(path: str, content: str) -> None:
182
+ """Replace the ser2net config at absolute ``path`` with ``content``.
183
+
184
+ `ser2net generate --install` may run as root (sudo), with the path
185
+ taken from ``ser2net.config_file``. Nothing is followed: the directory
186
+ is opened with no symlink on any component, and an existing file must
187
+ be a regular file with a single link.
188
+
189
+ The write is atomic: a temporary file is created in the same directory
190
+ (relative to that directory descriptor), given the existing file's
191
+ owner and mode (0644 for a new one), fsynced, and renamed over the old
192
+ file. A labctl group member without write access to the directory
193
+ (/etc) but with it to the file (install-services.sh makes
194
+ /etc/ser2net.yaml group-writable) can't create the temporary file;
195
+ the existing file is then rewritten in place instead.
196
+ """
197
+ import secrets
198
+
199
+ from labctl.core.safe_open import open_dir_nofollow, open_regular_nonblocking
200
+
201
+ if not os.path.isabs(path):
202
+ raise ValueError(f"ser2net config path must be absolute: {path!r}")
203
+ directory, name = os.path.split(path)
204
+ data = content.encode()
205
+ dir_fd = open_dir_nofollow(directory)
206
+ try:
207
+ existing = _existing_single_link(name, dir_fd, path, open_regular_nonblocking)
208
+ if existing is not None and _owner_change_refused(existing, dir_fd):
209
+ # A temporary file couldn't be given the existing file's owner
210
+ # (not root, and it is someone else's): rewrite it in place.
211
+ _rewrite_in_place(name, dir_fd, path, data, open_regular_nonblocking)
212
+ return
213
+ tmp_name = f".{name}.labctl-{secrets.token_hex(4)}"
214
+ try:
215
+ tmp = os.open(
216
+ tmp_name,
217
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
218
+ 0o600,
219
+ dir_fd=dir_fd,
220
+ )
221
+ except PermissionError:
222
+ if existing is None:
223
+ raise
224
+ _rewrite_in_place(name, dir_fd, path, data, open_regular_nonblocking)
225
+ return
226
+ try:
227
+ try:
228
+ if existing is not None and (
229
+ os.fstat(tmp).st_uid,
230
+ os.fstat(tmp).st_gid,
231
+ ) != (existing.st_uid, existing.st_gid):
232
+ os.fchown(tmp, existing.st_uid, existing.st_gid)
233
+ except PermissionError:
234
+ # Predicted above; kept as a backstop.
235
+ os.close(tmp)
236
+ tmp = None
237
+ os.unlink(tmp_name, dir_fd=dir_fd)
238
+ _rewrite_in_place(name, dir_fd, path, data, open_regular_nonblocking)
239
+ return
240
+ mode = existing.st_mode & 0o7777 if existing is not None else 0o644
241
+ os.fchmod(tmp, mode)
242
+ _write_all(tmp, data)
243
+ os.fsync(tmp)
244
+ os.close(tmp)
245
+ tmp = None
246
+ os.rename(tmp_name, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
247
+ os.fsync(dir_fd)
248
+ except BaseException:
249
+ if tmp is not None:
250
+ os.close(tmp)
251
+ try:
252
+ os.unlink(tmp_name, dir_fd=dir_fd)
253
+ except FileNotFoundError:
254
+ pass
255
+ raise
256
+ finally:
257
+ os.close(dir_fd)
258
+
259
+
260
+ def check_ser2net_config_writable(path: str) -> None:
261
+ """Raise as :func:`write_ser2net_config` would if it couldn't write
262
+ ``path``, changing nothing: the directory opens with no symlink on the
263
+ way, an existing file is a single-link regular file, and either the
264
+ directory takes new files (the atomic replace) or the file can be
265
+ opened for writing (the in-place rewrite)."""
266
+ from labctl.core.safe_open import open_dir_nofollow, open_regular_nonblocking
267
+
268
+ if not os.path.isabs(path):
269
+ raise ValueError(f"ser2net config path must be absolute: {path!r}")
270
+ directory, name = os.path.split(path)
271
+ dir_fd = open_dir_nofollow(directory)
272
+ try:
273
+ existing = _existing_single_link(name, dir_fd, path, open_regular_nonblocking)
274
+ # The same choice write_ser2net_config makes: the atomic replace
275
+ # when the directory takes new files and the temporary file can be
276
+ # given the existing file's owner; otherwise the in-place rewrite,
277
+ # which needs the file itself writable.
278
+ atomic = os.access(directory, os.W_OK | os.X_OK) and not (
279
+ existing is not None and _owner_change_refused(existing, dir_fd)
280
+ )
281
+ if atomic:
282
+ return
283
+ if existing is None:
284
+ raise PermissionError(errno.EACCES, f"can't create {path}")
285
+ fd = open_regular_nonblocking(name, os.O_WRONLY, dir_fd=dir_fd)
286
+ os.close(fd)
287
+ finally:
288
+ os.close(dir_fd)
289
+
290
+
291
+ def _owner_change_refused(existing: os.stat_result, dir_fd: int) -> bool:
292
+ """Whether a new file in the directory couldn't be given ``existing``'s
293
+ owner and group: it would be ours (and the directory's group if it is
294
+ setgid, else our group), and only root may give a file away or to a
295
+ group it isn't in."""
296
+ euid = os.geteuid()
297
+ if euid == 0:
298
+ return False
299
+ dir_st = os.fstat(dir_fd)
300
+ new_gid = dir_st.st_gid if dir_st.st_mode & stat.S_ISGID else os.getegid()
301
+ if (existing.st_uid, existing.st_gid) == (euid, new_gid):
302
+ return False
303
+ if existing.st_uid != euid:
304
+ return True
305
+ return existing.st_gid not in set(os.getgroups()) | {os.getegid()}
306
+
307
+
308
+ def _existing_single_link(name, dir_fd, path, opener):
309
+ """The existing file's stat (None if missing); refuses a symlink, a
310
+ non-regular file or one with more than one hard link."""
311
+ try:
312
+ fd = opener(name, os.O_RDONLY, dir_fd=dir_fd)
313
+ except FileNotFoundError:
314
+ return None
315
+ try:
316
+ st = os.fstat(fd)
317
+ finally:
318
+ os.close(fd)
319
+ if st.st_nlink != 1:
320
+ raise OSError(errno.EMLINK, f"{path} has more than one hard link")
321
+ return st
322
+
323
+
324
+ def _rewrite_in_place(name, dir_fd, path, data, opener):
325
+ fd = opener(name, os.O_WRONLY, dir_fd=dir_fd)
326
+ try:
327
+ if os.fstat(fd).st_nlink != 1:
328
+ raise OSError(errno.EMLINK, f"{path} has more than one hard link")
329
+ os.ftruncate(fd, 0)
330
+ _write_all(fd, data)
331
+ os.fsync(fd)
332
+ finally:
333
+ os.close(fd)
334
+
335
+
336
+ def _write_all(fd: int, data: bytes) -> None:
337
+ while data:
338
+ data = data[os.write(fd, data) :]
labctl/serial/udev.py ADDED
@@ -0,0 +1,273 @@
1
+ """
2
+ Udev rule generation for lab serial devices.
3
+
4
+ Generates and installs udev rules from the serial_devices database table,
5
+ creating /dev/lab/<name> symlinks for registered USB-serial adapters.
6
+ """
7
+
8
+ import re
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from labctl.core.models import SerialDevice
15
+
16
+ UDEV_RULES_PATH = Path("/etc/udev/rules.d/99-lab-serial.rules")
17
+
18
+ UDEV_HEADER = """\
19
+ # Lab Controller Serial Device Rules
20
+ # Auto-generated by labctl - do not edit manually
21
+ # Regenerate with: labctl serial udev --install
22
+ #
23
+ # Devices are root:dialout 0660: the labctl services (in dialout) and
24
+ # ser2net (root) open them; users outside dialout reach consoles through
25
+ # ser2net, but can't drive relays or open a console that has no ser2net
26
+ # port (`labctl console` then opens the device itself).
27
+ """
28
+
29
+
30
+ def generate_udev_rules(devices: list["SerialDevice"]) -> str:
31
+ """Generate udev rules text from a list of serial devices."""
32
+ lines = [UDEV_HEADER]
33
+
34
+ for dev in sorted(devices, key=lambda d: d.name):
35
+ comment_parts = []
36
+ if dev.vendor:
37
+ comment_parts.append(dev.vendor)
38
+ if dev.model:
39
+ comment_parts.append(dev.model)
40
+ comment = f" ({', '.join(comment_parts)})" if comment_parts else ""
41
+
42
+ lines.append(f"# {dev.name}{comment}")
43
+ lines.append(
44
+ f'SUBSYSTEM=="tty", KERNELS=="{dev.usb_path}", '
45
+ f'SYMLINK+="lab/{dev.name}", GROUP="dialout", MODE="0660"'
46
+ )
47
+ lines.append("")
48
+
49
+ return "\n".join(lines)
50
+
51
+
52
+ # udev allows whitespace around operators (`SYMLINK += "lab/x"`). The
53
+ # assignment pattern doesn't match the `SYMLINK=="..."` comparison.
54
+ _RULE_SYMLINK_RE = re.compile(r'\bSYMLINK\s*\+?=\s*"([^"]*)"')
55
+ _RULE_KERNELS_RE = re.compile(r'\bKERNELS\s*==\s*"([^"]*)"')
56
+
57
+
58
+ def rule_links(rules_text: str) -> dict[str, str | None]:
59
+ """The ``/dev/lab/<name>`` links a rules file creates: name -> USB path
60
+ (KERNELS), or None when the rule's USB path can't be parsed. Every
61
+ ``SYMLINK`` / ``SYMLINK+`` value naming ``lab/<name>`` counts, in any
62
+ attribute order and among other link names."""
63
+ links: dict[str, str | None] = {}
64
+ for line in rules_text.splitlines():
65
+ line = line.strip()
66
+ if not line or line.startswith("#"):
67
+ continue
68
+ names = [
69
+ link[len("lab/") :]
70
+ for value in _RULE_SYMLINK_RE.findall(line)
71
+ for link in value.split()
72
+ if link.startswith("lab/") and len(link) > len("lab/")
73
+ ]
74
+ if not names:
75
+ continue
76
+ kernels = _RULE_KERNELS_RE.search(line)
77
+ for name in names:
78
+ links[name] = kernels.group(1) if kernels else None
79
+ return links
80
+
81
+
82
+ def installed_rule_links(
83
+ rules_path: Path | None = None,
84
+ ) -> dict[str, str | None] | None:
85
+ """The links the installed rules file creates ({} when there is none
86
+ yet), or None when it exists but can't be read."""
87
+ try:
88
+ return rule_links((rules_path or UDEV_RULES_PATH).read_text())
89
+ except FileNotFoundError:
90
+ return {}
91
+ except (OSError, UnicodeDecodeError):
92
+ return None
93
+
94
+
95
+ def changed_links(old: dict[str, str | None], new: dict[str, str]) -> set[str]:
96
+ """Link names that installing ``new`` over ``old`` removes or points
97
+ at a different USB path; an old link whose USB path couldn't be
98
+ parsed (None) counts as changed. New names only add links, so they're
99
+ not included."""
100
+ return {name for name, usb_path in old.items() if new.get(name) != usb_path}
101
+
102
+
103
+ def device_group_note(group: str = "dialout") -> str | None:
104
+ """What the user running labctl must do to open the root:<group> 0660
105
+ devices the rules create, or None when nothing. Under sudo that is the
106
+ invoking user (SUDO_USER), not root; plain root needs nothing, nor does
107
+ a system without ``group``."""
108
+ import grp
109
+ import os
110
+ import pwd
111
+
112
+ try:
113
+ entry = grp.getgrnam(group)
114
+ except KeyError:
115
+ return None
116
+ gid = entry.gr_gid
117
+ sudo_user = os.environ.get("SUDO_USER") if os.geteuid() == 0 else None
118
+ if os.geteuid() == 0 and sudo_user in (None, "root"):
119
+ return None
120
+ if sudo_user is None:
121
+ if gid == os.getegid() or gid in os.getgroups():
122
+ return None
123
+ try:
124
+ account = pwd.getpwuid(os.geteuid())
125
+ except KeyError:
126
+ return None
127
+ user, primary_gid = account.pw_name, account.pw_gid
128
+ else:
129
+ try:
130
+ user, primary_gid = sudo_user, pwd.getpwnam(sudo_user).pw_gid
131
+ except KeyError:
132
+ return None
133
+ member = user in entry.gr_mem or primary_gid == gid
134
+ if member and sudo_user is not None:
135
+ return None
136
+ access = (
137
+ f"the devices are root:{group} 0660, so you can't open them directly "
138
+ f"(relays from the CLI, `labctl console` without ser2net) until "
139
+ )
140
+ if member:
141
+ return access + (
142
+ f"you log in again: {user} was added to {group} after this "
143
+ f"session started"
144
+ )
145
+ return access + (
146
+ f"{user} joins {group}: sudo usermod -aG {group} {user} (then log in " f"again)"
147
+ )
148
+
149
+
150
+ def install_udev_rules(
151
+ rules_text: str,
152
+ rules_path: Path = UDEV_RULES_PATH,
153
+ ) -> None:
154
+ """Write udev rules to the system rules directory.
155
+
156
+ Raises PermissionError if unable to write.
157
+ """
158
+ rules_path.write_text(rules_text)
159
+
160
+
161
+ def reload_udev() -> bool:
162
+ """Reload udev rules and trigger device re-evaluation.
163
+
164
+ Tries direct execution first, falls back to sudo if permission denied.
165
+ Returns True on success.
166
+ """
167
+ for use_sudo in (False, True):
168
+ try:
169
+ prefix = ["sudo"] if use_sudo else []
170
+ subprocess.run(
171
+ [*prefix, "udevadm", "control", "--reload-rules"],
172
+ check=True,
173
+ capture_output=True,
174
+ )
175
+ subprocess.run(
176
+ [*prefix, "udevadm", "trigger", "--subsystem-match=tty"],
177
+ check=True,
178
+ capture_output=True,
179
+ )
180
+ return True
181
+ except subprocess.CalledProcessError:
182
+ if not use_sudo:
183
+ continue # Retry with sudo
184
+ return False
185
+ except FileNotFoundError:
186
+ return False
187
+ return False
188
+
189
+
190
+ def discover_usb_serial() -> list[dict]:
191
+ """Discover connected USB-serial devices by reading /sys.
192
+
193
+ Returns a list of dicts with keys:
194
+ device, path, usb_path, vendor, model, serial
195
+ """
196
+ devices = []
197
+ tty_classes = [Path("/sys/class/tty")]
198
+
199
+ for tty_dir in tty_classes:
200
+ if not tty_dir.exists():
201
+ continue
202
+ for entry in sorted(tty_dir.iterdir()):
203
+ name = entry.name
204
+ if not (name.startswith("ttyUSB") or name.startswith("ttyACM")):
205
+ continue
206
+
207
+ device_link = entry / "device"
208
+ if not device_link.exists():
209
+ continue
210
+
211
+ # Walk up to find the USB device path
212
+ info = _get_udev_info(name)
213
+ if info and info.get("usb_path"):
214
+ devices.append(info)
215
+
216
+ return devices
217
+
218
+
219
+ def _get_udev_info(tty_name: str) -> dict | None:
220
+ """Get USB device info via udevadm."""
221
+ try:
222
+ result = subprocess.run(
223
+ ["udevadm", "info", "--name", f"/dev/{tty_name}", "--attribute-walk"],
224
+ capture_output=True,
225
+ text=True,
226
+ timeout=5,
227
+ )
228
+ if result.returncode != 0:
229
+ return None
230
+
231
+ usb_path = None
232
+ vendor = None
233
+ model = None
234
+ serial = None
235
+
236
+ # udevadm --attribute-walk starts at the device and walks UP
237
+ # to the root. We want the first (most specific) match for each field.
238
+ for line in result.stdout.splitlines():
239
+ line = line.strip()
240
+ if line.startswith('KERNELS=="') and not line.startswith('KERNELS=="tty'):
241
+ val = line.split('"')[1]
242
+ # USB device paths look like "1-10.1.3"
243
+ # Skip: PCI paths (0000:), interface descriptors (contain :),
244
+ # and bare hub paths (no dot = just "1-12")
245
+ if (
246
+ val
247
+ and "-" in val
248
+ and ":" not in val
249
+ and "." in val
250
+ and not val.startswith("0000:")
251
+ and usb_path is None
252
+ ):
253
+ usb_path = val
254
+ elif line.startswith('ATTRS{manufacturer}=="') and vendor is None:
255
+ vendor = line.split('"')[1]
256
+ elif line.startswith('ATTRS{product}=="') and model is None:
257
+ model = line.split('"')[1]
258
+ elif line.startswith('ATTRS{serial}=="') and serial is None:
259
+ serial = line.split('"')[1]
260
+
261
+ if not usb_path:
262
+ return None
263
+
264
+ return {
265
+ "device": tty_name,
266
+ "path": f"/dev/{tty_name}",
267
+ "usb_path": usb_path,
268
+ "vendor": vendor or "",
269
+ "model": model or "",
270
+ "serial": serial or "",
271
+ }
272
+ except (subprocess.TimeoutExpired, FileNotFoundError):
273
+ return None