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,1116 @@
1
+ """
2
+ SDWire controller for switching SD cards between DUT and host.
3
+
4
+ Wraps the sdwire Python library to control SDWire devices.
5
+
6
+ Supported device types:
7
+ - sdwirec: SDWireC (Realtek-based, VID 0BDA:0316) — newer, faster
8
+ - sdwire: Legacy SDWire (FTDI-based, VID 04E8:6001)
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import stat
15
+ import subprocess
16
+ import tempfile
17
+ from contextlib import contextmanager
18
+ from datetime import datetime, timezone
19
+ from typing import Optional
20
+
21
+ from labctl.extras import BrokenExtraError, MissingExtraError, is_missing
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SDWireSymlinkError(RuntimeError):
27
+ """Raised when a read operation encounters a symlink."""
28
+
29
+ def __init__(self, path: str, target: str):
30
+ super().__init__(f"Refusing to read symlink: {path}")
31
+ self.path = path
32
+ self.target = target
33
+
34
+
35
+ class SDWireController:
36
+ """Controls an SDWire SD card multiplexer."""
37
+
38
+ def __init__(self, serial_number: str, device_type: str = "sdwirec"):
39
+ self.serial_number = serial_number
40
+ self.device_type = device_type
41
+
42
+ def _get_device(self):
43
+ """Find and return the sdwire device object by serial number."""
44
+ try:
45
+ from sdwire.backend.detect import (
46
+ get_sdwire_devices,
47
+ get_sdwirec_devices,
48
+ )
49
+ except ImportError as e:
50
+ if not is_missing(e, "sdwire"):
51
+ raise BrokenExtraError("sdwire", e) from e
52
+ raise MissingExtraError("sdwire") from e
53
+
54
+ all_devices = []
55
+ try:
56
+ all_devices.extend(get_sdwirec_devices())
57
+ except Exception:
58
+ pass
59
+ try:
60
+ all_devices.extend(get_sdwire_devices())
61
+ except Exception:
62
+ pass
63
+
64
+ for dev in all_devices:
65
+ if dev.serial_string == self.serial_number:
66
+ return dev
67
+
68
+ raise RuntimeError(
69
+ f"SDWire device with serial '{self.serial_number}' not found. "
70
+ "Is it connected?"
71
+ )
72
+
73
+ def ensure_present(self) -> None:
74
+ """Raise RuntimeError unless the SDWire is connected."""
75
+ self._get_device()
76
+
77
+ def switch_to_dut(self) -> None:
78
+ """Switch SD card to the DUT (target SBC boots from it)."""
79
+ logger.debug("Switching SDWire %s to DUT", self.serial_number)
80
+ device = self._get_device()
81
+ device.switch_dut()
82
+ logger.debug("SDWire %s switched to DUT", self.serial_number)
83
+
84
+ def switch_to_host(self) -> None:
85
+ """Switch SD card to the host (dev machine can read/write it)."""
86
+ logger.debug("Switching SDWire %s to host", self.serial_number)
87
+ device = self._get_device()
88
+ device.switch_ts()
89
+ logger.debug("SDWire %s switched to host", self.serial_number)
90
+
91
+ def get_block_device(self, settle_time: float = 0) -> Optional[str]:
92
+ """Get the block device path when SD card is connected to host.
93
+
94
+ Validates the device has actual media (non-zero size) to avoid
95
+ returning stale device nodes from previous enumerations.
96
+
97
+ Args:
98
+ settle_time: Seconds to wait for device to settle (0 = no wait)
99
+
100
+ Returns /dev/sdX path or None if not available.
101
+ """
102
+ import time
103
+
104
+ if settle_time > 0:
105
+ time.sleep(settle_time)
106
+
107
+ try:
108
+ device = self._get_device()
109
+ block_dev = getattr(device, "block_dev", None)
110
+
111
+ if block_dev and _block_device_has_media(block_dev):
112
+ return block_dev
113
+
114
+ if block_dev:
115
+ logger.debug("Block device %s has no media (stale node)", block_dev)
116
+
117
+ return None
118
+
119
+ except (MissingExtraError, BrokenExtraError):
120
+ raise # not "no card": say why sdwire can't be used
121
+ except RuntimeError:
122
+ return None
123
+
124
+ def _block_node(self) -> Optional[str]:
125
+ """The reader's block device node, whether or not it has media."""
126
+ try:
127
+ node = getattr(self._get_device(), "block_dev", None)
128
+ except (MissingExtraError, BrokenExtraError):
129
+ raise
130
+ except RuntimeError:
131
+ return None
132
+ return node if node and os.path.exists(node) else None
133
+
134
+ @staticmethod
135
+ def _probe_media(block_dev: str) -> Optional[bool]:
136
+ """Force the kernel to re-check the reader for media.
137
+
138
+ ``/sys/block/<dev>/size`` lags a mux switch in both directions until
139
+ the kernel next checks the reader (it polls removable disks only if
140
+ ``block.events_dfl_poll_msecs`` is set, ~2 s on many distros, and
141
+ otherwise only on open). Opening the device forces the check, so a
142
+ one-sector read to /dev/null (``sudo dd`` is already permitted for
143
+ flashing) answers: True = readable media, False = "No medium found",
144
+ None = can't tell (no sudo rights, timeout, other errors). LC_ALL=C
145
+ keeps dd's message in English so the match is locale-independent.
146
+ """
147
+ try:
148
+ probe = subprocess.run(
149
+ [
150
+ "sudo",
151
+ "-n",
152
+ "dd",
153
+ f"if={block_dev}",
154
+ "of=/dev/null",
155
+ "bs=512",
156
+ "count=1",
157
+ "status=none",
158
+ ],
159
+ capture_output=True,
160
+ text=True,
161
+ timeout=10,
162
+ env={**os.environ, "LC_ALL": "C"},
163
+ )
164
+ except (OSError, subprocess.TimeoutExpired) as e:
165
+ logger.debug("Media probe of %s unavailable: %s", block_dev, e)
166
+ return None
167
+ if probe.returncode == 0:
168
+ return True
169
+ if "No medium found" in probe.stderr:
170
+ return False
171
+ logger.debug("Media probe of %s failed: %s", block_dev, probe.stderr.strip())
172
+ return None
173
+
174
+ def card_on_host(self) -> bool:
175
+ """True if the SD card is currently switched to the host.
176
+
177
+ Probes the reader (``_probe_media``) whenever its device node
178
+ exists, even if the cached size says 0, so neither a just-switched-
179
+ to-host card nor a just-switched-to-DUT card is misjudged. If the
180
+ probe can't tell, fall back to the cached size.
181
+ """
182
+ node = self._block_node()
183
+ if not node:
184
+ return False
185
+ probed = self._probe_media(node)
186
+ if probed is not None:
187
+ return probed
188
+ return _block_device_has_media(node)
189
+
190
+ def wait_for_host(
191
+ self, timeout: float = 10.0, interval: float = 0.5
192
+ ) -> Optional[str]:
193
+ """After ``switch_to_host``, wait until the card is readable.
194
+
195
+ Returns the block device, or None if it didn't appear in time
196
+ (USB re-enumeration and media detection take a moment).
197
+ """
198
+ import time
199
+
200
+ deadline = time.monotonic() + timeout
201
+ while True:
202
+ if self.card_on_host():
203
+ return self.get_block_device() or self._block_node()
204
+ if time.monotonic() >= deadline:
205
+ return None
206
+ time.sleep(interval)
207
+
208
+ def flash_image(
209
+ self,
210
+ image_path: str,
211
+ block_size: str = "4M",
212
+ timeout: int = 1800,
213
+ format_name: Optional[str] = None,
214
+ image_fd: Optional[int] = None,
215
+ ) -> dict:
216
+ """Write an image to the SD card.
217
+
218
+ Supports raw .img, .img.xz, and .img.gz files. Assumes the SD
219
+ card is already switched to host mode.
220
+
221
+ Args:
222
+ image_path: Path to the image file (what is actually read)
223
+ block_size: Block size for dd (default: 4M)
224
+ timeout: Max seconds for the write (default: 1800 / 30 min)
225
+ format_name: Name whose extension selects the format, if it
226
+ differs from ``image_path``. Callers that resolved a symlink
227
+ (e.g. ``latest.img.xz`` -> ``build-4711``) pass the name the
228
+ user gave, so the format still follows it.
229
+ image_fd: An already-open, checked descriptor for the image. When
230
+ given, the data is read from it (fed to xz/gzip/dd on stdin)
231
+ and ``image_path`` is only used for messages, so nothing
232
+ re-opens the path after the caller's check, and root (dd)
233
+ never opens a host path at all.
234
+
235
+ Returns:
236
+ Dict with bytes_written and elapsed_seconds.
237
+
238
+ Raises:
239
+ RuntimeError: On validation failure or write error.
240
+ """
241
+ import time as time_mod
242
+
243
+ block_dev = self.get_block_device()
244
+ if not block_dev:
245
+ raise RuntimeError(
246
+ "Cannot determine block device. "
247
+ "Is the SD card switched to host mode?"
248
+ )
249
+
250
+ # Safety checks
251
+ _validate_block_device(block_dev)
252
+ _validate_image_file(image_path, format_name, image_fd=image_fd)
253
+ fmt = format_name or image_path
254
+ if image_fd is not None:
255
+ os.lseek(image_fd, 0, os.SEEK_SET)
256
+ # Read from the checked descriptor when given, else from the path.
257
+ source = ["-"] if image_fd is not None else [image_path]
258
+
259
+ logger.info(
260
+ "Flashing %s to %s via SDWire %s",
261
+ image_path,
262
+ block_dev,
263
+ self.serial_number,
264
+ )
265
+
266
+ start = time_mod.monotonic()
267
+
268
+ try:
269
+ if fmt.endswith(".xz"):
270
+ # Pipe: xz -dc image | sudo dd of=dev bs=4M oflag=sync
271
+ decompress = subprocess.Popen(
272
+ ["xz", "-dc", *source],
273
+ stdin=image_fd,
274
+ stdout=subprocess.PIPE,
275
+ )
276
+ dd = subprocess.Popen(
277
+ [
278
+ "sudo",
279
+ "dd",
280
+ f"of={block_dev}",
281
+ f"bs={block_size}",
282
+ "oflag=sync",
283
+ "status=progress",
284
+ ],
285
+ stdin=decompress.stdout,
286
+ stderr=subprocess.PIPE,
287
+ )
288
+ decompress.stdout.close()
289
+ _, dd_stderr = dd.communicate(timeout=timeout)
290
+ if decompress.wait() != 0:
291
+ raise RuntimeError("xz decompression failed")
292
+ if dd.returncode != 0:
293
+ raise RuntimeError(
294
+ f"dd failed: {dd_stderr.decode('utf-8', errors='replace')}"
295
+ )
296
+ elif fmt.endswith(".gz"):
297
+ decompress = subprocess.Popen(
298
+ ["gzip", "-dc", *source],
299
+ stdin=image_fd,
300
+ stdout=subprocess.PIPE,
301
+ )
302
+ dd = subprocess.Popen(
303
+ [
304
+ "sudo",
305
+ "dd",
306
+ f"of={block_dev}",
307
+ f"bs={block_size}",
308
+ "oflag=sync",
309
+ "status=progress",
310
+ ],
311
+ stdin=decompress.stdout,
312
+ stderr=subprocess.PIPE,
313
+ )
314
+ decompress.stdout.close()
315
+ _, dd_stderr = dd.communicate(timeout=timeout)
316
+ if decompress.wait() != 0:
317
+ raise RuntimeError("gzip decompression failed")
318
+ if dd.returncode != 0:
319
+ raise RuntimeError(
320
+ f"dd failed: {dd_stderr.decode('utf-8', errors='replace')}"
321
+ )
322
+ else:
323
+ # With a descriptor, dd reads stdin: root never opens the
324
+ # host path, so a swapped symlink can't redirect the read.
325
+ dd_input = [] if image_fd is not None else [f"if={image_path}"]
326
+ subprocess.run(
327
+ [
328
+ "sudo",
329
+ "dd",
330
+ *dd_input,
331
+ f"of={block_dev}",
332
+ f"bs={block_size}",
333
+ "status=progress",
334
+ "conv=fsync",
335
+ ],
336
+ stdin=image_fd,
337
+ check=True,
338
+ timeout=timeout,
339
+ )
340
+
341
+ subprocess.run(["sudo", "sync"], check=True)
342
+
343
+ elapsed = time_mod.monotonic() - start
344
+ image_size = (
345
+ os.fstat(image_fd).st_size
346
+ if image_fd is not None
347
+ else os.path.getsize(image_path)
348
+ )
349
+
350
+ return {
351
+ "bytes_written": image_size,
352
+ "elapsed_seconds": round(elapsed, 1),
353
+ "block_device": block_dev,
354
+ }
355
+ except subprocess.TimeoutExpired:
356
+ raise RuntimeError(f"Flash timed out after {timeout}s")
357
+ except subprocess.CalledProcessError as e:
358
+ raise RuntimeError(f"Flash failed: {e}") from e
359
+
360
+ def update_files(
361
+ self,
362
+ partition: int,
363
+ file_pairs: list[tuple[str | int, str]],
364
+ renames: list[tuple[str, str]] | None = None,
365
+ deletes: list[str] | None = None,
366
+ source_names: list[str] | None = None,
367
+ ) -> dict[str, list[str]]:
368
+ """Copy, rename, and/or delete files on a partition on the SD card.
369
+
370
+ Mounts the partition, performs operations in order (copies, renames,
371
+ deletes), unmounts. Assumes SD card is already switched to host mode.
372
+
373
+ Args:
374
+ partition: Partition number (e.g., 1 for /dev/sdb1)
375
+ file_pairs: List of (source, dest_path_relative_to_partition_root);
376
+ source is a host path or an already-open, checked file
377
+ descriptor (read from directly, never re-opened by path)
378
+ renames: List of (old_name, new_name) relative to partition root
379
+ deletes: List of filenames relative to partition root
380
+ source_names: Optional file names for the copies, aligned with
381
+ ``file_pairs``. When a destination is an existing
382
+ directory the copy is named after this instead of the
383
+ source path, so a caller that resolved a symlink (e.g.
384
+ ``Image`` -> ``Image-6.1.55``) still gets ``Image``.
385
+
386
+ Returns:
387
+ Dict with keys "copied", "renamed", "deleted", each a list of strings.
388
+
389
+ Raises:
390
+ RuntimeError: If block device not found, mount fails, or any op fails.
391
+ """
392
+ block_dev = self.get_block_device()
393
+ if not block_dev:
394
+ raise RuntimeError(
395
+ "Cannot determine block device. "
396
+ "Is the SD card switched to host mode?"
397
+ )
398
+
399
+ result = {"copied": [], "renamed": [], "deleted": []}
400
+ with self.host_mount(partition, mode="rw", owner_mount=True) as mount:
401
+ # 1. Copies
402
+ for i, (src, dest_relative) in enumerate(file_pairs):
403
+ dest = mount.resolve_path(dest_relative)
404
+ if source_names and os.path.isdir(dest):
405
+ dest = mount.resolve_path(
406
+ os.path.join(dest_relative, source_names[i])
407
+ )
408
+
409
+ dest_dir = os.path.dirname(dest)
410
+ if dest_dir and not os.path.exists(dest_dir):
411
+ os.makedirs(dest_dir, exist_ok=True)
412
+
413
+ logger.info("Copying %s -> %s", src, dest_relative)
414
+ if isinstance(src, int):
415
+ _copy_from_fd(src, dest)
416
+ else:
417
+ shutil.copy2(src, dest)
418
+ result["copied"].append(dest_relative)
419
+
420
+ # 2. Renames
421
+ for old_name, new_name in renames or []:
422
+ old_path = mount.resolve_path(old_name)
423
+ new_path = mount.resolve_path(new_name)
424
+
425
+ if not os.path.exists(old_path):
426
+ raise RuntimeError(
427
+ f"Rename failed: '{old_name}' not found on partition"
428
+ )
429
+ if os.path.exists(new_path):
430
+ raise RuntimeError(
431
+ f"Rename failed: '{new_name}' already exists on partition"
432
+ )
433
+
434
+ new_dir = os.path.dirname(new_path)
435
+ if new_dir and not os.path.exists(new_dir):
436
+ os.makedirs(new_dir, exist_ok=True)
437
+
438
+ logger.info("Renaming %s -> %s", old_name, new_name)
439
+ os.rename(old_path, new_path)
440
+ result["renamed"].append(f"{old_name} -> {new_name}")
441
+
442
+ # 3. Deletes
443
+ for filename in deletes or []:
444
+ file_path = mount.resolve_path(filename)
445
+
446
+ if not os.path.exists(file_path):
447
+ raise RuntimeError(
448
+ f"Delete failed: '{filename}' not found on partition"
449
+ )
450
+ if os.path.isdir(file_path):
451
+ raise RuntimeError(
452
+ f"Delete failed: '{filename}' is a directory, not a file"
453
+ )
454
+
455
+ logger.info("Deleting %s", filename)
456
+ os.remove(file_path)
457
+ result["deleted"].append(filename)
458
+
459
+ subprocess.run(["sudo", "sync"], check=True)
460
+ return result
461
+
462
+ @contextmanager
463
+ def host_mount(self, partition: int, mode: str = "ro", owner_mount: bool = False):
464
+ """Mount a partition on the SD card and yield a safe mount helper."""
465
+ if mode not in {"ro", "rw"}:
466
+ raise ValueError("mode must be 'ro' or 'rw'")
467
+
468
+ block_dev = self.get_block_device()
469
+ if not block_dev:
470
+ raise RuntimeError(
471
+ "Cannot determine block device. "
472
+ "Is the SD card switched to host mode?"
473
+ )
474
+
475
+ part_dev = f"{block_dev}{partition}"
476
+ mount_point = tempfile.mkdtemp(prefix="labctl-sdwire-")
477
+ base = ["nosuid", "nodev", "noexec", "noatime"]
478
+ if mode == "ro":
479
+ attempts = [["ro", *extra, *base] for extra in _ro_no_replay(part_dev)]
480
+ else:
481
+ attempts = [[f"uid={os.getuid()},gid={os.getgid()}", *base]]
482
+
483
+ logger.info("Mounting %s at %s (%s)", part_dev, mount_point, mode)
484
+
485
+ last_err: Optional[subprocess.CalledProcessError] = None
486
+ for options in attempts:
487
+ try:
488
+ subprocess.run(
489
+ [
490
+ "sudo",
491
+ "mount",
492
+ "-o",
493
+ ",".join(options),
494
+ part_dev,
495
+ mount_point,
496
+ ],
497
+ check=True,
498
+ capture_output=True,
499
+ text=True,
500
+ )
501
+ last_err = None
502
+ break
503
+ except subprocess.CalledProcessError as e:
504
+ last_err = e
505
+ if last_err is not None:
506
+ os.rmdir(mount_point)
507
+ stderr = (last_err.stderr or "").strip()
508
+ raise RuntimeError(f"Failed to mount {part_dev}: {stderr}") from last_err
509
+
510
+ mount = _MountedPartition(mount_point, owner_mount=owner_mount)
511
+ try:
512
+ yield mount
513
+ finally:
514
+ logger.info("Unmounting %s", mount_point)
515
+ subprocess.run(
516
+ ["sudo", "umount", mount_point],
517
+ capture_output=True,
518
+ )
519
+ try:
520
+ os.rmdir(mount_point)
521
+ except OSError:
522
+ pass
523
+
524
+ def list_files(
525
+ self,
526
+ partition: int,
527
+ path: str = "/",
528
+ recursive: bool = False,
529
+ max_entries: int = 1000,
530
+ ) -> dict:
531
+ """List entries on a partition with bounded output."""
532
+ if max_entries <= 0:
533
+ raise RuntimeError("max_entries must be greater than 0")
534
+
535
+ with self.host_mount(partition, mode="ro") as mount:
536
+ target = mount.resolve_path(path)
537
+ if not os.path.exists(target):
538
+ raise FileNotFoundError(path)
539
+ if not os.path.isdir(target):
540
+ raise NotADirectoryError(path)
541
+
542
+ entries: list[dict] = []
543
+ truncated = False
544
+ pending = [(target, path.rstrip("/") or "/")]
545
+
546
+ while pending:
547
+ current_fs_path, current_display_path = pending.pop(0)
548
+ try:
549
+ with os.scandir(current_fs_path) as it:
550
+ for entry in it:
551
+ if len(entries) >= max_entries:
552
+ truncated = True
553
+ break
554
+
555
+ entry_path = (
556
+ f"{current_display_path.rstrip('/')}/{entry.name}"
557
+ if current_display_path != "/"
558
+ else f"/{entry.name}"
559
+ )
560
+ entries.append(
561
+ _serialize_dir_entry(
562
+ entry,
563
+ path=entry_path,
564
+ root=target,
565
+ )
566
+ )
567
+
568
+ if recursive and entry.is_dir(follow_symlinks=False):
569
+ pending.append((entry.path, entry_path))
570
+ except PermissionError as e:
571
+ raise PermissionError(path) from e
572
+
573
+ if truncated:
574
+ break
575
+
576
+ return {
577
+ "entries": entries,
578
+ "truncated": truncated,
579
+ "_truncated": truncated,
580
+ }
581
+
582
+ def read_file(
583
+ self,
584
+ partition: int,
585
+ path: str,
586
+ max_bytes: int = 1024 * 1024,
587
+ encoding: str = "text",
588
+ ) -> dict:
589
+ """Read a file from a partition with size and encoding guards."""
590
+ if max_bytes <= 0:
591
+ raise RuntimeError("max_bytes must be greater than 0")
592
+ if encoding not in {"text", "base64", "hex"}:
593
+ raise RuntimeError("encoding must be one of: text, base64, hex")
594
+
595
+ with self.host_mount(partition, mode="ro") as mount:
596
+ target = mount.resolve_path(path)
597
+ if not os.path.lexists(target):
598
+ raise FileNotFoundError(path)
599
+ if os.path.islink(target):
600
+ raise SDWireSymlinkError(path, os.readlink(target))
601
+ if not os.path.isfile(target):
602
+ raise IsADirectoryError(path)
603
+
604
+ stat_result = os.stat(target)
605
+ if stat_result.st_size > max_bytes:
606
+ raise RuntimeError(
607
+ f"File exceeds max_bytes ({stat_result.st_size} > {max_bytes})"
608
+ )
609
+
610
+ try:
611
+ with open(target, "rb") as f:
612
+ data = f.read(max_bytes + 1)
613
+ except PermissionError as e:
614
+ raise PermissionError(path) from e
615
+
616
+ if len(data) > max_bytes:
617
+ raise RuntimeError(
618
+ f"File exceeds max_bytes ({len(data)} > {max_bytes})"
619
+ )
620
+
621
+ metadata = _serialize_stat(stat_result, owner_mount=False)
622
+
623
+ if encoding == "text":
624
+ try:
625
+ content = data.decode("utf-8")
626
+ except UnicodeDecodeError as e:
627
+ raise ValueError("binary_content") from e
628
+ elif encoding == "base64":
629
+ import base64
630
+
631
+ content = base64.b64encode(data).decode("ascii")
632
+ else:
633
+ content = data.hex()
634
+
635
+ return {
636
+ "content": content,
637
+ "encoding": encoding,
638
+ **metadata,
639
+ "truncated": False,
640
+ }
641
+
642
+ def get_disk_info(self) -> dict:
643
+ """Return partition and filesystem metadata for the current SD card."""
644
+ block_dev = self.get_block_device()
645
+ if not block_dev:
646
+ raise RuntimeError(
647
+ "Cannot determine block device. "
648
+ "Is the SD card switched to host mode?"
649
+ )
650
+
651
+ try:
652
+ parted = subprocess.run(
653
+ [
654
+ "sudo",
655
+ "parted",
656
+ "-s",
657
+ "-m",
658
+ block_dev,
659
+ "unit",
660
+ "MiB",
661
+ "print",
662
+ "free",
663
+ ],
664
+ check=True,
665
+ capture_output=True,
666
+ text=True,
667
+ )
668
+ except OSError as e:
669
+ raise RuntimeError(f"Failed to read partition table: {e}") from e
670
+ except subprocess.CalledProcessError as e:
671
+ stderr = (e.stderr or "").strip()
672
+ raise RuntimeError(f"Failed to read partition table: {stderr}") from e
673
+
674
+ return _parse_parted_output(block_dev, parted.stdout)
675
+
676
+
677
+ class _MountedPartition:
678
+ def __init__(self, mount_point: str, owner_mount: bool = False):
679
+ self.mount_point = mount_point
680
+ self.owner_mount = owner_mount
681
+ self._real_mount = os.path.realpath(mount_point)
682
+
683
+ def resolve_path(self, path: str) -> str:
684
+ relative = path.lstrip("/")
685
+ full = os.path.normpath(os.path.join(self.mount_point, relative))
686
+ if os.path.commonpath([self.mount_point, full]) != self.mount_point:
687
+ raise RuntimeError(f"Path traversal rejected: '{path}' escapes partition")
688
+
689
+ parent = full if full == self.mount_point else os.path.dirname(full)
690
+ real_parent = os.path.realpath(parent)
691
+ if (
692
+ not real_parent.startswith(self._real_mount + "/")
693
+ and real_parent != self._real_mount
694
+ ):
695
+ raise RuntimeError(
696
+ f"Path traversal rejected: '{path}' escapes partition via symlink"
697
+ )
698
+ if os.path.lexists(full):
699
+ real_full = os.path.realpath(full)
700
+ if (
701
+ not real_full.startswith(self._real_mount + "/")
702
+ and real_full != self._real_mount
703
+ ):
704
+ raise RuntimeError(
705
+ f"Path traversal rejected: '{path}' target escapes partition"
706
+ )
707
+ return full
708
+
709
+
710
+ def _serialize_mtime(timestamp: float) -> str:
711
+ # timezone.utc rather than datetime.UTC: the latter is Python 3.11+.
712
+ utc = datetime.fromtimestamp(timestamp, timezone.utc)
713
+ return utc.isoformat().replace("+00:00", "Z")
714
+
715
+
716
+ def _serialize_stat(stat_result, owner_mount: bool) -> dict:
717
+ mode = stat_result.st_mode & 0o7777
718
+ if owner_mount:
719
+ mode &= ~0o200
720
+ return {
721
+ "size": stat_result.st_size,
722
+ "mtime": _serialize_mtime(stat_result.st_mtime),
723
+ "mode": f"{mode:04o}",
724
+ }
725
+
726
+
727
+ def _serialize_dir_entry(entry, path: str, root: str) -> dict:
728
+ stat_result = entry.stat(follow_symlinks=False)
729
+ if entry.is_file(follow_symlinks=False):
730
+ entry_type = "file"
731
+ elif entry.is_dir(follow_symlinks=False):
732
+ entry_type = "dir"
733
+ elif entry.is_symlink():
734
+ entry_type = "symlink"
735
+ else:
736
+ entry_type = "other"
737
+
738
+ rel_path = os.path.relpath(entry.path, root)
739
+ return {
740
+ "path": path,
741
+ "name": entry.name,
742
+ "relative_path": "." if rel_path == "." else rel_path,
743
+ "type": entry_type,
744
+ **_serialize_stat(stat_result, owner_mount=False),
745
+ }
746
+
747
+
748
+ def _parse_parted_output(block_dev: str, output: str) -> dict:
749
+ try:
750
+ blkid = subprocess.run(
751
+ ["sudo", "blkid", "-o", "export", block_dev],
752
+ check=False,
753
+ capture_output=True,
754
+ text=True,
755
+ )
756
+ base_device_meta = _parse_blkid_export(blkid.stdout)
757
+ except Exception:
758
+ base_device_meta = {}
759
+
760
+ partitions = []
761
+ free_regions = []
762
+ device_total_bytes = None
763
+ disklabel_type = None
764
+
765
+ for raw_line in output.splitlines():
766
+ line = raw_line.strip()
767
+ if not line or line.startswith("BYT;"):
768
+ continue
769
+ parts = [segment.strip('"') for segment in line.split(":")]
770
+ if line.startswith(block_dev + ":"):
771
+ if len(parts) >= 6:
772
+ device_total_bytes = _mib_to_bytes(parts[1])
773
+ disklabel_type = parts[5] or None
774
+ continue
775
+ if parts[0] in {"Number", "Model", "Disk", "Sector", "Partition", ""}:
776
+ continue
777
+ if len(parts) < 5:
778
+ continue
779
+
780
+ start_mib = _parse_mib(parts[1])
781
+ end_mib = _parse_mib(parts[2])
782
+ size_mib = _parse_mib(parts[3])
783
+ if parts[4].rstrip(";") == "free":
784
+ free_regions.append(
785
+ {
786
+ "start_mib": start_mib,
787
+ "end_mib": end_mib,
788
+ "size_mib": size_mib,
789
+ }
790
+ )
791
+ continue
792
+
793
+ part_num = int(parts[0])
794
+ part_dev = f"{block_dev}{part_num}"
795
+ try:
796
+ blkid_part = subprocess.run(
797
+ ["sudo", "blkid", "-o", "export", part_dev],
798
+ check=False,
799
+ capture_output=True,
800
+ text=True,
801
+ )
802
+ part_meta = _parse_blkid_export(blkid_part.stdout)
803
+ except Exception:
804
+ part_meta = {}
805
+
806
+ partitions.append(
807
+ {
808
+ "num": part_num,
809
+ "start_mib": start_mib,
810
+ "end_mib": end_mib,
811
+ "size_mib": size_mib,
812
+ "type": parts[4] or None,
813
+ "label": parts[5] or part_meta.get("LABEL"),
814
+ "flags": (
815
+ [flag for flag in parts[6].rstrip(";").split(",") if flag]
816
+ if len(parts) > 6 and parts[6].rstrip(";")
817
+ else []
818
+ ),
819
+ "partuuid": part_meta.get("PARTUUID"),
820
+ "filesystem_uuid": part_meta.get("UUID"),
821
+ "mount_status": "mounted" if _is_mounted(part_dev) else "clean",
822
+ }
823
+ )
824
+
825
+ if device_total_bytes is None:
826
+ raise RuntimeError("Unable to parse partition table")
827
+
828
+ return {
829
+ "device_total_bytes": device_total_bytes,
830
+ "disklabel_type": disklabel_type or base_device_meta.get("PTTYPE"),
831
+ "partitions": partitions,
832
+ "free_space_regions": free_regions,
833
+ }
834
+
835
+
836
+ def _parse_blkid_export(output: str) -> dict[str, str]:
837
+ result = {}
838
+ for line in output.splitlines():
839
+ if "=" not in line:
840
+ continue
841
+ key, value = line.split("=", 1)
842
+ result[key.strip()] = value.strip()
843
+ return result
844
+
845
+
846
+ def _parse_mib(value: str) -> float:
847
+ return float(value.removesuffix("MiB"))
848
+
849
+
850
+ def _mib_to_bytes(value: str) -> int:
851
+ return int(round(_parse_mib(value) * 1024 * 1024))
852
+
853
+
854
+ def _is_mounted(device: str) -> bool:
855
+ try:
856
+ with open("/proc/mounts") as f:
857
+ for line in f:
858
+ mount_dev = line.split()[0] if line.strip() else ""
859
+ if mount_dev == device:
860
+ return True
861
+ except OSError:
862
+ return False
863
+ return False
864
+
865
+
866
+ def _validate_block_device(block_dev: str) -> None:
867
+ """Validate a block device is safe to write to.
868
+
869
+ Checks:
870
+ - Device has media (non-zero size)
871
+ - Device is not too large (max 256 GB — not a system disk)
872
+ - No partitions are currently mounted
873
+
874
+ Raises:
875
+ RuntimeError: If any check fails.
876
+ """
877
+ dev_name = os.path.basename(block_dev)
878
+ size_path = f"/sys/block/{dev_name}/size"
879
+
880
+ try:
881
+ with open(size_path) as f:
882
+ sectors = int(f.read().strip())
883
+ except (OSError, ValueError):
884
+ raise RuntimeError(f"Cannot read size of {block_dev}")
885
+
886
+ if sectors == 0:
887
+ raise RuntimeError(f"Block device {block_dev} reports 0 size (no media?)")
888
+
889
+ # 512 bytes per sector; 256 GB max
890
+ size_bytes = sectors * 512
891
+ max_bytes = 256 * 1024 * 1024 * 1024
892
+ if size_bytes > max_bytes:
893
+ size_gb = size_bytes / (1024**3)
894
+ raise RuntimeError(
895
+ f"Block device {block_dev} is {size_gb:.0f} GB — too large for SD card "
896
+ f"(max 256 GB). Refusing to write for safety."
897
+ )
898
+
899
+ # Check no partitions are mounted
900
+ try:
901
+ with open("/proc/mounts") as f:
902
+ mounts = f.read()
903
+ if block_dev in mounts or f"{block_dev}p" in mounts:
904
+ raise RuntimeError(
905
+ f"Block device {block_dev} has mounted partitions. "
906
+ f"Unmount before flashing."
907
+ )
908
+ # Also check numbered partitions (e.g., /dev/sdb1)
909
+ for line in mounts.splitlines():
910
+ mount_dev = line.split()[0] if line.strip() else ""
911
+ if mount_dev.startswith(block_dev):
912
+ raise RuntimeError(
913
+ f"Partition {mount_dev} is mounted. Unmount before flashing."
914
+ )
915
+ except OSError:
916
+ pass # /proc/mounts not available — skip check
917
+
918
+
919
+ def _copy_from_fd(src_fd: int, dest: str) -> None:
920
+ """Copy an open file to ``dest`` like ``shutil.copy2``, by descriptor.
921
+
922
+ Reads from the start of ``src_fd`` (the caller's checked handle), then
923
+ copies mode and timestamps best-effort (FAT filesystems may refuse).
924
+ """
925
+ os.lseek(src_fd, 0, os.SEEK_SET)
926
+ with open(dest, "wb") as out:
927
+ while chunk := os.read(src_fd, 1024 * 1024):
928
+ out.write(chunk)
929
+ st = os.fstat(src_fd)
930
+ try:
931
+ os.chmod(dest, stat.S_IMODE(st.st_mode))
932
+ os.utime(dest, ns=(st.st_atime_ns, st.st_mtime_ns))
933
+ except OSError as e:
934
+ logger.debug("Could not copy metadata to %s: %s", dest, e)
935
+
936
+
937
+ def _validate_image_file(
938
+ image_path: str,
939
+ format_name: Optional[str] = None,
940
+ image_fd: Optional[int] = None,
941
+ ) -> None:
942
+ """Validate an image file before flashing.
943
+
944
+ With ``image_fd`` the open file itself is checked (no path lookup).
945
+
946
+ Raises:
947
+ RuntimeError: If the file is invalid.
948
+ """
949
+ if image_fd is not None:
950
+ if not stat.S_ISREG(os.fstat(image_fd).st_mode):
951
+ raise RuntimeError(f"Not a regular file: {image_path}")
952
+ elif not os.path.exists(image_path):
953
+ raise RuntimeError(f"Image file not found: {image_path}")
954
+ elif not os.path.isfile(image_path):
955
+ raise RuntimeError(f"Not a regular file: {image_path}")
956
+
957
+ supported = (".img", ".img.xz", ".img.gz")
958
+ name = format_name or image_path
959
+ if not any(name.endswith(ext) for ext in supported):
960
+ raise RuntimeError(
961
+ f"Unsupported image format: {name}. " f"Supported: {', '.join(supported)}"
962
+ )
963
+
964
+
965
+ # On-disk signatures of the filesystems whose read-only mount would replay
966
+ # a journal/log: (offset, magic). Used when udev hasn't probed the partition.
967
+ _REPLAYING_FS_MAGIC = {
968
+ "ext4": (1080, b"\x53\xef"), # s_magic 0xEF53 (ext2/3/4)
969
+ "xfs": (0, b"XFSB"),
970
+ "f2fs": (1024, b"\x10\x20\xf5\xf2"), # 0xF2F52010 little-endian
971
+ "btrfs": (65600, b"_BHRfS_M"),
972
+ }
973
+ _FS_PROBE_BYTES = 17 * 4096 # covers the btrfs superblock at 64 KiB
974
+
975
+
976
+ def _fs_by_magic(part_dev: str) -> Optional[str]:
977
+ """Identify a journal-replaying filesystem from its on-disk signature.
978
+
979
+ Returns a key of ``_REPLAYING_FS_MAGIC``, ``"other"`` if none matches
980
+ (no journal replay to worry about), or None if the partition can't be
981
+ read. Reads the first 68 KiB with ``sudo dd`` (already permitted).
982
+ """
983
+ try:
984
+ head = subprocess.run(
985
+ [
986
+ "sudo",
987
+ "-n",
988
+ "dd",
989
+ f"if={part_dev}",
990
+ "bs=4096",
991
+ f"count={_FS_PROBE_BYTES // 4096}",
992
+ "status=none",
993
+ ],
994
+ capture_output=True,
995
+ timeout=10,
996
+ )
997
+ except (OSError, subprocess.TimeoutExpired):
998
+ return None
999
+ if head.returncode != 0 or not head.stdout:
1000
+ return None
1001
+ data = head.stdout
1002
+ for fstype, (offset, magic) in _REPLAYING_FS_MAGIC.items():
1003
+ if data[offset : offset + len(magic)] == magic:
1004
+ return fstype
1005
+ return "other"
1006
+
1007
+
1008
+ def _ro_no_replay(part_dev: str) -> list[list[str]]:
1009
+ """Extra mount options, in order to try, so a read-only mount never writes.
1010
+
1011
+ A plain ``-o ro`` mount of ext3/ext4 (and XFS, F2FS, btrfs) still
1012
+ replays a dirty journal/log, which writes to the card; a dirty journal
1013
+ is normal after a board loses power. ``noload`` / ``norecovery`` /
1014
+ ``nologreplay`` skip the replay (the view may then miss the last
1015
+ unflushed changes, which is fine for reading).
1016
+
1017
+ The type comes from ``lsblk`` (udev's probe, no root). udev probes
1018
+ asynchronously, so right after a mux switch it may not know yet; then
1019
+ the on-disk signature decides (``_fs_by_magic``). Plain ``ro`` is used
1020
+ only for a filesystem positively identified as non-replaying. If the
1021
+ type can't be determined at all, only ``noload`` is tried: a clear mount
1022
+ failure beats a silent write.
1023
+ """
1024
+ try:
1025
+ fstype = subprocess.run(
1026
+ ["lsblk", "-no", "FSTYPE", part_dev],
1027
+ capture_output=True,
1028
+ text=True,
1029
+ timeout=10,
1030
+ ).stdout.strip()
1031
+ except (OSError, subprocess.TimeoutExpired):
1032
+ fstype = ""
1033
+ if not fstype:
1034
+ fstype = _fs_by_magic(part_dev) or ""
1035
+ if fstype in {"ext2", "ext3", "ext4"}:
1036
+ return [["noload"]]
1037
+ if fstype in {"xfs", "f2fs"}:
1038
+ return [["norecovery"]]
1039
+ if fstype == "btrfs":
1040
+ # Both skip log replay: rescue=nologreplay on kernels >= 5.9,
1041
+ # nologreplay before that.
1042
+ return [["rescue=nologreplay"], ["nologreplay"]]
1043
+ if fstype:
1044
+ return [[]] # identified, non-replaying (e.g. vfat, exfat, "other")
1045
+ return [["noload"]]
1046
+
1047
+
1048
+ def _block_device_has_media(block_dev: str) -> bool:
1049
+ """Check if a block device has actual media (non-zero size)."""
1050
+ if not block_dev or not os.path.exists(block_dev):
1051
+ return False
1052
+ size_path = f"/sys/block/{os.path.basename(block_dev)}/size"
1053
+ try:
1054
+ with open(size_path) as f:
1055
+ return int(f.read().strip()) > 0
1056
+ except (OSError, ValueError):
1057
+ return False
1058
+
1059
+
1060
+ def discover_sdwire_devices() -> list[dict]:
1061
+ """Discover all connected SDWire devices.
1062
+
1063
+ Returns a list of dicts with serial_number, device_type, and block_dev.
1064
+ Detects both SDWireC (Realtek) and legacy SDWire (FTDI) devices.
1065
+ """
1066
+ try:
1067
+ from sdwire.backend.detect import (
1068
+ get_sdwire_devices,
1069
+ get_sdwirec_devices,
1070
+ )
1071
+ except ImportError as e:
1072
+ if not is_missing(e, "sdwire"):
1073
+ raise BrokenExtraError("sdwire", e) from e
1074
+ raise MissingExtraError("sdwire") from e
1075
+
1076
+ results = []
1077
+ seen_serials = set()
1078
+
1079
+ # Legacy SDWire (FTDI-based, VID 04E8:6001)
1080
+ try:
1081
+ for dev in get_sdwirec_devices():
1082
+ serial = dev.serial_string
1083
+ if serial and serial not in seen_serials:
1084
+ seen_serials.add(serial)
1085
+ results.append(
1086
+ {
1087
+ "serial_number": serial,
1088
+ "device_type": "sdwire",
1089
+ "product": getattr(dev, "product_string", ""),
1090
+ "manufacturer": getattr(dev, "manufacturer_string", ""),
1091
+ "block_dev": getattr(dev, "block_dev", None),
1092
+ }
1093
+ )
1094
+ except Exception:
1095
+ pass
1096
+
1097
+ # SDWireC (Realtek-based, VID 0BDA:0316)
1098
+ # get_sdwire_devices() also returns legacy devices, so deduplicate
1099
+ try:
1100
+ for dev in get_sdwire_devices():
1101
+ serial = dev.serial_string
1102
+ if serial and serial not in seen_serials:
1103
+ seen_serials.add(serial)
1104
+ results.append(
1105
+ {
1106
+ "serial_number": serial,
1107
+ "device_type": "sdwirec",
1108
+ "product": getattr(dev, "product_string", ""),
1109
+ "manufacturer": getattr(dev, "manufacturer_string", ""),
1110
+ "block_dev": getattr(dev, "block_dev", None),
1111
+ }
1112
+ )
1113
+ except Exception:
1114
+ pass
1115
+
1116
+ return results