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,430 @@
1
+ """
2
+ Serial port capture and send utilities.
3
+
4
+ Provides functions to capture serial output and send data via ser2net TCP
5
+ connections. Used by CLI commands and MCP tools.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ import select
11
+ import socket
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Optional, Union
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class CaptureResult:
21
+ """Result of a serial capture operation."""
22
+
23
+ output: str = ""
24
+ lines: int = 0
25
+ pattern_matched: bool = False
26
+ elapsed_seconds: float = 0.0
27
+
28
+ def to_mcp_string(self, pattern: Optional[str] = None) -> str:
29
+ """Format as MCP tool return string."""
30
+ status = f"pattern '{pattern}' matched" if self.pattern_matched else "timeout"
31
+ header = (
32
+ f"[Captured {self.lines} lines in {self.elapsed_seconds:.1f}s, "
33
+ f"{status}]"
34
+ )
35
+ return f"{header}\n{self.output}"
36
+
37
+
38
+ @dataclass
39
+ class SendResult:
40
+ """Result of a serial send operation."""
41
+
42
+ sent: bool = False
43
+ bytes_sent: int = 0
44
+ capture: Optional[CaptureResult] = None
45
+
46
+ def to_mcp_string(self) -> str:
47
+ """Format as MCP tool return string."""
48
+ if self.capture:
49
+ return self.capture.to_mcp_string()
50
+ return f"Sent {self.bytes_sent} bytes"
51
+
52
+
53
+ @dataclass
54
+ class SendPacing:
55
+ """Outbound write pacing to avoid receiver UART RX-FIFO overrun.
56
+
57
+ A gapless burst gives the receiver's RX ISR no window to drain its
58
+ FIFO, so a sustained write can lose a contiguous mid-payload byte
59
+ window. Writing ``chunk_size`` bytes per slice with ``delay_ms``
60
+ between slices inserts the gaps the receiver needs. ``chunk_size=1``
61
+ expresses per-byte pacing.
62
+
63
+ The wire still transmits at the configured baud rate; pacing only
64
+ controls how the payload is fed to ser2net, which is what creates
65
+ the inter-slice gaps on the UART.
66
+ """
67
+
68
+ chunk_size: int = 0
69
+ delay_ms: float = 0.0
70
+
71
+ @property
72
+ def enabled(self) -> bool:
73
+ return self.chunk_size > 0 and self.delay_ms > 0
74
+
75
+
76
+ # Pause between the parts of a multi-part send (one per named key). Meant
77
+ # to let consoles that time out a lone ESC quickly (e.g. U-Boot's menus)
78
+ # see it as a key; not checked on hardware, and firmware that waits longer
79
+ # for the rest of a sequence (EDK2 waits up to ~2 s) is not helped by it.
80
+ PART_GAP_SECONDS = 0.05
81
+
82
+
83
+ def _write_payload(
84
+ sock: socket.socket,
85
+ raw: Union[bytes, list[bytes]],
86
+ pacing: Optional[SendPacing] = None,
87
+ ) -> None:
88
+ """Write ``raw`` to ``sock``, optionally paced into delayed slices.
89
+
90
+ A list is written part by part with PART_GAP_SECONDS between parts.
91
+ """
92
+ if isinstance(raw, list):
93
+ for i, part in enumerate(raw):
94
+ if i:
95
+ time.sleep(PART_GAP_SECONDS)
96
+ _write_payload(sock, part, pacing)
97
+ return
98
+ if pacing is None or not pacing.enabled:
99
+ sock.sendall(raw)
100
+ return
101
+ delay = pacing.delay_ms / 1000.0
102
+ step = pacing.chunk_size
103
+ for i in range(0, len(raw), step):
104
+ sock.sendall(raw[i : i + step])
105
+ if i + step < len(raw):
106
+ time.sleep(delay)
107
+
108
+
109
+ def _payload_len(raw: Union[bytes, list[bytes]]) -> int:
110
+ return sum(map(len, raw)) if isinstance(raw, list) else len(raw)
111
+
112
+
113
+ def resolve_port(manager, port_name: str):
114
+ """Resolve a port name to a SerialPort object.
115
+
116
+ Tries alias first, then falls back to SBC name + console port.
117
+
118
+ Args:
119
+ manager: ResourceManager instance
120
+ port_name: Port alias or SBC name
121
+
122
+ Returns:
123
+ SerialPort object
124
+
125
+ Raises:
126
+ ValueError: If port cannot be resolved
127
+ """
128
+ # Try alias first
129
+ port = manager.get_serial_port_by_alias(port_name)
130
+ if port:
131
+ return port
132
+
133
+ # Fall back to SBC name -> console port
134
+ sbc = manager.get_sbc_by_name(port_name)
135
+ if sbc:
136
+ if sbc.console_port:
137
+ return sbc.console_port
138
+ raise ValueError(f"SBC '{port_name}' has no console port assigned")
139
+
140
+ raise ValueError(f"'{port_name}' is not a known port alias or SBC name")
141
+
142
+
143
+ def capture_serial_output(
144
+ tcp_host: str,
145
+ tcp_port: int,
146
+ timeout: float = 15.0,
147
+ until_pattern: Optional[str] = None,
148
+ tail: Optional[int] = None,
149
+ ) -> CaptureResult:
150
+ """Capture serial output from a ser2net TCP port.
151
+
152
+ Connects to the ser2net TCP port, reads output until timeout or
153
+ pattern match, then disconnects.
154
+
155
+ Args:
156
+ tcp_host: Host where ser2net is running (usually "localhost")
157
+ tcp_port: TCP port for the serial connection
158
+ timeout: Maximum seconds to capture
159
+ until_pattern: Regex pattern to stop on (matched per line)
160
+ tail: Return only last N lines (None = all)
161
+
162
+ Returns:
163
+ CaptureResult with captured output and metadata.
164
+ """
165
+ compiled_pattern = None
166
+ if until_pattern:
167
+ compiled_pattern = re.compile(until_pattern)
168
+
169
+ captured_lines: list[str] = []
170
+ pattern_matched = False
171
+ start_time = time.monotonic()
172
+ buffer = b""
173
+
174
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
175
+ sock.settimeout(5.0) # Connection timeout
176
+
177
+ try:
178
+ sock.connect((tcp_host, tcp_port))
179
+ sock.setblocking(False)
180
+
181
+ while True:
182
+ elapsed = time.monotonic() - start_time
183
+ remaining = timeout - elapsed
184
+ if remaining <= 0:
185
+ break
186
+
187
+ wait = min(remaining, 0.5)
188
+ readable, _, _ = select.select([sock], [], [], wait)
189
+
190
+ if readable:
191
+ try:
192
+ data = sock.recv(4096)
193
+ if not data:
194
+ break # Connection closed
195
+
196
+ buffer += data
197
+
198
+ while b"\n" in buffer:
199
+ line, buffer = buffer.split(b"\n", 1)
200
+ line_str = line.decode("utf-8", errors="replace").rstrip()
201
+ captured_lines.append(line_str)
202
+
203
+ if compiled_pattern and compiled_pattern.search(line_str):
204
+ pattern_matched = True
205
+ break
206
+
207
+ # Check partial buffer for pattern (e.g. prompts without newline)
208
+ if not pattern_matched and compiled_pattern and buffer:
209
+ partial = buffer.decode("utf-8", errors="replace")
210
+ if compiled_pattern.search(partial):
211
+ captured_lines.append(partial.rstrip())
212
+ buffer = b""
213
+ pattern_matched = True
214
+
215
+ if pattern_matched:
216
+ break
217
+
218
+ except BlockingIOError:
219
+ pass
220
+
221
+ # Handle any remaining data in buffer (no trailing newline)
222
+ if buffer:
223
+ line_str = buffer.decode("utf-8", errors="replace").rstrip()
224
+ if line_str:
225
+ captured_lines.append(line_str)
226
+ if compiled_pattern and compiled_pattern.search(line_str):
227
+ pattern_matched = True
228
+
229
+ except (ConnectionRefusedError, OSError) as e:
230
+ raise RuntimeError(
231
+ f"Cannot connect to serial port at {tcp_host}:{tcp_port}: {e}"
232
+ )
233
+ finally:
234
+ try:
235
+ sock.close()
236
+ except Exception:
237
+ pass
238
+
239
+ elapsed = time.monotonic() - start_time
240
+
241
+ if tail and len(captured_lines) > tail:
242
+ output_lines = captured_lines[-tail:]
243
+ else:
244
+ output_lines = captured_lines
245
+
246
+ return CaptureResult(
247
+ output="\n".join(output_lines),
248
+ lines=len(output_lines),
249
+ pattern_matched=pattern_matched,
250
+ elapsed_seconds=round(elapsed, 1),
251
+ )
252
+
253
+
254
+ def send_serial_data(
255
+ tcp_host: str,
256
+ tcp_port: int,
257
+ data: Union[str, list[bytes]],
258
+ newline: bool = True,
259
+ capture_timeout: Optional[float] = None,
260
+ capture_until: Optional[str] = None,
261
+ pacing: Optional[SendPacing] = None,
262
+ ) -> SendResult:
263
+ """Send data to a serial port via ser2net TCP.
264
+
265
+ Optionally captures the response after sending.
266
+
267
+ Args:
268
+ tcp_host: Host where ser2net is running
269
+ tcp_port: TCP port for the serial connection
270
+ data: Text to send (UTF-8), or a list of byte parts written with
271
+ a short pause between them
272
+ newline: Append \\r\\n after data (default True)
273
+ capture_timeout: If set, capture response for this many seconds
274
+ capture_until: If set, capture until this regex matches
275
+ pacing: Optional write pacing to avoid receiver RX-FIFO overrun
276
+
277
+ Returns:
278
+ SendResult with send status and optional capture.
279
+ """
280
+ if isinstance(data, list):
281
+ raw = list(data)
282
+ if newline:
283
+ raw.append(b"\r\n")
284
+ else:
285
+ raw = data.encode("utf-8")
286
+ if newline:
287
+ raw += b"\r\n"
288
+
289
+ if capture_timeout is not None:
290
+ # Send and capture in one connection
291
+ return _send_and_capture(
292
+ tcp_host,
293
+ tcp_port,
294
+ raw,
295
+ capture_timeout,
296
+ capture_until,
297
+ pacing,
298
+ )
299
+
300
+ # Send only
301
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
302
+ sock.settimeout(5.0)
303
+
304
+ try:
305
+ sock.connect((tcp_host, tcp_port))
306
+ _write_payload(sock, raw, pacing)
307
+ return SendResult(sent=True, bytes_sent=_payload_len(raw))
308
+ except (ConnectionRefusedError, OSError) as e:
309
+ raise RuntimeError(
310
+ f"Cannot connect to serial port at {tcp_host}:{tcp_port}: {e}"
311
+ )
312
+ finally:
313
+ try:
314
+ sock.close()
315
+ except Exception:
316
+ pass
317
+
318
+
319
+ def _send_and_capture(
320
+ tcp_host: str,
321
+ tcp_port: int,
322
+ raw_data: Union[bytes, list[bytes]],
323
+ timeout: float,
324
+ until_pattern: Optional[str] = None,
325
+ pacing: Optional[SendPacing] = None,
326
+ ) -> SendResult:
327
+ """Send data then capture response on the same connection."""
328
+ compiled_pattern = None
329
+ if until_pattern:
330
+ compiled_pattern = re.compile(until_pattern)
331
+
332
+ captured_lines: list[str] = []
333
+ pattern_matched = False
334
+ buffer = b""
335
+
336
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
337
+ sock.settimeout(5.0)
338
+
339
+ try:
340
+ sock.connect((tcp_host, tcp_port))
341
+
342
+ # Drain stale data (100ms)
343
+ sock.setblocking(False)
344
+ drain_end = time.monotonic() + 0.1
345
+ while time.monotonic() < drain_end:
346
+ readable, _, _ = select.select([sock], [], [], 0.05)
347
+ if readable:
348
+ try:
349
+ sock.recv(4096)
350
+ except BlockingIOError:
351
+ pass
352
+
353
+ # Send data
354
+ sock.setblocking(True)
355
+ sock.settimeout(5.0)
356
+ _write_payload(sock, raw_data, pacing)
357
+ bytes_sent = _payload_len(raw_data)
358
+
359
+ # Capture response
360
+ sock.setblocking(False)
361
+ start_time = time.monotonic()
362
+
363
+ while True:
364
+ elapsed = time.monotonic() - start_time
365
+ remaining = timeout - elapsed
366
+ if remaining <= 0:
367
+ break
368
+
369
+ wait = min(remaining, 0.5)
370
+ readable, _, _ = select.select([sock], [], [], wait)
371
+
372
+ if readable:
373
+ try:
374
+ data = sock.recv(4096)
375
+ if not data:
376
+ break
377
+
378
+ buffer += data
379
+
380
+ while b"\n" in buffer:
381
+ line, buffer = buffer.split(b"\n", 1)
382
+ line_str = line.decode("utf-8", errors="replace").rstrip()
383
+ captured_lines.append(line_str)
384
+
385
+ if compiled_pattern and compiled_pattern.search(line_str):
386
+ pattern_matched = True
387
+ break
388
+
389
+ # Check partial buffer for pattern (e.g. prompts without newline)
390
+ if not pattern_matched and compiled_pattern and buffer:
391
+ partial = buffer.decode("utf-8", errors="replace")
392
+ if compiled_pattern.search(partial):
393
+ captured_lines.append(partial.rstrip())
394
+ buffer = b""
395
+ pattern_matched = True
396
+
397
+ if pattern_matched:
398
+ break
399
+
400
+ except BlockingIOError:
401
+ pass
402
+
403
+ # Remaining buffer
404
+ if buffer:
405
+ line_str = buffer.decode("utf-8", errors="replace").rstrip()
406
+ if line_str:
407
+ captured_lines.append(line_str)
408
+ if compiled_pattern and compiled_pattern.search(line_str):
409
+ pattern_matched = True
410
+
411
+ elapsed = time.monotonic() - start_time
412
+
413
+ capture = CaptureResult(
414
+ output="\n".join(captured_lines),
415
+ lines=len(captured_lines),
416
+ pattern_matched=pattern_matched,
417
+ elapsed_seconds=round(elapsed, 1),
418
+ )
419
+
420
+ return SendResult(sent=True, bytes_sent=bytes_sent, capture=capture)
421
+
422
+ except (ConnectionRefusedError, OSError) as e:
423
+ raise RuntimeError(
424
+ f"Cannot connect to serial port at {tcp_host}:{tcp_port}: {e}"
425
+ )
426
+ finally:
427
+ try:
428
+ sock.close()
429
+ except Exception:
430
+ pass
labctl/serial/keys.py ADDED
@@ -0,0 +1,89 @@
1
+ """Named keys for serial consoles, as the bytes a terminal sends.
2
+
3
+ Tool arguments are text written by a model, which can't reliably carry
4
+ raw control characters (an ESC is dropped before it reaches the server),
5
+ so MCP clients name keys instead (issue #33). Encodings are xterm's,
6
+ except Backspace (0x08, which U-Boot and EDK2 take as backspace; Linux
7
+ ttys usually erase with 0x7F). None has been checked on real firmware.
8
+ Function keys vary by terminal type (EDK2 VT100+ uses ESC 1..ESC 0,
9
+ ESC ! and ESC @ for F1-F12); callers can send those as raw hex.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ _NAMED = {
15
+ "esc": b"\x1b",
16
+ "enter": b"\r",
17
+ "tab": b"\t",
18
+ "space": b" ",
19
+ "backspace": b"\x08",
20
+ "up": b"\x1b[A",
21
+ "down": b"\x1b[B",
22
+ "right": b"\x1b[C",
23
+ "left": b"\x1b[D",
24
+ "home": b"\x1b[H",
25
+ "end": b"\x1b[F",
26
+ "insert": b"\x1b[2~",
27
+ "delete": b"\x1b[3~",
28
+ "pgup": b"\x1b[5~",
29
+ "pgdn": b"\x1b[6~",
30
+ "f1": b"\x1bOP",
31
+ "f2": b"\x1bOQ",
32
+ "f3": b"\x1bOR",
33
+ "f4": b"\x1bOS",
34
+ "f5": b"\x1b[15~",
35
+ "f6": b"\x1b[17~",
36
+ "f7": b"\x1b[18~",
37
+ "f8": b"\x1b[19~",
38
+ "f9": b"\x1b[20~",
39
+ "f10": b"\x1b[21~",
40
+ "f11": b"\x1b[23~",
41
+ "f12": b"\x1b[24~",
42
+ }
43
+ _ALIASES = {
44
+ "escape": "esc",
45
+ "return": "enter",
46
+ "pageup": "pgup",
47
+ "pagedown": "pgdn",
48
+ "page up": "pgup",
49
+ "page down": "pgdn",
50
+ "del": "delete",
51
+ "ins": "insert",
52
+ }
53
+
54
+ KEY_NAMES = (
55
+ "ESC, Enter, Tab, Space, Backspace (0x08), Up, Down, Left, Right, "
56
+ "Home, End, Insert, Delete, PgUp, PgDn, F1-F12, Ctrl-A..Ctrl-Z "
57
+ "(0x01-0x1A; also written Ctrl+C or ^C)"
58
+ )
59
+ _CTRL_PREFIXES = ("ctrl-", "ctrl+", "control-", "control+", "^")
60
+
61
+
62
+ def encode_key(name: str) -> bytes:
63
+ """The bytes for key ``name`` (case-insensitive); ValueError if unknown."""
64
+ key = name.strip().lower() if isinstance(name, str) else ""
65
+ key = _ALIASES.get(key, key)
66
+ if key in _NAMED:
67
+ return _NAMED[key]
68
+ for prefix in _CTRL_PREFIXES:
69
+ if key.startswith(prefix):
70
+ letter = key[len(prefix) :]
71
+ if len(letter) == 1 and "a" <= letter <= "z":
72
+ return bytes([ord(letter) - ord("a") + 1])
73
+ raise ValueError(f"unknown key {name!r}; known keys: {KEY_NAMES}")
74
+
75
+
76
+ def encode_keys(names: list[str]) -> list[bytes]:
77
+ """The bytes for each of a sequence of key names, in order."""
78
+ return [encode_key(n) for n in names]
79
+
80
+
81
+ def decode_hex(text: str) -> bytes:
82
+ """Bytes from hex text, e.g. "1b5b32337e" or "1b 5b 32"; ValueError if
83
+ malformed."""
84
+ try:
85
+ return bytes.fromhex(text)
86
+ except (TypeError, ValueError):
87
+ raise ValueError(
88
+ f"data_hex must be pairs of hex digits (spaces allowed): {text!r}"
89
+ ) from None