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,178 @@
|
|
|
1
|
+
"""Test profiles: one YAML file per product variant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .checks import STEP_TYPES
|
|
13
|
+
|
|
14
|
+
_TOP_REQUIRED = {"name", "version", "steps"}
|
|
15
|
+
_TOP_OPTIONAL = {"setup", "detect", "serial_number", "reference", "max_attempts"}
|
|
16
|
+
_DETECT_KEYS = {"dmi_product_name", "devicetree_model"}
|
|
17
|
+
_STEP_COMMON = {"type", "name", "id", "equipment"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Step:
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
type: str
|
|
25
|
+
config: Any
|
|
26
|
+
equipment: str | None
|
|
27
|
+
line: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Profile:
|
|
32
|
+
name: str
|
|
33
|
+
version: int
|
|
34
|
+
steps: list[Step]
|
|
35
|
+
setup: str = ""
|
|
36
|
+
detect: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
serial_pattern: str | None = None
|
|
38
|
+
reference: bool = False
|
|
39
|
+
max_attempts: int = 4
|
|
40
|
+
path: Path | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProfileError(Exception):
|
|
44
|
+
def __init__(self, message: str, line: int | None = None):
|
|
45
|
+
self.message = message
|
|
46
|
+
self.line = line
|
|
47
|
+
super().__init__(f"line {line}: {message}" if line else message)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def slug(text: str) -> str:
|
|
51
|
+
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _step_lines(root: yaml.Node) -> list[int]:
|
|
55
|
+
if isinstance(root, yaml.MappingNode):
|
|
56
|
+
for key, value in root.value:
|
|
57
|
+
if key.value == "steps" and isinstance(value, yaml.SequenceNode):
|
|
58
|
+
return [item.start_mark.line + 1 for item in value.value]
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _key_line(root: yaml.Node, name: str) -> int | None:
|
|
63
|
+
if isinstance(root, yaml.MappingNode):
|
|
64
|
+
for key, _ in root.value:
|
|
65
|
+
if key.value == name:
|
|
66
|
+
return key.start_mark.line + 1
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_step(index: int, raw: Any, line: int | None) -> Step:
|
|
71
|
+
label = f"steps[{index}]"
|
|
72
|
+
if not isinstance(raw, dict):
|
|
73
|
+
raise ProfileError(f"{label}: must be a mapping", line)
|
|
74
|
+
if isinstance(raw.get("name"), str) and raw["name"].strip():
|
|
75
|
+
label += f" ({raw['name']})"
|
|
76
|
+
try:
|
|
77
|
+
if "name" not in raw or not str(raw["name"]).strip():
|
|
78
|
+
raise ValueError("name: missing")
|
|
79
|
+
if "type" not in raw:
|
|
80
|
+
raise ValueError("type: missing")
|
|
81
|
+
check = STEP_TYPES.get(raw["type"])
|
|
82
|
+
if check is None:
|
|
83
|
+
raise ValueError(f"type: unknown step type '{raw['type']}' "
|
|
84
|
+
f"(known: {', '.join(sorted(STEP_TYPES))})")
|
|
85
|
+
name = str(raw["name"])
|
|
86
|
+
step_id = str(raw.get("id") or slug(name))
|
|
87
|
+
equipment = raw.get("equipment")
|
|
88
|
+
config = check.parse({"name": name,
|
|
89
|
+
**{k: v for k, v in raw.items() if k not in _STEP_COMMON}})
|
|
90
|
+
except ValueError as e:
|
|
91
|
+
raise ProfileError(f"{label}: {e}", line) from None
|
|
92
|
+
return Step(step_id, name, raw["type"], config,
|
|
93
|
+
str(equipment) if equipment is not None else None, line or 0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_profile(path: Path) -> Profile:
|
|
97
|
+
path = Path(path)
|
|
98
|
+
text = path.read_text()
|
|
99
|
+
try:
|
|
100
|
+
root = yaml.compose(text, Loader=yaml.SafeLoader)
|
|
101
|
+
data = yaml.safe_load(text)
|
|
102
|
+
except yaml.YAMLError as e:
|
|
103
|
+
mark = getattr(e, "problem_mark", None)
|
|
104
|
+
raise ProfileError(f"invalid YAML: {getattr(e, 'problem', e)}",
|
|
105
|
+
mark.line + 1 if mark else None) from None
|
|
106
|
+
if not isinstance(data, dict):
|
|
107
|
+
raise ProfileError("a profile must be a mapping with name, version and steps")
|
|
108
|
+
|
|
109
|
+
def err(key: str, message: str) -> ProfileError:
|
|
110
|
+
return ProfileError(f"{key}: {message}", _key_line(root, key))
|
|
111
|
+
|
|
112
|
+
for key in data:
|
|
113
|
+
if key not in _TOP_REQUIRED and key not in _TOP_OPTIONAL:
|
|
114
|
+
raise err(key, f"unknown field '{key}'")
|
|
115
|
+
for key in sorted(_TOP_REQUIRED):
|
|
116
|
+
if key not in data:
|
|
117
|
+
raise ProfileError(f"{key}: missing")
|
|
118
|
+
if not isinstance(data["name"], str) or not data["name"].strip():
|
|
119
|
+
raise err("name", "must be a non-empty text")
|
|
120
|
+
version = data["version"]
|
|
121
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
122
|
+
raise err("version", "must be a whole number of 1 or more")
|
|
123
|
+
max_attempts = data.get("max_attempts", 4)
|
|
124
|
+
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool) \
|
|
125
|
+
or not 1 <= max_attempts <= 10:
|
|
126
|
+
raise err("max_attempts", "must be a whole number from 1 to 10")
|
|
127
|
+
reference = data.get("reference", False)
|
|
128
|
+
if not isinstance(reference, bool):
|
|
129
|
+
raise err("reference", "must be true or false")
|
|
130
|
+
setup = data.get("setup", "") or ""
|
|
131
|
+
if not isinstance(setup, str):
|
|
132
|
+
raise err("setup", "must be a text")
|
|
133
|
+
detect = data.get("detect") or {}
|
|
134
|
+
if not isinstance(detect, dict):
|
|
135
|
+
raise err("detect", "must be a mapping")
|
|
136
|
+
for key in detect:
|
|
137
|
+
if key not in _DETECT_KEYS:
|
|
138
|
+
raise err("detect", f"unknown key '{key}' (known: {', '.join(sorted(_DETECT_KEYS))})")
|
|
139
|
+
pattern = None
|
|
140
|
+
if "serial_number" in data:
|
|
141
|
+
sn = data["serial_number"]
|
|
142
|
+
if not isinstance(sn, dict) or set(sn) - {"pattern"}:
|
|
143
|
+
raise err("serial_number", "must be a mapping with a pattern")
|
|
144
|
+
pattern = sn.get("pattern")
|
|
145
|
+
try:
|
|
146
|
+
re.compile(str(pattern))
|
|
147
|
+
except re.error as e:
|
|
148
|
+
raise err("serial_number", f"pattern is not a valid regular expression ({e})") from None
|
|
149
|
+
steps_raw = data["steps"]
|
|
150
|
+
if not isinstance(steps_raw, list) or not steps_raw:
|
|
151
|
+
raise err("steps", "must be a non-empty list")
|
|
152
|
+
|
|
153
|
+
lines = _step_lines(root)
|
|
154
|
+
steps: list[Step] = []
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
for i, raw in enumerate(steps_raw):
|
|
157
|
+
line = lines[i] if i < len(lines) else None
|
|
158
|
+
step = _parse_step(i, raw, line)
|
|
159
|
+
if step.id in seen:
|
|
160
|
+
raise ProfileError(f"steps[{i}] ({step.name}): duplicate step id '{step.id}' "
|
|
161
|
+
"(give the step a different name or an id)", line)
|
|
162
|
+
seen.add(step.id)
|
|
163
|
+
steps.append(step)
|
|
164
|
+
|
|
165
|
+
return Profile(name=data["name"], version=version, steps=steps, setup=setup,
|
|
166
|
+
detect={k: str(v) for k, v in detect.items()},
|
|
167
|
+
serial_pattern=str(pattern) if pattern is not None else None,
|
|
168
|
+
reference=reference, max_attempts=max_attempts, path=path)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load_profiles(directory: Path) -> list[Profile]:
|
|
172
|
+
profiles = []
|
|
173
|
+
for path in sorted(Path(directory).glob("*.yaml")):
|
|
174
|
+
try:
|
|
175
|
+
profiles.append(load_profile(path))
|
|
176
|
+
except ProfileError as e:
|
|
177
|
+
raise ProfileError(f"{path}: {e}") from None
|
|
178
|
+
return sorted(profiles, key=lambda p: p.name)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Profile for `itk demo`: a simulated EPC-24 variant B panel PC.
|
|
2
|
+
name: EPC-24 variant B (demo)
|
|
3
|
+
version: 1
|
|
4
|
+
detect: {dmi_product_name: EPC-24B}
|
|
5
|
+
serial_number: {pattern: "^EPC-24-\\d{5}$"}
|
|
6
|
+
setup: >-
|
|
7
|
+
Plug the loopback plugs into COM1 and COM2, the two test sticks into the
|
|
8
|
+
blue USB ports, the network cable into LAN1 and the CAN cable between CAN0 and CAN1.
|
|
9
|
+
steps:
|
|
10
|
+
- {type: serial, name: Serial 1, port: /dev/ttyS0, loopback: 9pin-full, bauds: [9600, 115200]}
|
|
11
|
+
- {type: serial, name: Serial 2, port: /dev/ttyS1, loopback: 9pin}
|
|
12
|
+
- type: usb
|
|
13
|
+
name: USB
|
|
14
|
+
count: 2
|
|
15
|
+
ports: [1-1, 2-1]
|
|
16
|
+
size_mb: 8
|
|
17
|
+
min_write_mbs: 10
|
|
18
|
+
min_read_mbs: 20
|
|
19
|
+
min_speed_mbps: 5000
|
|
20
|
+
- {type: ethernet, name: LAN1, iface: eth0, speed: 1000, duplex: full, ping: 192.168.100.1}
|
|
21
|
+
- {type: can, name: CAN, pair: [can0, can1], bitrate: 500000}
|
|
22
|
+
- {type: confirm, name: Power LED, prompt: "Is the power LED green?"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Write run reports to the network drive, or spool them locally until it is back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .model import RunResult
|
|
12
|
+
from .station import Station
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def folder_name(run: RunResult) -> str:
|
|
16
|
+
stamp = run.started.strftime("%Y%m%d-%H%M%S")
|
|
17
|
+
if run.mode == "selftest":
|
|
18
|
+
name = f"selftest_{run.station}_{stamp}"
|
|
19
|
+
else:
|
|
20
|
+
# a scanner glitch can deliver hundreds of characters; keep names well below 255 bytes
|
|
21
|
+
parts = [run.serial_number[:60]] + ([run.rma[:40]] if run.rma else []) + [stamp]
|
|
22
|
+
name = "_".join(parts)
|
|
23
|
+
name = re.sub(r"[^A-Za-z0-9._-]", "_", name)
|
|
24
|
+
return "_" + name if name.startswith(".") else name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _relative(run: RunResult) -> Path:
|
|
28
|
+
folder = folder_name(run)
|
|
29
|
+
return Path("selftest", folder) if run.mode == "selftest" else Path(folder)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def report_reachable(station: Station) -> bool:
|
|
33
|
+
target = Path(station.report_dir)
|
|
34
|
+
if not target.is_dir() or not os.access(target, os.W_OK | os.X_OK):
|
|
35
|
+
return False
|
|
36
|
+
if station.mount_point is not None:
|
|
37
|
+
# An unmounted share leaves an empty local folder behind; writing there
|
|
38
|
+
# would put the report on the local disk without anyone noticing.
|
|
39
|
+
mount = Path(station.mount_point).resolve()
|
|
40
|
+
if not os.path.ismount(mount):
|
|
41
|
+
return False
|
|
42
|
+
if mount != target.resolve() and mount not in target.resolve().parents:
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fmt(value: float) -> str:
|
|
48
|
+
return f"{value:g}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_text(run: RunResult) -> str:
|
|
52
|
+
lines = [f"Interface test report ({'equipment self-test' if run.mode == 'selftest' else 'unit test'})",
|
|
53
|
+
"", f"Serial number: {run.serial_number}"]
|
|
54
|
+
if run.rma:
|
|
55
|
+
lines.append(f"RMA: {run.rma}")
|
|
56
|
+
lines.append(f"Profile: {run.profile_name} v{run.profile_version}")
|
|
57
|
+
lines.append(f"Station: {run.station}")
|
|
58
|
+
if run.operator:
|
|
59
|
+
lines.append(f"Operator: {run.operator}")
|
|
60
|
+
lines.append(f"Started: {run.started.isoformat(timespec='seconds')}")
|
|
61
|
+
if run.finished:
|
|
62
|
+
lines.append(f"Finished: {run.finished.isoformat(timespec='seconds')}")
|
|
63
|
+
lines.append(f"Tool version: {run.tool_version}")
|
|
64
|
+
if run.only:
|
|
65
|
+
lines.append(f"Only steps: {', '.join(run.only)}")
|
|
66
|
+
lines += [f"Result: {run.status}", ""]
|
|
67
|
+
for step in run.steps:
|
|
68
|
+
note = " (passed after retry)" if step.passed_after_retry else ""
|
|
69
|
+
msg = f" — {step.message}" if step.message else ""
|
|
70
|
+
lines.append(f"[{step.status}] {step.name}{msg}{note}")
|
|
71
|
+
if step.equipment_id:
|
|
72
|
+
lines.append(f" Equipment: {step.equipment_id}")
|
|
73
|
+
for m in step.attempts[-1].measurements if step.attempts else []:
|
|
74
|
+
limits = []
|
|
75
|
+
if m.low is not None:
|
|
76
|
+
limits.append(f">= {_fmt(m.low)}")
|
|
77
|
+
if m.high is not None:
|
|
78
|
+
limits.append(f"<= {_fmt(m.high)}")
|
|
79
|
+
limit = f" (limit {' and '.join(limits)})" if limits else ""
|
|
80
|
+
unit = f" {m.unit}" if m.unit else ""
|
|
81
|
+
lines.append(f" {m.name}: {_fmt(m.value)}{unit}{limit} "
|
|
82
|
+
f"{'ok' if m.passed else 'out of limits'}")
|
|
83
|
+
if len(step.attempts) > 1:
|
|
84
|
+
for i, a in enumerate(step.attempts, 1):
|
|
85
|
+
lines.append(f" attempt {i}: {a.status}{' — ' + a.message if a.message else ''}")
|
|
86
|
+
for a in step.attempts:
|
|
87
|
+
for d in a.details:
|
|
88
|
+
lines.append(f" {d}")
|
|
89
|
+
return "\n".join(lines) + "\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _new_dir(path: Path) -> Path:
|
|
93
|
+
"""Create ``path``, or ``path-2``, ``path-3``... so no earlier report is overwritten."""
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
for n in range(1, 1000):
|
|
96
|
+
candidate = path if n == 1 else path.with_name(f"{path.name}-{n}")
|
|
97
|
+
try:
|
|
98
|
+
candidate.mkdir()
|
|
99
|
+
return candidate
|
|
100
|
+
except FileExistsError:
|
|
101
|
+
continue
|
|
102
|
+
raise OSError(f"too many reports named {path.name}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_into(base: Path, run: RunResult) -> Path:
|
|
106
|
+
path = _new_dir(base / _relative(run))
|
|
107
|
+
(path / "result.json").write_text(json.dumps(run.to_dict(), indent=2) + "\n")
|
|
108
|
+
(path / "report.txt").write_text(render_text(run))
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_run(run: RunResult, station: Station) -> tuple[Path, bool]:
|
|
113
|
+
"""Returns the folder written and whether it went to the local spool."""
|
|
114
|
+
if report_reachable(station):
|
|
115
|
+
try:
|
|
116
|
+
return _write_into(Path(station.report_dir), run), False
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
return _write_into(Path(station.spool_dir), run), True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _spooled_runs(spool: Path) -> list[Path]:
|
|
123
|
+
if not spool.is_dir():
|
|
124
|
+
return []
|
|
125
|
+
return sorted(p.parent for p in spool.rglob("result.json"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sync_spool(station: Station) -> list[Path]:
|
|
129
|
+
spool = Path(station.spool_dir)
|
|
130
|
+
runs = _spooled_runs(spool)
|
|
131
|
+
if not runs or not report_reachable(station):
|
|
132
|
+
return []
|
|
133
|
+
moved = []
|
|
134
|
+
for src in runs:
|
|
135
|
+
try:
|
|
136
|
+
dest = _new_dir(Path(station.report_dir) / src.relative_to(spool))
|
|
137
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
141
|
+
moved.append(dest)
|
|
142
|
+
return moved
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Run the steps of a profile, applying the retry rule."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .checks import STEP_TYPES
|
|
6
|
+
from .context import Context
|
|
7
|
+
from .model import Attempt, StepResult
|
|
8
|
+
from .profile import Profile, Step
|
|
9
|
+
from .ui import UI
|
|
10
|
+
|
|
11
|
+
RETRY_PROMPT = "Retry this step?"
|
|
12
|
+
CONFIRM_INFO = "Passed. Running once more to confirm."
|
|
13
|
+
INTERNAL_ERROR = "Internal error in this test step. Tell the test engineer."
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _attempt(step: Step, ctx: Context, ui: UI) -> Attempt:
|
|
17
|
+
try:
|
|
18
|
+
return STEP_TYPES[step.type].run(step.config, ctx, ui)
|
|
19
|
+
except KeyboardInterrupt:
|
|
20
|
+
raise
|
|
21
|
+
except Exception as e: # a broken check must not stop the run
|
|
22
|
+
return Attempt.fail(INTERNAL_ERROR, details=[f"{type(e).__name__}: {e}"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _passed(attempts: list[Attempt]) -> bool:
|
|
26
|
+
if len(attempts) == 1:
|
|
27
|
+
return attempts[0].status == "PASS"
|
|
28
|
+
return attempts[-1].status == "PASS" and attempts[-2].status == "PASS"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_step(step: Step, ctx: Context, ui: UI, max_attempts: int) -> StepResult:
|
|
32
|
+
"""A step passes on its first attempt, or when its last two attempts passed."""
|
|
33
|
+
attempts = [_attempt(step, ctx, ui)]
|
|
34
|
+
while not _passed(attempts) and len(attempts) < max_attempts:
|
|
35
|
+
if attempts[-1].status == "FAIL":
|
|
36
|
+
if not ui.ask_yes_no(f"{attempts[-1].message}\n{RETRY_PROMPT}"):
|
|
37
|
+
break
|
|
38
|
+
else:
|
|
39
|
+
ui.info(CONFIRM_INFO)
|
|
40
|
+
attempts.append(_attempt(step, ctx, ui))
|
|
41
|
+
status = "PASS" if _passed(attempts) else "FAIL"
|
|
42
|
+
return StepResult(step.id, step.name, step.type, status, attempts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def select_steps(profile: Profile, only: list[str] | None = None) -> list[Step]:
|
|
46
|
+
"""The profile's steps, limited to the ids in ``only`` (case-insensitive)."""
|
|
47
|
+
if not only:
|
|
48
|
+
return list(profile.steps)
|
|
49
|
+
wanted = [o.lower() for o in only]
|
|
50
|
+
known = {s.id.lower() for s in profile.steps}
|
|
51
|
+
for o in wanted:
|
|
52
|
+
if o not in known:
|
|
53
|
+
raise ValueError(f"unknown step id: {o}")
|
|
54
|
+
return [s for s in profile.steps if s.id.lower() in wanted]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_steps(profile: Profile, ctx: Context, ui: UI, only: list[str] | None = None,
|
|
58
|
+
results: list[StepResult] | None = None) -> list[StepResult]:
|
|
59
|
+
"""Finished steps are appended to ``results`` as they complete, so a caller
|
|
60
|
+
still has them when the operator aborts with Ctrl+C."""
|
|
61
|
+
results = [] if results is None else results
|
|
62
|
+
for step in select_steps(profile, only):
|
|
63
|
+
equipment_id = None
|
|
64
|
+
if ctx.mode == "selftest" and step.equipment:
|
|
65
|
+
equipment_id = ui.ask_text(f"{step.equipment}: ")
|
|
66
|
+
ui.step_started(step)
|
|
67
|
+
result = run_step(step, ctx, ui, profile.max_attempts)
|
|
68
|
+
result.equipment_id = equipment_id
|
|
69
|
+
ui.step_finished(result)
|
|
70
|
+
results.append(result)
|
|
71
|
+
return results
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""One operator session: serial number, profile, preflight, steps, result and report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .context import Context
|
|
10
|
+
from .detect import detect_profiles
|
|
11
|
+
from .model import RunResult, StepResult
|
|
12
|
+
from .profile import Profile, load_profiles
|
|
13
|
+
from .report import sync_spool, write_run
|
|
14
|
+
from .runner import run_steps, select_steps
|
|
15
|
+
from .station import Station
|
|
16
|
+
from .ui import UI
|
|
17
|
+
|
|
18
|
+
SERIAL_PROMPT = "Scan or enter the serial number: "
|
|
19
|
+
SERIAL_RETRY = "The serial number does not match the expected format. Try again."
|
|
20
|
+
SELFTEST_HINT = "Same failure on several units? Run the equipment self-test."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def real_context(mode: str) -> Context:
|
|
24
|
+
from .checks.can import open_socketcan
|
|
25
|
+
from .checks.serial import open_pyserial
|
|
26
|
+
return Context(open_serial=open_pyserial, open_can=open_socketcan, mode=mode)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ask_serial(ui: UI, pattern: str | None) -> str:
|
|
30
|
+
while True:
|
|
31
|
+
serial = ui.ask_text(SERIAL_PROMPT)
|
|
32
|
+
if serial and (pattern is None or re.fullmatch(pattern, serial)):
|
|
33
|
+
return serial
|
|
34
|
+
ui.info(SERIAL_RETRY)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _choose(ui: UI, candidates: list[Profile]) -> Profile:
|
|
38
|
+
for i, p in enumerate(candidates, 1):
|
|
39
|
+
ui.info(f" {i} {p.name} (v{p.version})")
|
|
40
|
+
while True:
|
|
41
|
+
answer = ui.ask_text("Choose the profile number: ")
|
|
42
|
+
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
|
|
43
|
+
return candidates[int(answer) - 1]
|
|
44
|
+
ui.info(f"Enter a number from 1 to {len(candidates)}.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _select_profile(ui: UI, ctx: Context, candidates: list[Profile]) -> Profile:
|
|
48
|
+
detected = detect_profiles(candidates, ctx) if len(candidates) > 1 else candidates
|
|
49
|
+
profile = detected[0] if len(detected) == 1 else _choose(ui, candidates)
|
|
50
|
+
while not ui.ask_yes_no(f"Profile: {profile.name} (v{profile.version}). Correct?"):
|
|
51
|
+
if len(candidates) == 1:
|
|
52
|
+
raise ValueError("Profile not confirmed. Check the profiles with the test engineer.")
|
|
53
|
+
profile = _choose(ui, candidates)
|
|
54
|
+
return profile
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_session(station: Station, ctx: Context, ui: UI, *, rma: str | None = None,
|
|
58
|
+
only: list[str] | None = None, profiles: list[Profile] | None = None) -> RunResult:
|
|
59
|
+
moved = sync_spool(station)
|
|
60
|
+
if moved:
|
|
61
|
+
ui.info(f"Uploaded {len(moved)} saved report(s) to the network drive.")
|
|
62
|
+
|
|
63
|
+
selftest = ctx.mode == "selftest"
|
|
64
|
+
if profiles is None:
|
|
65
|
+
profiles = load_profiles(station.profile_dir)
|
|
66
|
+
candidates = [p for p in profiles if p.reference == selftest]
|
|
67
|
+
if not candidates:
|
|
68
|
+
kind = "reference profiles" if selftest else "profiles"
|
|
69
|
+
raise ValueError(f"No {kind} found in {station.profile_dir}")
|
|
70
|
+
|
|
71
|
+
# The serial pattern is per profile; with several candidates we only know it
|
|
72
|
+
# after the profile is chosen, so ask first and check against all patterns.
|
|
73
|
+
patterns = {p.serial_pattern for p in candidates}
|
|
74
|
+
pattern = patterns.pop() if len(patterns) == 1 else None
|
|
75
|
+
serial = "selftest" if selftest else _ask_serial(ui, pattern)
|
|
76
|
+
operator = ui.ask_text("Operator: ") if station.ask_operator else None
|
|
77
|
+
profile = _select_profile(ui, ctx, candidates)
|
|
78
|
+
if not selftest and profile.serial_pattern and not re.fullmatch(profile.serial_pattern, serial):
|
|
79
|
+
ui.info(SERIAL_RETRY)
|
|
80
|
+
serial = _ask_serial(ui, profile.serial_pattern)
|
|
81
|
+
|
|
82
|
+
steps = select_steps(profile, only)
|
|
83
|
+
run = RunResult(ctx.mode, serial, rma, profile.name, profile.version, station.name, operator,
|
|
84
|
+
datetime.now().astimezone(), None, __version__, [], "ABORTED", only)
|
|
85
|
+
|
|
86
|
+
from .checks import STEP_TYPES
|
|
87
|
+
problems = [p for s in steps for p in STEP_TYPES[s.type].preflight(s.config, ctx)]
|
|
88
|
+
if problems:
|
|
89
|
+
for p in problems:
|
|
90
|
+
ui.info(p)
|
|
91
|
+
else:
|
|
92
|
+
results: list[StepResult] = run.steps
|
|
93
|
+
try:
|
|
94
|
+
if profile.setup:
|
|
95
|
+
ui.wait_key(profile.setup + "\nPress Enter when ready.")
|
|
96
|
+
run_steps(profile, ctx, ui, only, results=results)
|
|
97
|
+
run.status = "PASS" if all(s.status == "PASS" for s in results) else "FAIL"
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
run.status = "ABORTED"
|
|
100
|
+
|
|
101
|
+
run.finished = datetime.now().astimezone()
|
|
102
|
+
ui.banner(run.status)
|
|
103
|
+
if run.status == "FAIL" and not selftest:
|
|
104
|
+
ui.info(SELFTEST_HINT)
|
|
105
|
+
path, spooled = write_run(run, station)
|
|
106
|
+
if spooled:
|
|
107
|
+
ui.info(f"Network drive not reachable. Report saved locally: {path}")
|
|
108
|
+
else:
|
|
109
|
+
ui.info(f"Report saved: {path}")
|
|
110
|
+
return run
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Simulated interfaces for tests and ``itk demo``."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""A simulated CAN network: frames sent on one interface arrive at all other open ones."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SimCanBus:
|
|
9
|
+
def __init__(self, network: "SimCanNetwork", iface: str):
|
|
10
|
+
self.network = network
|
|
11
|
+
self.iface = iface
|
|
12
|
+
self.queue: deque = deque()
|
|
13
|
+
|
|
14
|
+
def send(self, arb_id: int, data: bytes, extended: bool) -> None:
|
|
15
|
+
self.network.deliver(self, (arb_id, bytes(data), extended))
|
|
16
|
+
|
|
17
|
+
def recv(self, timeout: float):
|
|
18
|
+
return self.queue.popleft() if self.queue else None
|
|
19
|
+
|
|
20
|
+
def close(self) -> None:
|
|
21
|
+
if self in self.network.buses:
|
|
22
|
+
self.network.buses.remove(self)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SimCanNetwork:
|
|
26
|
+
def __init__(self, drop: set[str] | None = None):
|
|
27
|
+
self.drop = set(drop or ())
|
|
28
|
+
self.buses: list[SimCanBus] = []
|
|
29
|
+
|
|
30
|
+
def open(self, iface: str) -> SimCanBus:
|
|
31
|
+
bus = SimCanBus(self, iface)
|
|
32
|
+
self.buses.append(bus)
|
|
33
|
+
return bus
|
|
34
|
+
|
|
35
|
+
def deliver(self, sender: SimCanBus, frame) -> None:
|
|
36
|
+
if sender.iface in self.drop:
|
|
37
|
+
return
|
|
38
|
+
for bus in self.buses:
|
|
39
|
+
if bus is not sender:
|
|
40
|
+
bus.queue.append(frame)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Fake system pieces: a command runner and sysfs trees under a fake root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..context import CommandResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeRunner:
|
|
13
|
+
"""Answers commands by their first two words; records every call."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, responses: dict[str, CommandResult] | None = None):
|
|
16
|
+
self.responses = dict(responses or {})
|
|
17
|
+
self.calls: list[list[str]] = []
|
|
18
|
+
|
|
19
|
+
def __call__(self, args: list[str]) -> CommandResult:
|
|
20
|
+
self.calls.append(list(args))
|
|
21
|
+
key = " ".join(args[:2])
|
|
22
|
+
if key in self.responses:
|
|
23
|
+
response = self.responses[key]
|
|
24
|
+
return response(args) if callable(response) else response
|
|
25
|
+
return CommandResult(127, "", f"fake: no response for {key}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lsblk_result(nodes: list[dict]) -> CommandResult:
|
|
29
|
+
return CommandResult(0, json.dumps({"blockdevices": nodes}), "")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_usb_stick(root: Path, kname: str, label: str | None, port: str, speed: int,
|
|
33
|
+
mountpoint: str | None, tran: str = "usb") -> dict:
|
|
34
|
+
"""Create the sysfs entries of a USB stick partition and return its lsblk disk node."""
|
|
35
|
+
disk = re.sub(r"\d+$", "", kname)
|
|
36
|
+
bus = port.split("-")[0]
|
|
37
|
+
dev = root / f"sys/devices/pci0000:00/0000:00:14.0/usb{bus}/{port}"
|
|
38
|
+
dev.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
(dev / "speed").write_text(f"{speed}\n")
|
|
40
|
+
(dev / "devnum").write_text("2\n")
|
|
41
|
+
part = dev / f"{port}:1.0/host0/target0:0:0/0:0:0:0/block/{disk}/{kname}"
|
|
42
|
+
part.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
links = root / "sys/class/block"
|
|
44
|
+
links.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
(links / kname).symlink_to(part)
|
|
46
|
+
if mountpoint:
|
|
47
|
+
(root / mountpoint.lstrip("/")).mkdir(parents=True, exist_ok=True)
|
|
48
|
+
return {"name": disk, "kname": disk, "path": f"/dev/{disk}", "label": None,
|
|
49
|
+
"mountpoint": None, "tran": tran,
|
|
50
|
+
"children": [{"name": kname, "kname": kname, "path": f"/dev/{kname}", "label": label,
|
|
51
|
+
"mountpoint": mountpoint, "tran": None}]}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_netdev(root: Path, iface: str, carrier: int | None = 1, speed: int | None = 1000,
|
|
55
|
+
duplex: str | None = "full", hardware: bool = True) -> Path:
|
|
56
|
+
"""Create /sys/class/net/<iface>; None leaves the attribute unreadable like a down link."""
|
|
57
|
+
d = root / "sys/class/net" / iface
|
|
58
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
for name, value in (("carrier", carrier), ("speed", speed), ("duplex", duplex)):
|
|
60
|
+
if value is not None:
|
|
61
|
+
(d / name).write_text(f"{value}\n")
|
|
62
|
+
if hardware:
|
|
63
|
+
(d / "device").mkdir(exist_ok=True)
|
|
64
|
+
return d
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""A simulated serial port with a loopback plug, optionally broken."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SimLoopback:
|
|
7
|
+
def __init__(self, wiring: dict[str, tuple[str, ...]], broken_lines: set[str] | None = None,
|
|
8
|
+
data_fault: str | None = None):
|
|
9
|
+
self.wiring = wiring
|
|
10
|
+
self.broken_lines = set(broken_lines or ())
|
|
11
|
+
self.data_fault = data_fault
|
|
12
|
+
self.outputs = {"RTS": False, "DTR": False}
|
|
13
|
+
self.buffer = bytearray()
|
|
14
|
+
self.baud = 0
|
|
15
|
+
|
|
16
|
+
def set_baud(self, baud: int) -> None:
|
|
17
|
+
self.baud = baud
|
|
18
|
+
|
|
19
|
+
def write(self, data: bytes) -> None:
|
|
20
|
+
if self.data_fault == "no_echo":
|
|
21
|
+
return
|
|
22
|
+
data = bytearray(data)
|
|
23
|
+
if self.data_fault == "corrupt" and len(data) > 100:
|
|
24
|
+
data[100] ^= 0x01
|
|
25
|
+
self.buffer += data
|
|
26
|
+
|
|
27
|
+
def read(self, n: int, timeout: float) -> bytes:
|
|
28
|
+
out = bytes(self.buffer[:n])
|
|
29
|
+
del self.buffer[:n]
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
def reset_input(self) -> None:
|
|
33
|
+
self.buffer.clear()
|
|
34
|
+
|
|
35
|
+
def set_line(self, name: str, state: bool) -> None:
|
|
36
|
+
self.outputs[name] = state
|
|
37
|
+
|
|
38
|
+
def get_line(self, name: str) -> bool:
|
|
39
|
+
return any(self.outputs.get(out, False)
|
|
40
|
+
for out, inputs in self.wiring.items()
|
|
41
|
+
if name in inputs and f"{out}->{name}" not in self.broken_lines)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
pass
|