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,533 @@
1
+ """
2
+ Health check implementations for lab controller.
3
+
4
+ Provides ping, serial, and power health checks for SBCs.
5
+ """
6
+
7
+ import socket
8
+ import subprocess
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from enum import Enum
13
+ from typing import Optional
14
+
15
+ from labctl.core.models import SBC, PortType, Status
16
+ from labctl.power.base import PowerController, PowerState
17
+
18
+
19
+ class CheckType(Enum):
20
+ """Types of health checks available."""
21
+
22
+ PING = "ping"
23
+ SERIAL = "serial"
24
+ POWER = "power"
25
+
26
+
27
+ @dataclass
28
+ class CheckResult:
29
+ """Result of a single health check."""
30
+
31
+ sbc_name: str
32
+ check_type: CheckType
33
+ success: bool
34
+ message: str
35
+ duration_ms: float
36
+ timestamp: datetime = field(default_factory=datetime.now)
37
+
38
+ @property
39
+ def status_char(self) -> str:
40
+ """Get single character status indicator."""
41
+ if self.success:
42
+ return "\u2713" # checkmark
43
+ return "\u2717" # X mark
44
+
45
+
46
+ @dataclass
47
+ class HealthCheckSummary:
48
+ """Summary of health checks for an SBC."""
49
+
50
+ sbc_name: str
51
+ ping_result: Optional[CheckResult] = None
52
+ serial_result: Optional[CheckResult] = None
53
+ power_result: Optional[CheckResult] = None
54
+ power_state: Optional[PowerState] = None
55
+ recommended_status: Optional[Status] = None
56
+
57
+ def determine_status(self) -> Status:
58
+ """Determine recommended SBC status based on check results."""
59
+ # If ping fails, SBC is offline
60
+ if self.ping_result and not self.ping_result.success:
61
+ return Status.OFFLINE
62
+
63
+ # If power is off, SBC is offline
64
+ if self.power_state == PowerState.OFF:
65
+ return Status.OFFLINE
66
+
67
+ # If ping succeeds but serial fails, there's an error
68
+ if self.ping_result and self.ping_result.success:
69
+ if self.serial_result and not self.serial_result.success:
70
+ return Status.ERROR
71
+
72
+ # If ping succeeds, SBC is online
73
+ if self.ping_result and self.ping_result.success:
74
+ return Status.ONLINE
75
+
76
+ # Can't determine status
77
+ return Status.UNKNOWN
78
+
79
+
80
+ class HealthChecker:
81
+ """Performs health checks on SBCs."""
82
+
83
+ def __init__(
84
+ self,
85
+ ping_timeout: float = 2.0,
86
+ serial_timeout: float = 2.0,
87
+ ):
88
+ """
89
+ Initialize health checker.
90
+
91
+ Args:
92
+ ping_timeout: Timeout for ping checks in seconds
93
+ serial_timeout: Timeout for serial port checks in seconds
94
+ """
95
+ self.ping_timeout = ping_timeout
96
+ self.serial_timeout = serial_timeout
97
+
98
+ def ping_check(self, ip: str, sbc_name: str = "") -> CheckResult:
99
+ """
100
+ Check if an IP address responds to ICMP ping.
101
+
102
+ Args:
103
+ ip: IP address to ping
104
+ sbc_name: Name of SBC for result tracking
105
+
106
+ Returns:
107
+ CheckResult with success/failure status
108
+ """
109
+ start_time = time.time()
110
+
111
+ try:
112
+ # Use ping command with 1 packet and timeout
113
+ # -c 1: send 1 packet
114
+ # -W: timeout in seconds (Linux)
115
+ result = subprocess.run(
116
+ ["ping", "-c", "1", "-W", str(int(self.ping_timeout)), ip],
117
+ capture_output=True,
118
+ timeout=self.ping_timeout + 1,
119
+ )
120
+ duration_ms = (time.time() - start_time) * 1000
121
+
122
+ if result.returncode == 0:
123
+ return CheckResult(
124
+ sbc_name=sbc_name,
125
+ check_type=CheckType.PING,
126
+ success=True,
127
+ message=f"Host {ip} is reachable",
128
+ duration_ms=duration_ms,
129
+ )
130
+ else:
131
+ return CheckResult(
132
+ sbc_name=sbc_name,
133
+ check_type=CheckType.PING,
134
+ success=False,
135
+ message=f"Host {ip} is unreachable",
136
+ duration_ms=duration_ms,
137
+ )
138
+
139
+ except subprocess.TimeoutExpired:
140
+ duration_ms = (time.time() - start_time) * 1000
141
+ return CheckResult(
142
+ sbc_name=sbc_name,
143
+ check_type=CheckType.PING,
144
+ success=False,
145
+ message=f"Ping to {ip} timed out",
146
+ duration_ms=duration_ms,
147
+ )
148
+ except Exception as e:
149
+ duration_ms = (time.time() - start_time) * 1000
150
+ return CheckResult(
151
+ sbc_name=sbc_name,
152
+ check_type=CheckType.PING,
153
+ success=False,
154
+ message=f"Ping error: {e}",
155
+ duration_ms=duration_ms,
156
+ )
157
+
158
+ def serial_check(self, host: str, port: int, sbc_name: str = "") -> CheckResult:
159
+ """
160
+ Check if a serial port (via TCP) is accessible.
161
+
162
+ Args:
163
+ host: Host address (usually localhost for ser2net)
164
+ port: TCP port number
165
+ sbc_name: Name of SBC for result tracking
166
+
167
+ Returns:
168
+ CheckResult with success/failure status
169
+ """
170
+ start_time = time.time()
171
+
172
+ try:
173
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
174
+ sock.settimeout(self.serial_timeout)
175
+ sock.connect((host, port))
176
+ sock.close()
177
+
178
+ duration_ms = (time.time() - start_time) * 1000
179
+ return CheckResult(
180
+ sbc_name=sbc_name,
181
+ check_type=CheckType.SERIAL,
182
+ success=True,
183
+ message=f"Serial port {host}:{port} is accessible",
184
+ duration_ms=duration_ms,
185
+ )
186
+
187
+ except socket.timeout:
188
+ duration_ms = (time.time() - start_time) * 1000
189
+ return CheckResult(
190
+ sbc_name=sbc_name,
191
+ check_type=CheckType.SERIAL,
192
+ success=False,
193
+ message=f"Connection to {host}:{port} timed out",
194
+ duration_ms=duration_ms,
195
+ )
196
+ except ConnectionRefusedError:
197
+ duration_ms = (time.time() - start_time) * 1000
198
+ return CheckResult(
199
+ sbc_name=sbc_name,
200
+ check_type=CheckType.SERIAL,
201
+ success=False,
202
+ message=f"Connection to {host}:{port} refused",
203
+ duration_ms=duration_ms,
204
+ )
205
+ except Exception as e:
206
+ duration_ms = (time.time() - start_time) * 1000
207
+ return CheckResult(
208
+ sbc_name=sbc_name,
209
+ check_type=CheckType.SERIAL,
210
+ success=False,
211
+ message=f"Serial check error: {e}",
212
+ duration_ms=duration_ms,
213
+ )
214
+
215
+ def serial_probe(
216
+ self,
217
+ host: str,
218
+ port: int,
219
+ probe_string: str = "\r\n",
220
+ expect_pattern: Optional[str] = None,
221
+ sbc_name: str = "",
222
+ ) -> CheckResult:
223
+ """
224
+ Send a probe string to serial port and optionally check for response.
225
+
226
+ This is a more active check than serial_check - it actually sends
227
+ data and optionally verifies the response matches a pattern.
228
+
229
+ Common use cases:
230
+ - Send Enter key, expect login prompt
231
+ - Send "echo test", expect "test" back
232
+ - Send newline, expect any response
233
+
234
+ Args:
235
+ host: Host address (usually localhost for ser2net)
236
+ port: TCP port number
237
+ probe_string: String to send to the serial port
238
+ expect_pattern: Optional pattern to look for in response (substring match)
239
+ sbc_name: Name of SBC for result tracking
240
+
241
+ Returns:
242
+ CheckResult with success/failure status
243
+ """
244
+ import select
245
+
246
+ start_time = time.time()
247
+
248
+ try:
249
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
250
+ sock.settimeout(self.serial_timeout)
251
+ sock.connect((host, port))
252
+
253
+ # Clear any buffered data first
254
+ sock.setblocking(False)
255
+ try:
256
+ while True:
257
+ ready, _, _ = select.select([sock], [], [], 0.1)
258
+ if not ready:
259
+ break
260
+ data = sock.recv(4096)
261
+ if not data:
262
+ break
263
+ except BlockingIOError:
264
+ pass
265
+
266
+ # Send probe string
267
+ sock.setblocking(True)
268
+ sock.settimeout(self.serial_timeout)
269
+ sock.sendall(probe_string.encode("utf-8"))
270
+
271
+ # Wait for response if pattern expected
272
+ if expect_pattern:
273
+ sock.setblocking(False)
274
+ response = b""
275
+ deadline = time.time() + self.serial_timeout
276
+
277
+ while time.time() < deadline:
278
+ try:
279
+ ready, _, _ = select.select(
280
+ [sock], [], [], min(0.1, deadline - time.time())
281
+ )
282
+ if ready:
283
+ data = sock.recv(4096)
284
+ if not data:
285
+ break
286
+ response += data
287
+
288
+ # Check if pattern found
289
+ if expect_pattern in response.decode(
290
+ "utf-8", errors="replace"
291
+ ):
292
+ sock.close()
293
+ duration_ms = (time.time() - start_time) * 1000
294
+ msg = f"Probe OK, found '{expect_pattern}'"
295
+ return CheckResult(
296
+ sbc_name=sbc_name,
297
+ check_type=CheckType.SERIAL,
298
+ success=True,
299
+ message=msg,
300
+ duration_ms=duration_ms,
301
+ )
302
+ except BlockingIOError:
303
+ pass
304
+
305
+ # Pattern not found
306
+ sock.close()
307
+ duration_ms = (time.time() - start_time) * 1000
308
+ preview = response[:100].decode("utf-8", errors="replace")
309
+ msg = f"Pattern '{expect_pattern}' not found. Got: {repr(preview)}"
310
+ return CheckResult(
311
+ sbc_name=sbc_name,
312
+ check_type=CheckType.SERIAL,
313
+ success=False,
314
+ message=msg,
315
+ duration_ms=duration_ms,
316
+ )
317
+ else:
318
+ # No pattern expected, just check for any response
319
+ sock.setblocking(False)
320
+ response = b""
321
+ deadline = time.time() + self.serial_timeout
322
+
323
+ while time.time() < deadline:
324
+ try:
325
+ ready, _, _ = select.select(
326
+ [sock], [], [], min(0.1, deadline - time.time())
327
+ )
328
+ if ready:
329
+ data = sock.recv(4096)
330
+ if data:
331
+ response += data
332
+ break
333
+ except BlockingIOError:
334
+ pass
335
+
336
+ sock.close()
337
+ duration_ms = (time.time() - start_time) * 1000
338
+
339
+ if response:
340
+ msg = f"Serial probe successful, received {len(response)} bytes"
341
+ return CheckResult(
342
+ sbc_name=sbc_name,
343
+ check_type=CheckType.SERIAL,
344
+ success=True,
345
+ message=msg,
346
+ duration_ms=duration_ms,
347
+ )
348
+ else:
349
+ return CheckResult(
350
+ sbc_name=sbc_name,
351
+ check_type=CheckType.SERIAL,
352
+ success=True,
353
+ message="Serial probe sent, no response (may be normal)",
354
+ duration_ms=duration_ms,
355
+ )
356
+
357
+ except socket.timeout:
358
+ duration_ms = (time.time() - start_time) * 1000
359
+ return CheckResult(
360
+ sbc_name=sbc_name,
361
+ check_type=CheckType.SERIAL,
362
+ success=False,
363
+ message=f"Serial probe to {host}:{port} timed out",
364
+ duration_ms=duration_ms,
365
+ )
366
+ except ConnectionRefusedError:
367
+ duration_ms = (time.time() - start_time) * 1000
368
+ return CheckResult(
369
+ sbc_name=sbc_name,
370
+ check_type=CheckType.SERIAL,
371
+ success=False,
372
+ message=f"Connection to {host}:{port} refused",
373
+ duration_ms=duration_ms,
374
+ )
375
+ except Exception as e:
376
+ duration_ms = (time.time() - start_time) * 1000
377
+ return CheckResult(
378
+ sbc_name=sbc_name,
379
+ check_type=CheckType.SERIAL,
380
+ success=False,
381
+ message=f"Serial probe error: {e}",
382
+ duration_ms=duration_ms,
383
+ )
384
+
385
+ def power_check(
386
+ self, controller: PowerController, sbc_name: str = ""
387
+ ) -> tuple[CheckResult, PowerState]:
388
+ """
389
+ Check power state of an SBC via its power controller.
390
+
391
+ Args:
392
+ controller: Power controller instance
393
+ sbc_name: Name of SBC for result tracking
394
+
395
+ Returns:
396
+ Tuple of (CheckResult, PowerState)
397
+ """
398
+ start_time = time.time()
399
+
400
+ try:
401
+ state = controller.get_state()
402
+ duration_ms = (time.time() - start_time) * 1000
403
+
404
+ if state == PowerState.UNKNOWN:
405
+ return (
406
+ CheckResult(
407
+ sbc_name=sbc_name,
408
+ check_type=CheckType.POWER,
409
+ success=False,
410
+ message="Power state unknown",
411
+ duration_ms=duration_ms,
412
+ ),
413
+ state,
414
+ )
415
+ else:
416
+ return (
417
+ CheckResult(
418
+ sbc_name=sbc_name,
419
+ check_type=CheckType.POWER,
420
+ success=True,
421
+ message=f"Power is {state.value.upper()}",
422
+ duration_ms=duration_ms,
423
+ ),
424
+ state,
425
+ )
426
+
427
+ except Exception as e:
428
+ duration_ms = (time.time() - start_time) * 1000
429
+ return (
430
+ CheckResult(
431
+ sbc_name=sbc_name,
432
+ check_type=CheckType.POWER,
433
+ success=False,
434
+ message=f"Power check error: {e}",
435
+ duration_ms=duration_ms,
436
+ ),
437
+ PowerState.UNKNOWN,
438
+ )
439
+
440
+ def check_sbc(
441
+ self,
442
+ sbc: SBC,
443
+ check_types: Optional[list[CheckType]] = None,
444
+ ) -> HealthCheckSummary:
445
+ """
446
+ Run health checks on a single SBC.
447
+
448
+ Args:
449
+ sbc: SBC to check
450
+ check_types: List of check types to run (default: all applicable)
451
+
452
+ Returns:
453
+ HealthCheckSummary with all check results
454
+ """
455
+ if check_types is None:
456
+ check_types = [CheckType.PING, CheckType.SERIAL, CheckType.POWER]
457
+
458
+ summary = HealthCheckSummary(sbc_name=sbc.name)
459
+
460
+ # Ping check - requires IP address
461
+ if CheckType.PING in check_types and sbc.primary_ip:
462
+ summary.ping_result = self.ping_check(sbc.primary_ip, sbc.name)
463
+
464
+ # Serial check - requires console port with TCP
465
+ if CheckType.SERIAL in check_types and sbc.serial_ports:
466
+ console_port = next(
467
+ (p for p in sbc.serial_ports if p.port_type == PortType.CONSOLE),
468
+ None,
469
+ )
470
+ if console_port and console_port.tcp_port:
471
+ summary.serial_result = self.serial_check(
472
+ "localhost", console_port.tcp_port, sbc.name
473
+ )
474
+
475
+ # Power check - requires power plug
476
+ if CheckType.POWER in check_types and sbc.power_plug:
477
+ controller = PowerController.from_plug(sbc.power_plug)
478
+ result, state = self.power_check(controller, sbc.name)
479
+ summary.power_result = result
480
+ summary.power_state = state
481
+
482
+ # Determine recommended status
483
+ summary.recommended_status = summary.determine_status()
484
+
485
+ return summary
486
+
487
+ def check_all(
488
+ self,
489
+ sbcs: list[SBC],
490
+ check_types: Optional[list[CheckType]] = None,
491
+ max_workers: int = 16,
492
+ ) -> dict[str, HealthCheckSummary]:
493
+ """
494
+ Run health checks on multiple SBCs in parallel.
495
+
496
+ The probes are network-I/O bound (ICMP ping, TCP serial open,
497
+ plug HTTP/UDP) so a small thread pool gives near-linear speedup:
498
+ total wall time becomes the *max* probe latency rather than the
499
+ sum, which is what makes it safe to run more frequently.
500
+
501
+ Args:
502
+ sbcs: List of SBCs to check
503
+ check_types: List of check types to run (default: all applicable)
504
+ max_workers: Cap on concurrent probes. The pool is sized to
505
+ ``min(len(sbcs), max_workers)``.
506
+
507
+ Returns:
508
+ Dictionary mapping SBC names to their check summaries
509
+ """
510
+ if not sbcs:
511
+ return {}
512
+
513
+ import concurrent.futures
514
+
515
+ results: dict[str, HealthCheckSummary] = {}
516
+ workers = min(len(sbcs), max_workers)
517
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
518
+ futures = {
519
+ executor.submit(self.check_sbc, sbc, check_types): sbc.name
520
+ for sbc in sbcs
521
+ }
522
+ for future in concurrent.futures.as_completed(futures):
523
+ sbc_name = futures[future]
524
+ try:
525
+ results[sbc_name] = future.result()
526
+ except Exception: # noqa: BLE001
527
+ # One SBC's probe failing must not silently drop it
528
+ # from the result; record an empty summary so the
529
+ # daemon still sees the row and can flag UNKNOWN.
530
+ summary = HealthCheckSummary(sbc_name=sbc_name)
531
+ summary.recommended_status = summary.determine_status()
532
+ results[sbc_name] = summary
533
+ return results