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,1178 @@
|
|
|
1
|
+
"""Runtime layer between the manager's persistent state and drivers.
|
|
2
|
+
|
|
3
|
+
Implements the user-facing verbs (`actuate`, `release`, `press`, etc.)
|
|
4
|
+
in terms of :class:`labctl.actuators.base.RelayDriver` calls, with:
|
|
5
|
+
|
|
6
|
+
* Per-channel non-blocking locks so concurrent verbs on the same
|
|
7
|
+
channel surface a clean "channel busy" error rather than racing.
|
|
8
|
+
* Verb/shape congruence enforcement (`actuate`/`release` need a
|
|
9
|
+
latch binding; `press` needs a momentary binding) — see
|
|
10
|
+
docs/SPEC_actuators.md §"Verb / shape congruence".
|
|
11
|
+
* Atomic state updates: a successful write stamps `last_state`,
|
|
12
|
+
bumps `cycle_count`, and updates `desired_state` in that order.
|
|
13
|
+
Failed writes leave persistent state untouched, so a daemon
|
|
14
|
+
restart's safe-drive logic (Phase 5) sees the truth.
|
|
15
|
+
|
|
16
|
+
Used by the CLI (`labctl actuate / release / press / bindings status`)
|
|
17
|
+
and, in Phase 7, the MCP server.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from labctl.actuators import get_driver
|
|
30
|
+
from labctl.actuators.base import (
|
|
31
|
+
DriverError,
|
|
32
|
+
ProbeOutcome,
|
|
33
|
+
ProbeResult,
|
|
34
|
+
RelayDriver,
|
|
35
|
+
Transport,
|
|
36
|
+
WriteOutcome,
|
|
37
|
+
)
|
|
38
|
+
from labctl.core.models import (
|
|
39
|
+
POST_POWER_INACTIVE,
|
|
40
|
+
Actuator,
|
|
41
|
+
ActuatorChannel,
|
|
42
|
+
Binding,
|
|
43
|
+
ChannelState,
|
|
44
|
+
DesiredState,
|
|
45
|
+
SamplePhase,
|
|
46
|
+
ShapeMode,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Errors
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ActuationError(Exception):
|
|
58
|
+
"""Driver-level failure during a verb (e.g. WRITE_FAILED, DEVICE_GONE)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PrePowerCheckError(ActuationError):
|
|
62
|
+
"""A pre_power actuator can't be used; power was not switched."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BindingShapeError(Exception):
|
|
66
|
+
"""Verb does not match the binding's declared shape."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ChannelBusyError(Exception):
|
|
70
|
+
"""Per-channel lock could not be acquired (another verb is in flight)."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Per-channel locks (process-local)
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_channel_locks: dict[int, threading.Lock] = {}
|
|
79
|
+
_channel_locks_meta = threading.Lock()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_channel_lock(channel_id: int) -> threading.Lock:
|
|
83
|
+
with _channel_locks_meta:
|
|
84
|
+
lock = _channel_locks.get(channel_id)
|
|
85
|
+
if lock is None:
|
|
86
|
+
lock = threading.Lock()
|
|
87
|
+
_channel_locks[channel_id] = lock
|
|
88
|
+
return lock
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _clear_runtime_state() -> None:
|
|
92
|
+
"""Test-only: drop all per-channel locks."""
|
|
93
|
+
with _channel_locks_meta:
|
|
94
|
+
_channel_locks.clear()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def drop_channel_lock(channel_id: int) -> None:
|
|
98
|
+
"""Drop the per-channel lock entry, if any.
|
|
99
|
+
|
|
100
|
+
Called when a channel (or its parent actuator) is deleted so the
|
|
101
|
+
process-local lock dict doesn't accumulate stale entries across
|
|
102
|
+
reprovisioning. Safe to call for IDs that never had a lock.
|
|
103
|
+
"""
|
|
104
|
+
with _channel_locks_meta:
|
|
105
|
+
_channel_locks.pop(channel_id, None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def _channel_lock(channel_id: int):
|
|
110
|
+
"""Non-blocking acquire; raise :class:`ChannelBusyError` if held."""
|
|
111
|
+
lock = _get_channel_lock(channel_id)
|
|
112
|
+
if not lock.acquire(blocking=False):
|
|
113
|
+
raise ChannelBusyError(
|
|
114
|
+
f"channel {channel_id} is busy (another operation in flight)"
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
yield
|
|
118
|
+
finally:
|
|
119
|
+
lock.release()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_no_channel_busy_for_sbc(manager, sbc_id: int) -> None:
|
|
123
|
+
"""Snapshot check: raise :class:`ChannelBusyError` if any binding's
|
|
124
|
+
channel is currently held by an in-flight verb.
|
|
125
|
+
|
|
126
|
+
Called from ``claim_sbc`` so a claim can't sneak in while another
|
|
127
|
+
process is mid-actuate. Locks are released immediately after the
|
|
128
|
+
check — claim ownership across processes is enforced by the DB-
|
|
129
|
+
backed claim, not by these in-memory locks.
|
|
130
|
+
"""
|
|
131
|
+
bindings = manager.list_bindings(sbc_id=sbc_id)
|
|
132
|
+
acquired: list[threading.Lock] = []
|
|
133
|
+
busy: Optional[Binding] = None
|
|
134
|
+
try:
|
|
135
|
+
for b in bindings:
|
|
136
|
+
lock = _get_channel_lock(b.actuator_channel_id)
|
|
137
|
+
if lock.acquire(blocking=False):
|
|
138
|
+
acquired.append(lock)
|
|
139
|
+
else:
|
|
140
|
+
busy = b
|
|
141
|
+
break
|
|
142
|
+
finally:
|
|
143
|
+
for lock in acquired:
|
|
144
|
+
lock.release()
|
|
145
|
+
if busy is not None:
|
|
146
|
+
label = (
|
|
147
|
+
f"{busy.actuator_name}[{busy.channel_index}]"
|
|
148
|
+
if busy.actuator_name
|
|
149
|
+
else f"channel id {busy.actuator_channel_id}"
|
|
150
|
+
)
|
|
151
|
+
raise ChannelBusyError(
|
|
152
|
+
f"actuator channel {label} is busy " f"(binding {busy.purpose!r} in flight)"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Driver helpers
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build_transport(actuator: Actuator) -> Transport:
|
|
162
|
+
return Transport(
|
|
163
|
+
device_path=actuator.device_path,
|
|
164
|
+
vid=actuator.vid,
|
|
165
|
+
pid=actuator.pid,
|
|
166
|
+
serial_no=actuator.serial_no,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def open_driver_for(actuator: Actuator) -> RelayDriver:
|
|
172
|
+
"""Open a driver for ``actuator`` and close it on exit.
|
|
173
|
+
|
|
174
|
+
A driver that isn't implemented or can't open its device raises
|
|
175
|
+
:class:`ActuationError`, which the verbs' callers report, rather than
|
|
176
|
+
a bare DriverError or NotImplementedError.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
driver = get_driver(
|
|
180
|
+
actuator.driver, expected_channel_count=len(actuator.channels) or None
|
|
181
|
+
)
|
|
182
|
+
driver.open(_build_transport(actuator))
|
|
183
|
+
except (DriverError, NotImplementedError) as e:
|
|
184
|
+
raise ActuationError(str(e)) from e
|
|
185
|
+
try:
|
|
186
|
+
yield driver
|
|
187
|
+
finally:
|
|
188
|
+
driver.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _opposite(state: ChannelState) -> ChannelState:
|
|
192
|
+
return ChannelState.OPEN if state is ChannelState.CLOSED else ChannelState.CLOSED
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _drive(
|
|
196
|
+
driver: RelayDriver, channel_index: int, target: ChannelState
|
|
197
|
+
) -> WriteOutcome:
|
|
198
|
+
return driver.set_channel(channel_index, closed=target is ChannelState.CLOSED)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _audit_actuator_event(
|
|
202
|
+
manager,
|
|
203
|
+
action: str,
|
|
204
|
+
binding: Binding,
|
|
205
|
+
actuator: Actuator,
|
|
206
|
+
channel: ActuatorChannel,
|
|
207
|
+
target: ChannelState,
|
|
208
|
+
*,
|
|
209
|
+
ok: bool,
|
|
210
|
+
error: Optional[str] = None,
|
|
211
|
+
extra: Optional[dict] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Emit an audit_log row for a binding-mediated hardware write.
|
|
214
|
+
|
|
215
|
+
Hardware writes are operationally significant and the activity
|
|
216
|
+
stream is the project's "who did what to which device" log. Every
|
|
217
|
+
successful or failed actuate/release/press lands here.
|
|
218
|
+
|
|
219
|
+
Defensive: any failure here is logged but never raised — a verb
|
|
220
|
+
that physically succeeded must not be reported as failed because
|
|
221
|
+
audit bookkeeping went sideways.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
from labctl.core import audit
|
|
225
|
+
|
|
226
|
+
details: dict = {
|
|
227
|
+
"actuator": actuator.name,
|
|
228
|
+
"channel": channel.channel_index,
|
|
229
|
+
"target_state": target.value,
|
|
230
|
+
}
|
|
231
|
+
if extra:
|
|
232
|
+
details.update(extra)
|
|
233
|
+
if error is not None:
|
|
234
|
+
details["error"] = error
|
|
235
|
+
audit.emit(
|
|
236
|
+
manager.db,
|
|
237
|
+
action=action,
|
|
238
|
+
entity_type="binding",
|
|
239
|
+
entity_id=binding.id,
|
|
240
|
+
entity_name=(
|
|
241
|
+
f"{binding.sbc_name}:{binding.purpose}"
|
|
242
|
+
if binding.sbc_name
|
|
243
|
+
else binding.purpose
|
|
244
|
+
),
|
|
245
|
+
result="ok" if ok else "error",
|
|
246
|
+
details=details,
|
|
247
|
+
)
|
|
248
|
+
except Exception as e: # noqa: BLE001
|
|
249
|
+
logger.warning(
|
|
250
|
+
"audit emit failed for action=%s binding=%s: %s",
|
|
251
|
+
action,
|
|
252
|
+
binding.purpose,
|
|
253
|
+
e,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _audit_raw_actuator_set(
|
|
258
|
+
manager,
|
|
259
|
+
actuator: Actuator,
|
|
260
|
+
channel: ActuatorChannel,
|
|
261
|
+
target: ChannelState,
|
|
262
|
+
*,
|
|
263
|
+
ok: bool,
|
|
264
|
+
error: Optional[str] = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Audit-log a raw `actuator set` that bypasses any binding."""
|
|
267
|
+
try:
|
|
268
|
+
from labctl.core import audit
|
|
269
|
+
|
|
270
|
+
details: dict = {
|
|
271
|
+
"channel": channel.channel_index,
|
|
272
|
+
"target_state": target.value,
|
|
273
|
+
"raw": True,
|
|
274
|
+
}
|
|
275
|
+
if error is not None:
|
|
276
|
+
details["error"] = error
|
|
277
|
+
audit.emit(
|
|
278
|
+
manager.db,
|
|
279
|
+
action="actuator_set",
|
|
280
|
+
entity_type="actuator_channel",
|
|
281
|
+
entity_id=channel.id,
|
|
282
|
+
entity_name=f"{actuator.name}[{channel.channel_index}]",
|
|
283
|
+
result="ok" if ok else "error",
|
|
284
|
+
details=details,
|
|
285
|
+
)
|
|
286
|
+
except Exception as e: # noqa: BLE001
|
|
287
|
+
logger.warning(
|
|
288
|
+
"audit emit failed for actuator_set %s[%d]: %s",
|
|
289
|
+
actuator.name,
|
|
290
|
+
channel.channel_index,
|
|
291
|
+
e,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _audit_safe_drive(
|
|
296
|
+
manager, summary: dict, *, ok: bool, error: Optional[str] = None
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Emit a synthetic 'safe_drive' audit event summarizing daemon-start
|
|
299
|
+
reconciliation: which channels were left held, which were driven to
|
|
300
|
+
default, and which writes failed. This is the activity-stream
|
|
301
|
+
counterpart to the per-channel logger.warning lines."""
|
|
302
|
+
try:
|
|
303
|
+
from labctl.core import audit
|
|
304
|
+
|
|
305
|
+
details: dict = {
|
|
306
|
+
"held_count": len(summary.get("held", [])),
|
|
307
|
+
"drove_count": len(summary.get("drove", [])),
|
|
308
|
+
"failed_count": len(summary.get("failed", [])),
|
|
309
|
+
"held": summary.get("held", []),
|
|
310
|
+
"drove": summary.get("drove", []),
|
|
311
|
+
"failed": summary.get("failed", []),
|
|
312
|
+
}
|
|
313
|
+
if error is not None:
|
|
314
|
+
details["error"] = error
|
|
315
|
+
audit.emit(
|
|
316
|
+
manager.db,
|
|
317
|
+
action="safe_drive",
|
|
318
|
+
entity_type="daemon",
|
|
319
|
+
entity_name="actuators",
|
|
320
|
+
result="ok" if ok else "error",
|
|
321
|
+
details=details,
|
|
322
|
+
)
|
|
323
|
+
except Exception as e: # noqa: BLE001
|
|
324
|
+
logger.warning("audit emit failed for safe_drive: %s", e)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _audit_pre_power_apply(
|
|
328
|
+
manager,
|
|
329
|
+
binding: Binding,
|
|
330
|
+
actuator: Actuator,
|
|
331
|
+
channel: ActuatorChannel,
|
|
332
|
+
target: ChannelState,
|
|
333
|
+
*,
|
|
334
|
+
ok: bool,
|
|
335
|
+
error: Optional[str] = None,
|
|
336
|
+
cycle_changed: Optional[bool] = None,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Emit a 'pre_power_apply' audit event for a strap driven before a
|
|
339
|
+
power transition. Distinct from 'actuate' so the activity stream
|
|
340
|
+
distinguishes operator-initiated verbs from automatic power-flow
|
|
341
|
+
re-application.
|
|
342
|
+
|
|
343
|
+
``cycle_changed`` indicates whether the channel was already in the
|
|
344
|
+
target state pre-write (False) or required a real transition (True).
|
|
345
|
+
Lets operators read paired actuate→pre_power events without thinking
|
|
346
|
+
the strap was double-toggled.
|
|
347
|
+
"""
|
|
348
|
+
extra: dict = {"desired_state": binding.desired_state.value}
|
|
349
|
+
if cycle_changed is not None:
|
|
350
|
+
extra["cycle_changed"] = cycle_changed
|
|
351
|
+
_audit_actuator_event(
|
|
352
|
+
manager,
|
|
353
|
+
"pre_power_apply",
|
|
354
|
+
binding,
|
|
355
|
+
actuator,
|
|
356
|
+
channel,
|
|
357
|
+
target,
|
|
358
|
+
ok=ok,
|
|
359
|
+
error=error,
|
|
360
|
+
extra=extra,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# Verb implementations
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _resolve(manager, binding: Binding) -> tuple[Actuator, ActuatorChannel]:
|
|
370
|
+
"""Load the actuator + channel referenced by a binding.
|
|
371
|
+
|
|
372
|
+
Returns the channel record (so the caller can stamp last_state).
|
|
373
|
+
"""
|
|
374
|
+
channel_row = manager.db.execute_one(
|
|
375
|
+
"SELECT * FROM actuator_channels WHERE id = ?",
|
|
376
|
+
(binding.actuator_channel_id,),
|
|
377
|
+
)
|
|
378
|
+
if not channel_row:
|
|
379
|
+
raise ActuationError(
|
|
380
|
+
f"binding {binding.id} references missing channel "
|
|
381
|
+
f"{binding.actuator_channel_id}"
|
|
382
|
+
)
|
|
383
|
+
channel = ActuatorChannel.from_row(channel_row)
|
|
384
|
+
actuator = manager.get_actuator(channel.actuator_id)
|
|
385
|
+
if actuator is None:
|
|
386
|
+
raise ActuationError(
|
|
387
|
+
f"binding {binding.id} references missing actuator "
|
|
388
|
+
f"{channel.actuator_id}"
|
|
389
|
+
)
|
|
390
|
+
return actuator, channel
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _require_shape(binding: Binding, expected: ShapeMode, verb: str) -> None:
|
|
394
|
+
if binding.shape_mode is not expected:
|
|
395
|
+
if expected is ShapeMode.LATCH:
|
|
396
|
+
hint = (
|
|
397
|
+
"Use 'press' for momentary bindings, or rebind with "
|
|
398
|
+
"--mode latch for sustained state."
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
hint = (
|
|
402
|
+
"Use 'actuate'/'release' for latch bindings, or rebind "
|
|
403
|
+
"with --mode momentary if you want press semantics."
|
|
404
|
+
)
|
|
405
|
+
raise BindingShapeError(
|
|
406
|
+
f"{verb!r} requires a {expected.value} binding; "
|
|
407
|
+
f"{binding.purpose!r} is bound as {binding.shape_mode.value}. {hint}"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def actuate_binding(manager, binding: Binding) -> None:
|
|
412
|
+
"""Drive a latch binding to its active state and persist intent."""
|
|
413
|
+
_require_shape(binding, ShapeMode.LATCH, "actuate")
|
|
414
|
+
actuator, channel = _resolve(manager, binding)
|
|
415
|
+
target = binding.shape_active
|
|
416
|
+
|
|
417
|
+
with _channel_lock(channel.id):
|
|
418
|
+
with open_driver_for(actuator) as driver:
|
|
419
|
+
outcome = _drive(driver, channel.channel_index, target)
|
|
420
|
+
if outcome is not WriteOutcome.OK:
|
|
421
|
+
_audit_actuator_event(
|
|
422
|
+
manager,
|
|
423
|
+
"actuate",
|
|
424
|
+
binding,
|
|
425
|
+
actuator,
|
|
426
|
+
channel,
|
|
427
|
+
target,
|
|
428
|
+
ok=False,
|
|
429
|
+
error=outcome.value,
|
|
430
|
+
)
|
|
431
|
+
raise ActuationError(f"actuate failed: driver returned {outcome.value}")
|
|
432
|
+
# Atomic: channel state + binding desired_state in one transaction.
|
|
433
|
+
manager.commit_actuation(channel.id, target, binding.id, DesiredState.ASSERTED)
|
|
434
|
+
_audit_actuator_event(
|
|
435
|
+
manager,
|
|
436
|
+
"actuate",
|
|
437
|
+
binding,
|
|
438
|
+
actuator,
|
|
439
|
+
channel,
|
|
440
|
+
target,
|
|
441
|
+
ok=True,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def release_binding(manager, binding: Binding) -> None:
|
|
446
|
+
"""Drive a latch binding away from its active state."""
|
|
447
|
+
_require_shape(binding, ShapeMode.LATCH, "release")
|
|
448
|
+
actuator, channel = _resolve(manager, binding)
|
|
449
|
+
target = _opposite(binding.shape_active)
|
|
450
|
+
|
|
451
|
+
with _channel_lock(channel.id):
|
|
452
|
+
with open_driver_for(actuator) as driver:
|
|
453
|
+
outcome = _drive(driver, channel.channel_index, target)
|
|
454
|
+
if outcome is not WriteOutcome.OK:
|
|
455
|
+
_audit_actuator_event(
|
|
456
|
+
manager,
|
|
457
|
+
"release",
|
|
458
|
+
binding,
|
|
459
|
+
actuator,
|
|
460
|
+
channel,
|
|
461
|
+
target,
|
|
462
|
+
ok=False,
|
|
463
|
+
error=outcome.value,
|
|
464
|
+
)
|
|
465
|
+
raise ActuationError(f"release failed: driver returned {outcome.value}")
|
|
466
|
+
manager.commit_actuation(channel.id, target, binding.id, DesiredState.RELEASED)
|
|
467
|
+
_audit_actuator_event(
|
|
468
|
+
manager,
|
|
469
|
+
"release",
|
|
470
|
+
binding,
|
|
471
|
+
actuator,
|
|
472
|
+
channel,
|
|
473
|
+
target,
|
|
474
|
+
ok=True,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def press_binding(manager, binding: Binding, *, sleep_fn=time.sleep) -> None:
|
|
479
|
+
"""Pulse a momentary binding to active for momentary_pulse_ms then back.
|
|
480
|
+
|
|
481
|
+
Both writes must succeed. If the second write fails the channel may
|
|
482
|
+
be left asserted — we surface that as :class:`ActuationError` so
|
|
483
|
+
the operator can intervene; ``last_state`` reflects the last
|
|
484
|
+
successful drive.
|
|
485
|
+
"""
|
|
486
|
+
_require_shape(binding, ShapeMode.MOMENTARY, "press")
|
|
487
|
+
if not binding.momentary_pulse_ms or binding.momentary_pulse_ms <= 0:
|
|
488
|
+
raise ActuationError(f"binding {binding.purpose!r} has no momentary_pulse_ms")
|
|
489
|
+
|
|
490
|
+
actuator, channel = _resolve(manager, binding)
|
|
491
|
+
active = binding.shape_active
|
|
492
|
+
inactive = _opposite(active)
|
|
493
|
+
|
|
494
|
+
with _channel_lock(channel.id):
|
|
495
|
+
with open_driver_for(actuator) as driver:
|
|
496
|
+
out1 = _drive(driver, channel.channel_index, active)
|
|
497
|
+
if out1 is not WriteOutcome.OK:
|
|
498
|
+
_audit_actuator_event(
|
|
499
|
+
manager,
|
|
500
|
+
"press",
|
|
501
|
+
binding,
|
|
502
|
+
actuator,
|
|
503
|
+
channel,
|
|
504
|
+
active,
|
|
505
|
+
ok=False,
|
|
506
|
+
error=f"assert: {out1.value}",
|
|
507
|
+
extra={"pulse_ms": binding.momentary_pulse_ms},
|
|
508
|
+
)
|
|
509
|
+
raise ActuationError(f"press: failed to assert: {out1.value}")
|
|
510
|
+
manager.update_channel_state(channel.id, active)
|
|
511
|
+
|
|
512
|
+
sleep_fn(binding.momentary_pulse_ms / 1000.0)
|
|
513
|
+
|
|
514
|
+
out2 = _drive(driver, channel.channel_index, inactive)
|
|
515
|
+
if out2 is not WriteOutcome.OK:
|
|
516
|
+
_audit_actuator_event(
|
|
517
|
+
manager,
|
|
518
|
+
"press",
|
|
519
|
+
binding,
|
|
520
|
+
actuator,
|
|
521
|
+
channel,
|
|
522
|
+
inactive,
|
|
523
|
+
ok=False,
|
|
524
|
+
error=f"release: {out2.value}",
|
|
525
|
+
extra={
|
|
526
|
+
"pulse_ms": binding.momentary_pulse_ms,
|
|
527
|
+
"left_in_state": active.value,
|
|
528
|
+
},
|
|
529
|
+
)
|
|
530
|
+
raise ActuationError(
|
|
531
|
+
f"press: assert ok, but release failed ({out2.value}). "
|
|
532
|
+
f"Channel may be left in {active.value} state."
|
|
533
|
+
)
|
|
534
|
+
manager.update_channel_state(channel.id, inactive)
|
|
535
|
+
_audit_actuator_event(
|
|
536
|
+
manager,
|
|
537
|
+
"press",
|
|
538
|
+
binding,
|
|
539
|
+
actuator,
|
|
540
|
+
channel,
|
|
541
|
+
inactive,
|
|
542
|
+
ok=True,
|
|
543
|
+
extra={"pulse_ms": binding.momentary_pulse_ms},
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
# Status / inspection
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def binding_status(manager, binding: Binding) -> dict:
|
|
553
|
+
"""Return a dict describing the current binding state for display.
|
|
554
|
+
|
|
555
|
+
Does not touch the hardware — pure DB read. Combine with
|
|
556
|
+
:func:`probe_actuator` if you also want a fresh reachability check.
|
|
557
|
+
"""
|
|
558
|
+
actuator, channel = _resolve(manager, binding)
|
|
559
|
+
return {
|
|
560
|
+
"binding": {
|
|
561
|
+
"sbc": binding.sbc_name,
|
|
562
|
+
"purpose": binding.purpose,
|
|
563
|
+
"shape_mode": binding.shape_mode.value,
|
|
564
|
+
"shape_active": binding.shape_active.value,
|
|
565
|
+
"sample_phase": binding.sample_phase.value,
|
|
566
|
+
**(
|
|
567
|
+
{"sample_phase_note": POST_POWER_INACTIVE}
|
|
568
|
+
if binding.sample_phase is SamplePhase.POST_POWER
|
|
569
|
+
else {}
|
|
570
|
+
),
|
|
571
|
+
"momentary_pulse_ms": binding.momentary_pulse_ms,
|
|
572
|
+
"desired_state": binding.desired_state.value,
|
|
573
|
+
},
|
|
574
|
+
"actuator": {
|
|
575
|
+
"name": actuator.name,
|
|
576
|
+
"driver": actuator.driver.value,
|
|
577
|
+
"device_path": actuator.device_path,
|
|
578
|
+
},
|
|
579
|
+
"channel": {
|
|
580
|
+
"index": channel.channel_index,
|
|
581
|
+
"default_state": channel.default_state.value,
|
|
582
|
+
"last_state": (channel.last_state.value if channel.last_state else None),
|
|
583
|
+
"last_changed_at": (
|
|
584
|
+
channel.last_changed_at.isoformat() if channel.last_changed_at else None
|
|
585
|
+
),
|
|
586
|
+
"cycle_count": channel.cycle_count,
|
|
587
|
+
},
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def probe_actuator(actuator: Actuator) -> ProbeOutcome:
|
|
592
|
+
"""Probe an actuator and return the outcome (read-only)."""
|
|
593
|
+
try:
|
|
594
|
+
with open_driver_for(actuator) as driver:
|
|
595
|
+
return driver.probe()
|
|
596
|
+
except Exception as e: # noqa: BLE001
|
|
597
|
+
return ProbeOutcome(result=ProbeResult.UNREACHABLE, detail=str(e))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# Power-aware integration (Phase 5)
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _bindings_with_sample_phase(manager, sbc_id: int, phase) -> list[Binding]:
|
|
606
|
+
"""Return bindings on ``sbc_id`` whose sample_phase matches ``phase``."""
|
|
607
|
+
return [b for b in manager.list_bindings(sbc_id=sbc_id) if b.sample_phase == phase]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def check_pre_power_actuators(manager, sbc) -> None:
|
|
611
|
+
"""Raise :class:`PrePowerCheckError` if an actuator that
|
|
612
|
+
apply_pre_power_bindings will drive for ``sbc`` can't be opened.
|
|
613
|
+
|
|
614
|
+
For power cycle, so a relay that would fail after power-off (e.g. no
|
|
615
|
+
permission) is found while the board is still running. Read-only: the
|
|
616
|
+
probe opens and closes the device without touching a channel.
|
|
617
|
+
"""
|
|
618
|
+
from labctl.core.models import SamplePhase
|
|
619
|
+
|
|
620
|
+
seen = set()
|
|
621
|
+
for binding in _bindings_with_sample_phase(manager, sbc.id, SamplePhase.PRE_POWER):
|
|
622
|
+
# Resolved first, as apply_pre_power_bindings does, so a binding
|
|
623
|
+
# to a missing channel fails here rather than after power-off.
|
|
624
|
+
try:
|
|
625
|
+
actuator, channel = _resolve(manager, binding)
|
|
626
|
+
except ActuationError as e:
|
|
627
|
+
raise PrePowerCheckError(str(e)) from e
|
|
628
|
+
if binding.desired_state not in (
|
|
629
|
+
DesiredState.ASSERTED,
|
|
630
|
+
DesiredState.RELEASED,
|
|
631
|
+
):
|
|
632
|
+
continue
|
|
633
|
+
if _get_channel_lock(channel.id).locked():
|
|
634
|
+
raise PrePowerCheckError(
|
|
635
|
+
f"actuator {actuator.name!r} channel {channel.channel_index} "
|
|
636
|
+
f"for {binding.purpose!r} is busy (another operation in "
|
|
637
|
+
f"flight)"
|
|
638
|
+
)
|
|
639
|
+
if actuator.id in seen:
|
|
640
|
+
continue
|
|
641
|
+
seen.add(actuator.id)
|
|
642
|
+
outcome = probe_actuator(actuator)
|
|
643
|
+
if outcome.result is not ProbeResult.OK:
|
|
644
|
+
raise PrePowerCheckError(
|
|
645
|
+
f"actuator {actuator.name!r} for {binding.purpose!r} can't "
|
|
646
|
+
f"be used: {outcome.detail or outcome.result.value}"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def apply_pre_power_bindings(manager, sbc) -> None:
|
|
651
|
+
"""Drive every pre_power binding on ``sbc`` to match its desired_state.
|
|
652
|
+
|
|
653
|
+
Called BEFORE ``power_on`` / ``power_cycle`` so a held recovery_mode
|
|
654
|
+
strap is in place before the device boots. No-op for SBCs with no
|
|
655
|
+
pre_power bindings.
|
|
656
|
+
|
|
657
|
+
Raises :class:`ActuationError` on any driver failure — power must
|
|
658
|
+
NOT proceed without the strap in place.
|
|
659
|
+
"""
|
|
660
|
+
from labctl.core.models import SamplePhase
|
|
661
|
+
|
|
662
|
+
pre_bindings = _bindings_with_sample_phase(manager, sbc.id, SamplePhase.PRE_POWER)
|
|
663
|
+
for binding in pre_bindings:
|
|
664
|
+
actuator, channel = _resolve(manager, binding)
|
|
665
|
+
if binding.desired_state == DesiredState.ASSERTED:
|
|
666
|
+
target = binding.shape_active
|
|
667
|
+
elif binding.desired_state == DesiredState.RELEASED:
|
|
668
|
+
target = _opposite(binding.shape_active)
|
|
669
|
+
else:
|
|
670
|
+
# FOLLOWING_POWER — driven by the binding's own logic post-on.
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
# Capture pre-write state for cycle_changed before the drive +
|
|
674
|
+
# update_channel_state pair invalidates it.
|
|
675
|
+
prior_state = channel.last_state
|
|
676
|
+
with _channel_lock(channel.id):
|
|
677
|
+
with open_driver_for(actuator) as driver:
|
|
678
|
+
outcome = _drive(driver, channel.channel_index, target)
|
|
679
|
+
if outcome is not WriteOutcome.OK:
|
|
680
|
+
_audit_pre_power_apply(
|
|
681
|
+
manager,
|
|
682
|
+
binding,
|
|
683
|
+
actuator,
|
|
684
|
+
channel,
|
|
685
|
+
target,
|
|
686
|
+
ok=False,
|
|
687
|
+
error=outcome.value,
|
|
688
|
+
)
|
|
689
|
+
raise ActuationError(
|
|
690
|
+
f"pre_power binding {binding.purpose!r} on "
|
|
691
|
+
f"{binding.sbc_name!r}: {outcome.value}"
|
|
692
|
+
)
|
|
693
|
+
manager.update_channel_state(channel.id, target)
|
|
694
|
+
_audit_pre_power_apply(
|
|
695
|
+
manager,
|
|
696
|
+
binding,
|
|
697
|
+
actuator,
|
|
698
|
+
channel,
|
|
699
|
+
target,
|
|
700
|
+
ok=True,
|
|
701
|
+
cycle_changed=(prior_state != target),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def boards_on_plug(manager, sbc) -> list:
|
|
706
|
+
"""``sbc`` and every other SBC on its plug outlet: switching the plug
|
|
707
|
+
powers them all, so each one's straps must be set first."""
|
|
708
|
+
boards = [sbc]
|
|
709
|
+
plug = sbc.power_plug
|
|
710
|
+
if plug is None:
|
|
711
|
+
return boards
|
|
712
|
+
for name in manager.sbcs_sharing_hardware(
|
|
713
|
+
plug=(plug.plug_type.value, plug.address, plug.plug_index)
|
|
714
|
+
):
|
|
715
|
+
if name != sbc.name:
|
|
716
|
+
other = manager.get_sbc_by_name(name)
|
|
717
|
+
if other is not None:
|
|
718
|
+
boards.append(other)
|
|
719
|
+
return boards
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
MAX_CYCLE_DELAY_SECONDS = 86400.0
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def validate_cycle_delay(delay_s) -> None:
|
|
726
|
+
"""Raise ValueError unless ``delay_s`` is a number of seconds from 0
|
|
727
|
+
to :data:`MAX_CYCLE_DELAY_SECONDS` (time.sleep can't take much more,
|
|
728
|
+
and a cycle validates before it switches anything)."""
|
|
729
|
+
try:
|
|
730
|
+
ok = (
|
|
731
|
+
not isinstance(delay_s, bool)
|
|
732
|
+
and isinstance(delay_s, (int, float))
|
|
733
|
+
and 0 <= float(delay_s) <= MAX_CYCLE_DELAY_SECONDS
|
|
734
|
+
)
|
|
735
|
+
except OverflowError: # an int too large for a float
|
|
736
|
+
ok = False
|
|
737
|
+
if not ok:
|
|
738
|
+
raise ValueError(
|
|
739
|
+
f"delay must be a number of seconds from 0 to "
|
|
740
|
+
f"{MAX_CYCLE_DELAY_SECONDS:g}, not {delay_s!r}"
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
DEFAULT_CYCLE_DELAY_SECONDS = 3.0
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class StoredCycleDelayError(ValueError):
|
|
748
|
+
"""The cycle delay stored for a board is out of range (it was stored
|
|
749
|
+
before ``edit --cycle-delay`` checked the range)."""
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def stored_cycle_delay(sbc) -> Optional[float]:
|
|
753
|
+
"""The board's stored power-cycle delay, or None when none is set.
|
|
754
|
+
|
|
755
|
+
Raises :class:`StoredCycleDelayError` (a ValueError) when the stored
|
|
756
|
+
value is out of range, so no path ever sleeps on it."""
|
|
757
|
+
value = getattr(sbc, "power_cycle_delay_seconds", None)
|
|
758
|
+
if value is None:
|
|
759
|
+
return None
|
|
760
|
+
try:
|
|
761
|
+
validate_cycle_delay(value)
|
|
762
|
+
except ValueError:
|
|
763
|
+
raise StoredCycleDelayError(
|
|
764
|
+
f"the cycle delay stored for {sbc.name!r} ({value!r}) is out of "
|
|
765
|
+
f"range (0 to {MAX_CYCLE_DELAY_SECONDS:g} seconds); clear it with: "
|
|
766
|
+
f"labctl edit {sbc.name} --cycle-delay -1"
|
|
767
|
+
) from None
|
|
768
|
+
return float(value)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@dataclass(frozen=True)
|
|
772
|
+
class CycleDelay:
|
|
773
|
+
"""The delay a power cycle waits between off and on, from
|
|
774
|
+
:func:`board_cycle_delay` (or :func:`cycle_delay_floor`): ``seconds``,
|
|
775
|
+
and ``source`` naming the floor it came from (``"SBC minimum"``,
|
|
776
|
+
``"default minimum"``, or ``"minimum of '<board>' (on the same
|
|
777
|
+
outlet)"``). ``board`` names the board whose minimum it is (None when
|
|
778
|
+
the requested value is used). ``requested`` is the caller's value
|
|
779
|
+
(None: omitted), and ``raised`` says it was below the floor."""
|
|
780
|
+
|
|
781
|
+
seconds: float
|
|
782
|
+
source: str
|
|
783
|
+
requested: Optional[float] = None
|
|
784
|
+
board: Optional[str] = None
|
|
785
|
+
|
|
786
|
+
def from_other_board(self, sbc_name: str) -> bool:
|
|
787
|
+
"""Whether the delay is another outlet board's minimum."""
|
|
788
|
+
return self.board is not None and self.board != sbc_name
|
|
789
|
+
|
|
790
|
+
@property
|
|
791
|
+
def raised(self) -> bool:
|
|
792
|
+
return self.requested is not None and self.seconds > self.requested
|
|
793
|
+
|
|
794
|
+
def raised_note(self) -> Optional[str]:
|
|
795
|
+
"""``"<requested>s is below the <source> of <seconds>s"`` when the
|
|
796
|
+
requested value was raised, else None."""
|
|
797
|
+
if not self.raised:
|
|
798
|
+
return None
|
|
799
|
+
return (
|
|
800
|
+
f"{float(self.requested)}s is below the {self.source} of "
|
|
801
|
+
f"{self.seconds}s"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def cycle_delay_floor(manager, sbc) -> CycleDelay:
|
|
806
|
+
"""The minimum delay of a power cycle of ``sbc``.
|
|
807
|
+
|
|
808
|
+
Each board's own floor is its stored delay, or
|
|
809
|
+
:data:`DEFAULT_CYCLE_DELAY_SECONDS` if it has none. A cycle switches
|
|
810
|
+
the board's plug outlet, so every board on it is cycled: the floor is
|
|
811
|
+
the largest of their floors (``sbc``'s own on a tie), with ``source``
|
|
812
|
+
naming it. Raises :class:`StoredCycleDelayError` when any of their
|
|
813
|
+
stored delays is out of range.
|
|
814
|
+
"""
|
|
815
|
+
boards = (
|
|
816
|
+
boards_on_plug(manager, sbc)
|
|
817
|
+
if getattr(sbc, "power_plug", None) is not None
|
|
818
|
+
else [sbc]
|
|
819
|
+
)
|
|
820
|
+
best: Optional[CycleDelay] = None
|
|
821
|
+
for board in boards: # sbc first
|
|
822
|
+
try:
|
|
823
|
+
stored = stored_cycle_delay(board)
|
|
824
|
+
except StoredCycleDelayError as e:
|
|
825
|
+
if board is sbc:
|
|
826
|
+
raise
|
|
827
|
+
raise StoredCycleDelayError(
|
|
828
|
+
f"{e} ({board.name!r} shares {sbc.name!r}'s power outlet)"
|
|
829
|
+
) from None
|
|
830
|
+
kind = "minimum" if stored is not None else "default minimum"
|
|
831
|
+
floor = stored if stored is not None else DEFAULT_CYCLE_DELAY_SECONDS
|
|
832
|
+
if board is sbc:
|
|
833
|
+
source = "SBC minimum" if stored is not None else "default minimum"
|
|
834
|
+
else:
|
|
835
|
+
source = f"{kind} of {board.name!r} (on the same outlet)"
|
|
836
|
+
if best is None or floor > best.seconds:
|
|
837
|
+
best = CycleDelay(floor, source, board=board.name)
|
|
838
|
+
return best
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def board_cycle_delay(manager, sbc, requested=None) -> CycleDelay:
|
|
842
|
+
"""The delay a power cycle of ``sbc`` waits between off and on, the
|
|
843
|
+
same on every surface (CLI, MCP, REST, web), with its source.
|
|
844
|
+
|
|
845
|
+
The floor is :func:`cycle_delay_floor` (the largest of the floors of
|
|
846
|
+
the boards on ``sbc``'s outlet, each its stored delay or 3 s). With
|
|
847
|
+
``requested`` None the delay is the floor; otherwise ``requested``
|
|
848
|
+
raised to the floor. Raises ValueError for a ``requested`` value out
|
|
849
|
+
of range (negative included), and :class:`StoredCycleDelayError` for
|
|
850
|
+
an out-of-range stored one.
|
|
851
|
+
"""
|
|
852
|
+
if requested is not None:
|
|
853
|
+
validate_cycle_delay(requested)
|
|
854
|
+
floor = cycle_delay_floor(manager, sbc)
|
|
855
|
+
if requested is None or float(requested) <= floor.seconds:
|
|
856
|
+
return CycleDelay(floor.seconds, floor.source, requested, floor.board)
|
|
857
|
+
return CycleDelay(float(requested), "requested", requested)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _resolved_delay(manager, sbc, delay) -> float:
|
|
861
|
+
"""Seconds to wait for ``delay``: a :class:`CycleDelay` the caller got
|
|
862
|
+
from :func:`board_cycle_delay` is used as is; anything else (a number
|
|
863
|
+
or None) goes through it here, so no path skips the rule."""
|
|
864
|
+
if isinstance(delay, CycleDelay):
|
|
865
|
+
return delay.seconds
|
|
866
|
+
return board_cycle_delay(manager, sbc, delay).seconds
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def check_power_on(manager, sbc) -> list:
|
|
870
|
+
"""Check the pre_power relays of ``sbc`` and of every board sharing
|
|
871
|
+
its plug outlet; return those boards. Raises
|
|
872
|
+
:class:`PrePowerCheckError` (naming a sharer) before anything is
|
|
873
|
+
switched."""
|
|
874
|
+
boards = boards_on_plug(manager, sbc)
|
|
875
|
+
for board in boards:
|
|
876
|
+
try:
|
|
877
|
+
check_pre_power_actuators(manager, board)
|
|
878
|
+
except PrePowerCheckError as e:
|
|
879
|
+
if board is sbc:
|
|
880
|
+
raise
|
|
881
|
+
raise PrePowerCheckError(
|
|
882
|
+
f"{e} ({board.name!r} shares {sbc.name!r}'s power outlet)"
|
|
883
|
+
) from e
|
|
884
|
+
return boards
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _left_off(sbc, boards) -> str:
|
|
888
|
+
others = [b.name for b in boards if b is not sbc]
|
|
889
|
+
if not others:
|
|
890
|
+
return f"{sbc.name!r} was left powered off"
|
|
891
|
+
return (
|
|
892
|
+
f"{sbc.name!r} and the boards sharing its outlet "
|
|
893
|
+
f"({', '.join(repr(n) for n in others)}) were left powered off"
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _apply_pre_power(manager, boards) -> None:
|
|
898
|
+
"""apply_pre_power_bindings for each board, with a busy channel
|
|
899
|
+
(another verb in flight in this process) reported as an
|
|
900
|
+
:class:`ActuationError` like any other strap failure."""
|
|
901
|
+
try:
|
|
902
|
+
for board in boards:
|
|
903
|
+
apply_pre_power_bindings(manager, board)
|
|
904
|
+
except ChannelBusyError as e:
|
|
905
|
+
raise ActuationError(str(e)) from e
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _apply_after_power_off(manager, sbc, boards) -> None:
|
|
909
|
+
"""_apply_pre_power once the outlet is off: a failure says the boards
|
|
910
|
+
were left off (they aren't powered on without their straps)."""
|
|
911
|
+
try:
|
|
912
|
+
_apply_pre_power(manager, boards)
|
|
913
|
+
except ActuationError as e:
|
|
914
|
+
raise ActuationError(f"{e}; {_left_off(sbc, boards)}") from e
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def power_on_with_bindings(manager, sbc, controller) -> bool:
|
|
918
|
+
"""Drive the pre_power bindings of ``sbc`` (and of any board sharing
|
|
919
|
+
its plug outlet), then power it on.
|
|
920
|
+
|
|
921
|
+
Every path that powers a board on goes through here (or
|
|
922
|
+
:func:`power_cycle_with_bindings`) so a strap is in its desired state
|
|
923
|
+
whichever interface did it (issue #36). The relays are checked before
|
|
924
|
+
any is driven; :class:`ActuationError` (a
|
|
925
|
+
:class:`PrePowerCheckError` from the check) means power wasn't
|
|
926
|
+
switched. Returns the controller's result otherwise.
|
|
927
|
+
"""
|
|
928
|
+
boards = check_power_on(manager, sbc)
|
|
929
|
+
_apply_pre_power(manager, boards)
|
|
930
|
+
return controller.power_on()
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def power_cycle_with_bindings(
|
|
934
|
+
manager,
|
|
935
|
+
sbc,
|
|
936
|
+
controller,
|
|
937
|
+
delay_s=None,
|
|
938
|
+
*,
|
|
939
|
+
sleep_fn=None,
|
|
940
|
+
) -> bool:
|
|
941
|
+
"""Power-cycle ``sbc``: off, wait, drive pre_power bindings (its own
|
|
942
|
+
and those of any board sharing its plug outlet), on.
|
|
943
|
+
|
|
944
|
+
``delay_s`` is a :class:`CycleDelay` from :func:`board_cycle_delay`
|
|
945
|
+
(used as is), or a number or None, which goes through it here: the
|
|
946
|
+
largest of the floors of the boards on the outlet (each its stored
|
|
947
|
+
delay, or 3 s) is the minimum, and None means that minimum.
|
|
948
|
+
|
|
949
|
+
Before anything is switched: a bad ``delay_s`` or stored delay raises
|
|
950
|
+
ValueError, and
|
|
951
|
+
a pre_power relay that can't be opened or is busy raises
|
|
952
|
+
:class:`PrePowerCheckError`, so the board keeps running. After
|
|
953
|
+
power-off, a strap that can't be applied raises :class:`ActuationError`
|
|
954
|
+
saying the board was left off (it is not powered on without its
|
|
955
|
+
strap). A plug failure raises RuntimeError. Returns the result of
|
|
956
|
+
power_on.
|
|
957
|
+
"""
|
|
958
|
+
delay_s = _resolved_delay(manager, sbc, delay_s)
|
|
959
|
+
boards = check_power_on(manager, sbc)
|
|
960
|
+
if not controller.power_off():
|
|
961
|
+
raise RuntimeError(f"power_off failed on {sbc.name!r}")
|
|
962
|
+
(sleep_fn or time.sleep)(delay_s)
|
|
963
|
+
_apply_after_power_off(manager, sbc, boards)
|
|
964
|
+
return controller.power_on()
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def enter_recovery(
|
|
968
|
+
manager,
|
|
969
|
+
sbc,
|
|
970
|
+
controller,
|
|
971
|
+
*,
|
|
972
|
+
delay_s=None,
|
|
973
|
+
sleep_fn=time.sleep,
|
|
974
|
+
) -> None:
|
|
975
|
+
"""Power-aware "enter USB Force Recovery (or equivalent)" composite.
|
|
976
|
+
|
|
977
|
+
Sequence (see SPEC_actuators.md §"Composite, power-aware operations"):
|
|
978
|
+
|
|
979
|
+
0. Pre-flight probe every actuator we'll touch — abort BEFORE
|
|
980
|
+
power_off if any is unreachable.
|
|
981
|
+
1. power_off
|
|
982
|
+
2. sleep the delay: ``delay_s`` as for
|
|
983
|
+
:func:`power_cycle_with_bindings` (the largest floor among the
|
|
984
|
+
boards on the outlet is the minimum; ValueError before step 0 if a
|
|
985
|
+
stored delay or ``delay_s`` is out of range)
|
|
986
|
+
3. actuate recovery_mode → desired=asserted, strap engaged
|
|
987
|
+
4. power_on (consults pre_power bindings; idempotent for the
|
|
988
|
+
strap we just engaged)
|
|
989
|
+
"""
|
|
990
|
+
# The board's stored delay is the minimum; an out-of-range one raises
|
|
991
|
+
# ValueError before anything is touched.
|
|
992
|
+
delay_s = _resolved_delay(manager, sbc, delay_s)
|
|
993
|
+
binding = manager.get_binding_by_target(sbc.id, "recovery_mode")
|
|
994
|
+
if binding is None:
|
|
995
|
+
raise ActuationError(f"no 'recovery_mode' binding on {sbc.name!r}")
|
|
996
|
+
actuator, _channel = _resolve(manager, binding)
|
|
997
|
+
|
|
998
|
+
# 0. Pre-flight probe. Abort before any power transition.
|
|
999
|
+
outcome = probe_actuator(actuator)
|
|
1000
|
+
if outcome.result is not ProbeResult.OK:
|
|
1001
|
+
raise ActuationError(
|
|
1002
|
+
f"pre-flight probe failed on {actuator.name!r}: "
|
|
1003
|
+
f"{outcome.result.value} ({outcome.detail or 'no detail'})"
|
|
1004
|
+
)
|
|
1005
|
+
# The power-on step also drives the SBC's other pre_power bindings,
|
|
1006
|
+
# and those of boards sharing its outlet.
|
|
1007
|
+
boards = check_power_on(manager, sbc)
|
|
1008
|
+
|
|
1009
|
+
# 1. Power off.
|
|
1010
|
+
if not controller.power_off():
|
|
1011
|
+
raise ActuationError(f"power_off failed on {sbc.name!r}")
|
|
1012
|
+
# 2. Settling delay.
|
|
1013
|
+
sleep_fn(delay_s)
|
|
1014
|
+
# 3. Engage recovery strap.
|
|
1015
|
+
actuate_binding(manager, binding)
|
|
1016
|
+
# 4. Power on (re-applies the strap via apply_pre_power_bindings).
|
|
1017
|
+
_apply_after_power_off(manager, sbc, boards)
|
|
1018
|
+
if not controller.power_on():
|
|
1019
|
+
raise ActuationError(f"power_on failed on {sbc.name!r}")
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def exit_recovery(
|
|
1023
|
+
manager,
|
|
1024
|
+
sbc,
|
|
1025
|
+
controller,
|
|
1026
|
+
*,
|
|
1027
|
+
delay_s=None,
|
|
1028
|
+
sleep_fn=time.sleep,
|
|
1029
|
+
) -> None:
|
|
1030
|
+
"""Power-aware "leave recovery" composite.
|
|
1031
|
+
|
|
1032
|
+
Same shape as :func:`enter_recovery` but releases the strap.
|
|
1033
|
+
"""
|
|
1034
|
+
# The board's stored delay is the minimum; an out-of-range one raises
|
|
1035
|
+
# ValueError before anything is touched.
|
|
1036
|
+
delay_s = _resolved_delay(manager, sbc, delay_s)
|
|
1037
|
+
binding = manager.get_binding_by_target(sbc.id, "recovery_mode")
|
|
1038
|
+
if binding is None:
|
|
1039
|
+
raise ActuationError(f"no 'recovery_mode' binding on {sbc.name!r}")
|
|
1040
|
+
actuator, _channel = _resolve(manager, binding)
|
|
1041
|
+
|
|
1042
|
+
outcome = probe_actuator(actuator)
|
|
1043
|
+
if outcome.result is not ProbeResult.OK:
|
|
1044
|
+
raise ActuationError(
|
|
1045
|
+
f"pre-flight probe failed on {actuator.name!r}: "
|
|
1046
|
+
f"{outcome.result.value} ({outcome.detail or 'no detail'})"
|
|
1047
|
+
)
|
|
1048
|
+
# The power-on step also drives the SBC's other pre_power bindings,
|
|
1049
|
+
# and those of boards sharing its outlet.
|
|
1050
|
+
boards = check_power_on(manager, sbc)
|
|
1051
|
+
|
|
1052
|
+
if not controller.power_off():
|
|
1053
|
+
raise ActuationError(f"power_off failed on {sbc.name!r}")
|
|
1054
|
+
sleep_fn(delay_s)
|
|
1055
|
+
release_binding(manager, binding)
|
|
1056
|
+
_apply_after_power_off(manager, sbc, boards)
|
|
1057
|
+
if not controller.power_on():
|
|
1058
|
+
raise ActuationError(f"power_on failed on {sbc.name!r}")
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# ---------------------------------------------------------------------------
|
|
1062
|
+
# Daemon-start safe drive
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def apply_safe_drive_on_startup(manager) -> dict:
|
|
1067
|
+
"""Reconcile every actuator channel against its binding's desired_state.
|
|
1068
|
+
|
|
1069
|
+
On daemon start every channel is evaluated:
|
|
1070
|
+
|
|
1071
|
+
* No binding for channel → drive to default_state.
|
|
1072
|
+
* Binding desired_state=released → drive to default_state.
|
|
1073
|
+
* Binding desired_state=asserted → LEAVE ALONE, warn.
|
|
1074
|
+
* Binding desired_state=following_power → leave alone (driven later
|
|
1075
|
+
by power_on consulting pre_power bindings).
|
|
1076
|
+
|
|
1077
|
+
The "leave alone" path is the keystone: a recovery_mode strap held
|
|
1078
|
+
mid-flash MUST survive daemon restarts. Snapping it to default_state
|
|
1079
|
+
would drop the flash. The warning is the operator's signal that
|
|
1080
|
+
something was mid-operation when the daemon went down.
|
|
1081
|
+
|
|
1082
|
+
Returns a dict summarising what happened (test/observability hook).
|
|
1083
|
+
"""
|
|
1084
|
+
bindings_by_channel: dict[int, Binding] = {
|
|
1085
|
+
b.actuator_channel_id: b for b in manager.list_bindings()
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
held: list[dict] = []
|
|
1089
|
+
drove: list[dict] = []
|
|
1090
|
+
failed: list[dict] = []
|
|
1091
|
+
|
|
1092
|
+
for actuator in manager.list_actuators():
|
|
1093
|
+
_safe_drive_one_actuator(
|
|
1094
|
+
manager,
|
|
1095
|
+
actuator,
|
|
1096
|
+
bindings_by_channel,
|
|
1097
|
+
held=held,
|
|
1098
|
+
drove=drove,
|
|
1099
|
+
failed=failed,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
summary = {"held": held, "drove": drove, "failed": failed}
|
|
1103
|
+
_audit_safe_drive(manager, summary, ok=not failed)
|
|
1104
|
+
return summary
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _safe_drive_one_actuator(
|
|
1108
|
+
manager,
|
|
1109
|
+
actuator: Actuator,
|
|
1110
|
+
bindings_by_channel: dict[int, Binding],
|
|
1111
|
+
*,
|
|
1112
|
+
held: list[dict],
|
|
1113
|
+
drove: list[dict],
|
|
1114
|
+
failed: list[dict],
|
|
1115
|
+
) -> None:
|
|
1116
|
+
"""Reconcile one actuator's channels; appends to held/drove/failed.
|
|
1117
|
+
|
|
1118
|
+
Skips entirely if the actuator can't be opened (logs a warning). For
|
|
1119
|
+
each channel: leave alone if its binding is held, otherwise drive
|
|
1120
|
+
to the channel's default_state.
|
|
1121
|
+
"""
|
|
1122
|
+
try:
|
|
1123
|
+
cm = open_driver_for(actuator)
|
|
1124
|
+
driver = cm.__enter__()
|
|
1125
|
+
except Exception as e: # noqa: BLE001
|
|
1126
|
+
logger.warning("safe-drive: cannot open %s: %s", actuator.name, e)
|
|
1127
|
+
return
|
|
1128
|
+
try:
|
|
1129
|
+
for ch in actuator.channels:
|
|
1130
|
+
binding = bindings_by_channel.get(ch.id)
|
|
1131
|
+
if binding is not None and binding.desired_state in (
|
|
1132
|
+
DesiredState.ASSERTED,
|
|
1133
|
+
DesiredState.FOLLOWING_POWER,
|
|
1134
|
+
):
|
|
1135
|
+
held.append(
|
|
1136
|
+
{
|
|
1137
|
+
"actuator": actuator.name,
|
|
1138
|
+
"channel": ch.channel_index,
|
|
1139
|
+
"purpose": binding.purpose,
|
|
1140
|
+
"desired_state": binding.desired_state.value,
|
|
1141
|
+
}
|
|
1142
|
+
)
|
|
1143
|
+
logger.warning(
|
|
1144
|
+
"safe-drive: leaving %s[%d] alone " "(binding %s desired=%s)",
|
|
1145
|
+
actuator.name,
|
|
1146
|
+
ch.channel_index,
|
|
1147
|
+
binding.purpose,
|
|
1148
|
+
binding.desired_state.value,
|
|
1149
|
+
)
|
|
1150
|
+
continue
|
|
1151
|
+
|
|
1152
|
+
target = ch.default_state
|
|
1153
|
+
outcome = _drive(driver, ch.channel_index, target)
|
|
1154
|
+
if outcome is WriteOutcome.OK:
|
|
1155
|
+
manager.update_channel_state(ch.id, target)
|
|
1156
|
+
drove.append(
|
|
1157
|
+
{
|
|
1158
|
+
"actuator": actuator.name,
|
|
1159
|
+
"channel": ch.channel_index,
|
|
1160
|
+
"target": target.value,
|
|
1161
|
+
}
|
|
1162
|
+
)
|
|
1163
|
+
else:
|
|
1164
|
+
failed.append(
|
|
1165
|
+
{
|
|
1166
|
+
"actuator": actuator.name,
|
|
1167
|
+
"channel": ch.channel_index,
|
|
1168
|
+
"outcome": outcome.value,
|
|
1169
|
+
}
|
|
1170
|
+
)
|
|
1171
|
+
logger.warning(
|
|
1172
|
+
"safe-drive: write to %s[%d] returned %s",
|
|
1173
|
+
actuator.name,
|
|
1174
|
+
ch.channel_index,
|
|
1175
|
+
outcome.value,
|
|
1176
|
+
)
|
|
1177
|
+
finally:
|
|
1178
|
+
cm.__exit__(None, None, None)
|