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
|
@@ -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
|