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,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)