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,190 @@
1
+ """Power a board off before its SD card is switched to the host, and
2
+ check that it is off before the card is taken from it."""
3
+
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ # Why a board isn't confirmed off (BoardOffCheck.problem).
9
+ NO_PLUG = "no_plug"
10
+ UNREADABLE = "unreadable"
11
+ POWERED_ON = "powered_on"
12
+ NOT_OFF = "not_off" # a state other than ON or OFF (e.g. unknown)
13
+
14
+
15
+ @dataclass
16
+ class BoardOffCheck:
17
+ """Result of :func:`check_board_off`: ``problem`` is None when the
18
+ plug reports the board OFF, else one of NO_PLUG, UNREADABLE (``error``
19
+ says why), POWERED_ON or NOT_OFF (``state`` says which)."""
20
+
21
+ problem: Optional[str] = None
22
+ state: object = None # a PowerState when the plug was read
23
+ error: Optional[str] = None
24
+
25
+ @property
26
+ def is_off(self) -> bool:
27
+ return self.problem is None
28
+
29
+
30
+ def check_board_off(sbc) -> BoardOffCheck:
31
+ """Whether ``sbc``'s plug reports it OFF: the one check behind every
32
+ "proceed only when the board is off" refusal (``sdwire host`` and
33
+ MCP ``sdwire_to_host``/``sdwire_ls``/``cat``/``info``, and the CLI
34
+ ``sdwire ls``/``cat``/``info``). Fails closed: no plug, a plug that
35
+ can't be read, or any state but OFF is a problem. Each caller words
36
+ its own refusal."""
37
+ if not sbc.power_plug:
38
+ return BoardOffCheck(NO_PLUG)
39
+ from labctl.power import PowerController
40
+ from labctl.power.base import PowerState
41
+
42
+ try:
43
+ state = PowerController.from_plug(sbc.power_plug).get_state()
44
+ except Exception as e:
45
+ return BoardOffCheck(UNREADABLE, error=str(e))
46
+ if state == PowerState.OFF:
47
+ return BoardOffCheck(state=state)
48
+ if state == PowerState.ON:
49
+ return BoardOffCheck(POWERED_ON, state=state)
50
+ return BoardOffCheck(NOT_OFF, state=state)
51
+
52
+
53
+ def board_off_problem_text(
54
+ sbc_name: str, check: BoardOffCheck, *, sentence_start: bool = False
55
+ ) -> str:
56
+ """One sentence (no final period) saying why ``check`` doesn't confirm
57
+ ``sbc_name`` off; callers add their own framing and hints. Generic
58
+ leading words start lower-case unless ``sentence_start``; the board
59
+ name is never re-cased."""
60
+
61
+ def generic(text: str) -> str:
62
+ return text[0].upper() + text[1:] if sentence_start else text
63
+
64
+ if check.problem == NO_PLUG:
65
+ return f"{sbc_name} has no power plug, so labctl can't confirm it is off"
66
+ if check.problem == UNREADABLE:
67
+ return generic(f"could not read the power state of {sbc_name}: {check.error}")
68
+ if check.problem == POWERED_ON:
69
+ return f"{sbc_name} is powered on"
70
+ state = getattr(check.state, "value", check.state)
71
+ return generic(
72
+ f"the power state of {sbc_name} is {state}, so labctl can't confirm "
73
+ "it is off"
74
+ )
75
+
76
+
77
+ # Second look before treating the card as on the board (see read_access).
78
+ READ_RECHECK_SECONDS = 1.0
79
+
80
+
81
+ def read_access(sbc, ctrl, *, force: bool = False):
82
+ """How a read of the SD card (CLI ``sdwire ls``/``cat``/``info``, the
83
+ MCP read tools) reaches it. Returns ``(needs_switch, refusal)``:
84
+
85
+ - Card already on the host (``ctrl.card_on_host()`` probes the reader
86
+ for media): read it in place and leave the mux alone; the board isn't
87
+ using the card, so its power doesn't matter. ``(False, None)``.
88
+ - Card on the board: switch to the host and back only when
89
+ :func:`check_board_off` confirms it off (or ``force``):
90
+ ``(True, None)``; otherwise ``(False, check)`` with the failed
91
+ :class:`BoardOffCheck` for the caller to word.
92
+
93
+ A card switched to the host moments ago (by another process) may still
94
+ be initialising and probe as "no medium"; concluding "on the board"
95
+ would make the cleanup switch it there, so it is probed once more
96
+ after :data:`READ_RECHECK_SECONDS`. (The mux can't be read back.)
97
+ """
98
+ if ctrl.card_on_host():
99
+ return False, None
100
+ time.sleep(READ_RECHECK_SECONDS)
101
+ if ctrl.card_on_host():
102
+ return False, None
103
+ if force:
104
+ return True, None
105
+ check = check_board_off(sbc)
106
+ if not check.is_off:
107
+ return False, check
108
+ return True, None
109
+
110
+
111
+ class NoPlugError(RuntimeError):
112
+ """The board has no power plug, so it can't be switched off (or known
113
+ to be off) before its SD card is taken; callers add how to proceed."""
114
+
115
+
116
+ def no_plug_refusal(sbc) -> str:
117
+ return (
118
+ f"{sbc.name!r} has no power plug, so labctl can't switch it off or "
119
+ "confirm it is off before switching its SD card to the host; nothing "
120
+ "was written to the card"
121
+ )
122
+
123
+
124
+ def power_off_before_host_switch(sbc, *, no_plug_ok: bool = False) -> None:
125
+ """Switch ``sbc`` off before its SD card goes to the host.
126
+
127
+ Raises RuntimeError, before the card is touched, when the board can't
128
+ be switched off: moving the card away from a running board risks bus
129
+ contention and a corrupted card. A board with no power plug fails
130
+ closed too (:class:`NoPlugError`), unless ``no_plug_ok``: the caller
131
+ has made sure it is off. ``no_plug_ok`` never skips a power-off that a
132
+ plug makes possible, nor excuses one that fails.
133
+ """
134
+ if not sbc.power_plug:
135
+ if no_plug_ok:
136
+ return
137
+ raise NoPlugError(no_plug_refusal(sbc))
138
+ from labctl.power import PowerController
139
+
140
+ try:
141
+ ok = PowerController.from_plug(sbc.power_plug).power_off()
142
+ except Exception as e:
143
+ ok, why = False, f": {e}"
144
+ else:
145
+ why = ""
146
+ if not ok:
147
+ raise RuntimeError(
148
+ f"could not power off {sbc.name!r} before switching its SD card "
149
+ f"to the host{why}; nothing was written to the card"
150
+ )
151
+ time.sleep(1)
152
+
153
+
154
+ def deploy_to_card(manager, sbc, ctrl, write) -> None:
155
+ """Power ``sbc`` off, switch its card to the host, run ``write()``, and
156
+ hand the card back to the board.
157
+
158
+ Raises RuntimeError for any failure. Once the board is off, the error
159
+ says it was left off, and whether the card is still on the host.
160
+ """
161
+ from labctl.actuators.runtime import _left_off, boards_on_plug
162
+
163
+ # A missing SDWire must not cost a running board its power.
164
+ try:
165
+ ctrl.ensure_present()
166
+ except Exception as e:
167
+ raise RuntimeError(f"{e}; nothing changed") from e
168
+ power_off_before_host_switch(sbc)
169
+ try:
170
+ ctrl.switch_to_host()
171
+ time.sleep(2)
172
+ write()
173
+ except Exception as e:
174
+ try:
175
+ ctrl.switch_to_dut()
176
+ card = ""
177
+ except Exception:
178
+ card = " (its SD card may still be on the host)"
179
+ raise RuntimeError(
180
+ f"deploy to {sbc.name!r} failed: {e}; "
181
+ f"{_left_off(sbc, boards_on_plug(manager, sbc))}{card}"
182
+ ) from e
183
+ try:
184
+ ctrl.switch_to_dut()
185
+ except Exception as e:
186
+ raise RuntimeError(
187
+ f"deployed to {sbc.name!r}, but its SD card could not be switched "
188
+ f"back to the board: {e}; the card is still on the host and "
189
+ f"{_left_off(sbc, boards_on_plug(manager, sbc))}"
190
+ ) from e
@@ -0,0 +1,18 @@
1
+ """
2
+ Serial port management for lab controller.
3
+
4
+ Handles ser2net configuration generation, serial port discovery,
5
+ and multi-client serial proxying.
6
+ """
7
+
8
+ from labctl.serial.proxy import ProxyClient, ProxyManager, SerialProxy, SessionLogger
9
+ from labctl.serial.ser2net import Ser2NetPort, generate_ser2net_config
10
+
11
+ __all__ = [
12
+ "generate_ser2net_config",
13
+ "Ser2NetPort",
14
+ "SerialProxy",
15
+ "ProxyManager",
16
+ "ProxyClient",
17
+ "SessionLogger",
18
+ ]
@@ -0,0 +1,342 @@
1
+ """
2
+ Automated boot reliability testing.
3
+
4
+ Deploys a kernel image, reboots the SBC multiple times, and captures
5
+ boot output to determine success rate.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from labctl.serial.capture import capture_serial_output
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _write_run_file(path: Path | str, text: str, dir_fd: Optional[int] = None) -> None:
21
+ """Write one run's output without following a planted link.
22
+
23
+ The output directory may be shared (e.g. group-writable
24
+ /var/lib/labctl/output), and the content is console output the DUT
25
+ controls. A symlink planted at ``run_NN.txt`` would otherwise redirect
26
+ the write to any file the service user can write, so open with
27
+ O_NOFOLLOW (fails with ELOOP on a symlink), and refuse a file with
28
+ other hard links. Anything that isn't a regular file (e.g. a planted
29
+ FIFO, which would otherwise block the open forever while the caller
30
+ holds the hardware lock) is refused without blocking. With ``dir_fd``
31
+ (a checked directory handle) the file is created relative to it, so the
32
+ directory path itself can't be swapped either. Raises OSError on
33
+ refusal.
34
+ """
35
+ from labctl.core.safe_open import open_regular_nonblocking
36
+
37
+ fd = open_regular_nonblocking(
38
+ str(path), os.O_WRONLY | os.O_CREAT, 0o644, dir_fd=dir_fd
39
+ )
40
+ try:
41
+ if os.fstat(fd).st_nlink > 1:
42
+ raise OSError(f"refusing to write {path}: it has other hard links")
43
+ os.ftruncate(fd, 0)
44
+ with os.fdopen(fd, "w", closefd=False) as f:
45
+ f.write(text)
46
+ finally:
47
+ os.close(fd)
48
+
49
+
50
+ @dataclass
51
+ class BootRunResult:
52
+ """Result of a single boot run."""
53
+
54
+ run_number: int
55
+ passed: bool
56
+ elapsed_seconds: float
57
+ pattern_matched: bool
58
+ output: str = ""
59
+ last_line: str = ""
60
+ error: str = ""
61
+
62
+
63
+ @dataclass
64
+ class BootTestResult:
65
+ """Result of a complete boot test."""
66
+
67
+ sbc_name: str
68
+ expect_pattern: str
69
+ total_runs: int
70
+ timeout_per_run: float
71
+ image: Optional[str] = None
72
+ dest: Optional[str] = None
73
+ partition: Optional[int] = None
74
+ runs: list[BootRunResult] = field(default_factory=list)
75
+ # Per-run output files that couldn't be written (name: reason); the boot
76
+ # results still stand, but the caller must be told the logs are missing.
77
+ output_errors: list[str] = field(default_factory=list)
78
+ # Why the test stopped before all its runs (e.g. the caller's claim
79
+ # on the board was lost), or None when every run was made.
80
+ stopped_reason: Optional[str] = None
81
+ # The deploy ran (it leaves the board powered off until the first run).
82
+ deployed: bool = False
83
+
84
+ @property
85
+ def passed_count(self) -> int:
86
+ return sum(1 for r in self.runs if r.passed)
87
+
88
+ @property
89
+ def failed_count(self) -> int:
90
+ return sum(1 for r in self.runs if not r.passed)
91
+
92
+ @property
93
+ def pass_rate(self) -> float:
94
+ if not self.runs:
95
+ return 0.0
96
+ return self.passed_count / len(self.runs) * 100
97
+
98
+ @property
99
+ def avg_boot_time(self) -> float:
100
+ passed = [r.elapsed_seconds for r in self.runs if r.passed]
101
+ if not passed:
102
+ return 0.0
103
+ return sum(passed) / len(passed)
104
+
105
+ def format_summary(self) -> str:
106
+ """Format as human-readable summary."""
107
+ lines = [
108
+ f"Boot Test Results: {self.sbc_name}",
109
+ "=" * 40,
110
+ ]
111
+
112
+ if self.image:
113
+ lines.append(
114
+ f"Image: {self.image} -> {self.dest} (partition {self.partition})"
115
+ )
116
+ else:
117
+ lines.append("Image: (no deploy, testing current SD contents)")
118
+
119
+ lines.append(f"Pattern: '{self.expect_pattern}'")
120
+ lines.append(f"Timeout: {self.timeout_per_run:.0f}s per boot")
121
+ lines.append("")
122
+
123
+ for r in self.runs:
124
+ status = "PASS" if r.passed else "FAIL"
125
+ detail = "pattern matched" if r.pattern_matched else ""
126
+ if not r.passed:
127
+ if r.error:
128
+ detail = r.error
129
+ elif r.last_line:
130
+ detail = f'timeout, last output: "{r.last_line}"'
131
+ else:
132
+ detail = "timeout, no output"
133
+ lines.append(
134
+ f"Run {r.run_number:2d}/{self.total_runs}: "
135
+ f"{status} ({r.elapsed_seconds:.1f}s) - {detail}"
136
+ )
137
+
138
+ if self.stopped_reason:
139
+ lines.append(
140
+ f"STOPPED after {len(self.runs)} of {self.total_runs} runs: "
141
+ f"{self.stopped_reason}"
142
+ )
143
+ if not self.runs and self.deployed:
144
+ lines.append(
145
+ f"{self.sbc_name} was powered off for the deploy and is "
146
+ "left off."
147
+ )
148
+
149
+ lines.append("")
150
+ if self.stopped_reason and not self.runs:
151
+ lines.append("Result: STOPPED before the first run (no boots tested)")
152
+ elif self.stopped_reason:
153
+ lines.append(
154
+ f"Result: INCOMPLETE, {self.passed_count}/{len(self.runs)} "
155
+ f"boots successful ({self.pass_rate:.0f}%) in "
156
+ f"{len(self.runs)} of {self.total_runs} runs"
157
+ )
158
+ else:
159
+ lines.append(
160
+ f"Result: {self.passed_count}/{len(self.runs)} boots successful "
161
+ f"({self.pass_rate:.0f}%)"
162
+ )
163
+
164
+ if self.passed_count > 0:
165
+ lines.append(f"Average boot time (successful): {self.avg_boot_time:.1f}s")
166
+
167
+ # Failure mode breakdown
168
+ if self.failed_count > 0:
169
+ timeout_partial = sum(
170
+ 1 for r in self.runs if not r.passed and not r.error and r.last_line
171
+ )
172
+ timeout_none = sum(
173
+ 1 for r in self.runs if not r.passed and not r.error and not r.last_line
174
+ )
175
+ errors = sum(1 for r in self.runs if not r.passed and r.error)
176
+ lines.append("Failure modes:")
177
+ if timeout_partial:
178
+ lines.append(f" - Timeout with partial output: {timeout_partial}")
179
+ if timeout_none:
180
+ lines.append(f" - Timeout with no output: {timeout_none}")
181
+ if errors:
182
+ lines.append(f" - Errors: {errors}")
183
+
184
+ if self.output_errors:
185
+ lines.append("")
186
+ lines.append(
187
+ f"WARNING: {len(self.output_errors)} run output file(s) "
188
+ "were NOT saved:"
189
+ )
190
+ lines.extend(f" - {err}" for err in self.output_errors)
191
+
192
+ return "\n".join(lines)
193
+
194
+
195
+ def run_boot_test(
196
+ sbc_name: str,
197
+ expect_pattern: str,
198
+ tcp_host: str,
199
+ tcp_port: int,
200
+ power_cycle_fn,
201
+ runs: int = 10,
202
+ timeout: float = 30.0,
203
+ deploy_fn=None,
204
+ image: str | None = None,
205
+ dest: str | None = None,
206
+ partition: int = 1,
207
+ output_dir: str | None = None,
208
+ output_dir_fd: Optional[int] = None,
209
+ progress_fn=None,
210
+ before_run_fn=None,
211
+ ) -> BootTestResult:
212
+ """Run automated boot reliability test.
213
+
214
+ Args:
215
+ sbc_name: Name of the SBC being tested
216
+ expect_pattern: Regex to match for "success"
217
+ tcp_host: Host where ser2net is running
218
+ tcp_port: TCP port for serial console
219
+ power_cycle_fn: Callable that power cycles the SBC (off, wait, on)
220
+ runs: Number of boot cycles
221
+ timeout: Seconds to wait per boot for pattern match
222
+ deploy_fn: Optional callable to deploy image before testing
223
+ image: Image filename (for reporting)
224
+ dest: Destination filename (for reporting)
225
+ partition: Partition number (for reporting)
226
+ output_dir: Save per-run output to files here
227
+ output_dir_fd: Optional open handle for ``output_dir`` (already
228
+ checked and created by the caller); files are created
229
+ relative to it and ``output_dir`` is only used for messages
230
+ progress_fn: Optional callback(run_number, total, result) for progress
231
+ before_run_fn: Optional callable run before each run's power cycle;
232
+ it returns None to go on, or a reason to stop the test there
233
+ (recorded as ``stopped_reason``). Used to stop when the
234
+ caller's claim on the board is lost mid-test.
235
+ """
236
+ result = BootTestResult(
237
+ sbc_name=sbc_name,
238
+ expect_pattern=expect_pattern,
239
+ total_runs=runs,
240
+ timeout_per_run=timeout,
241
+ image=image,
242
+ dest=dest,
243
+ partition=partition,
244
+ )
245
+
246
+ # Create output dir if needed (a caller-provided handle already exists).
247
+ # Before the deploy, which powers the board off: a failure here must
248
+ # leave the board as it was.
249
+ if output_dir and output_dir_fd is None:
250
+ os.makedirs(output_dir, exist_ok=True)
251
+
252
+ # Deploy if requested
253
+ if deploy_fn:
254
+ deploy_fn()
255
+ result.deployed = True
256
+
257
+ for i in range(1, runs + 1):
258
+ if before_run_fn:
259
+ reason = before_run_fn()
260
+ if reason:
261
+ logger.warning("Boot test of %s stopped: %s", sbc_name, reason)
262
+ result.stopped_reason = reason
263
+ break
264
+
265
+ run_result = _run_single_boot(
266
+ run_number=i,
267
+ tcp_host=tcp_host,
268
+ tcp_port=tcp_port,
269
+ power_cycle_fn=power_cycle_fn,
270
+ expect_pattern=expect_pattern,
271
+ timeout=timeout,
272
+ )
273
+
274
+ result.runs.append(run_result)
275
+
276
+ # Save per-run output
277
+ if output_dir and run_result.output:
278
+ name = f"run_{i:02d}.txt"
279
+ run_file = Path(output_dir) / name
280
+ try:
281
+ if output_dir_fd is not None:
282
+ _write_run_file(name, run_result.output, dir_fd=output_dir_fd)
283
+ else:
284
+ _write_run_file(run_file, run_result.output)
285
+ except OSError as e:
286
+ logger.warning("Not writing %s: %s", run_file, e)
287
+ result.output_errors.append(f"{name}: {e.strerror or e}")
288
+
289
+ if progress_fn:
290
+ progress_fn(i, runs, run_result)
291
+
292
+ return result
293
+
294
+
295
+ def _run_single_boot(
296
+ run_number: int,
297
+ tcp_host: str,
298
+ tcp_port: int,
299
+ power_cycle_fn,
300
+ expect_pattern: str,
301
+ timeout: float,
302
+ ) -> BootRunResult:
303
+ """Execute a single boot cycle and capture output."""
304
+ try:
305
+ # Power cycle: off, wait, on
306
+ power_cycle_fn()
307
+
308
+ # Small delay for power to settle before capturing
309
+ time.sleep(1)
310
+
311
+ # Capture serial output
312
+ capture = capture_serial_output(
313
+ tcp_host=tcp_host,
314
+ tcp_port=tcp_port,
315
+ timeout=timeout,
316
+ until_pattern=expect_pattern,
317
+ )
318
+
319
+ # Extract last non-empty line for failure reporting
320
+ last_line = ""
321
+ if capture.output:
322
+ output_lines = capture.output.strip().splitlines()
323
+ if output_lines:
324
+ last_line = output_lines[-1][:80] # Truncate
325
+
326
+ return BootRunResult(
327
+ run_number=run_number,
328
+ passed=capture.pattern_matched,
329
+ elapsed_seconds=capture.elapsed_seconds,
330
+ pattern_matched=capture.pattern_matched,
331
+ output=capture.output,
332
+ last_line=last_line,
333
+ )
334
+
335
+ except RuntimeError as e:
336
+ return BootRunResult(
337
+ run_number=run_number,
338
+ passed=False,
339
+ elapsed_seconds=0.0,
340
+ pattern_matched=False,
341
+ error=str(e),
342
+ )