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,397 @@
1
+ """
2
+ Monitoring daemon for lab controller.
3
+
4
+ Runs periodic health checks and triggers alerts on status changes.
5
+ """
6
+
7
+ import logging
8
+ import signal
9
+ import time
10
+ from typing import Optional
11
+
12
+ from labctl.core.manager import ResourceManager
13
+ from labctl.core.models import Status
14
+ from labctl.health.alerts import Alert, AlertLevel, AlertManager
15
+ from labctl.health.checks import HealthChecker, HealthCheckSummary
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class MonitorDaemon:
21
+ """
22
+ Monitoring daemon that runs periodic health checks.
23
+
24
+ Tracks status changes and triggers alerts when SBC states change.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ manager: ResourceManager,
30
+ checker: HealthChecker,
31
+ alert_manager: AlertManager,
32
+ interval: int = 10,
33
+ power_check_interval: int = 60,
34
+ min_sleep_seconds: float = 1.0,
35
+ update_status: bool = True,
36
+ alert_on_offline: bool = True,
37
+ alert_on_power_change: bool = True,
38
+ status_retention_days: Optional[int] = None,
39
+ ):
40
+ """
41
+ Initialize monitoring daemon.
42
+
43
+ The daemon runs two cadences:
44
+
45
+ - Fast track (`interval`, default 10 s): ping + serial probe.
46
+ These are cheap localhost / ICMP operations, safe to run
47
+ frequently.
48
+ - Slow track (`power_check_interval`, default 60 s): power
49
+ probe. Network-bound and sometimes slow (Kasa KLAP retries
50
+ can take seconds), so it runs less often. Power is included
51
+ on every Nth fast tick where N = ceil(power_check_interval
52
+ / interval).
53
+
54
+ Args:
55
+ manager: Resource manager for SBC access
56
+ checker: Health checker instance
57
+ alert_manager: Alert manager for notifications
58
+ interval: Fast-track cycle interval (ping + serial), seconds
59
+ power_check_interval: Slow-track cadence (power), seconds
60
+ min_sleep_seconds: Floor on between-cycle sleep so a runaway
61
+ cycle (elapsed >= interval) can't pin a CPU spinning.
62
+ update_status: Whether to update SBC status in database
63
+ alert_on_offline: Alert when SBC goes offline
64
+ alert_on_power_change: Alert on power state changes
65
+ status_retention_days: Delete status history older than this
66
+ many days, at startup and then hourly (None or 0: keep it
67
+ all)
68
+ """
69
+ self.manager = manager
70
+ self.checker = checker
71
+ self.alert_manager = alert_manager
72
+ self.interval = interval
73
+ self.power_check_interval = power_check_interval
74
+ self.min_sleep_seconds = min_sleep_seconds
75
+ self.update_status = update_status
76
+ self.alert_on_offline = alert_on_offline
77
+ self.alert_on_power_change = alert_on_power_change
78
+ self.status_retention_days = status_retention_days
79
+
80
+ self._running = False
81
+ self._last_status: dict[str, Status] = {}
82
+ self._last_power: dict[str, str] = {}
83
+ self._last_power_check: float = 0.0 # monotonic time of last power probe
84
+ self._last_prune: Optional[float] = None # monotonic time of last prune
85
+
86
+ PRUNE_INTERVAL = 3600.0 # seconds between status-history prunes
87
+
88
+ def _prune_status_log(self, now_monotonic: float) -> None:
89
+ """Delete status history past ``status_retention_days``, at most
90
+ once per PRUNE_INTERVAL. A failure is logged and retried next
91
+ interval; it never stops the health checks."""
92
+ if not self.status_retention_days: # None or 0: keep everything
93
+ return
94
+ if (
95
+ self._last_prune is not None
96
+ and now_monotonic - self._last_prune < self.PRUNE_INTERVAL
97
+ ):
98
+ return
99
+ self._last_prune = now_monotonic
100
+ try:
101
+ deleted = self.manager.cleanup_old_status_logs(self.status_retention_days)
102
+ except Exception as e: # noqa: BLE001
103
+ logger.warning("Pruning status history failed: %s", e)
104
+ return
105
+ if deleted:
106
+ logger.info(
107
+ "Pruned %d status history entries older than %d days",
108
+ deleted,
109
+ self.status_retention_days,
110
+ )
111
+
112
+ def _should_check_power(self, now_monotonic: float) -> bool:
113
+ """Return True if this tick should include the power probe."""
114
+ return (now_monotonic - self._last_power_check) >= self.power_check_interval
115
+
116
+ def run_once(
117
+ self, include_power: Optional[bool] = None
118
+ ) -> dict[str, HealthCheckSummary]:
119
+ """
120
+ Run a single health check pass.
121
+
122
+ Args:
123
+ include_power: When True, probe power; when False, skip it
124
+ (cheap fast-track cycle). When None, decide based on
125
+ elapsed time since the last power probe.
126
+
127
+ Returns:
128
+ Dictionary of SBC names to health check summaries.
129
+ """
130
+ from labctl.health.checks import CheckType
131
+
132
+ if include_power is None:
133
+ include_power = self._should_check_power(time.monotonic())
134
+
135
+ check_types = [CheckType.PING, CheckType.SERIAL]
136
+ if include_power:
137
+ check_types.append(CheckType.POWER)
138
+
139
+ sbcs = self.manager.list_sbcs()
140
+ results = self.checker.check_all(sbcs, check_types=check_types)
141
+
142
+ if include_power:
143
+ self._last_power_check = time.monotonic()
144
+
145
+ for sbc_name, summary in results.items():
146
+ self._process_result(sbc_name, summary)
147
+
148
+ return results
149
+
150
+ def _process_result(self, sbc_name: str, summary: HealthCheckSummary) -> None:
151
+ """Process a health check result and trigger alerts if needed."""
152
+ new_status = summary.recommended_status
153
+ old_status = self._last_status.get(sbc_name)
154
+
155
+ logger.info(
156
+ "Health check %s: ping=%s serial=%s power=%s -> %s",
157
+ sbc_name,
158
+ summary.ping_result.success if summary.ping_result else "n/a",
159
+ summary.serial_result.success if summary.serial_result else "n/a",
160
+ summary.power_state.value if summary.power_state else "n/a",
161
+ new_status.value if new_status else "n/a",
162
+ )
163
+
164
+ # Update status in database if enabled
165
+ sbc = None
166
+ if self.update_status and new_status:
167
+ sbc = self.manager.get_sbc_by_name(sbc_name)
168
+ if sbc and sbc.status != new_status:
169
+ # Build details string
170
+ details_parts = []
171
+ if summary.ping_result:
172
+ details_parts.append(summary.ping_result.message)
173
+ if summary.serial_result:
174
+ details_parts.append(summary.serial_result.message)
175
+ if summary.power_result:
176
+ details_parts.append(summary.power_result.message)
177
+ details = "; ".join(details_parts) if details_parts else None
178
+
179
+ self.manager.update_sbc(sbc.id, status=new_status)
180
+ self.manager.log_status(sbc.id, new_status, details)
181
+
182
+ # Cache the power observation so `labctl status --fast` can read
183
+ # it without making live network calls. Stamp every cycle (even
184
+ # when the value is unchanged) so freshness is meaningful.
185
+ if self.update_status and summary.power_state is not None:
186
+ if sbc is None:
187
+ sbc = self.manager.get_sbc_by_name(sbc_name)
188
+ if sbc is not None:
189
+ try:
190
+ self.manager.update_power_observation(
191
+ sbc.id, summary.power_state.value
192
+ )
193
+ except Exception as e: # noqa: BLE001
194
+ logger.debug(
195
+ "Failed to cache power observation for %s: %s", sbc_name, e
196
+ )
197
+
198
+ # Check for status change alerts
199
+ if self.alert_on_offline and new_status:
200
+ if old_status and old_status != new_status:
201
+ if new_status == Status.OFFLINE:
202
+ self.alert_manager.trigger_critical(
203
+ sbc_name,
204
+ "SBC went OFFLINE",
205
+ f"Previous status: {old_status.value}",
206
+ )
207
+ elif new_status == Status.ONLINE and old_status == Status.OFFLINE:
208
+ self.alert_manager.trigger_info(
209
+ sbc_name,
210
+ "SBC came ONLINE",
211
+ f"Previous status: {old_status.value}",
212
+ )
213
+ elif new_status == Status.ERROR:
214
+ self.alert_manager.trigger_warning(
215
+ sbc_name,
216
+ "SBC in ERROR state",
217
+ (
218
+ summary.serial_result.message
219
+ if summary.serial_result
220
+ else None
221
+ ),
222
+ )
223
+
224
+ # Check for power state changes
225
+ if self.alert_on_power_change and summary.power_state:
226
+ power_str = summary.power_state.value
227
+ old_power = self._last_power.get(sbc_name)
228
+
229
+ if old_power and old_power != power_str:
230
+ level = AlertLevel.WARNING if power_str == "off" else AlertLevel.INFO
231
+ self.alert_manager.trigger(
232
+ Alert(
233
+ level=level,
234
+ sbc_name=sbc_name,
235
+ message=f"Power changed to {power_str.upper()}",
236
+ details=f"Previous: {old_power.upper()}",
237
+ )
238
+ )
239
+ self._last_power[sbc_name] = power_str
240
+
241
+ # Update last known status
242
+ if new_status:
243
+ self._last_status[sbc_name] = new_status
244
+
245
+ def start(self) -> None:
246
+ """
247
+ Start the monitoring daemon.
248
+
249
+ Runs until stop() is called or SIGTERM/SIGINT is received.
250
+ """
251
+ self._running = True
252
+
253
+ # Set up signal handlers for graceful shutdown
254
+ def signal_handler(signum, frame):
255
+ logger.info(f"Received signal {signum}, stopping daemon...")
256
+ self.stop()
257
+
258
+ signal.signal(signal.SIGTERM, signal_handler)
259
+ signal.signal(signal.SIGINT, signal_handler)
260
+
261
+ logger.info(
262
+ "Starting monitor daemon: fast=%ds, power=%ds, min_sleep=%.1fs",
263
+ self.interval,
264
+ self.power_check_interval,
265
+ self.min_sleep_seconds,
266
+ )
267
+
268
+ # Reconcile actuator channels against bindings' desired_state on
269
+ # startup. Held bindings (recovery_mode mid-flash, etc.) survive
270
+ # a daemon restart untouched; unheld channels return to their
271
+ # default_state. Failures don't stop the daemon — health checks
272
+ # still run, the operator gets a warning per affected channel.
273
+ try:
274
+ from labctl.actuators.runtime import apply_safe_drive_on_startup
275
+
276
+ summary = apply_safe_drive_on_startup(self.manager)
277
+ if summary["held"]:
278
+ logger.warning(
279
+ "safe-drive: %d binding(s) left held across restart "
280
+ "(recovery in progress?): %s",
281
+ len(summary["held"]),
282
+ summary["held"],
283
+ )
284
+ if summary["failed"]:
285
+ logger.warning(
286
+ "safe-drive: %d channel(s) failed to reset: %s",
287
+ len(summary["failed"]),
288
+ summary["failed"],
289
+ )
290
+ except Exception as e: # noqa: BLE001
291
+ logger.warning(
292
+ "safe-drive on startup raised %s: %s; continuing without it",
293
+ type(e).__name__,
294
+ e,
295
+ )
296
+
297
+ # Initialize last known states
298
+ sbcs = self.manager.list_sbcs()
299
+ for sbc in sbcs:
300
+ self._last_status[sbc.name] = sbc.status
301
+
302
+ while self._running:
303
+ try:
304
+ start_time = time.monotonic()
305
+ self._prune_status_log(start_time)
306
+ results = self.run_once()
307
+ elapsed = time.monotonic() - start_time
308
+
309
+ logger.debug(
310
+ f"Health check completed for {len(results)} SBCs "
311
+ f"in {elapsed:.2f}s"
312
+ )
313
+
314
+ # Sleep until the next tick. Apply min_sleep_seconds floor
315
+ # so a cycle that overruns the interval can't pin a CPU
316
+ # in a tight loop.
317
+ sleep_time = max(self.min_sleep_seconds, self.interval - elapsed)
318
+ if self._running:
319
+ time.sleep(sleep_time)
320
+
321
+ except Exception as e:
322
+ logger.error(f"Error during health check: {e}")
323
+ if self._running:
324
+ time.sleep(max(self.min_sleep_seconds, self.interval))
325
+
326
+ logger.info("Monitor daemon stopped")
327
+
328
+ def stop(self) -> None:
329
+ """Stop the monitoring daemon."""
330
+ self._running = False
331
+
332
+ @property
333
+ def is_running(self) -> bool:
334
+ """Check if daemon is running."""
335
+ return self._running
336
+
337
+
338
+ def format_check_table(
339
+ results: dict[str, HealthCheckSummary],
340
+ show_details: bool = False,
341
+ ) -> str:
342
+ """
343
+ Format health check results as a table.
344
+
345
+ Args:
346
+ results: Dictionary of SBC names to check summaries
347
+ show_details: Whether to show detailed messages
348
+
349
+ Returns:
350
+ Formatted table string
351
+ """
352
+ if not results:
353
+ return "No SBCs to check."
354
+
355
+ # Header
356
+ lines = []
357
+ header = f"{'SBC':<20} {'PING':<8} {'SERIAL':<8} {'POWER':<8} {'STATUS':<10}"
358
+ lines.append(header)
359
+ lines.append("-" * len(header))
360
+
361
+ # Rows
362
+ for sbc_name, summary in sorted(results.items()):
363
+ ping = _format_check(summary.ping_result)
364
+ serial = _format_check(summary.serial_result)
365
+ power = _format_power(summary.power_result, summary.power_state)
366
+ status = summary.recommended_status.value if summary.recommended_status else "-"
367
+
368
+ lines.append(f"{sbc_name:<20} {ping:<8} {serial:<8} {power:<8} {status:<10}")
369
+
370
+ if show_details:
371
+ if summary.ping_result:
372
+ lines.append(f" Ping: {summary.ping_result.message}")
373
+ if summary.serial_result:
374
+ lines.append(f" Serial: {summary.serial_result.message}")
375
+ if summary.power_result:
376
+ lines.append(f" Power: {summary.power_result.message}")
377
+
378
+ return "\n".join(lines)
379
+
380
+
381
+ def _format_check(result: Optional[object]) -> str:
382
+ """Format a check result for table display."""
383
+ if result is None:
384
+ return "-"
385
+ return "\u2713" if result.success else "\u2717"
386
+
387
+
388
+ def _format_power(
389
+ result: Optional[object],
390
+ state: Optional[object],
391
+ ) -> str:
392
+ """Format power check result for table display."""
393
+ if result is None:
394
+ return "-"
395
+ if state:
396
+ return state.value.upper()
397
+ return "\u2717" if not result.success else "?"