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,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))
|