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/health/daemon.py
ADDED
|
@@ -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 "?"
|