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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/serial/ser2net.py
ADDED
|
@@ -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
|