interface-test-kit 0.1.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.
- interface_test_kit/__init__.py +3 -0
- interface_test_kit/checks/__init__.py +47 -0
- interface_test_kit/checks/can.py +186 -0
- interface_test_kit/checks/confirm.py +30 -0
- interface_test_kit/checks/ethernet.py +97 -0
- interface_test_kit/checks/serial.py +172 -0
- interface_test_kit/checks/usb.py +243 -0
- interface_test_kit/cli.py +130 -0
- interface_test_kit/context.py +43 -0
- interface_test_kit/detect.py +22 -0
- interface_test_kit/model.py +108 -0
- interface_test_kit/profile.py +178 -0
- interface_test_kit/profiles/demo.yaml +22 -0
- interface_test_kit/report.py +142 -0
- interface_test_kit/runner.py +71 -0
- interface_test_kit/session.py +110 -0
- interface_test_kit/sim/__init__.py +1 -0
- interface_test_kit/sim/can.py +40 -0
- interface_test_kit/sim/fakes.py +64 -0
- interface_test_kit/sim/serial.py +44 -0
- interface_test_kit/sim/world.py +96 -0
- interface_test_kit/station.py +54 -0
- interface_test_kit/ui.py +127 -0
- interface_test_kit-0.1.0.dist-info/METADATA +257 -0
- interface_test_kit-0.1.0.dist-info/RECORD +29 -0
- interface_test_kit-0.1.0.dist-info/WHEEL +5 -0
- interface_test_kit-0.1.0.dist-info/entry_points.txt +2 -0
- interface_test_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- interface_test_kit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""USB check with labelled test sticks: count, port, negotiated speed, write/read and throughput."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..model import Attempt, Measurement
|
|
14
|
+
from . import CheckType, fields, register
|
|
15
|
+
|
|
16
|
+
BLOCK = 1 << 20
|
|
17
|
+
TEST_FILE = "itk-test.bin"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class UsbConfig:
|
|
22
|
+
name: str
|
|
23
|
+
count: int
|
|
24
|
+
label: str = "ITKTEST"
|
|
25
|
+
size_mb: int = 64
|
|
26
|
+
min_write_mbs: float | None = None
|
|
27
|
+
min_read_mbs: float | None = None
|
|
28
|
+
min_speed_mbps: int | None = None
|
|
29
|
+
ports: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Stick:
|
|
34
|
+
device: str
|
|
35
|
+
label: str
|
|
36
|
+
mountpoint: str | None
|
|
37
|
+
usb_port: str | None
|
|
38
|
+
speed_mbps: int | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _positive(value, name: str, whole: bool = False):
|
|
42
|
+
ok_type = int if whole else (int, float)
|
|
43
|
+
if value is None:
|
|
44
|
+
return None
|
|
45
|
+
if isinstance(value, bool) or not isinstance(value, ok_type) or value <= 0:
|
|
46
|
+
raise ValueError(f"{name}: must be a positive {'whole ' if whole else ''}number")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse(raw: dict) -> UsbConfig:
|
|
51
|
+
f = fields(raw, {"count"}, {"label": "ITKTEST", "size_mb": 64, "min_write_mbs": None,
|
|
52
|
+
"min_read_mbs": None, "min_speed_mbps": None, "ports": []})
|
|
53
|
+
_positive(f["count"], "count", whole=True)
|
|
54
|
+
_positive(f["size_mb"], "size_mb", whole=True)
|
|
55
|
+
for key in ("min_write_mbs", "min_read_mbs"):
|
|
56
|
+
_positive(f[key], key)
|
|
57
|
+
_positive(f["min_speed_mbps"], "min_speed_mbps", whole=True)
|
|
58
|
+
if not isinstance(f["label"], str) or not f["label"]:
|
|
59
|
+
raise ValueError("label: must be a non-empty text")
|
|
60
|
+
if not isinstance(f["ports"], list):
|
|
61
|
+
raise ValueError("ports: must be a list of USB port names like 1-2")
|
|
62
|
+
return UsbConfig(str(f["name"]), f["count"], f["label"], f["size_mb"], f["min_write_mbs"],
|
|
63
|
+
f["min_read_mbs"], f["min_speed_mbps"], tuple(str(p) for p in f["ports"]))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _usb_device(ctx, kname: str) -> tuple[str | None, int | None]:
|
|
67
|
+
"""Walk up from the block device in sysfs to the USB device (the dir with speed and devnum)."""
|
|
68
|
+
sysfs = ctx.path("/sys").resolve()
|
|
69
|
+
try:
|
|
70
|
+
p = ctx.path(f"/sys/class/block/{kname}").resolve(strict=True)
|
|
71
|
+
except OSError:
|
|
72
|
+
return None, None
|
|
73
|
+
while p != sysfs and p != p.parent:
|
|
74
|
+
if (p / "speed").is_file() and (p / "devnum").is_file():
|
|
75
|
+
try:
|
|
76
|
+
return p.name, int(float((p / "speed").read_text().strip()))
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
return p.name, None
|
|
79
|
+
p = p.parent
|
|
80
|
+
return None, None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _walk(nodes: list[dict], tran: str | None = None):
|
|
84
|
+
for node in nodes:
|
|
85
|
+
t = node.get("tran") or tran
|
|
86
|
+
yield node, t
|
|
87
|
+
yield from _walk(node.get("children") or [], t)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_sticks(cfg: UsbConfig, ctx, ignored: list[str] | None = None) -> list[Stick]:
|
|
91
|
+
result = ctx.run(["lsblk", "-J", "-o", "NAME,KNAME,PATH,LABEL,MOUNTPOINT,TRAN"])
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
raise RuntimeError(f"lsblk failed: {result.stderr.strip()}")
|
|
94
|
+
sticks = []
|
|
95
|
+
for node, tran in _walk(json.loads(result.stdout).get("blockdevices", [])):
|
|
96
|
+
label = node.get("label")
|
|
97
|
+
if tran != "usb" or not label:
|
|
98
|
+
continue
|
|
99
|
+
if not label.startswith(cfg.label):
|
|
100
|
+
if ignored is not None:
|
|
101
|
+
ignored.append(f"ignored: {node.get('path')} label {label}")
|
|
102
|
+
continue
|
|
103
|
+
port, speed = _usb_device(ctx, node["kname"])
|
|
104
|
+
sticks.append(Stick(node["path"], label, node.get("mountpoint"), port, speed))
|
|
105
|
+
return sorted(sticks, key=lambda s: s.device)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@lru_cache(maxsize=1)
|
|
109
|
+
def _payload() -> bytes:
|
|
110
|
+
return random.Random(0).randbytes(BLOCK - 8)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _block(i: int) -> bytes:
|
|
114
|
+
return i.to_bytes(8, "big") + _payload()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _same_device(ctx, stick: Stick) -> bool:
|
|
118
|
+
"""True if the mountpoint really is the stick's filesystem right now."""
|
|
119
|
+
mountpoint = ctx.path(stick.mountpoint)
|
|
120
|
+
device = ctx.path(stick.device)
|
|
121
|
+
if not device.exists():
|
|
122
|
+
# Only the simulated worlds under a fake root have no device nodes.
|
|
123
|
+
return Path(ctx.root) != Path("/")
|
|
124
|
+
try:
|
|
125
|
+
return (stat.S_ISBLK(os.stat(device).st_mode) and os.path.ismount(mountpoint)
|
|
126
|
+
and os.stat(mountpoint).st_dev == os.stat(device).st_rdev)
|
|
127
|
+
except OSError:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _still_mounted(ctx, cfg: UsbConfig, stick: Stick) -> bool:
|
|
132
|
+
"""Ask lsblk again right before writing: the stick may have been pulled meanwhile."""
|
|
133
|
+
try:
|
|
134
|
+
now = {s.device: s.mountpoint for s in find_sticks(cfg, ctx)}
|
|
135
|
+
except (RuntimeError, ValueError):
|
|
136
|
+
return False
|
|
137
|
+
return now.get(stick.device) == stick.mountpoint and _same_device(ctx, stick)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_file(path: Path, size_mb: int, clock) -> float:
|
|
141
|
+
if os.path.lexists(path): # never follow a link someone left on the stick
|
|
142
|
+
os.unlink(path)
|
|
143
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
|
|
144
|
+
try:
|
|
145
|
+
t0 = clock()
|
|
146
|
+
for i in range(size_mb):
|
|
147
|
+
os.write(fd, _block(i))
|
|
148
|
+
os.fsync(fd)
|
|
149
|
+
return clock() - t0
|
|
150
|
+
finally:
|
|
151
|
+
os.close(fd)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _read_back(path: Path, size_mb: int, clock) -> tuple[float, int | None]:
|
|
155
|
+
"""Returns (seconds, index of the first bad block or None)."""
|
|
156
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
157
|
+
try:
|
|
158
|
+
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
|
159
|
+
bad = None
|
|
160
|
+
t0 = clock()
|
|
161
|
+
for i in range(size_mb):
|
|
162
|
+
data = os.read(fd, BLOCK)
|
|
163
|
+
if bad is None and data != _block(i):
|
|
164
|
+
bad = i
|
|
165
|
+
return clock() - t0, bad
|
|
166
|
+
finally:
|
|
167
|
+
os.close(fd)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _test_stick(stick: Stick, cfg: UsbConfig, ctx, details: list[str]) -> tuple[str | None, list[Measurement]]:
|
|
171
|
+
"""Returns (unit-mode problem or None, measurements). Never writes outside the mountpoint."""
|
|
172
|
+
where = stick.usb_port or stick.device
|
|
173
|
+
if not _still_mounted(ctx, cfg, stick):
|
|
174
|
+
details.append(f"{stick.device}: not mounted at {stick.mountpoint} any more")
|
|
175
|
+
return f"USB: test stick in port {where} is not mounted", []
|
|
176
|
+
path = ctx.path(stick.mountpoint) / TEST_FILE
|
|
177
|
+
measurements = []
|
|
178
|
+
try:
|
|
179
|
+
try:
|
|
180
|
+
t_write = _write_file(path, cfg.size_mb, ctx.clock)
|
|
181
|
+
except OSError as e:
|
|
182
|
+
details.append(f"{stick.device}: write failed: {e}")
|
|
183
|
+
return f"USB: could not write to the test stick ({where}): full or write-protected", []
|
|
184
|
+
t_read, bad = _read_back(path, cfg.size_mb, ctx.clock)
|
|
185
|
+
finally:
|
|
186
|
+
try:
|
|
187
|
+
path.unlink()
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
write_mbs = round(cfg.size_mb / max(t_write, 1e-6), 1)
|
|
191
|
+
read_mbs = round(cfg.size_mb / max(t_read, 1e-6), 1)
|
|
192
|
+
measurements.append(Measurement(f"{where} write", write_mbs, "MB/s", low=cfg.min_write_mbs))
|
|
193
|
+
measurements.append(Measurement(f"{where} read", read_mbs, "MB/s", low=cfg.min_read_mbs))
|
|
194
|
+
if stick.speed_mbps is not None:
|
|
195
|
+
measurements.append(Measurement(f"{where} USB speed", stick.speed_mbps, "Mbit/s",
|
|
196
|
+
low=cfg.min_speed_mbps))
|
|
197
|
+
details.append(f"{stick.device} ({stick.label}) on {where}: write {write_mbs} MB/s, "
|
|
198
|
+
f"read {read_mbs} MB/s, USB {stick.speed_mbps} Mbit/s")
|
|
199
|
+
if bad is not None:
|
|
200
|
+
details.append(f"{stick.device}: block {bad} read back wrong")
|
|
201
|
+
return f"USB: stick data corrupted ({where})", measurements
|
|
202
|
+
if any(not m.passed for m in measurements[:2]):
|
|
203
|
+
return f"USB: port {where} too slow", measurements
|
|
204
|
+
if len(measurements) > 2 and not measurements[2].passed:
|
|
205
|
+
return (f"USB: port {where} runs at {stick.speed_mbps} Mbit/s, "
|
|
206
|
+
f"expected at least {cfg.min_speed_mbps}"), measurements
|
|
207
|
+
return None, measurements
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run(cfg: UsbConfig, ctx, ui) -> Attempt:
|
|
211
|
+
details: list[str] = []
|
|
212
|
+
try:
|
|
213
|
+
sticks = find_sticks(cfg, ctx, details)
|
|
214
|
+
except (RuntimeError, ValueError) as e:
|
|
215
|
+
return Attempt.fail("USB: could not look for test sticks. Tell the test engineer.",
|
|
216
|
+
details=[str(e)])
|
|
217
|
+
if len(sticks) != cfg.count:
|
|
218
|
+
details.append("found: " + (", ".join(f"{s.device} on {s.usb_port}" for s in sticks) or "none"))
|
|
219
|
+
return Attempt.fail(f"USB: found {len(sticks)} test stick(s), expected {cfg.count}. "
|
|
220
|
+
f"Insert the test sticks labelled {cfg.label}.", details=details)
|
|
221
|
+
ports = {s.usb_port for s in sticks}
|
|
222
|
+
for port in cfg.ports:
|
|
223
|
+
if port not in ports:
|
|
224
|
+
return Attempt.fail(f"USB: no test stick in port {port}", details=details)
|
|
225
|
+
measurements: list[Measurement] = []
|
|
226
|
+
problems: list[tuple[str, Stick]] = []
|
|
227
|
+
for stick in sticks:
|
|
228
|
+
if not stick.mountpoint:
|
|
229
|
+
problems.append((f"USB: test stick in port {stick.usb_port or stick.device} is not mounted", stick))
|
|
230
|
+
continue
|
|
231
|
+
problem, ms = _test_stick(stick, cfg, ctx, details)
|
|
232
|
+
measurements += ms
|
|
233
|
+
if problem:
|
|
234
|
+
problems.append((problem, stick))
|
|
235
|
+
if problems:
|
|
236
|
+
unit, stick = problems[0]
|
|
237
|
+
stick_fault = "not mounted" not in unit
|
|
238
|
+
message = ctx.say(unit, f"Test stick {stick.device} faulty or too slow") if stick_fault else unit
|
|
239
|
+
return Attempt.fail(message, details=details, measurements=measurements)
|
|
240
|
+
return Attempt.ok(measurements=measurements, details=details)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
register("usb", CheckType(parse=parse, run=run))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Command line interface: ``itk run``, ``itk selftest``, ``itk demo`` and helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .profile import ProfileError, load_profile
|
|
10
|
+
from .report import report_reachable, sync_spool
|
|
11
|
+
from .station import load_station, station_path
|
|
12
|
+
from .ui import ConsoleUI
|
|
13
|
+
|
|
14
|
+
_EXIT = {"PASS": 0, "FAIL": 1, "ABORTED": 2}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_profile(files: list[str]) -> int:
|
|
18
|
+
rc = 0
|
|
19
|
+
for name in files:
|
|
20
|
+
try:
|
|
21
|
+
p = load_profile(name)
|
|
22
|
+
except (ProfileError, OSError) as e:
|
|
23
|
+
print(f"ERROR {name}: {e}")
|
|
24
|
+
rc = 2
|
|
25
|
+
else:
|
|
26
|
+
print(f"OK {name} {p.name} v{p.version}, {len(p.steps)} steps")
|
|
27
|
+
return rc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sync(station_arg: str | None) -> int:
|
|
31
|
+
try:
|
|
32
|
+
station = load_station(station_path(station_arg))
|
|
33
|
+
except (ValueError, OSError) as e:
|
|
34
|
+
print(f"Error: {e}")
|
|
35
|
+
return 2
|
|
36
|
+
if not report_reachable(station):
|
|
37
|
+
print(f"Network drive not reachable: {station.report_dir}")
|
|
38
|
+
return 2
|
|
39
|
+
for path in sync_spool(station):
|
|
40
|
+
print(f"Uploaded {path}")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _session(args, mode: str) -> int:
|
|
45
|
+
from .session import real_context, run_session
|
|
46
|
+
try:
|
|
47
|
+
station = load_station(station_path(args.station))
|
|
48
|
+
only = [s.strip() for s in args.only.split(",") if s.strip()] if getattr(args, "only", None) else None
|
|
49
|
+
run = run_session(station, real_context(mode), ConsoleUI(),
|
|
50
|
+
rma=getattr(args, "rma", None), only=only)
|
|
51
|
+
except KeyboardInterrupt:
|
|
52
|
+
print("\nAborted.")
|
|
53
|
+
return 2
|
|
54
|
+
except (ValueError, ProfileError, OSError) as e:
|
|
55
|
+
print(f"Error: {e}")
|
|
56
|
+
return 2
|
|
57
|
+
return _EXIT[run.status]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _demo(args) -> int:
|
|
61
|
+
import tempfile
|
|
62
|
+
from importlib import resources
|
|
63
|
+
|
|
64
|
+
from .runner import RETRY_PROMPT
|
|
65
|
+
from .session import run_session
|
|
66
|
+
from .sim.world import FAULTS, SERIAL_NUMBER, AutoUI, build_world
|
|
67
|
+
from .station import Station
|
|
68
|
+
|
|
69
|
+
if args.list_faults:
|
|
70
|
+
for name, text in FAULTS.items():
|
|
71
|
+
print(f" {name:<16} {text}")
|
|
72
|
+
return 0
|
|
73
|
+
fault = None if args.fault == "none" else args.fault
|
|
74
|
+
profile = load_profile(Path(str(resources.files("interface_test_kit"))) / "profiles" / "demo.yaml")
|
|
75
|
+
with tempfile.TemporaryDirectory(prefix="itk-demo-") as tmp:
|
|
76
|
+
root = Path(tmp)
|
|
77
|
+
out = Path(args.out)
|
|
78
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
station = Station(report_dir=out, spool_dir=root / "spool", profile_dir=root, name="demo")
|
|
80
|
+
no_to = (RETRY_PROMPT,) + (("power LED",) if fault == "led_off" else ())
|
|
81
|
+
print(f"Simulated unit, fault: {args.fault}\n")
|
|
82
|
+
run = run_session(station, build_world(fault, root), AutoUI(SERIAL_NUMBER, no_to),
|
|
83
|
+
profiles=[profile])
|
|
84
|
+
return _EXIT[run.status]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
88
|
+
parser = argparse.ArgumentParser(prog="itk", description=__doc__)
|
|
89
|
+
parser.add_argument("--version", action="version", version=f"itk {__version__}")
|
|
90
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
91
|
+
|
|
92
|
+
p_check = sub.add_parser("check-profile", help="validate profile files")
|
|
93
|
+
p_check.add_argument("files", nargs="+", metavar="FILE")
|
|
94
|
+
|
|
95
|
+
station_help = "station file (default: $ITK_STATION or /etc/itk/station.yaml)"
|
|
96
|
+
p_run = sub.add_parser("run", help="test a unit")
|
|
97
|
+
p_run.add_argument("--station", help=station_help)
|
|
98
|
+
p_run.add_argument("--rma", help="RMA number, stored in the report")
|
|
99
|
+
p_run.add_argument("--only", help="comma-separated step ids to run, e.g. serial-1,usb")
|
|
100
|
+
|
|
101
|
+
p_self = sub.add_parser("selftest", help="check the test equipment on a reference unit")
|
|
102
|
+
p_self.add_argument("--station", help=station_help)
|
|
103
|
+
|
|
104
|
+
from .sim.world import FAULTS
|
|
105
|
+
p_demo = sub.add_parser("demo", help="run a full session against a simulated unit")
|
|
106
|
+
p_demo.add_argument("--fault", default="none", choices=["none", *FAULTS], metavar="FAULT",
|
|
107
|
+
help="inject a fault (see --list-faults)")
|
|
108
|
+
p_demo.add_argument("--list-faults", action="store_true", help="list the faults and exit")
|
|
109
|
+
p_demo.add_argument("--out", default="reports", help="report folder (default: reports)")
|
|
110
|
+
|
|
111
|
+
p_sync = sub.add_parser("sync", help="upload reports saved locally while the network drive was down")
|
|
112
|
+
p_sync.add_argument("--station", help=station_help)
|
|
113
|
+
return parser
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
parser = build_parser()
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
if args.cmd == "check-profile":
|
|
120
|
+
return _check_profile(args.files)
|
|
121
|
+
if args.cmd == "run":
|
|
122
|
+
return _session(args, "unit")
|
|
123
|
+
if args.cmd == "selftest":
|
|
124
|
+
return _session(args, "selftest")
|
|
125
|
+
if args.cmd == "demo":
|
|
126
|
+
return _demo(args)
|
|
127
|
+
if args.cmd == "sync":
|
|
128
|
+
return _sync(args.station)
|
|
129
|
+
parser.print_help()
|
|
130
|
+
return 2
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Everything a check needs from the outside world, so tests can swap it out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CommandResult:
|
|
14
|
+
returncode: int
|
|
15
|
+
stdout: str
|
|
16
|
+
stderr: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_command(args: list[str], timeout: float = 30) -> CommandResult:
|
|
20
|
+
try:
|
|
21
|
+
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
return CommandResult(127, "", f"command not found: {args[0]}")
|
|
24
|
+
except subprocess.TimeoutExpired:
|
|
25
|
+
return CommandResult(124, "", f"timed out after {timeout} s: {' '.join(args)}")
|
|
26
|
+
return CommandResult(p.returncode, p.stdout, p.stderr)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Context:
|
|
31
|
+
root: Path = Path("/")
|
|
32
|
+
run: Callable[[list[str]], CommandResult] = run_command
|
|
33
|
+
open_serial: Callable[[str, int], Any] | None = None
|
|
34
|
+
open_can: Callable[[str], Any] | None = None
|
|
35
|
+
clock: Callable[[], float] = time.monotonic
|
|
36
|
+
sleep: Callable[[float], None] = time.sleep
|
|
37
|
+
mode: str = "unit"
|
|
38
|
+
|
|
39
|
+
def path(self, p: str) -> Path:
|
|
40
|
+
return Path(self.root) / p.lstrip("/")
|
|
41
|
+
|
|
42
|
+
def say(self, unit: str, equipment: str) -> str:
|
|
43
|
+
return equipment if self.mode == "selftest" else unit
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Pick the profile that matches this device's DMI product name or device-tree model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .context import Context
|
|
6
|
+
from .profile import Profile
|
|
7
|
+
|
|
8
|
+
_SOURCES = {"dmi_product_name": "/sys/class/dmi/id/product_name",
|
|
9
|
+
"devicetree_model": "/proc/device-tree/model"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read(ctx: Context, path: str) -> str | None:
|
|
13
|
+
try:
|
|
14
|
+
return ctx.path(path).read_bytes().decode(errors="replace").strip().rstrip("\0").strip()
|
|
15
|
+
except OSError:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_profiles(profiles: list[Profile], ctx: Context) -> list[Profile]:
|
|
20
|
+
values = {key: _read(ctx, path) for key, path in _SOURCES.items()}
|
|
21
|
+
return [p for p in profiles
|
|
22
|
+
if p.detect and all(values.get(k) == v for k, v in p.detect.items())]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Result data: measurements, attempts, step results and a whole run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Measurement:
|
|
11
|
+
name: str
|
|
12
|
+
value: float
|
|
13
|
+
unit: str = ""
|
|
14
|
+
low: float | None = None
|
|
15
|
+
high: float | None = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def passed(self) -> bool:
|
|
19
|
+
if self.low is not None and self.value < self.low:
|
|
20
|
+
return False
|
|
21
|
+
if self.high is not None and self.value > self.high:
|
|
22
|
+
return False
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return {"name": self.name, "value": self.value, "unit": self.unit,
|
|
27
|
+
"low": self.low, "high": self.high, "passed": self.passed}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Attempt:
|
|
32
|
+
status: str
|
|
33
|
+
message: str = ""
|
|
34
|
+
details: list[str] = field(default_factory=list)
|
|
35
|
+
measurements: list[Measurement] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def ok(cls, message: str = "", measurements: list[Measurement] | None = None,
|
|
39
|
+
details: list[str] | None = None) -> Attempt:
|
|
40
|
+
return cls("PASS", message, list(details or []), list(measurements or []))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def fail(cls, message: str, details: list[str] | None = None,
|
|
44
|
+
measurements: list[Measurement] | None = None) -> Attempt:
|
|
45
|
+
return cls("FAIL", message, list(details or []), list(measurements or []))
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
return {"status": self.status, "message": self.message, "details": list(self.details),
|
|
49
|
+
"measurements": [m.to_dict() for m in self.measurements]}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class StepResult:
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
type: str
|
|
57
|
+
status: str
|
|
58
|
+
attempts: list[Attempt]
|
|
59
|
+
equipment_id: str | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def message(self) -> str:
|
|
63
|
+
if not self.attempts:
|
|
64
|
+
return ""
|
|
65
|
+
last = self.attempts[-1]
|
|
66
|
+
if self.status == "FAIL" and last.status == "PASS":
|
|
67
|
+
failed = next(a for a in reversed(self.attempts) if a.status == "FAIL")
|
|
68
|
+
return f"{failed.message} (not stable: failed again on retry)"
|
|
69
|
+
return last.message
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def passed_after_retry(self) -> bool:
|
|
73
|
+
return self.status == "PASS" and len(self.attempts) > 1
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict:
|
|
76
|
+
return {"id": self.id, "name": self.name, "type": self.type, "status": self.status,
|
|
77
|
+
"message": self.message, "passed_after_retry": self.passed_after_retry,
|
|
78
|
+
"equipment_id": self.equipment_id,
|
|
79
|
+
"attempts": [a.to_dict() for a in self.attempts]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _iso(t: datetime | None) -> str | None:
|
|
83
|
+
return t.isoformat() if t is not None else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RunResult:
|
|
88
|
+
mode: str
|
|
89
|
+
serial_number: str
|
|
90
|
+
rma: str | None
|
|
91
|
+
profile_name: str
|
|
92
|
+
profile_version: int
|
|
93
|
+
station: str
|
|
94
|
+
operator: str | None
|
|
95
|
+
started: datetime
|
|
96
|
+
finished: datetime | None
|
|
97
|
+
tool_version: str
|
|
98
|
+
steps: list[StepResult]
|
|
99
|
+
status: str
|
|
100
|
+
only: list[str] | None = None
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {"mode": self.mode, "serial_number": self.serial_number, "rma": self.rma,
|
|
104
|
+
"profile": {"name": self.profile_name, "version": self.profile_version},
|
|
105
|
+
"station": self.station, "operator": self.operator,
|
|
106
|
+
"started": _iso(self.started), "finished": _iso(self.finished),
|
|
107
|
+
"tool_version": self.tool_version, "status": self.status, "only": self.only,
|
|
108
|
+
"steps": [s.to_dict() for s in self.steps]}
|