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.
@@ -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