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,3 @@
1
+ """interface-test-kit: operator-guided interface test for Linux embedded PCs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,47 @@
1
+ """Registry of step types. Each check module registers itself on import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ if TYPE_CHECKING:
9
+ from ..context import Context
10
+ from ..model import Attempt
11
+ from ..ui import UI
12
+
13
+
14
+ def _no_preflight(config: Any, ctx: "Context") -> list[str]:
15
+ return []
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CheckType:
20
+ parse: Callable[[dict], Any]
21
+ run: Callable[[Any, "Context", "UI"], "Attempt"]
22
+ preflight: Callable[[Any, "Context"], list[str]] = _no_preflight
23
+
24
+
25
+ STEP_TYPES: dict[str, CheckType] = {}
26
+
27
+
28
+ def register(name: str, check: CheckType) -> None:
29
+ STEP_TYPES[name] = check
30
+
31
+
32
+ def fields(raw: dict, required: set[str], optional: dict[str, Any]) -> dict:
33
+ """Check the keys of a step's type-specific fields and fill in defaults.
34
+
35
+ The step's ``name`` is always passed along, so checks can use it in messages.
36
+ """
37
+ optional = {"name": "", **optional}
38
+ for key in raw:
39
+ if key not in required and key not in optional:
40
+ raise ValueError(f"unknown field '{key}'")
41
+ for key in sorted(required):
42
+ if key not in raw:
43
+ raise ValueError(f"{key}: missing")
44
+ return {**optional, **raw}
45
+
46
+
47
+ from . import can, confirm, ethernet, serial, usb # noqa: E402,F401 (register themselves)
@@ -0,0 +1,186 @@
1
+ """CAN check in pair mode: two CAN ports wired together exchange test frames both ways."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import struct
9
+ from dataclasses import dataclass
10
+ from typing import Protocol
11
+
12
+ from ..model import Attempt
13
+ from . import CheckType, fields, register
14
+
15
+ # (id, data, extended): empty, short and full standard frames, one extended frame
16
+ TEST_FRAMES = [(0x123, b"", False), (0x123, bytes([0x55]), False),
17
+ (0x7FF, bytes(range(8)), False), (0x1ABCDE0, bytes([0xAA] * 8), True)]
18
+
19
+ # ERROR-WARNING is fine: after a failed attempt (cable missing) the TX error counter
20
+ # only counts down by one per good frame, so it stays above 96 for a while.
21
+ GOOD_STATES = ("ERROR-ACTIVE", "ERROR-WARNING")
22
+
23
+ _FRAME = struct.Struct("=IB3x8s")
24
+ _EFF_FLAG = 0x80000000
25
+ _RTR_FLAG = 0x40000000
26
+ _ERR_FLAG = 0x20000000
27
+
28
+
29
+ class CanBus(Protocol):
30
+ def send(self, arb_id: int, data: bytes, extended: bool) -> None: ...
31
+ def recv(self, timeout: float) -> tuple[int, bytes, bool] | None: ...
32
+ def close(self) -> None: ...
33
+
34
+
35
+ class SocketCanBus:
36
+ def __init__(self, iface: str):
37
+ self._sock = socket.socket(socket.AF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
38
+ self._sock.bind((iface,))
39
+
40
+ def send(self, arb_id: int, data: bytes, extended: bool) -> None:
41
+ can_id = arb_id | _EFF_FLAG if extended else arb_id
42
+ self._sock.send(_FRAME.pack(can_id, len(data), data.ljust(8, b"\0")))
43
+
44
+ def recv(self, timeout: float) -> tuple[int, bytes, bool] | None:
45
+ self._sock.settimeout(timeout)
46
+ while True:
47
+ try:
48
+ raw = self._sock.recv(_FRAME.size)
49
+ except socket.timeout:
50
+ return None
51
+ can_id, length, data = _FRAME.unpack(raw)
52
+ if can_id & (_ERR_FLAG | _RTR_FLAG):
53
+ continue
54
+ extended = bool(can_id & _EFF_FLAG)
55
+ return can_id & (0x1FFFFFFF if extended else 0x7FF), data[:length], extended
56
+
57
+ def close(self) -> None:
58
+ self._sock.close()
59
+
60
+
61
+ def open_socketcan(iface: str) -> CanBus:
62
+ return SocketCanBus(iface)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class CanConfig:
67
+ name: str
68
+ pair: tuple[str, str]
69
+ bitrate: int | None = None
70
+ timeout_s: float = 1.0
71
+
72
+
73
+ def parse(raw: dict) -> CanConfig:
74
+ f = fields(raw, {"pair"}, {"bitrate": None, "timeout_s": 1.0})
75
+ pair = f["pair"]
76
+ if not isinstance(pair, list) or len(pair) != 2:
77
+ raise ValueError("pair: must list exactly two CAN interfaces, e.g. [can0, can1]")
78
+ if pair[0] == pair[1]:
79
+ # one interface talking to itself passes through the kernel's local loopback, no cable needed
80
+ raise ValueError("pair: must name two different CAN interfaces")
81
+ rate = f["bitrate"]
82
+ if rate is not None and (isinstance(rate, bool) or not isinstance(rate, int) or rate <= 0):
83
+ raise ValueError("bitrate: must be a positive whole number, e.g. 500000")
84
+ t = f["timeout_s"]
85
+ if isinstance(t, bool) or not isinstance(t, (int, float)) or t <= 0:
86
+ raise ValueError("timeout_s: must be a positive number")
87
+ return CanConfig(str(f["name"]), (str(pair[0]), str(pair[1])), rate, float(t))
88
+
89
+
90
+ def _is_hardware(ctx, iface: str) -> bool:
91
+ return ctx.path(f"/sys/class/net/{iface}/device").exists()
92
+
93
+
94
+ def _ifaces(config: CanConfig) -> list[str]:
95
+ return list(dict.fromkeys(config.pair))
96
+
97
+
98
+ def preflight(config: CanConfig, ctx) -> list[str]:
99
+ problems = [f"CAN: network interface {i} not found" for i in _ifaces(config)
100
+ if not ctx.path(f"/sys/class/net/{i}").is_dir()]
101
+ if not problems and config.bitrate and os.geteuid() != 0 and \
102
+ any(_is_hardware(ctx, i) for i in _ifaces(config)):
103
+ problems.append("CAN: setting the bitrate needs root")
104
+ return problems
105
+
106
+
107
+ def _set_bitrate(ctx, iface: str, bitrate: int, details: list[str]) -> bool:
108
+ for args in (["ip", "link", "set", iface, "down"],
109
+ ["ip", "link", "set", iface, "type", "can", "bitrate", str(bitrate)],
110
+ ["ip", "link", "set", iface, "up"]):
111
+ result = ctx.run(args)
112
+ if result.returncode != 0:
113
+ details.append(f"{' '.join(args)}: {result.stderr.strip()}")
114
+ return False
115
+ details.append(f"{iface}: bitrate {bitrate}")
116
+ return True
117
+
118
+
119
+ def _state(ctx, iface: str) -> str | None:
120
+ result = ctx.run(["ip", "-d", "-j", "link", "show", iface])
121
+ try:
122
+ return json.loads(result.stdout)[0]["linkinfo"]["info_data"]["state"]
123
+ except (ValueError, LookupError, TypeError):
124
+ return None
125
+
126
+
127
+ def _drain(bus: CanBus) -> None:
128
+ """Drop frames still queued from an earlier attempt."""
129
+ for _ in range(100):
130
+ if bus.recv(0.05) is None:
131
+ return
132
+
133
+
134
+ def _exchange(tx: CanBus, rx: CanBus, timeout: float) -> str | None:
135
+ """Send every test frame from tx to rx. Returns 'missing', 'corrupt' or None."""
136
+ for frame in TEST_FRAMES:
137
+ tx.send(*frame)
138
+ got = rx.recv(timeout)
139
+ if got is None:
140
+ return "missing"
141
+ if got != frame:
142
+ return "corrupt"
143
+ return None
144
+
145
+
146
+ def run(config: CanConfig, ctx, ui) -> Attempt:
147
+ a, b = config.pair
148
+ details: list[str] = []
149
+ cable = f"CAN cable between {a} and {b} faulty"
150
+ hardware = [i for i in _ifaces(config) if _is_hardware(ctx, i)]
151
+ if config.bitrate:
152
+ for iface in hardware:
153
+ if not _set_bitrate(ctx, iface, config.bitrate, details):
154
+ return Attempt.fail(f"CAN: could not set up {iface}. Tell the test engineer.",
155
+ details=details)
156
+ bus_a = ctx.open_can(a)
157
+ try:
158
+ bus_b = ctx.open_can(b)
159
+ try:
160
+ _drain(bus_a)
161
+ _drain(bus_b)
162
+ for tx_name, tx, rx_name, rx in ((a, bus_a, b, bus_b), (b, bus_b, a, bus_a)):
163
+ problem = _exchange(tx, rx, config.timeout_s)
164
+ if problem == "missing":
165
+ details.append(f"{tx_name} -> {rx_name}: frame not received within {config.timeout_s:g} s")
166
+ return Attempt.fail(ctx.say(f"CAN: no frames from {tx_name} to {rx_name}. "
167
+ "Check the CAN cable and termination.", cable), details=details)
168
+ if problem == "corrupt":
169
+ details.append(f"{tx_name} -> {rx_name}: frame received with wrong id or data")
170
+ return Attempt.fail(ctx.say(f"CAN: frames from {tx_name} to {rx_name} corrupted", cable),
171
+ details=details)
172
+ details.append(f"{tx_name} -> {rx_name}: {len(TEST_FRAMES)} frames ok")
173
+ finally:
174
+ bus_b.close()
175
+ finally:
176
+ bus_a.close()
177
+ for iface in hardware:
178
+ state = _state(ctx, iface)
179
+ details.append(f"{iface}: state {state}")
180
+ if state not in GOOD_STATES:
181
+ return Attempt.fail(f"CAN: {iface} in error state {state}. "
182
+ "Check the CAN cable and termination, then retry.", details=details)
183
+ return Attempt.ok(details=details)
184
+
185
+
186
+ register("can", CheckType(parse=parse, run=run, preflight=preflight))
@@ -0,0 +1,30 @@
1
+ """Manual check: the operator looks at something and answers Y/N."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ..model import Attempt
8
+ from . import CheckType, fields, register
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ConfirmConfig:
13
+ prompt: str
14
+
15
+
16
+ def parse(raw: dict) -> ConfirmConfig:
17
+ f = fields(raw, {"prompt"}, {})
18
+ if not isinstance(f["prompt"], str) or not f["prompt"].strip():
19
+ raise ValueError("prompt: must be a non-empty text")
20
+ return ConfirmConfig(f["prompt"])
21
+
22
+
23
+ def run(config: ConfirmConfig, ctx, ui) -> Attempt:
24
+ if ui.ask_yes_no(config.prompt):
25
+ return Attempt.ok()
26
+ text = f"Operator answered No: {config.prompt}"
27
+ return Attempt.fail(ctx.say(text, text))
28
+
29
+
30
+ register("confirm", CheckType(parse=parse, run=run))
@@ -0,0 +1,97 @@
1
+ """Ethernet check: link, negotiated speed and duplex from sysfs, then ping a counterpart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from ..model import Attempt, Measurement
9
+ from . import CheckType, fields, register
10
+
11
+ _LOSS = re.compile(r"(\d+(?:\.\d+)?)% packet loss")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class EthernetConfig:
16
+ name: str
17
+ iface: str
18
+ speed: int | None = None
19
+ duplex: str | None = None
20
+ ping: str | None = None
21
+ ping_count: int = 5
22
+ max_loss_pct: float = 0.0
23
+
24
+
25
+ def parse(raw: dict) -> EthernetConfig:
26
+ f = fields(raw, {"iface"}, {"speed": None, "duplex": None, "ping": None,
27
+ "ping_count": 5, "max_loss_pct": 0.0})
28
+ if f["speed"] is not None and (not isinstance(f["speed"], int) or f["speed"] <= 0):
29
+ raise ValueError("speed: must be a positive whole number (Mbit/s)")
30
+ if f["duplex"] is not None and f["duplex"] not in ("full", "half"):
31
+ raise ValueError("duplex: must be full or half")
32
+ if not isinstance(f["ping_count"], int) or not 1 <= f["ping_count"] <= 100:
33
+ raise ValueError("ping_count: must be a whole number from 1 to 100")
34
+ loss = f["max_loss_pct"]
35
+ if isinstance(loss, bool) or not isinstance(loss, (int, float)) or not 0 <= loss <= 100:
36
+ raise ValueError("max_loss_pct: must be a number from 0 to 100")
37
+ return EthernetConfig(str(f["name"]), str(f["iface"]), f["speed"], f["duplex"],
38
+ str(f["ping"]) if f["ping"] is not None else None, f["ping_count"], float(loss))
39
+
40
+
41
+ def preflight(config: EthernetConfig, ctx) -> list[str]:
42
+ if not ctx.path(f"/sys/class/net/{config.iface}").is_dir():
43
+ return [f"{config.name}: network interface {config.iface} not found"]
44
+ return []
45
+
46
+
47
+ def _read(ctx, iface: str, attr: str) -> str | None:
48
+ try:
49
+ return ctx.path(f"/sys/class/net/{iface}/{attr}").read_text().strip()
50
+ except OSError: # reading speed/duplex of a link that is down fails with EINVAL
51
+ return None
52
+
53
+
54
+ def run(config: EthernetConfig, ctx, ui) -> Attempt:
55
+ name = config.name
56
+ faulty_equipment = f"{name}: network cable or test network faulty"
57
+ details: list[str] = []
58
+ measurements: list[Measurement] = []
59
+ if _read(ctx, config.iface, "carrier") != "1":
60
+ details.append(f"{config.iface}: no carrier")
61
+ return Attempt.fail(ctx.say(f"{name}: no network link. Check the cable.", faulty_equipment),
62
+ details=details)
63
+ speed_text = _read(ctx, config.iface, "speed")
64
+ duplex = _read(ctx, config.iface, "duplex")
65
+ details.append(f"{config.iface}: link up, speed {speed_text} Mbit/s, duplex {duplex}")
66
+ if config.speed is not None:
67
+ try:
68
+ speed = int(speed_text or "")
69
+ except ValueError:
70
+ speed = -1
71
+ measurements.append(Measurement("link speed", speed, "Mbit/s", low=config.speed))
72
+ if speed != config.speed:
73
+ return Attempt.fail(f"{name}: link at {speed_text} Mbit/s, expected {config.speed}",
74
+ details=details, measurements=measurements)
75
+ if config.duplex is not None and duplex != config.duplex:
76
+ return Attempt.fail(f"{name}: {duplex} duplex, expected {config.duplex}",
77
+ details=details, measurements=measurements)
78
+ if config.ping:
79
+ result = ctx.run(["ping", "-c", str(config.ping_count), "-W", "1", "-I", config.iface, config.ping])
80
+ match = _LOSS.search(result.stdout)
81
+ if not match:
82
+ details += [f"ping exit {result.returncode}", result.stdout.strip(), result.stderr.strip()]
83
+ return Attempt.fail(ctx.say(f"{name}: no answer from {config.ping}", faulty_equipment),
84
+ details=[d for d in details if d], measurements=measurements)
85
+ loss = float(match.group(1))
86
+ measurements.append(Measurement("packet loss", loss, "%", high=config.max_loss_pct))
87
+ details.append(f"ping {config.ping}: {loss:g}% loss")
88
+ if loss >= 100:
89
+ return Attempt.fail(ctx.say(f"{name}: no answer from {config.ping}", faulty_equipment),
90
+ details=details, measurements=measurements)
91
+ if loss > config.max_loss_pct:
92
+ return Attempt.fail(ctx.say(f"{name}: {loss:g}% packet loss to {config.ping}", faulty_equipment),
93
+ details=details, measurements=measurements)
94
+ return Attempt.ok(measurements=measurements, details=details)
95
+
96
+
97
+ register("ethernet", CheckType(parse=parse, run=run, preflight=preflight))
@@ -0,0 +1,172 @@
1
+ """Serial port check through a loopback plug: data at each baud rate, then handshake lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ from dataclasses import dataclass
8
+ from typing import Protocol
9
+
10
+ from ..model import Attempt
11
+ from . import CheckType, fields, register
12
+
13
+ # Which output line drives which input lines on each kind of loopback plug.
14
+ LOOPBACK_TYPES: dict[str, dict[str, tuple[str, ...]]] = {
15
+ "9pin-full": {"RTS": ("CTS",), "DTR": ("DSR", "CD", "RI")},
16
+ "9pin": {"RTS": ("CTS",), "DTR": ("DSR", "CD")},
17
+ "data-only": {},
18
+ }
19
+
20
+ # USB-serial adapters report modem line changes over USB, several ms late.
21
+ LINE_SETTLE_S = 0.05
22
+
23
+ _REPAIR = ". Check the loopback plug; if it is fine, send the unit to repair."
24
+
25
+
26
+ class SerialLink(Protocol):
27
+ def set_baud(self, baud: int) -> None: ...
28
+ def write(self, data: bytes) -> None: ...
29
+ def read(self, n: int, timeout: float) -> bytes: ...
30
+ def reset_input(self) -> None: ...
31
+ def set_line(self, name: str, state: bool) -> None: ...
32
+ def get_line(self, name: str) -> bool: ...
33
+ def close(self) -> None: ...
34
+
35
+
36
+ class PySerialLink:
37
+ def __init__(self, port: str, baud: int):
38
+ import serial
39
+ self._ser = serial.Serial(port, baud, timeout=1.0)
40
+
41
+ def set_baud(self, baud: int) -> None:
42
+ self._ser.baudrate = baud
43
+
44
+ def write(self, data: bytes) -> None:
45
+ self._ser.write(data)
46
+ self._ser.flush()
47
+
48
+ def read(self, n: int, timeout: float) -> bytes:
49
+ self._ser.timeout = timeout
50
+ return self._ser.read(n)
51
+
52
+ def reset_input(self) -> None:
53
+ self._ser.reset_input_buffer()
54
+
55
+ def set_line(self, name: str, state: bool) -> None:
56
+ setattr(self._ser, name.lower(), state)
57
+
58
+ def get_line(self, name: str) -> bool:
59
+ return bool(getattr(self._ser, name.lower()))
60
+
61
+ def close(self) -> None:
62
+ self._ser.close()
63
+
64
+
65
+ def open_pyserial(port: str, baud: int) -> SerialLink:
66
+ return PySerialLink(port, baud)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SerialConfig:
71
+ name: str
72
+ port: str
73
+ loopback: str = "9pin"
74
+ bauds: tuple[int, ...] = (9600, 115200)
75
+ pattern_bytes: int = 512
76
+
77
+
78
+ def parse(raw: dict) -> SerialConfig:
79
+ f = fields(raw, {"port"}, {"loopback": "9pin", "bauds": [9600, 115200], "pattern_bytes": 512})
80
+ if f["loopback"] not in LOOPBACK_TYPES:
81
+ raise ValueError(f"loopback: unknown type '{f['loopback']}' "
82
+ f"(known: {', '.join(LOOPBACK_TYPES)})")
83
+ bauds = f["bauds"]
84
+ if not isinstance(bauds, list) or not bauds or \
85
+ not all(isinstance(b, int) and not isinstance(b, bool) and b > 0 for b in bauds):
86
+ raise ValueError("bauds: must be a list of positive whole numbers")
87
+ pb = f["pattern_bytes"]
88
+ if not isinstance(pb, int) or pb < 256:
89
+ raise ValueError("pattern_bytes: must be a whole number of at least 256")
90
+ return SerialConfig(str(f["name"]), str(f["port"]), f["loopback"], tuple(bauds), pb)
91
+
92
+
93
+ def preflight(config: SerialConfig, ctx) -> list[str]:
94
+ path = ctx.path(config.port)
95
+ if not path.exists():
96
+ return [f"{config.name}: port {config.port} not found"]
97
+ if not os.access(path, os.R_OK | os.W_OK):
98
+ return [f"{config.name}: no access to port {config.port} (add the user to group dialout)"]
99
+ return []
100
+
101
+
102
+ def _pattern(baud: int, size: int) -> bytes:
103
+ return bytes(range(256)) + random.Random(baud).randbytes(size - 256)
104
+
105
+
106
+ def _check_data(link: SerialLink, config: SerialConfig, details: list[str]) -> tuple[str, int] | None:
107
+ """Returns (problem, baud) for the first failing baud rate, or None."""
108
+ for baud in config.bauds:
109
+ link.set_baud(baud)
110
+ link.reset_input()
111
+ sent = _pattern(baud, config.pattern_bytes)
112
+ link.write(sent)
113
+ got = link.read(len(sent), max(1.0, 20 * len(sent) * 10 / baud))
114
+ if not got:
115
+ details.append(f"{baud} baud: nothing received")
116
+ return "no_data", baud
117
+ if got != sent:
118
+ offset = next((i for i, (a, b) in enumerate(zip(sent, got)) if a != b), len(got))
119
+ details.append(f"{baud} baud: received {len(got)} of {len(sent)} bytes, "
120
+ f"first difference at offset {offset}")
121
+ return "corrupt", baud
122
+ details.append(f"{baud} baud: {len(sent)} bytes ok")
123
+ return None
124
+
125
+
126
+ def _check_lines(link: SerialLink, wiring: dict[str, tuple[str, ...]], details: list[str],
127
+ sleep) -> list[str]:
128
+ broken: list[str] = []
129
+ for out, inputs in wiring.items():
130
+ for state in (True, False):
131
+ link.set_line(out, state)
132
+ for other in wiring:
133
+ if other != out:
134
+ link.set_line(other, not state)
135
+ sleep(LINE_SETTLE_S)
136
+ for inp in inputs:
137
+ if link.get_line(inp) != state:
138
+ pair = f"{out}->{inp}"
139
+ details.append(f"{pair}: input did not follow output set {'high' if state else 'low'}")
140
+ if pair not in broken:
141
+ broken.append(pair)
142
+ if wiring and not broken:
143
+ details.append("handshake lines ok: " + ", ".join(
144
+ f"{o}->{i}" for o, ins in wiring.items() for i in ins))
145
+ return broken
146
+
147
+
148
+ def run(config: SerialConfig, ctx, ui) -> Attempt:
149
+ details: list[str] = []
150
+ link = ctx.open_serial(config.port, config.bauds[0])
151
+ try:
152
+ problem = _check_data(link, config, details)
153
+ if problem:
154
+ kind, baud = problem
155
+ if kind == "no_data":
156
+ unit = f"{config.name}: no data received back{_REPAIR}"
157
+ else:
158
+ unit = f"{config.name}: data corrupted at {baud} baud{_REPAIR}"
159
+ return Attempt.fail(ctx.say(unit, f"Loopback plug on {config.name} faulty or not plugged in"),
160
+ details=details)
161
+ broken = _check_lines(link, LOOPBACK_TYPES[config.loopback], details, ctx.sleep)
162
+ finally:
163
+ link.close()
164
+ if broken:
165
+ pairs = ", ".join(broken)
166
+ return Attempt.fail(ctx.say(f"{config.name}: handshake line faulty ({pairs}){_REPAIR}",
167
+ f"Loopback plug on {config.name} faulty ({pairs} not connected)"),
168
+ details=details)
169
+ return Attempt.ok(details=details)
170
+
171
+
172
+ register("serial", CheckType(parse=parse, run=run, preflight=preflight))