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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- 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"
|