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
labctl/services.py ADDED
@@ -0,0 +1,210 @@
1
+ """
2
+ Status checks for systemd services labctl depends on.
3
+
4
+ Provides a single call that reports on all of labctl's own services
5
+ plus external dependencies (ser2net). Used by `labctl services status`
6
+ to give operators a quick at-a-glance health view without having to
7
+ run `systemctl` against each unit individually.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timedelta
16
+ from typing import Optional
17
+
18
+ # Units labctl depends on. First three are labctl's own; ser2net is
19
+ # the hardware-facing TCP-to-serial proxy used for every console.
20
+ DEFAULT_UNITS: tuple[str, ...] = (
21
+ "labctl-monitor",
22
+ "labctl-mcp",
23
+ "labctl-web",
24
+ "ser2net",
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class ServiceStatus:
30
+ """One service's health snapshot."""
31
+
32
+ unit: str
33
+ active_state: str = "unknown" # active | inactive | failed | activating | ...
34
+ sub_state: str = "" # running | dead | exited | ...
35
+ active_since: Optional[datetime] = None
36
+ n_restarts: int = 0
37
+ exec_main_status: Optional[int] = None
38
+ result: str = "" # success | exit-code | signal | oom-kill | ...
39
+ recent_errors: list[str] = field(default_factory=list)
40
+ error: Optional[str] = None # set if systemctl itself failed
41
+
42
+ @property
43
+ def healthy(self) -> bool:
44
+ return self.active_state == "active" and self.sub_state in {"running", "exited"}
45
+
46
+ def uptime_str(self) -> str:
47
+ if not self.active_since:
48
+ return "-"
49
+ delta = datetime.now().astimezone() - self.active_since
50
+ return _format_duration(delta)
51
+
52
+
53
+ def _format_duration(delta: timedelta) -> str:
54
+ total = int(delta.total_seconds())
55
+ if total < 0:
56
+ return "-"
57
+ days, rem = divmod(total, 86400)
58
+ hours, rem = divmod(rem, 3600)
59
+ mins, secs = divmod(rem, 60)
60
+ if days:
61
+ return f"{days}d{hours}h"
62
+ if hours:
63
+ return f"{hours}h{mins}m"
64
+ if mins:
65
+ return f"{mins}m{secs}s"
66
+ return f"{secs}s"
67
+
68
+
69
+ def _run(cmd: list[str], timeout: float = 5.0) -> tuple[int, str]:
70
+ """Run a subprocess and capture combined output."""
71
+ try:
72
+ result = subprocess.run(
73
+ cmd,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=timeout,
77
+ check=False,
78
+ )
79
+ return result.returncode, (result.stdout or "") + (result.stderr or "")
80
+ except FileNotFoundError as e:
81
+ return 127, str(e)
82
+ except subprocess.TimeoutExpired:
83
+ return 124, f"timeout after {timeout}s"
84
+
85
+
86
+ def _parse_systemctl_show(output: str) -> dict[str, str]:
87
+ """Parse `systemctl show -p Key1,Key2 unit` KEY=VALUE lines."""
88
+ props: dict[str, str] = {}
89
+ for line in output.splitlines():
90
+ if "=" in line:
91
+ k, v = line.split("=", 1)
92
+ props[k.strip()] = v.strip()
93
+ return props
94
+
95
+
96
+ def _parse_systemd_timestamp(value: str) -> Optional[datetime]:
97
+ """Parse a systemd timestamp like 'Sun 2026-04-19 16:08:26 PDT'.
98
+
99
+ Returns None for empty / sentinel values ("n/a", "0", "").
100
+ """
101
+ if not value or value in {"n/a", "0"}:
102
+ return None
103
+ # Format example: "Sun 2026-04-19 16:08:26 PDT"
104
+ for fmt in (
105
+ "%a %Y-%m-%d %H:%M:%S %Z",
106
+ "%Y-%m-%d %H:%M:%S %Z",
107
+ "%a %Y-%m-%d %H:%M:%S",
108
+ "%Y-%m-%d %H:%M:%S",
109
+ ):
110
+ try:
111
+ dt = datetime.strptime(value, fmt)
112
+ return dt.astimezone()
113
+ except ValueError:
114
+ continue
115
+ return None
116
+
117
+
118
+ def _collect_recent_errors(unit: str, hours: int = 24, limit: int = 3) -> list[str]:
119
+ """Pull the last few distinct error/warning messages from the unit's journal.
120
+
121
+ Uses journalctl with priority <= err. Returns at most `limit` lines.
122
+ Silently returns [] if journalctl isn't available.
123
+ """
124
+ if shutil.which("journalctl") is None:
125
+ return []
126
+ code, out = _run(
127
+ [
128
+ "journalctl",
129
+ "-u",
130
+ unit,
131
+ f"--since={hours} hours ago",
132
+ "--no-pager",
133
+ "-q",
134
+ "-p",
135
+ "err",
136
+ "-o",
137
+ "short-iso",
138
+ ],
139
+ timeout=5.0,
140
+ )
141
+ if code != 0:
142
+ return []
143
+ lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
144
+ # De-duplicate while preserving order, keep the last `limit` distinct.
145
+ seen: set[str] = set()
146
+ uniq: list[str] = []
147
+ for ln in reversed(lines):
148
+ # Strip leading timestamp + hostname + unit[pid]: to dedupe by message.
149
+ msg = ln.split(": ", 1)[-1] if ": " in ln else ln
150
+ if msg in seen:
151
+ continue
152
+ seen.add(msg)
153
+ uniq.append(ln)
154
+ if len(uniq) >= limit:
155
+ break
156
+ return list(reversed(uniq))
157
+
158
+
159
+ def check_service(unit: str) -> ServiceStatus:
160
+ """Check one systemd unit and return a structured status."""
161
+ status = ServiceStatus(unit=unit)
162
+
163
+ if shutil.which("systemctl") is None:
164
+ status.error = "systemctl not found"
165
+ return status
166
+
167
+ code, out = _run(
168
+ [
169
+ "systemctl",
170
+ "show",
171
+ unit,
172
+ "-p",
173
+ "ActiveState,SubState,ActiveEnterTimestamp,NRestarts,"
174
+ "ExecMainStatus,Result,LoadState",
175
+ ]
176
+ )
177
+ if code != 0:
178
+ status.error = out.strip() or f"systemctl show exited {code}"
179
+ return status
180
+
181
+ props = _parse_systemctl_show(out)
182
+ if props.get("LoadState") in {"not-found", "masked"}:
183
+ status.error = f"unit not loaded (LoadState={props.get('LoadState')})"
184
+ status.active_state = "not-found"
185
+ return status
186
+
187
+ status.active_state = props.get("ActiveState", "unknown")
188
+ status.sub_state = props.get("SubState", "")
189
+ status.active_since = _parse_systemd_timestamp(
190
+ props.get("ActiveEnterTimestamp", "")
191
+ )
192
+ try:
193
+ status.n_restarts = int(props.get("NRestarts", "0"))
194
+ except ValueError:
195
+ status.n_restarts = 0
196
+ try:
197
+ status.exec_main_status = int(props.get("ExecMainStatus", "0"))
198
+ except ValueError:
199
+ status.exec_main_status = None
200
+ status.result = props.get("Result", "")
201
+
202
+ if not status.healthy or status.n_restarts > 0:
203
+ status.recent_errors = _collect_recent_errors(unit)
204
+
205
+ return status
206
+
207
+
208
+ def check_all(units: tuple[str, ...] = DEFAULT_UNITS) -> list[ServiceStatus]:
209
+ """Check all services. Returned in the order given."""
210
+ return [check_service(u) for u in units]
labctl/web/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Web interface for lab controller.
3
+
4
+ Provides REST API and web dashboard for managing lab resources.
5
+ """
6
+
7
+ from labctl.web.app import create_app
8
+
9
+ __all__ = ["create_app"]
@@ -0,0 +1,204 @@
1
+ """
2
+ Activity-stream broadcaster for the web server.
3
+
4
+ A single background thread polls `audit_log` for new rows and fans
5
+ them out to per-subscriber queues. SSE endpoints iterate their queue
6
+ to stream events to clients.
7
+
8
+ Polling the DB (rather than hooking `audit.emit()` directly) means
9
+ events from CLI, MCP, and the monitor daemon — which run in separate
10
+ processes — all reach the browser.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import queue
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass
21
+ from typing import Iterator, Optional
22
+
23
+ from labctl.core.database import Database
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ POLL_INTERVAL_SECONDS = 0.5
28
+ BATCH_LIMIT = 500
29
+ SUBSCRIBER_QUEUE_MAXSIZE = 1000
30
+ INITIAL_BACKFILL = 200 # Hydration events a new subscriber receives immediately.
31
+
32
+
33
+ @dataclass
34
+ class ActivityEvent:
35
+ """One event pushed to subscribers."""
36
+
37
+ id: int
38
+ logged_at: str
39
+ actor: str
40
+ source: str
41
+ action: str
42
+ entity_type: str
43
+ entity_id: Optional[int]
44
+ entity_name: Optional[str]
45
+ result: str
46
+ details: Optional[str] # JSON string as stored
47
+ claim_id: Optional[int]
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "id": self.id,
52
+ "logged_at": self.logged_at,
53
+ "actor": self.actor,
54
+ "source": self.source,
55
+ "action": self.action,
56
+ "entity_type": self.entity_type,
57
+ "entity_id": self.entity_id,
58
+ "entity_name": self.entity_name,
59
+ "result": self.result,
60
+ "details": self.details,
61
+ "claim_id": self.claim_id,
62
+ }
63
+
64
+ def to_sse(self) -> str:
65
+ """Render as a single SSE frame."""
66
+ payload = json.dumps(self.to_dict(), separators=(",", ":"))
67
+ return f"id: {self.id}\nevent: activity\ndata: {payload}\n\n"
68
+
69
+
70
+ def _row_to_event(row) -> ActivityEvent:
71
+ return ActivityEvent(
72
+ id=row["id"],
73
+ logged_at=row["logged_at"] or "",
74
+ actor=row["actor"] or "internal",
75
+ source=row["source"] or "internal",
76
+ action=row["action"] or "",
77
+ entity_type=row["entity_type"] or "",
78
+ entity_id=row["entity_id"],
79
+ entity_name=row["entity_name"],
80
+ result=row["result"] or "ok",
81
+ details=row["details"],
82
+ claim_id=row["claim_id"],
83
+ )
84
+
85
+
86
+ class ActivityBroadcaster:
87
+ """Singleton broadcaster: polls the audit_log and feeds subscriber queues."""
88
+
89
+ def __init__(self, db: Database, poll_interval: float = POLL_INTERVAL_SECONDS):
90
+ self.db = db
91
+ self.poll_interval = poll_interval
92
+ self._last_id: int = 0
93
+ self._subscribers: set[queue.Queue] = set()
94
+ self._subscribers_lock = threading.Lock()
95
+ self._stop_event = threading.Event()
96
+ self._thread: Optional[threading.Thread] = None
97
+
98
+ def start(self) -> None:
99
+ """Start the background poll thread. Safe to call multiple times."""
100
+ if self._thread and self._thread.is_alive():
101
+ return
102
+ # Seed the cursor with the current max id so we don't replay history.
103
+ row = self.db.execute_one("SELECT COALESCE(MAX(id), 0) AS m FROM audit_log")
104
+ self._last_id = row["m"] if row else 0
105
+ self._stop_event.clear()
106
+ self._thread = threading.Thread(
107
+ target=self._run, name="activity-broadcaster", daemon=True
108
+ )
109
+ self._thread.start()
110
+ logger.info(
111
+ "ActivityBroadcaster started (last_id=%d, interval=%.2fs)",
112
+ self._last_id,
113
+ self.poll_interval,
114
+ )
115
+
116
+ def stop(self) -> None:
117
+ """Signal the poll thread to stop. Does not join."""
118
+ self._stop_event.set()
119
+
120
+ def subscribe(self) -> queue.Queue:
121
+ """Register a new subscriber queue. Call unsubscribe() when done."""
122
+ q: queue.Queue = queue.Queue(maxsize=SUBSCRIBER_QUEUE_MAXSIZE)
123
+ with self._subscribers_lock:
124
+ self._subscribers.add(q)
125
+ return q
126
+
127
+ def unsubscribe(self, q: queue.Queue) -> None:
128
+ with self._subscribers_lock:
129
+ self._subscribers.discard(q)
130
+
131
+ def recent(self, limit: int = INITIAL_BACKFILL) -> list[ActivityEvent]:
132
+ """Return the most recent N events (oldest-first for replay)."""
133
+ rows = self.db.execute(
134
+ "SELECT * FROM audit_log ORDER BY id DESC LIMIT ?",
135
+ (int(limit),),
136
+ )
137
+ return [_row_to_event(r) for r in reversed(rows)]
138
+
139
+ def _run(self) -> None:
140
+ while not self._stop_event.is_set():
141
+ try:
142
+ self._poll_once()
143
+ except Exception as e: # noqa: BLE001
144
+ logger.warning("ActivityBroadcaster poll failed: %s", e)
145
+ self._stop_event.wait(self.poll_interval)
146
+
147
+ def _poll_once(self) -> None:
148
+ rows = self.db.execute(
149
+ "SELECT * FROM audit_log WHERE id > ? ORDER BY id ASC LIMIT ?",
150
+ (self._last_id, BATCH_LIMIT),
151
+ )
152
+ if not rows:
153
+ return
154
+ events = [_row_to_event(r) for r in rows]
155
+ self._last_id = events[-1].id
156
+ self._fanout(events)
157
+
158
+ def _fanout(self, events: list[ActivityEvent]) -> None:
159
+ """Push events to every subscriber queue. Drop slow consumers."""
160
+ with self._subscribers_lock:
161
+ subscribers = list(self._subscribers)
162
+ for q in subscribers:
163
+ for event in events:
164
+ try:
165
+ q.put_nowait(event)
166
+ except queue.Full:
167
+ # Slow consumer: drop all events to catch up rather than
168
+ # block others. The client will still have its initial
169
+ # backfill + whatever lands next.
170
+ try:
171
+ while True:
172
+ q.get_nowait()
173
+ except queue.Empty:
174
+ pass
175
+ logger.info("ActivityBroadcaster: dropped slow subscriber queue")
176
+ break # don't keep adding to a drained queue this tick
177
+
178
+
179
+ def iter_sse_frames(
180
+ broadcaster: ActivityBroadcaster,
181
+ backfill: int = INITIAL_BACKFILL,
182
+ heartbeat_seconds: float = 15.0,
183
+ ) -> Iterator[str]:
184
+ """Yield SSE frames: first a hydration batch, then live events.
185
+
186
+ Sends a comment-only heartbeat line every `heartbeat_seconds` so
187
+ intermediaries (nginx, browsers) don't drop an idle connection.
188
+ """
189
+ q = broadcaster.subscribe()
190
+ try:
191
+ yield "retry: 2000\n\n"
192
+ for event in broadcaster.recent(backfill):
193
+ yield event.to_sse()
194
+ last_beat = time.monotonic()
195
+ while True:
196
+ timeout = max(0.0, heartbeat_seconds - (time.monotonic() - last_beat))
197
+ try:
198
+ event = q.get(timeout=timeout or 0.1)
199
+ yield event.to_sse()
200
+ except queue.Empty:
201
+ yield ": keepalive\n\n"
202
+ last_beat = time.monotonic()
203
+ finally:
204
+ broadcaster.unsubscribe(q)