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,342 @@
1
+ """Keep a caller's claim alive during one long operation (issue #2).
2
+
3
+ A claim's deadline slides only when a call that touches the board starts
4
+ (the call's claim check: :meth:`ResourceManager.check_claim`, which
5
+ heartbeats the caller's claim with :meth:`ResourceManager.touch_claim`),
6
+ so a single long call
7
+ (``boot_test``, a long ``serial_capture``, flashing a card) could outlast
8
+ the claim, and another agent could then claim the board and power-cycle
9
+ it mid-run. :class:`ClaimKeepalive` heartbeats the claim the caller
10
+ *already holds* from a background thread while the operation runs. It
11
+ never creates a claim.
12
+
13
+ Between steps of a multi-step operation (each boot-test run, the reboot
14
+ after a write), :meth:`ClaimKeepalive.checkpoint` says whether it may go
15
+ on: not when someone else has claimed the board (or a board sharing its
16
+ hardware) since the operation started, nor when the caller's claim was
17
+ lost meanwhile (released, force-released or expired).
18
+
19
+ The thread only reads and updates the claims table; each database call
20
+ opens its own SQLite connection, so it is safe beside the caller's
21
+ thread (and beside the MCP server's worker threads). A database error is
22
+ a missed heartbeat, not a lost claim, until the claim's deadline has
23
+ passed without a successful one.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import threading
30
+ import time
31
+ from typing import Iterable, Optional
32
+
33
+ from labctl.core.models import (
34
+ ClaimActor,
35
+ ClaimConflict,
36
+ ClaimRequiredError,
37
+ UnknownSBCError,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ #: Heartbeats come at a quarter of the claim's duration, but at least
43
+ #: this often and no more often than MIN_INTERVAL_SECONDS.
44
+ MAX_INTERVAL_SECONDS = 60.0
45
+ MIN_INTERVAL_SECONDS = 5.0
46
+
47
+ #: A caller holding no claim has no deadline to go by: when the claim
48
+ #: recheck has failed (database errors) for this long without a success,
49
+ #: the operation stops rather than run on unchecked.
50
+ UNCHECKED_LIMIT_SECONDS = 60.0
51
+
52
+ # StopReason.kind values.
53
+ LOST = "lost" # the caller's own claim is gone
54
+ CLAIMED = "claimed" # someone else holds a claim on the board or a sharer
55
+ REQUIRED = "required" # claims.require_claim and nobody holds it
56
+ GONE = "gone" # the board was deleted
57
+ UNCHECKED = "unchecked" # claims couldn't be checked for too long
58
+
59
+
60
+ class StopReason(str):
61
+ """Why an operation must stop (the text), with its ``kind``."""
62
+
63
+ kind: str
64
+
65
+ def __new__(cls, text: str, kind: str) -> "StopReason":
66
+ obj = super().__new__(cls, text)
67
+ obj.kind = kind
68
+ return obj
69
+
70
+
71
+ class ClaimKeepalive:
72
+ """Heartbeat ``actor``'s claim on ``sbc_name`` while an operation runs.
73
+
74
+ Use as a context manager around the operation. With ``enabled=False``
75
+ (claims are off) it does nothing. When the actor holds no claim at
76
+ the start, nothing is heartbeated; :meth:`checkpoint` still stops the
77
+ operation once someone else claims the board (``recheck``), or when
78
+ ``require_claim`` is set.
79
+
80
+ ``allowed_claim_ids`` are other users' claims the caller overrode to
81
+ start (CLI ``--override-claim``): they don't stop it, but a claim
82
+ taken by anyone after the start still does.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ manager,
88
+ sbc_name: str,
89
+ actor: ClaimActor,
90
+ *,
91
+ enabled: bool = True,
92
+ require_claim: bool = False,
93
+ recheck: bool = True,
94
+ allowed_claim_ids: Iterable[int] = (),
95
+ interval: Optional[float] = None,
96
+ ):
97
+ self.manager = manager
98
+ self.sbc_name = sbc_name
99
+ self.actor = actor
100
+ self.enabled = enabled
101
+ self.require_claim = require_claim
102
+ self.recheck = recheck
103
+ self.allowed_claim_ids = set(allowed_claim_ids)
104
+ self._interval = interval
105
+ #: The actor held the board's claim when the operation started.
106
+ self.held = False
107
+ #: That claim's id: a later claim (even by the same holder) is not it.
108
+ self.claim_id: Optional[int] = None
109
+ #: Why the claim counts as lost, once it does.
110
+ self.lost_reason: Optional[StopReason] = None
111
+ self.beats = 0
112
+ self._duration = 0.0
113
+ # Monotonic time by which the claim expires unless heartbeated.
114
+ self._deadline = 0.0
115
+ # Monotonic time of the last recheck that succeeded (or the start).
116
+ self._last_checked = time.monotonic()
117
+ self._lock = threading.Lock()
118
+ self._stop = threading.Event()
119
+ self._thread: Optional[threading.Thread] = None
120
+
121
+ # -- lifecycle ---------------------------------------------------------
122
+
123
+ def start(self) -> "ClaimKeepalive":
124
+ if not self.enabled:
125
+ return self
126
+ self._last_checked = time.monotonic()
127
+ try:
128
+ claim = self.manager.get_open_claim(self.sbc_name)
129
+ except UnknownSBCError:
130
+ return self
131
+ except Exception:
132
+ # Never fail the call over this: it just runs unprotected.
133
+ logger.warning(
134
+ "Can't read the claim on %s; carrying on without keeping it " "alive",
135
+ self.sbc_name,
136
+ exc_info=True,
137
+ )
138
+ return self
139
+ if claim is None or not claim.is_active or not self.actor.holds(claim):
140
+ return self
141
+ self.held = True
142
+ self.claim_id = claim.id
143
+ self._duration = float(claim.duration_seconds or 0)
144
+ remaining = (claim.expires_at.timestamp() - time.time()) if claim else 0
145
+ self._deadline = time.monotonic() + max(0.0, remaining)
146
+ self._thread = threading.Thread(
147
+ target=self._run,
148
+ daemon=True,
149
+ name=f"claim-keepalive-{self.sbc_name}",
150
+ )
151
+ self._thread.start()
152
+ return self
153
+
154
+ def stop(self) -> None:
155
+ self._stop.set()
156
+ if self._thread is not None:
157
+ self._thread.join()
158
+ self._thread = None
159
+
160
+ def __enter__(self) -> "ClaimKeepalive":
161
+ return self.start()
162
+
163
+ def __exit__(self, *exc) -> None:
164
+ self.stop()
165
+
166
+ def next_interval(self) -> float:
167
+ """Seconds to the next heartbeat: a quarter of the claim's current
168
+ duration (re-read, so a renew to a shorter one is followed),
169
+ within MIN/MAX_INTERVAL_SECONDS."""
170
+ if self._interval is not None:
171
+ return self._interval
172
+ try:
173
+ claim = self.manager.get_open_claim(self.sbc_name)
174
+ if claim is not None and claim.duration_seconds:
175
+ self._duration = float(claim.duration_seconds)
176
+ except Exception:
177
+ logger.debug("Claim keepalive duration read failed", exc_info=True)
178
+ return min(MAX_INTERVAL_SECONDS, max(MIN_INTERVAL_SECONDS, self._duration / 4))
179
+
180
+ def _run(self) -> None:
181
+ while not self._stop.wait(self.next_interval()):
182
+ if not self.beat():
183
+ return
184
+
185
+ # -- heartbeat and checks ----------------------------------------------
186
+
187
+ def beat(self) -> bool:
188
+ """Heartbeat the claim if the actor still holds it.
189
+
190
+ Returns False once the claim is lost (and from then on); True
191
+ when it was heartbeated, when the actor held none to begin with,
192
+ and when the database couldn't be reached this time but the
193
+ claim's deadline hasn't passed (the next beat tries again).
194
+ """
195
+ if not self.held:
196
+ return True
197
+ with self._lock:
198
+ if self.lost_reason is not None:
199
+ return False
200
+ try:
201
+ still_held = self.manager.heartbeat_held_claim(
202
+ self.sbc_name, self.actor, claim_id=self.claim_id
203
+ )
204
+ except UnknownSBCError:
205
+ self.lost_reason = StopReason(
206
+ f"SBC '{self.sbc_name}' no longer exists", GONE
207
+ )
208
+ return False
209
+ except Exception:
210
+ logger.warning(
211
+ "Claim keepalive for %s couldn't renew the claim",
212
+ self.sbc_name,
213
+ exc_info=True,
214
+ )
215
+ if time.monotonic() >= self._deadline:
216
+ self.lost_reason = StopReason(
217
+ f"the claim on '{self.sbc_name}' couldn't be renewed "
218
+ "before it expired (database error)",
219
+ LOST,
220
+ )
221
+ return False
222
+ return True
223
+ if not still_held:
224
+ self.lost_reason = StopReason(self._lost_text(), LOST)
225
+ return False
226
+ self.beats += 1
227
+ self._deadline = time.monotonic() + self._duration
228
+ return True
229
+
230
+ def _lost_text(self) -> str:
231
+ text = (
232
+ f"the claim on '{self.sbc_name}' is no longer held by this caller "
233
+ "(released, force-released or expired)"
234
+ )
235
+ try:
236
+ claim = self.manager.get_active_claim(self.sbc_name)
237
+ except Exception:
238
+ claim = None
239
+ if claim is not None:
240
+ text += f"; it is now claimed by '{claim.agent_name or 'unknown'}'"
241
+ return text
242
+
243
+ def checkpoint(self) -> Optional[StopReason]:
244
+ """Whether the operation may go on to its next step.
245
+
246
+ Returns None to go on, or the reason to stop. First, as in
247
+ ``check_claim``, nobody else may hold a claim on the board or a
248
+ board sharing its hardware (``recheck``; ``require_claim`` too),
249
+ and only then is the actor's own claim heartbeated: it must still
250
+ be held (the same claim the operation started with). Never raises:
251
+ a database error counts as a missed check until the claim's
252
+ deadline has passed, or, for a caller holding no claim, until the
253
+ checks have failed for UNCHECKED_LIMIT_SECONDS.
254
+ """
255
+ if not self.enabled:
256
+ return None
257
+ if self.recheck:
258
+ try:
259
+ reason = self._recheck()
260
+ self._last_checked = time.monotonic()
261
+ except Exception:
262
+ logger.warning(
263
+ "Claim check for %s failed; going on",
264
+ self.sbc_name,
265
+ exc_info=True,
266
+ )
267
+ reason = None
268
+ now = time.monotonic()
269
+ if self.held and now >= self._deadline:
270
+ reason = StopReason(
271
+ f"the claim on '{self.sbc_name}' couldn't be checked "
272
+ "before it expired (database error)",
273
+ LOST,
274
+ )
275
+ elif (
276
+ not self.held
277
+ and now - self._last_checked >= UNCHECKED_LIMIT_SECONDS
278
+ ):
279
+ reason = StopReason(
280
+ f"claims on '{self.sbc_name}' couldn't be checked for "
281
+ f"{UNCHECKED_LIMIT_SECONDS:.0f} s (database error)",
282
+ UNCHECKED,
283
+ )
284
+ if reason:
285
+ return reason
286
+ if self.held and not self.beat():
287
+ return self.lost_reason
288
+ return None
289
+
290
+ def _recheck(self) -> Optional[StopReason]:
291
+ """Someone else's claim on the board or a sharer (other than the
292
+ overridden ones), or require_claim unmet; None if clear."""
293
+ boards = [self.sbc_name]
294
+ try:
295
+ boards += list(self.manager.sbcs_sharing_hardware_with(self.sbc_name))
296
+ except UnknownSBCError:
297
+ return StopReason(f"SBC '{self.sbc_name}' no longer exists", GONE)
298
+ for i, board in enumerate(boards):
299
+ try:
300
+ claim = self.manager.check_claim(
301
+ board,
302
+ self.actor,
303
+ require_claim=self.require_claim and i == 0,
304
+ include_shared=False,
305
+ heartbeat=False,
306
+ )
307
+ if (
308
+ i == 0
309
+ and self.held
310
+ and (claim is None or claim.id != self.claim_id)
311
+ ):
312
+ # Released or expired, or replaced by a later claim (even
313
+ # one by the same holder): not the claim we started with.
314
+ return self._mark_lost()
315
+ except UnknownSBCError:
316
+ if i == 0:
317
+ return StopReason(f"SBC '{self.sbc_name}' no longer exists", GONE)
318
+ continue
319
+ except ClaimConflict as e:
320
+ if e.claim.id in self.allowed_claim_ids:
321
+ continue
322
+ if i == 0 and self.held:
323
+ return self._mark_lost()
324
+ holder = e.claim.sbc_name or board
325
+ return StopReason(
326
+ f"'{holder}' is now claimed by "
327
+ f"'{e.claim.agent_name or 'unknown'}'",
328
+ CLAIMED,
329
+ )
330
+ except ClaimRequiredError as e:
331
+ if self.held:
332
+ return self._mark_lost()
333
+ return StopReason(str(e), REQUIRED)
334
+ return None
335
+
336
+ def _mark_lost(self) -> StopReason:
337
+ """The actor's own claim on the board is gone (found by the
338
+ recheck): stop, and stop heartbeating."""
339
+ with self._lock:
340
+ if self.lost_reason is None:
341
+ self.lost_reason = StopReason(self._lost_text(), LOST)
342
+ return self.lost_reason