lockwire 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 (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/scan.py ADDED
@@ -0,0 +1,732 @@
1
+ """Read-only Linux/ROS 2 enumeration into a non-authoritative draft artifact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, ConfigDict
16
+
17
+ from lockwire.cli_line import cli_line
18
+ from lockwire.schema import (
19
+ SCHEMA_VERSION,
20
+ Bus,
21
+ DeviceFamily,
22
+ DeviceIdentity,
23
+ DeviceInstance,
24
+ DeviceModel,
25
+ Driver,
26
+ Host,
27
+ RobotInfo,
28
+ Topology,
29
+ )
30
+
31
+ SOURCE_NAMES = ("lsusb", "ip-link", "i2c-sysfs", "device-tree", "v4l2", "ros2-nodes")
32
+ _USB_LINE = re.compile(
33
+ r"^Bus \d+ Device \d+: ID (?P<vid_pid>[0-9A-Fa-f]{4}:[0-9A-Fa-f]{4})(?:\s+(?P<label>.*))?$"
34
+ )
35
+ _I2C_ENTRY = re.compile(r"^(?P<bus>\d+)-(?P<address>[0-9A-Fa-f]{4})$")
36
+ _ROS_DISTRO = re.compile(r"^[A-Za-z0-9_-]+$")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CommandResult:
41
+ returncode: int
42
+ stdout: str = ""
43
+ stderr: str = ""
44
+
45
+
46
+ CommandRunner = Callable[[tuple[str, ...]], CommandResult]
47
+
48
+
49
+ class SourceVisibility(BaseModel):
50
+ """Whether a source was usable; unavailable and malformed are normal draft states.
51
+
52
+ ``fs_access`` records *how* a filesystem source was read (i2c / device-tree).
53
+ ``None`` means unknown — callers that refuse host-side facts must treat it as
54
+ unsafe, never as an implicit runner read. Absent from older draft artifacts;
55
+ default keeps those loadable under ``extra="forbid"``.
56
+ """
57
+
58
+ model_config = ConfigDict(extra="forbid")
59
+
60
+ status: Literal["observed", "unavailable", "malformed"]
61
+ items: int = 0
62
+ detail: str | None = None
63
+ fs_access: Literal["local", "runner"] | None = None
64
+
65
+
66
+ class ScanCandidate(BaseModel):
67
+ """Scanned observations shaped for review, never an authoritative Contract."""
68
+
69
+ model_config = ConfigDict(extra="forbid")
70
+
71
+ artifact_type: Literal["lockwire-scan-candidate"]
72
+ schema_version: str = SCHEMA_VERSION
73
+ robot: RobotInfo
74
+ families: list[DeviceFamily]
75
+ device_models: list[DeviceModel]
76
+ devices: list[DeviceInstance]
77
+ topology: Topology
78
+
79
+
80
+ class ScanDraft(BaseModel):
81
+ """Scan evidence and its non-authoritative candidate."""
82
+
83
+ model_config = ConfigDict(extra="forbid")
84
+
85
+ artifact_type: Literal["lockwire-scan-draft"] = "lockwire-scan-draft"
86
+ complete: Literal[False] = False
87
+ sources: dict[str, SourceVisibility]
88
+ candidate: ScanCandidate
89
+
90
+
91
+ class UnsupportedPlatformError(RuntimeError):
92
+ pass
93
+
94
+
95
+ def subprocess_runner(command: tuple[str, ...], *, timeout: float = 10) -> CommandResult:
96
+ """Run a fixed read-only enumeration command without invoking a shell."""
97
+
98
+ try:
99
+ completed = subprocess.run(
100
+ command,
101
+ check=False,
102
+ capture_output=True,
103
+ text=True,
104
+ timeout=timeout,
105
+ )
106
+ except FileNotFoundError as error:
107
+ return CommandResult(127, stderr=str(error))
108
+ except (OSError, subprocess.TimeoutExpired) as error:
109
+ return CommandResult(1, stderr=str(error))
110
+ return CommandResult(completed.returncode, completed.stdout, completed.stderr)
111
+
112
+
113
+ def docker_runner(
114
+ container: str, *, timeout: float = 10, ros_distro: str = "jazzy"
115
+ ) -> CommandRunner:
116
+ """Run scan commands inside a container via ``docker exec``.
117
+
118
+ Same ``CommandResult`` shape and timeout/OSError behaviour as
119
+ ``subprocess_runner``. ROS 2 commands are prefixed with the distro setup
120
+ script (``bash -lc 'source /opt/ros/<distro>/setup.bash && …'``); other
121
+ commands still run under ``bash -lc`` so a missing binary surfaces as a
122
+ non-zero exit (scan's ``_unavailable`` path), not a local ``FileNotFoundError``.
123
+
124
+ ``ros_distro`` is spliced unquoted into the ``bash -lc`` string (it names a
125
+ path segment, ``/opt/ros/<distro>/setup.bash``, so quoting alone would not
126
+ stop a ``../`` traversal); it must match a plain distro-name charset or this
127
+ raises ``ValueError`` before any command runs.
128
+ """
129
+ if not _ROS_DISTRO.match(ros_distro):
130
+ raise ValueError(f"invalid --ros-distro {ros_distro!r}: must match {_ROS_DISTRO.pattern}")
131
+
132
+ def run(command: tuple[str, ...]) -> CommandResult:
133
+ quoted = cli_line(*command)
134
+ if "ros2" in command:
135
+ inner = f"source /opt/ros/{ros_distro}/setup.bash && {quoted}"
136
+ else:
137
+ inner = quoted
138
+ docker_cmd = ("docker", "exec", container, "bash", "-lc", inner)
139
+ try:
140
+ completed = subprocess.run(
141
+ docker_cmd,
142
+ check=False,
143
+ capture_output=True,
144
+ text=True,
145
+ timeout=timeout,
146
+ )
147
+ except FileNotFoundError as error:
148
+ return CommandResult(127, stderr=str(error))
149
+ except (OSError, subprocess.TimeoutExpired) as error:
150
+ return CommandResult(1, stderr=str(error))
151
+ return CommandResult(completed.returncode, completed.stdout, completed.stderr)
152
+
153
+ return run
154
+
155
+
156
+ Fetch = Callable[[str, Path], None]
157
+
158
+
159
+ def _ssh_error_detail(detail: str, identity_file: Path, known_hosts: Path) -> str:
160
+ """Do not return credential-file locations from ssh/scp diagnostics."""
161
+ return detail.replace(str(identity_file), "<ssh-key-file>").replace(
162
+ str(known_hosts), "<ssh-known-hosts-file>"
163
+ )
164
+
165
+
166
+ def ssh_runner(
167
+ host: str,
168
+ *,
169
+ port: int,
170
+ identity_file: Path,
171
+ known_hosts: Path,
172
+ user: str = "root",
173
+ timeout: float = 10,
174
+ ros_distro: str = "jazzy",
175
+ ) -> CommandRunner:
176
+ """Run the fixed live-capture commands over a host-verified SSH session.
177
+
178
+ This is the transport counterpart to :func:`docker_runner`: command tuples
179
+ stay command tuples until ``cli_line`` quotes the remote ``bash -lc`` input.
180
+ The caller supplies the target's known-hosts file; this helper never disables
181
+ host-key verification merely to make a local fixture convenient.
182
+ """
183
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]*", host):
184
+ raise ValueError(f"invalid SSH host {host!r}")
185
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", user):
186
+ raise ValueError(f"invalid SSH user {user!r}")
187
+ if not 1 <= port <= 65535:
188
+ raise ValueError(f"invalid SSH port {port!r}")
189
+ if not _ROS_DISTRO.match(ros_distro):
190
+ raise ValueError(f"invalid --ros-distro {ros_distro!r}: must match {_ROS_DISTRO.pattern}")
191
+
192
+ def run(command: tuple[str, ...]) -> CommandResult:
193
+ inner = cli_line(*command)
194
+ if "ros2" in command:
195
+ inner = f"source /opt/ros/{ros_distro}/setup.bash && {inner}"
196
+ argv = (
197
+ "ssh", "-T", "-p", str(port), "-i", str(identity_file),
198
+ "-o", "BatchMode=yes",
199
+ "-o", "StrictHostKeyChecking=yes",
200
+ "-o", f"UserKnownHostsFile={known_hosts}",
201
+ "-o", "GlobalKnownHostsFile=/dev/null",
202
+ "-o", f"ConnectTimeout={max(1, int(timeout))}",
203
+ "--", f"{user}@{host}", cli_line("bash", "-lc", inner),
204
+ )
205
+ try:
206
+ completed = subprocess.run(
207
+ argv, check=False, capture_output=True, text=True, timeout=timeout
208
+ )
209
+ except FileNotFoundError as error:
210
+ return CommandResult(127, stderr=_ssh_error_detail(str(error), identity_file, known_hosts))
211
+ except (OSError, subprocess.TimeoutExpired) as error:
212
+ return CommandResult(1, stderr=_ssh_error_detail(str(error), identity_file, known_hosts))
213
+ return CommandResult(
214
+ completed.returncode, completed.stdout,
215
+ _ssh_error_detail(completed.stderr, identity_file, known_hosts),
216
+ )
217
+
218
+ return run
219
+
220
+
221
+ def _is_live_capture_dir(remote_dir: str) -> bool:
222
+ """Accept only non-traversing paths produced by the live recorder."""
223
+ return (
224
+ remote_dir.startswith("/tmp/lockwire-live-")
225
+ and re.fullmatch(r"[A-Za-z0-9._/-]+", remote_dir) is not None
226
+ and all(part not in {".", ".."} for part in remote_dir.split("/"))
227
+ )
228
+
229
+
230
+ def _ssh_bag_manifest(runner: CommandRunner, remote_dir: str) -> dict[str, tuple[str, int]]:
231
+ """Read the finalized bag identity from the verified SSH target."""
232
+ entries = runner((
233
+ "find", remote_dir, "-mindepth", "1", "-maxdepth", "1", "-printf", "%f\t%y\n"
234
+ ))
235
+ if entries.returncode != 0:
236
+ raise OSError(f"remote bag listing failed: {entries.stderr.strip()}")
237
+ manifest: dict[str, tuple[str, int]] = {}
238
+ for line in entries.stdout.splitlines():
239
+ fields = line.split("\t")
240
+ if len(fields) != 2 or fields[1] != "f" or not re.fullmatch(r"[A-Za-z0-9._-]+", fields[0]):
241
+ raise OSError("remote bag contains an unsafe or non-file entry")
242
+ name = fields[0]
243
+ if name in manifest:
244
+ raise OSError("remote bag contains duplicate names")
245
+ digest = runner(("sha256sum", f"{remote_dir}/{name}"))
246
+ size = runner(("stat", "-c", "%s", f"{remote_dir}/{name}"))
247
+ if digest.returncode != 0 or size.returncode != 0:
248
+ raise OSError(f"remote bag identity unavailable for {name}")
249
+ sha = digest.stdout.split(maxsplit=1)[0] if digest.stdout.strip() else ""
250
+ raw_size = size.stdout.strip()
251
+ if not re.fullmatch(r"[0-9a-f]{64}", sha) or not raw_size.isdigit():
252
+ raise OSError(f"remote bag identity malformed for {name}")
253
+ manifest[name] = (sha, int(raw_size))
254
+ if "metadata.yaml" not in manifest or not any(name.endswith(".mcap") for name in manifest):
255
+ raise OSError("remote bag is missing metadata.yaml or MCAP data")
256
+ return manifest
257
+
258
+
259
+ def ssh_fetch(
260
+ host: str,
261
+ *,
262
+ port: int,
263
+ identity_file: Path,
264
+ known_hosts: Path,
265
+ remote_dir: str,
266
+ local_dir: Path,
267
+ user: str = "root",
268
+ timeout: float = 60,
269
+ ) -> None:
270
+ """Fetch one finalized SSH live-capture directory with host verification."""
271
+ if not _is_live_capture_dir(remote_dir):
272
+ raise ValueError(f"invalid live capture directory {remote_dir!r}")
273
+ runner = ssh_runner(
274
+ host, port=port, identity_file=identity_file, known_hosts=known_hosts,
275
+ user=user, timeout=timeout,
276
+ )
277
+ # The SSH host key anchors the expected source bytes before transfer.
278
+ expected = _ssh_bag_manifest(runner, remote_dir)
279
+ argv = (
280
+ "scp", "-q", "-r", "-P", str(port), "-i", str(identity_file),
281
+ "-o", "BatchMode=yes",
282
+ "-o", "StrictHostKeyChecking=yes",
283
+ "-o", f"UserKnownHostsFile={known_hosts}",
284
+ "-o", "GlobalKnownHostsFile=/dev/null",
285
+ f"{user}@{host}:{remote_dir}", str(local_dir),
286
+ )
287
+ try:
288
+ completed = subprocess.run(
289
+ argv, check=False, capture_output=True, text=True, timeout=timeout
290
+ )
291
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as error:
292
+ raise OSError(
293
+ f"scp fetch failed: {_ssh_error_detail(str(error), identity_file, known_hosts)}"
294
+ ) from None
295
+ if completed.returncode != 0:
296
+ detail = _ssh_error_detail(completed.stderr.strip(), identity_file, known_hosts) or f"scp exited {completed.returncode}"
297
+ raise OSError(f"scp fetch {host}:{remote_dir}: {detail}")
298
+ if _ssh_bag_manifest(runner, remote_dir) != expected:
299
+ raise OSError("remote bag changed during SSH transfer")
300
+ if not local_dir.is_dir():
301
+ raise OSError("scp did not create a local bag directory")
302
+ local_names = {item.name for item in local_dir.iterdir()}
303
+ if local_names != set(expected):
304
+ raise OSError("fetched bag file set differs from remote source")
305
+ for name, (digest, size) in expected.items():
306
+ item = local_dir / name
307
+ if not item.is_file() or item.is_symlink() or item.stat().st_size != size:
308
+ raise OSError(f"fetched bag file differs from remote source: {name}")
309
+ actual = hashlib.sha256()
310
+ with item.open("rb") as stream:
311
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
312
+ actual.update(chunk)
313
+ if actual.hexdigest() != digest:
314
+ raise OSError(f"fetched bag digest differs from remote source: {name}")
315
+
316
+
317
+ def docker_fetch(container: str, *, timeout: float = 60) -> Fetch:
318
+ """``docker cp`` a directory out of ``container`` into a local path.
319
+
320
+ Same error posture as ``docker_runner``: a failure raises rather than
321
+ returning a silently-empty directory, so a capture that never left the
322
+ container cannot be mistaken for an empty recording.
323
+ """
324
+
325
+ def fetch(remote_dir: str, local_dir: Path) -> None:
326
+ completed = subprocess.run(
327
+ ("docker", "cp", f"{container}:{remote_dir}", str(local_dir)),
328
+ check=False,
329
+ capture_output=True,
330
+ text=True,
331
+ timeout=timeout,
332
+ )
333
+ if completed.returncode != 0:
334
+ detail = completed.stderr.strip() or f"docker cp exited {completed.returncode}"
335
+ raise OSError(f"docker cp {container}:{remote_dir} {local_dir}: {detail}")
336
+
337
+ return fetch
338
+
339
+
340
+ def local_fetch(remote_dir: str, local_dir: Path) -> None:
341
+ """Copy a capture directory that already sits on the machine lockwire runs on."""
342
+ import shutil
343
+
344
+ shutil.copytree(remote_dir, local_dir)
345
+
346
+
347
+ def scan_machine(
348
+ robot: str,
349
+ *,
350
+ runner: CommandRunner = subprocess_runner,
351
+ fs_root: Path = Path("/"),
352
+ fs_access: Literal["local", "runner"] = "local",
353
+ platform: str = sys.platform,
354
+ ) -> ScanDraft:
355
+ """Enumerate a Linux machine; command execution and filesystem root are injectable.
356
+
357
+ ``fs_access="local"`` reads i2c/device-tree via ``Path`` under ``fs_root``
358
+ (host-side). ``fs_access="runner"`` reads the same trees by executing
359
+ ``ls``/``cat``/``find`` through ``runner`` — required when the target is a
360
+ container and host Path would silently sample the wrong machine.
361
+ """
362
+
363
+ if not platform.startswith("linux"):
364
+ raise UnsupportedPlatformError(
365
+ f"lockwire scan supports Linux + ROS 2 only; current platform is {platform!r}"
366
+ )
367
+
368
+ builder = _DraftBuilder(robot)
369
+ _scan_lsusb(builder, runner)
370
+ _scan_ip_link(builder, runner)
371
+ if fs_access == "runner":
372
+ _scan_i2c_via_runner(builder, runner, fs_root)
373
+ _scan_device_tree_via_runner(builder, runner, fs_root)
374
+ else:
375
+ _scan_i2c(builder, fs_root)
376
+ _scan_device_tree(builder, fs_root)
377
+ _scan_v4l2(builder, runner)
378
+ _scan_ros2_nodes(builder, runner)
379
+ return builder.build()
380
+
381
+
382
+ class _DraftBuilder:
383
+ def __init__(self, robot: str) -> None:
384
+ self.robot = robot
385
+ self.host_id = "host.local"
386
+ self.families: dict[str, DeviceFamily] = {}
387
+ self.models: dict[str, DeviceModel] = {}
388
+ self.devices: list[DeviceInstance] = []
389
+ self.buses: dict[str, Bus] = {}
390
+ self.sources: dict[str, SourceVisibility] = {}
391
+ self._ids: dict[str, int] = {}
392
+
393
+ def family(self, key: str, name: str) -> str:
394
+ family_id = f"fam.{_slug(key)}"
395
+ self.families.setdefault(family_id, DeviceFamily(id=family_id, name=name))
396
+ return family_id
397
+
398
+ def model(self, namespace: str, key: str, family: str) -> str:
399
+ model_id = f"mdl.{_slug(namespace)}.{_slug(key)}"
400
+ self.models.setdefault(model_id, DeviceModel(id=model_id, family=family))
401
+ return model_id
402
+
403
+ def device_id(self, namespace: str, key: str) -> str:
404
+ base = f"dev.{_slug(namespace)}.{_slug(key)}"
405
+ count = self._ids.get(base, 0) + 1
406
+ self._ids[base] = count
407
+ return base if count == 1 else f"{base}.{count}"
408
+
409
+ def bus(
410
+ self, bus_id: str, kind: Literal["usb", "ethernet", "serial", "can", "i2c", "spi"]
411
+ ) -> str:
412
+ self.buses.setdefault(bus_id, Bus(id=bus_id, kind=kind, host=self.host_id))
413
+ return bus_id
414
+
415
+ def visibility(
416
+ self,
417
+ source: str,
418
+ status: Literal["observed", "unavailable", "malformed"],
419
+ items: int = 0,
420
+ detail: str | None = None,
421
+ fs_access: Literal["local", "runner"] | None = None,
422
+ ) -> None:
423
+ self.sources[source] = SourceVisibility(
424
+ status=status, items=items, detail=detail, fs_access=fs_access
425
+ )
426
+
427
+ def build(self) -> ScanDraft:
428
+ candidate = ScanCandidate(
429
+ artifact_type="lockwire-scan-candidate",
430
+ robot=RobotInfo(name=self.robot),
431
+ families=list(self.families.values()),
432
+ device_models=list(self.models.values()),
433
+ devices=self.devices,
434
+ topology=Topology(
435
+ hosts=[Host(id=self.host_id)],
436
+ buses=list(self.buses.values()),
437
+ ),
438
+ )
439
+ return ScanDraft(
440
+ sources={name: self.sources[name] for name in SOURCE_NAMES},
441
+ candidate=candidate,
442
+ )
443
+
444
+
445
+ def _run(runner: CommandRunner, command: tuple[str, ...]) -> CommandResult:
446
+ try:
447
+ return runner(command)
448
+ except (FileNotFoundError, OSError) as error:
449
+ return CommandResult(127, stderr=str(error))
450
+
451
+
452
+ def _unavailable(builder: _DraftBuilder, source: str, result: CommandResult) -> bool:
453
+ if result.returncode == 0:
454
+ return False
455
+ detail = result.stderr.strip() or f"command exited {result.returncode}"
456
+ builder.visibility(source, "unavailable", detail=detail)
457
+ return True
458
+
459
+
460
+ def _scan_lsusb(builder: _DraftBuilder, runner: CommandRunner) -> None:
461
+ result = _run(runner, ("lsusb",))
462
+ if _unavailable(builder, "lsusb", result):
463
+ return
464
+ matches = [_USB_LINE.fullmatch(line.strip()) for line in result.stdout.splitlines()]
465
+ parsed = [match for match in matches if match is not None]
466
+ if any(match is None for match in matches):
467
+ builder.visibility("lsusb", "malformed", detail="one or more unparseable lsusb rows")
468
+ return
469
+
470
+ bus_ref = builder.bus("bus.usb", "usb") if parsed else None
471
+ family = builder.family("usb-device", "USB device") if parsed else None
472
+ for match in parsed:
473
+ vid_pid = match.group("vid_pid").lower()
474
+ label = match.group("label") or vid_pid
475
+ model = builder.model("usb", vid_pid, family)
476
+ builder.devices.append(
477
+ DeviceInstance(
478
+ id=builder.device_id("usb", label),
479
+ model=model,
480
+ identity=DeviceIdentity(
481
+ bus_ref=bus_ref,
482
+ host=builder.host_id,
483
+ vid_pid=vid_pid,
484
+ ),
485
+ owner="UNRESOLVED",
486
+ )
487
+ )
488
+ builder.visibility("lsusb", "observed", len(parsed))
489
+
490
+
491
+ def _scan_ip_link(builder: _DraftBuilder, runner: CommandRunner) -> None:
492
+ result = _run(runner, ("ip", "-j", "link"))
493
+ if _unavailable(builder, "ip-link", result):
494
+ return
495
+ try:
496
+ rows = json.loads(result.stdout)
497
+ if not isinstance(rows, list):
498
+ raise ValueError("top-level JSON is not a list")
499
+ except (json.JSONDecodeError, ValueError) as error:
500
+ builder.visibility("ip-link", "malformed", detail=str(error))
501
+ return
502
+
503
+ count = 0
504
+ ignored = 0
505
+ for row in rows:
506
+ if not isinstance(row, dict) or not isinstance(row.get("ifname"), str):
507
+ builder.visibility("ip-link", "malformed", detail="link row missing string ifname")
508
+ return
509
+ if row.get("link_type") == "loopback":
510
+ continue
511
+ info = row.get("linkinfo")
512
+ if isinstance(info, dict) and info.get("info_kind") == "can":
513
+ kind = "can"
514
+ elif row.get("link_type") == "ether":
515
+ kind = "ethernet"
516
+ else:
517
+ ignored += 1
518
+ continue
519
+ builder.bus(f"bus.net.{_slug(row['ifname'])}", kind)
520
+ count += 1
521
+ detail = f"{ignored} unsupported interface(s) omitted" if ignored else None
522
+ builder.visibility("ip-link", "observed", count, detail)
523
+
524
+
525
+ def _rooted(fs_root: Path, absolute: str) -> Path:
526
+ return fs_root / absolute.lstrip("/")
527
+
528
+
529
+ def _append_i2c_device(builder: _DraftBuilder, entry_name: str, label: str) -> None:
530
+ match = _I2C_ENTRY.fullmatch(entry_name)
531
+ if match is None:
532
+ return
533
+ family = builder.family("i2c-device", "I2C device")
534
+ bus_ref = builder.bus(f"bus.i2c{match.group('bus')}", "i2c")
535
+ model = builder.model("i2c", label, family)
536
+ builder.devices.append(
537
+ DeviceInstance(
538
+ id=builder.device_id("i2c", label),
539
+ model=model,
540
+ identity=DeviceIdentity(
541
+ bus_ref=bus_ref,
542
+ host=builder.host_id,
543
+ address=hex(int(match.group("address"), 16)),
544
+ ),
545
+ owner="UNRESOLVED",
546
+ )
547
+ )
548
+
549
+
550
+ def _scan_i2c(builder: _DraftBuilder, fs_root: Path) -> None:
551
+ path = _rooted(fs_root, "/sys/bus/i2c/devices")
552
+ if not path.is_dir():
553
+ builder.visibility(
554
+ "i2c-sysfs", "unavailable", detail=f"{path} is absent", fs_access="local"
555
+ )
556
+ return
557
+
558
+ count = 0
559
+ try:
560
+ entries = sorted(path.iterdir(), key=lambda entry: entry.name)
561
+ except OSError as error:
562
+ builder.visibility("i2c-sysfs", "unavailable", detail=str(error), fs_access="local")
563
+ return
564
+ for entry in entries:
565
+ if _I2C_ENTRY.fullmatch(entry.name) is None:
566
+ continue
567
+ label = _read_text(entry / "name") or entry.name
568
+ _append_i2c_device(builder, entry.name, label)
569
+ count += 1
570
+ builder.visibility("i2c-sysfs", "observed", count, fs_access="local")
571
+
572
+
573
+ def _scan_i2c_via_runner(builder: _DraftBuilder, runner: CommandRunner, fs_root: Path) -> None:
574
+ """Read i2c sysfs through ``runner`` so a container target is not replaced by the host."""
575
+
576
+ path = _rooted(fs_root, "/sys/bus/i2c/devices").as_posix()
577
+ listing = _run(runner, ("ls", "-1", path))
578
+ if listing.returncode != 0:
579
+ detail = listing.stderr.strip() or f"{path} is absent"
580
+ builder.visibility("i2c-sysfs", "unavailable", detail=detail, fs_access="runner")
581
+ return
582
+
583
+ count = 0
584
+ for entry_name in sorted(line.strip() for line in listing.stdout.splitlines() if line.strip()):
585
+ if _I2C_ENTRY.fullmatch(entry_name) is None:
586
+ continue
587
+ name_path = f"{path}/{entry_name}/name"
588
+ named = _run(runner, ("cat", name_path))
589
+ label = (
590
+ named.stdout.strip() if named.returncode == 0 and named.stdout.strip() else entry_name
591
+ )
592
+ _append_i2c_device(builder, entry_name, label)
593
+ count += 1
594
+ builder.visibility("i2c-sysfs", "observed", count, fs_access="runner")
595
+
596
+
597
+ def _scan_device_tree(builder: _DraftBuilder, fs_root: Path) -> None:
598
+ path = _rooted(fs_root, "/proc/device-tree")
599
+ if not path.is_dir():
600
+ builder.visibility(
601
+ "device-tree", "unavailable", detail=f"{path} is absent", fs_access="local"
602
+ )
603
+ return
604
+
605
+ count = 0
606
+ try:
607
+ compatible_files = sorted(path.rglob("compatible"))
608
+ except OSError as error:
609
+ builder.visibility("device-tree", "unavailable", detail=str(error), fs_access="local")
610
+ return
611
+ for compatible_file in compatible_files:
612
+ try:
613
+ compatible = compatible_file.read_bytes().split(b"\0", 1)[0].decode()
614
+ except (OSError, UnicodeDecodeError):
615
+ continue
616
+ if not compatible:
617
+ continue
618
+ family = builder.family("device-tree-peripheral", "Device-tree peripheral")
619
+ model = builder.model("device-tree", compatible, family)
620
+ relative = compatible_file.parent.relative_to(path).as_posix()
621
+ builder.devices.append(
622
+ DeviceInstance(
623
+ id=builder.device_id("device-tree", relative),
624
+ model=model,
625
+ owner="UNRESOLVED",
626
+ )
627
+ )
628
+ count += 1
629
+ builder.visibility("device-tree", "observed", count, fs_access="local")
630
+
631
+
632
+ def _scan_device_tree_via_runner(
633
+ builder: _DraftBuilder, runner: CommandRunner, fs_root: Path
634
+ ) -> None:
635
+ """Read device-tree compatibles through ``runner`` (never host Path)."""
636
+
637
+ path = _rooted(fs_root, "/proc/device-tree").as_posix()
638
+ listing = _run(runner, ("find", path, "-name", "compatible", "-type", "f"))
639
+ if listing.returncode != 0:
640
+ detail = listing.stderr.strip() or f"{path} is absent"
641
+ builder.visibility("device-tree", "unavailable", detail=detail, fs_access="runner")
642
+ return
643
+
644
+ count = 0
645
+ root_prefix = path.rstrip("/") + "/"
646
+ for compatible_path in sorted(
647
+ line.strip() for line in listing.stdout.splitlines() if line.strip()
648
+ ):
649
+ raw = _run(runner, ("cat", compatible_path))
650
+ if raw.returncode != 0 or not raw.stdout:
651
+ # ``cat`` text mode may stop at NUL; fall back to a byte-safe read.
652
+ raw = _run(runner, ("head", "-c", "256", compatible_path))
653
+ if raw.returncode != 0 or not raw.stdout:
654
+ continue
655
+ compatible = raw.stdout.split("\0", 1)[0].strip()
656
+ if not compatible:
657
+ continue
658
+ family = builder.family("device-tree-peripheral", "Device-tree peripheral")
659
+ model = builder.model("device-tree", compatible, family)
660
+ parent = compatible_path.rsplit("/", 1)[0]
661
+ relative = parent[len(root_prefix) :] if parent.startswith(root_prefix) else parent
662
+ builder.devices.append(
663
+ DeviceInstance(
664
+ id=builder.device_id("device-tree", relative or "root"),
665
+ model=model,
666
+ owner="UNRESOLVED",
667
+ )
668
+ )
669
+ count += 1
670
+ builder.visibility("device-tree", "observed", count, fs_access="runner")
671
+
672
+
673
+ def _scan_v4l2(builder: _DraftBuilder, runner: CommandRunner) -> None:
674
+ result = _run(runner, ("v4l2-ctl", "--list-devices"))
675
+ if _unavailable(builder, "v4l2", result):
676
+ return
677
+ labels = [
678
+ line.strip().removesuffix(":")
679
+ for line in result.stdout.splitlines()
680
+ if line and not line[0].isspace() and line.rstrip().endswith(":")
681
+ ]
682
+ if result.stdout.strip() and not labels:
683
+ builder.visibility("v4l2", "malformed", detail="no parseable v4l2 device groups")
684
+ return
685
+
686
+ for label in labels:
687
+ family = builder.family("camera", "Camera")
688
+ model = builder.model("v4l2", label, family)
689
+ builder.devices.append(
690
+ DeviceInstance(
691
+ id=builder.device_id("v4l2", label),
692
+ model=model,
693
+ owner="UNRESOLVED",
694
+ )
695
+ )
696
+ builder.visibility("v4l2", "observed", len(labels))
697
+
698
+
699
+ def _scan_ros2_nodes(builder: _DraftBuilder, runner: CommandRunner) -> None:
700
+ result = _run(runner, ("ros2", "node", "list"))
701
+ if _unavailable(builder, "ros2-nodes", result):
702
+ return
703
+ lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
704
+ nodes = [line for line in lines if line.startswith("/")]
705
+ if len(nodes) != len(lines):
706
+ builder.visibility("ros2-nodes", "malformed", detail="non-absolute node name in output")
707
+ return
708
+
709
+ for node in nodes:
710
+ family = builder.family("ros2-node", "ROS 2 node")
711
+ model = builder.model("ros2-node", node, family)
712
+ builder.devices.append(
713
+ DeviceInstance(
714
+ id=builder.device_id("ros2-node", node),
715
+ model=model,
716
+ driver=Driver(node=node),
717
+ owner="UNRESOLVED",
718
+ )
719
+ )
720
+ builder.visibility("ros2-nodes", "observed", len(nodes))
721
+
722
+
723
+ def _read_text(path: Path) -> str | None:
724
+ try:
725
+ return path.read_text().strip() or None
726
+ except (OSError, UnicodeDecodeError):
727
+ return None
728
+
729
+
730
+ def _slug(value: str) -> str:
731
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
732
+ return slug or "unknown"