embeddedci-openhtf 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.
- embeddedci_openhtf/__init__.py +70 -0
- embeddedci_openhtf/analog.py +184 -0
- embeddedci_openhtf/measurements.py +109 -0
- embeddedci_openhtf/phases.py +121 -0
- embeddedci_openhtf/plug.py +218 -0
- embeddedci_openhtf-0.1.0.dist-info/METADATA +170 -0
- embeddedci_openhtf-0.1.0.dist-info/RECORD +8 -0
- embeddedci_openhtf-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""embeddedci-openhtf — drive an EmbeddedCI BenchPod from OpenHTF.
|
|
2
|
+
|
|
3
|
+
An `OpenHTF <https://www.openhtf.com/>`_ plug (and a few phase helpers) that wrap
|
|
4
|
+
the :mod:`embeddedci` BenchPod SDK, for teams that want OpenHTF's test-sequencing
|
|
5
|
+
and record/GUI stack while connecting **directly** to a pod over TCP or serial —
|
|
6
|
+
no EmbeddedCI cloud account or web UI required.
|
|
7
|
+
|
|
8
|
+
import openhtf as htf
|
|
9
|
+
from embeddedci_openhtf import benchpod_plug, flash_phase, boot_banner_phase
|
|
10
|
+
|
|
11
|
+
bench = benchpod_plug("192.168.1.50:8080") # or "/dev/ttyACM0"
|
|
12
|
+
|
|
13
|
+
test = htf.Test(
|
|
14
|
+
flash_phase(bench, file="fw.elf", target="target/stm32f4x.cfg",
|
|
15
|
+
swclk=11, swdio=12, nreset=3),
|
|
16
|
+
boot_banner_phase(bench, rx=1, tx=2, expect="APP_OK"),
|
|
17
|
+
)
|
|
18
|
+
test.execute(test_start=lambda: "SN-0001")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
from .analog import (
|
|
26
|
+
adc_capture_phase,
|
|
27
|
+
loopback_measure_phase,
|
|
28
|
+
measure,
|
|
29
|
+
signal_generate,
|
|
30
|
+
signal_generate_phase,
|
|
31
|
+
signal_stop,
|
|
32
|
+
)
|
|
33
|
+
from .measurements import (
|
|
34
|
+
flash_ok_measurement,
|
|
35
|
+
record_flash,
|
|
36
|
+
record_samples,
|
|
37
|
+
record_uart,
|
|
38
|
+
uart_matched_measurement,
|
|
39
|
+
)
|
|
40
|
+
from .phases import boot_banner_phase, flash_phase, power_phase
|
|
41
|
+
from .plug import BenchPodPlug, benchpod_plug, close_persistent_benchpods
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
__version__ = version("embeddedci-openhtf")
|
|
45
|
+
except PackageNotFoundError: # running from a source tree without install
|
|
46
|
+
__version__ = "0.0.0+unknown"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"BenchPodPlug",
|
|
50
|
+
"benchpod_plug",
|
|
51
|
+
"close_persistent_benchpods",
|
|
52
|
+
# phase factories
|
|
53
|
+
"power_phase",
|
|
54
|
+
"flash_phase",
|
|
55
|
+
"boot_banner_phase",
|
|
56
|
+
"signal_generate_phase",
|
|
57
|
+
"adc_capture_phase",
|
|
58
|
+
"loopback_measure_phase",
|
|
59
|
+
# analog low-level helpers
|
|
60
|
+
"signal_generate",
|
|
61
|
+
"signal_stop",
|
|
62
|
+
"measure",
|
|
63
|
+
# measurement helpers
|
|
64
|
+
"flash_ok_measurement",
|
|
65
|
+
"uart_matched_measurement",
|
|
66
|
+
"record_flash",
|
|
67
|
+
"record_uart",
|
|
68
|
+
"record_samples",
|
|
69
|
+
"__version__",
|
|
70
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Analog stimulus/acquisition helpers for OpenHTF phases.
|
|
2
|
+
|
|
3
|
+
The BenchPod's analog path is a DAC output and an ADC input on the iCE40 FPGA.
|
|
4
|
+
The SDK exposes the acquisition side as ``BenchPod.capture(...)`` and everything
|
|
5
|
+
else through raw JSON commands; these helpers wrap the firmware's ``generate``
|
|
6
|
+
(DAC waveform), ``measure`` (synchronized DAC+ADC loopback), and ``capture``
|
|
7
|
+
(ADC snapshot) commands, and the phase factories turn the captured samples into
|
|
8
|
+
OpenHTF measurements via :func:`embeddedci_openhtf.record_samples`.
|
|
9
|
+
|
|
10
|
+
**TCP only.** These commands need the JSON/sample channel, which the serial
|
|
11
|
+
console does not provide — they raise ``BenchPodError`` on a serial connection.
|
|
12
|
+
|
|
13
|
+
The ``bench`` argument to every helper is a connected :class:`BenchPod` *or* a
|
|
14
|
+
:class:`~embeddedci_openhtf.BenchPodPlug` (the plug proxies the SDK methods), so
|
|
15
|
+
inside a phase you can pass the injected ``bench`` straight through::
|
|
16
|
+
|
|
17
|
+
@htf.plug(bench=benchpod_plug("192.168.1.50:8080"))
|
|
18
|
+
def stim(test, bench):
|
|
19
|
+
signal_generate(bench, waveform="sine", freq=1000, amplitude=100, offset=128)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, List, Optional, Union
|
|
25
|
+
|
|
26
|
+
import openhtf as htf
|
|
27
|
+
|
|
28
|
+
from embeddedci.benchpod.errors import BenchPodError
|
|
29
|
+
|
|
30
|
+
from .measurements import record_samples
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"signal_generate",
|
|
34
|
+
"signal_stop",
|
|
35
|
+
"measure",
|
|
36
|
+
"signal_generate_phase",
|
|
37
|
+
"adc_capture_phase",
|
|
38
|
+
"loopback_measure_phase",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_Range = Optional[tuple] # (min, max) inclusive, or None for no limit
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# -- low-level helpers (operate on a BenchPod or BenchPodPlug) ---------------
|
|
45
|
+
|
|
46
|
+
def signal_generate(bench: Any, *, waveform: str, freq: float, amplitude: float,
|
|
47
|
+
offset: float = 128.0, duration_ms: Optional[int] = None,
|
|
48
|
+
sample_rate_mhz: Optional[float] = None) -> Any:
|
|
49
|
+
"""Start a DAC waveform (``sine``/``square``/``sawtooth``/...).
|
|
50
|
+
|
|
51
|
+
``amplitude`` is 0-127 and ``offset`` 0-255 (firmware DAC units). With
|
|
52
|
+
``duration_ms`` unset/0 the waveform runs until the next command (e.g.
|
|
53
|
+
:func:`signal_stop` or a ``capture``).
|
|
54
|
+
"""
|
|
55
|
+
req: dict = {"cmd": "generate", "waveform": waveform, "freq": freq,
|
|
56
|
+
"amplitude": amplitude, "offset": offset}
|
|
57
|
+
if duration_ms is not None:
|
|
58
|
+
req["duration_ms"] = duration_ms
|
|
59
|
+
if sample_rate_mhz is not None:
|
|
60
|
+
req["sample_rate_mhz"] = sample_rate_mhz
|
|
61
|
+
return bench.command(req)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def signal_stop(bench: Any) -> Any:
|
|
65
|
+
"""Stop a free-running DAC waveform."""
|
|
66
|
+
return bench.command({"cmd": "dac_stop"})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def measure(bench: Any, *, waveform: str, freq: float, amplitude: float,
|
|
70
|
+
offset: float = 128.0, samples: int = 256,
|
|
71
|
+
sample_rate_mhz: Optional[float] = None) -> List[int]:
|
|
72
|
+
"""Drive the DAC and capture the ADC in one synchronized command (loopback).
|
|
73
|
+
|
|
74
|
+
Returns the reassembled ADC sample array. Use for round-trip checks where the
|
|
75
|
+
DAC output is wired (directly or through a DUT) back to the ADC input.
|
|
76
|
+
"""
|
|
77
|
+
# measure returns a (possibly chunked) sample array, so it needs the
|
|
78
|
+
# transport's chunk-reassembling `samples` path, not plain `command`.
|
|
79
|
+
transport = getattr(bench, "transport", None)
|
|
80
|
+
fn = getattr(transport, "samples", None)
|
|
81
|
+
if fn is None:
|
|
82
|
+
raise BenchPodError("measure is only available on the TCP transport")
|
|
83
|
+
req: dict = {"cmd": "measure", "waveform": waveform, "freq": freq,
|
|
84
|
+
"amplitude": amplitude, "offset": offset, "samples": samples}
|
|
85
|
+
if sample_rate_mhz is not None:
|
|
86
|
+
req["sample_rate_mhz"] = sample_rate_mhz
|
|
87
|
+
return fn(req)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# -- phase factories ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _stat_measures(prefix: str, *, mean_range: _Range, pp_range: _Range,
|
|
93
|
+
min_range: _Range, max_range: _Range) -> list:
|
|
94
|
+
"""Declare min/max/mean/pp measurements, applying any ranges as limits."""
|
|
95
|
+
spec = {"min": min_range, "max": max_range, "mean": mean_range, "pp": pp_range}
|
|
96
|
+
out = []
|
|
97
|
+
for suffix, rng in spec.items():
|
|
98
|
+
meas = htf.Measurement(f"{prefix}_{suffix}")
|
|
99
|
+
if rng is not None:
|
|
100
|
+
meas = meas.in_range(rng[0], rng[1])
|
|
101
|
+
out.append(meas)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def signal_generate_phase(plug: type, *, waveform: str, freq: float,
|
|
106
|
+
amplitude: float, offset: float = 128.0,
|
|
107
|
+
duration_ms: Optional[int] = None,
|
|
108
|
+
sample_rate_mhz: Optional[float] = None,
|
|
109
|
+
name: str = "signal_generate") -> object:
|
|
110
|
+
"""A setup phase that starts a DAC waveform and continues.
|
|
111
|
+
|
|
112
|
+
With ``duration_ms`` unset the waveform free-runs; pair it with a later
|
|
113
|
+
:func:`adc_capture_phase` (sampling a different channel) and stop it with
|
|
114
|
+
``signal_stop`` in a teardown phase if needed.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
@htf.PhaseOptions(name=name)
|
|
118
|
+
@htf.plug(bench=plug)
|
|
119
|
+
def _gen(test, bench):
|
|
120
|
+
signal_generate(bench, waveform=waveform, freq=freq, amplitude=amplitude,
|
|
121
|
+
offset=offset, duration_ms=duration_ms,
|
|
122
|
+
sample_rate_mhz=sample_rate_mhz)
|
|
123
|
+
test.logger.info("DAC %s @ %g Hz, amp %g, offset %g", waveform, freq,
|
|
124
|
+
amplitude, offset)
|
|
125
|
+
|
|
126
|
+
return _gen
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def adc_capture_phase(plug: type, *, samples: int = 4096,
|
|
130
|
+
sample_rate_mhz: Optional[float] = None,
|
|
131
|
+
prefix: str = "adc", mean_range: _Range = None,
|
|
132
|
+
pp_range: _Range = None, min_range: _Range = None,
|
|
133
|
+
max_range: _Range = None, name: str = "adc_capture") -> object:
|
|
134
|
+
"""A phase that snapshots the ADC and records min/max/mean/pp.
|
|
135
|
+
|
|
136
|
+
Pass any of ``mean_range`` / ``pp_range`` / ``min_range`` / ``max_range`` as
|
|
137
|
+
``(low, high)`` to turn that stat into a pass/fail limit; the raw samples are
|
|
138
|
+
always attached as ``adc.json``.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
@htf.PhaseOptions(name=name)
|
|
142
|
+
@htf.measures(*_stat_measures(prefix, mean_range=mean_range, pp_range=pp_range,
|
|
143
|
+
min_range=min_range, max_range=max_range))
|
|
144
|
+
@htf.plug(bench=plug)
|
|
145
|
+
def _cap(test, bench):
|
|
146
|
+
data = bench.capture(samples, sample_rate_mhz=sample_rate_mhz)
|
|
147
|
+
stats = record_samples(test, data, prefix=prefix)
|
|
148
|
+
test.logger.info("ADC %d samples: min=%s max=%s mean=%.1f pp=%s",
|
|
149
|
+
len(data), stats[f"{prefix}_min"], stats[f"{prefix}_max"],
|
|
150
|
+
stats[f"{prefix}_mean"], stats[f"{prefix}_pp"])
|
|
151
|
+
|
|
152
|
+
return _cap
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def loopback_measure_phase(plug: type, *, waveform: str, freq: float,
|
|
156
|
+
amplitude: float, offset: float = 128.0,
|
|
157
|
+
samples: int = 4096,
|
|
158
|
+
sample_rate_mhz: Optional[float] = None,
|
|
159
|
+
prefix: str = "adc", mean_range: _Range = None,
|
|
160
|
+
pp_range: _Range = None, min_range: _Range = None,
|
|
161
|
+
max_range: _Range = None,
|
|
162
|
+
name: str = "loopback_measure") -> object:
|
|
163
|
+
"""A phase that drives the DAC and captures the ADC together (loopback), then
|
|
164
|
+
records min/max/mean/pp.
|
|
165
|
+
|
|
166
|
+
The canonical analog self-test: stimulate with a known waveform and assert
|
|
167
|
+
the round-trip amplitude/offset, e.g. ``pp_range=(180, 255)`` for a healthy
|
|
168
|
+
signal path. Raw samples are attached as ``adc.json``.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
@htf.PhaseOptions(name=name)
|
|
172
|
+
@htf.measures(*_stat_measures(prefix, mean_range=mean_range, pp_range=pp_range,
|
|
173
|
+
min_range=min_range, max_range=max_range))
|
|
174
|
+
@htf.plug(bench=plug)
|
|
175
|
+
def _meas(test, bench):
|
|
176
|
+
data = measure(bench, waveform=waveform, freq=freq, amplitude=amplitude,
|
|
177
|
+
offset=offset, samples=samples,
|
|
178
|
+
sample_rate_mhz=sample_rate_mhz)
|
|
179
|
+
stats = record_samples(test, data, prefix=prefix)
|
|
180
|
+
test.logger.info("loopback %s @ %g Hz -> %d samples: mean=%.1f pp=%s",
|
|
181
|
+
waveform, freq, len(data), stats[f"{prefix}_mean"],
|
|
182
|
+
stats[f"{prefix}_pp"])
|
|
183
|
+
|
|
184
|
+
return _meas
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Helpers that map BenchPod SDK result objects onto OpenHTF measurements and
|
|
2
|
+
attachments.
|
|
3
|
+
|
|
4
|
+
OpenHTF wants measurements *declared* on a phase (``@htf.measures(...)``) and
|
|
5
|
+
*set* inside it (``test.measurements.x = ...``). These helpers give you the
|
|
6
|
+
matching declaration/record pair for the common BenchPod results so you don't
|
|
7
|
+
hand-roll the boilerplate:
|
|
8
|
+
|
|
9
|
+
@htf.measures(flash_ok_measurement())
|
|
10
|
+
@htf.plug(bench=benchpod_plug("192.168.1.50:8080"))
|
|
11
|
+
def flash(test, bench):
|
|
12
|
+
result = bench.flash(file="fw.elf", target="target/stm32f4x.cfg",
|
|
13
|
+
swclk=11, swdio=12, nreset=3, check=False)
|
|
14
|
+
record_flash(test, result) # sets flash_ok + attaches the log
|
|
15
|
+
|
|
16
|
+
The recorders never raise on a bad result — they record the failing value so the
|
|
17
|
+
measurement validator (not an exception) decides the phase outcome, and the
|
|
18
|
+
OpenOCD / UART output is always attached for triage.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
|
|
26
|
+
import openhtf as htf
|
|
27
|
+
|
|
28
|
+
from embeddedci.benchpod.flash import FlashResult
|
|
29
|
+
from embeddedci.benchpod.uart import UartCapture
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"flash_ok_measurement",
|
|
33
|
+
"uart_matched_measurement",
|
|
34
|
+
"record_flash",
|
|
35
|
+
"record_uart",
|
|
36
|
+
"record_samples",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# -- measurement declarations ------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def flash_ok_measurement(name: str = "flash_ok") -> htf.Measurement:
|
|
43
|
+
"""A boolean measurement that passes only when the flash succeeded."""
|
|
44
|
+
return htf.Measurement(name).equals(True).doc(
|
|
45
|
+
"OpenOCD programmed and verified the target over the pod's SWD probe"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def uart_matched_measurement(name: str = "boot_ok") -> htf.Measurement:
|
|
50
|
+
"""A boolean measurement that passes when the UART ``until`` pattern hit."""
|
|
51
|
+
return htf.Measurement(name).equals(True).doc(
|
|
52
|
+
"Expected pattern was seen on the DUT's UART within the capture window"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- recorders ---------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def record_flash(test: htf.TestApi, result: FlashResult, *,
|
|
59
|
+
name: str = "flash_ok", attachment: str = "openocd.log") -> bool:
|
|
60
|
+
"""Record a :class:`FlashResult`: set the ``name`` measurement to ``result.ok``
|
|
61
|
+
and attach the OpenOCD output. Returns ``result.ok``."""
|
|
62
|
+
log = (result.stdout or "")
|
|
63
|
+
if getattr(result, "stderr", ""):
|
|
64
|
+
log = f"{log}\n----- stderr -----\n{result.stderr}"
|
|
65
|
+
test.attach(attachment, log.encode("utf-8", "replace"), mimetype="text/plain")
|
|
66
|
+
test.measurements[name] = bool(result.ok)
|
|
67
|
+
return bool(result.ok)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def record_uart(test: htf.TestApi, capture: UartCapture, *,
|
|
71
|
+
name: Optional[str] = "boot_ok",
|
|
72
|
+
attachment: str = "uart.txt") -> bool:
|
|
73
|
+
"""Record a :class:`UartCapture`: attach the captured text and, when ``name``
|
|
74
|
+
is given, set that measurement to ``capture.matched``. Returns ``matched``."""
|
|
75
|
+
test.attach(attachment, capture.text.encode("utf-8", "replace"),
|
|
76
|
+
mimetype="text/plain")
|
|
77
|
+
if name is not None:
|
|
78
|
+
test.measurements[name] = bool(capture.matched)
|
|
79
|
+
return bool(capture.matched)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def record_samples(test: htf.TestApi, samples: List[int], *,
|
|
83
|
+
prefix: str = "adc", attachment: str = "adc.json") -> dict:
|
|
84
|
+
"""Attach raw ADC/LA ``samples`` (from ``bench.capture(...)`` / ``measure``)
|
|
85
|
+
as JSON and set ``<prefix>_min`` / ``_max`` / ``_mean`` / ``_pp``
|
|
86
|
+
(peak-to-peak) measurements.
|
|
87
|
+
|
|
88
|
+
Declare the matching measurements on the phase (e.g.
|
|
89
|
+
``htf.Measurement('adc_pp').in_range(...)``) to turn them into limits; only
|
|
90
|
+
declared ones are set. Returns the computed stats dict.
|
|
91
|
+
"""
|
|
92
|
+
test.attach(attachment, json.dumps(samples).encode("utf-8"),
|
|
93
|
+
mimetype="application/json")
|
|
94
|
+
lo = min(samples) if samples else 0
|
|
95
|
+
hi = max(samples) if samples else 0
|
|
96
|
+
stats = {
|
|
97
|
+
f"{prefix}_min": lo,
|
|
98
|
+
f"{prefix}_max": hi,
|
|
99
|
+
f"{prefix}_mean": (sum(samples) / len(samples)) if samples else 0.0,
|
|
100
|
+
f"{prefix}_pp": hi - lo,
|
|
101
|
+
}
|
|
102
|
+
for key, value in stats.items():
|
|
103
|
+
# Only set measurements the phase actually declared; ignore the rest so
|
|
104
|
+
# callers can opt into just the stats they care about.
|
|
105
|
+
try:
|
|
106
|
+
test.measurements[key] = value
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return stats
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Ready-made OpenHTF phase factories for common BenchPod steps.
|
|
2
|
+
|
|
3
|
+
Each factory returns a fully-decorated phase (plug + measurements wired up) so a
|
|
4
|
+
test is just a list of them::
|
|
5
|
+
|
|
6
|
+
import openhtf as htf
|
|
7
|
+
from embeddedci_openhtf import benchpod_plug, flash_phase, boot_banner_phase
|
|
8
|
+
|
|
9
|
+
bench = benchpod_plug("192.168.1.50:8080") # direct TCP, no cloud
|
|
10
|
+
|
|
11
|
+
test = htf.Test(
|
|
12
|
+
flash_phase(bench, file="fw.elf", target="target/stm32f4x.cfg",
|
|
13
|
+
swclk=11, swdio=12, nreset=3),
|
|
14
|
+
boot_banner_phase(bench, rx=1, tx=2, expect="APP_OK"),
|
|
15
|
+
)
|
|
16
|
+
test.execute(test_start=lambda: "SN-0001")
|
|
17
|
+
|
|
18
|
+
These are conveniences; for anything custom, write a normal phase with
|
|
19
|
+
``@htf.plug(bench=benchpod_plug(...))`` and the recorders in
|
|
20
|
+
:mod:`embeddedci_openhtf.measurements`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Optional, Union
|
|
26
|
+
|
|
27
|
+
import openhtf as htf
|
|
28
|
+
|
|
29
|
+
from embeddedci.benchpod.constants import Efuse, Pin
|
|
30
|
+
|
|
31
|
+
from .measurements import (
|
|
32
|
+
flash_ok_measurement,
|
|
33
|
+
record_flash,
|
|
34
|
+
record_uart,
|
|
35
|
+
uart_matched_measurement,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = ["power_phase", "flash_phase", "boot_banner_phase"]
|
|
39
|
+
|
|
40
|
+
_PinT = Union[Pin, int]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def power_phase(plug: type, *, efuse: Union[Efuse, int] = Efuse.INTERNAL,
|
|
44
|
+
on: bool = True, name: Optional[str] = None) -> object:
|
|
45
|
+
"""A phase that powers the target eFuse on (or off)."""
|
|
46
|
+
|
|
47
|
+
@htf.PhaseOptions(name=name or ("power_on" if on else "power_off"))
|
|
48
|
+
@htf.plug(bench=plug)
|
|
49
|
+
def _power(test, bench):
|
|
50
|
+
(bench.power_on if on else bench.power_off)(efuse)
|
|
51
|
+
test.logger.info("target power %s (efuse %s)", "on" if on else "off",
|
|
52
|
+
int(efuse))
|
|
53
|
+
|
|
54
|
+
return _power
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def flash_phase(plug: type, *, file: str, target: str,
|
|
58
|
+
swclk: _PinT, swdio: _PinT, nreset: Optional[_PinT] = None,
|
|
59
|
+
target_power: Optional[Union[Efuse, int]] = Efuse.INTERNAL,
|
|
60
|
+
name: str = "flash", stop_on_fail: bool = True,
|
|
61
|
+
**flash_kwargs) -> object:
|
|
62
|
+
"""A phase that flashes the DUT over SWD and records the result.
|
|
63
|
+
|
|
64
|
+
``swclk``/``swdio``/``nreset`` are LA channels (1-12). Records a ``flash_ok``
|
|
65
|
+
measurement and attaches the OpenOCD log. By default a failed flash stops the
|
|
66
|
+
test (``stop_on_fail``) so later phases don't run against an unprogrammed DUT.
|
|
67
|
+
Extra keyword args pass through to ``BenchPod.flash`` (``verify``,
|
|
68
|
+
``connect_under_reset``, ``extra_configs``, ...).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@htf.PhaseOptions(name=name)
|
|
72
|
+
@htf.measures(flash_ok_measurement())
|
|
73
|
+
@htf.plug(bench=plug)
|
|
74
|
+
def _flash(test, bench):
|
|
75
|
+
result = bench.flash(
|
|
76
|
+
file=file, target=target, swclk=swclk, swdio=swdio, nreset=nreset,
|
|
77
|
+
target_power=target_power, check=False, **flash_kwargs,
|
|
78
|
+
)
|
|
79
|
+
ok = record_flash(test, result)
|
|
80
|
+
if not ok:
|
|
81
|
+
test.logger.error("flash failed (openocd rc=%s)", result.returncode)
|
|
82
|
+
if stop_on_fail:
|
|
83
|
+
return htf.PhaseResult.STOP
|
|
84
|
+
return htf.PhaseResult.CONTINUE
|
|
85
|
+
|
|
86
|
+
return _flash
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def boot_banner_phase(plug: type, *, rx: _PinT, tx: _PinT, expect,
|
|
90
|
+
baud: int = 115200, duration: float = 5.0,
|
|
91
|
+
power_cycle: bool = True,
|
|
92
|
+
efuse: Union[Efuse, int] = Efuse.INTERNAL,
|
|
93
|
+
delay: float = 1.0, name: str = "boot",
|
|
94
|
+
measurement: str = "boot_ok") -> object:
|
|
95
|
+
"""A phase that captures the DUT's UART and checks for ``expect``.
|
|
96
|
+
|
|
97
|
+
``rx`` is the LA channel wired to the DUT's TX; ``tx`` drives the DUT's RX.
|
|
98
|
+
``expect`` is a substring / compiled regex / ``text->bool`` predicate. When
|
|
99
|
+
``power_cycle`` is true the target is power-cycled so the boot banner lands
|
|
100
|
+
inside the capture window; otherwise it captures the already-powered DUT.
|
|
101
|
+
Records ``measurement`` (True if ``expect`` was seen) and attaches the text.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
@htf.PhaseOptions(name=name)
|
|
105
|
+
@htf.measures(uart_matched_measurement(measurement))
|
|
106
|
+
@htf.plug(bench=plug)
|
|
107
|
+
def _boot(test, bench):
|
|
108
|
+
if power_cycle:
|
|
109
|
+
cap = bench.power_cycle_and_capture(
|
|
110
|
+
rx=rx, tx=tx, efuse=efuse, delay=delay, duration=duration,
|
|
111
|
+
baud=baud, until=expect,
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
cap = bench.capture_uart(rx=rx, tx=tx, baud=baud, duration=duration,
|
|
115
|
+
until=expect)
|
|
116
|
+
matched = record_uart(test, cap, name=measurement)
|
|
117
|
+
if not matched:
|
|
118
|
+
test.logger.error("did not see %r on UART within %.1fs", expect, duration)
|
|
119
|
+
return htf.PhaseResult.CONTINUE
|
|
120
|
+
|
|
121
|
+
return _boot
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""An OpenHTF plug that wraps the EmbeddedCI BenchPod SDK.
|
|
2
|
+
|
|
3
|
+
This plug is built for driving a BenchPod **directly** — over a TCP socket
|
|
4
|
+
(``host[:port]``) or a serial device path — with no EmbeddedCI cloud account or
|
|
5
|
+
web UI involved. (A direct TCP/serial connection never takes a cloud lease, so
|
|
6
|
+
nothing here talks to embeddedci.com.)
|
|
7
|
+
|
|
8
|
+
Two ways to point it at a pod:
|
|
9
|
+
|
|
10
|
+
* **Inline (most direct).** Bind the connection in the test file with
|
|
11
|
+
:func:`benchpod_plug`::
|
|
12
|
+
|
|
13
|
+
import openhtf as htf
|
|
14
|
+
from embeddedci_openhtf import benchpod_plug
|
|
15
|
+
|
|
16
|
+
@htf.plug(bench=benchpod_plug("192.168.1.50:8080"))
|
|
17
|
+
def power_up(test, bench):
|
|
18
|
+
bench.power_on() # methods proxy to the BenchPod SDK
|
|
19
|
+
bench.pod.flash(...) # or reach the full client via .pod
|
|
20
|
+
|
|
21
|
+
* **Via OpenHTF config.** Use :class:`BenchPodPlug` unbound and supply the
|
|
22
|
+
connection through OpenHTF's ``conf`` (a YAML file, ``--config-value``, or
|
|
23
|
+
:func:`openhtf.conf.load`), falling back to the ``BENCHPOD_CONNECTION`` env
|
|
24
|
+
var::
|
|
25
|
+
|
|
26
|
+
htf.conf.load(benchpod_connection="/dev/ttyACM0")
|
|
27
|
+
|
|
28
|
+
@htf.plug(bench=BenchPodPlug)
|
|
29
|
+
def power_up(test, bench):
|
|
30
|
+
bench.power_on()
|
|
31
|
+
|
|
32
|
+
**Connection lifecycle.** By default a fresh connection is opened on each test
|
|
33
|
+
execution and closed in :meth:`tearDown`, so OpenHTF owns the lifecycle. On a
|
|
34
|
+
station that cycles many DUTs back-to-back, pass ``persistent=True`` to
|
|
35
|
+
:func:`benchpod_plug` to keep one connection open across executions (re-checked
|
|
36
|
+
with a ping each run and reconnected if it dropped); close it explicitly with
|
|
37
|
+
:func:`close_persistent_benchpods` (also run automatically at process exit).
|
|
38
|
+
|
|
39
|
+
Unknown attribute access proxies to the underlying
|
|
40
|
+
:class:`~embeddedci.benchpod.BenchPod`, so ``bench.flash(...)`` /
|
|
41
|
+
``bench.power_on()`` / ``bench.capture_uart(...)`` work directly; ``bench.pod``
|
|
42
|
+
is the full client when you need it.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import atexit
|
|
48
|
+
import os
|
|
49
|
+
import threading
|
|
50
|
+
from typing import Any, Dict, Optional
|
|
51
|
+
|
|
52
|
+
import openhtf as htf
|
|
53
|
+
from openhtf.plugs import BasePlug
|
|
54
|
+
|
|
55
|
+
from embeddedci.benchpod import BenchPod
|
|
56
|
+
from embeddedci.benchpod.connection import ENV_VAR
|
|
57
|
+
from embeddedci.benchpod.errors import ConnectionConfigError
|
|
58
|
+
|
|
59
|
+
# Mirror the SDK's connection sources as OpenHTF config keys, so a station can be
|
|
60
|
+
# configured from a YAML file / --config-value instead of code.
|
|
61
|
+
htf.conf.declare(
|
|
62
|
+
"benchpod_connection",
|
|
63
|
+
description=(
|
|
64
|
+
"BenchPod connection for direct (non-cloud) access: host[:port], a "
|
|
65
|
+
f"serial device path, or 'serial'. Falls back to the {ENV_VAR} env var."
|
|
66
|
+
),
|
|
67
|
+
default_value=None,
|
|
68
|
+
)
|
|
69
|
+
htf.conf.declare(
|
|
70
|
+
"benchpod_timeout",
|
|
71
|
+
description="BenchPod transport timeout in seconds (default 30).",
|
|
72
|
+
default_value=30.0,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Process-wide pool of persistent connections, keyed by the bound plug class (one
|
|
76
|
+
# per benchpod_plug(persistent=True) call). Survives across test executions.
|
|
77
|
+
_PERSISTENT_POOL: Dict[type, BenchPod] = {}
|
|
78
|
+
_PERSISTENT_LOCK = threading.Lock()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def close_persistent_benchpods() -> None:
|
|
82
|
+
"""Close every persistent BenchPod connection and empty the pool.
|
|
83
|
+
|
|
84
|
+
Call at the end of a station run (or rely on the automatic ``atexit`` hook).
|
|
85
|
+
Idempotent — a plug with ``persistent=True`` reopens on its next execution.
|
|
86
|
+
"""
|
|
87
|
+
with _PERSISTENT_LOCK:
|
|
88
|
+
pods = list(_PERSISTENT_POOL.values())
|
|
89
|
+
_PERSISTENT_POOL.clear()
|
|
90
|
+
for pod in pods:
|
|
91
|
+
try:
|
|
92
|
+
pod.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
atexit.register(close_persistent_benchpods)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _acquire_persistent(cls: type, conn: Optional[str],
|
|
101
|
+
pod_kwargs: Dict[str, Any], health_check: bool) -> BenchPod:
|
|
102
|
+
"""Return the pooled connection for ``cls``, opening (or reopening, if a
|
|
103
|
+
health-check ping fails) it as needed."""
|
|
104
|
+
with _PERSISTENT_LOCK:
|
|
105
|
+
pod = _PERSISTENT_POOL.get(cls)
|
|
106
|
+
if pod is not None and health_check:
|
|
107
|
+
try:
|
|
108
|
+
pod.ping() # cheap liveness check; reconnect if the link died
|
|
109
|
+
except Exception:
|
|
110
|
+
try:
|
|
111
|
+
pod.close()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
pod = None
|
|
115
|
+
_PERSISTENT_POOL.pop(cls, None)
|
|
116
|
+
if pod is None:
|
|
117
|
+
pod = BenchPod(conn, **pod_kwargs)
|
|
118
|
+
_PERSISTENT_POOL[cls] = pod
|
|
119
|
+
return pod
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class BenchPodPlug(BasePlug):
|
|
123
|
+
"""OpenHTF plug wrapping a directly-connected :class:`BenchPod`.
|
|
124
|
+
|
|
125
|
+
Use unbound with OpenHTF config / ``BENCHPOD_CONNECTION``, or bind a
|
|
126
|
+
connection with :func:`benchpod_plug`. Subclasses may override the class
|
|
127
|
+
attributes below (that is what :func:`benchpod_plug` produces); leave
|
|
128
|
+
:data:`connection` / :data:`pod_kwargs` at their defaults to resolve from
|
|
129
|
+
config then env.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
#: Connection override set by :func:`benchpod_plug`. ``None`` => use config/env.
|
|
133
|
+
connection: Optional[str] = None
|
|
134
|
+
#: Extra keyword args forwarded to ``BenchPod(...)`` (e.g. ``transport=`` for
|
|
135
|
+
#: tests). Set by :func:`benchpod_plug`.
|
|
136
|
+
pod_kwargs: Dict[str, Any] = {}
|
|
137
|
+
#: Keep one connection open across test executions (station mode).
|
|
138
|
+
persistent: bool = False
|
|
139
|
+
#: In persistent mode, ping a pooled connection before reuse and reconnect
|
|
140
|
+
#: if it has dropped.
|
|
141
|
+
health_check: bool = True
|
|
142
|
+
|
|
143
|
+
@htf.conf.inject_positional_args
|
|
144
|
+
def __init__(self, benchpod_connection: Optional[str] = None,
|
|
145
|
+
benchpod_timeout: float = 30.0) -> None:
|
|
146
|
+
super().__init__()
|
|
147
|
+
cls = type(self)
|
|
148
|
+
pod_kwargs = dict(cls.pod_kwargs)
|
|
149
|
+
conn = self.connection or benchpod_connection or os.environ.get(ENV_VAR)
|
|
150
|
+
if not conn and "transport" not in pod_kwargs:
|
|
151
|
+
raise ConnectionConfigError(
|
|
152
|
+
"no BenchPod connection: bind one with benchpod_plug('host:port'), "
|
|
153
|
+
"set the OpenHTF 'benchpod_connection' config, or export "
|
|
154
|
+
f"{ENV_VAR}=<host:port|/dev/tty...>"
|
|
155
|
+
)
|
|
156
|
+
pod_kwargs.setdefault("timeout", benchpod_timeout)
|
|
157
|
+
if cls.persistent:
|
|
158
|
+
#: The connected SDK client (shared, kept open across executions).
|
|
159
|
+
self.pod: BenchPod = _acquire_persistent(
|
|
160
|
+
cls, conn, pod_kwargs, cls.health_check)
|
|
161
|
+
else:
|
|
162
|
+
# Direct TCP/serial connections never lease.
|
|
163
|
+
self.pod = BenchPod(conn, **pod_kwargs)
|
|
164
|
+
self.logger.info("BenchPod %s (%s)",
|
|
165
|
+
"reusing connection" if cls.persistent else "connected",
|
|
166
|
+
conn or "injected transport")
|
|
167
|
+
|
|
168
|
+
def tearDown(self) -> None:
|
|
169
|
+
"""Close the connection — unless it is persistent, in which case it is
|
|
170
|
+
left open for the next test execution."""
|
|
171
|
+
if type(self).persistent:
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
self.pod.close()
|
|
175
|
+
except Exception: # teardown must not mask a phase failure
|
|
176
|
+
self.logger.exception("error closing BenchPod")
|
|
177
|
+
|
|
178
|
+
def __getattr__(self, name: str) -> Any:
|
|
179
|
+
# Proxy unknown attributes (flash/power_on/capture_uart/...) to the SDK
|
|
180
|
+
# client. Only consulted when normal lookup fails, so it never shadows
|
|
181
|
+
# plug internals (logger, tearDown, pod). Guarded so attribute probes
|
|
182
|
+
# before pod is set, and dunder lookups, don't recurse or leak.
|
|
183
|
+
if name.startswith("_"):
|
|
184
|
+
raise AttributeError(name)
|
|
185
|
+
pod = self.__dict__.get("pod")
|
|
186
|
+
if pod is None:
|
|
187
|
+
raise AttributeError(name)
|
|
188
|
+
return getattr(pod, name)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def benchpod_plug(connection: Optional[str] = None, *, persistent: bool = False,
|
|
192
|
+
health_check: bool = True, **pod_kwargs: Any) -> type:
|
|
193
|
+
"""Return a :class:`BenchPodPlug` subclass bound to a specific connection.
|
|
194
|
+
|
|
195
|
+
Ergonomic for direct bench use — put the pod's address right in the test::
|
|
196
|
+
|
|
197
|
+
@htf.plug(bench=benchpod_plug("192.168.1.50:8080"))
|
|
198
|
+
def phase(test, bench): ...
|
|
199
|
+
|
|
200
|
+
@htf.plug(bench=benchpod_plug("/dev/ttyACM0"))
|
|
201
|
+
def phase(test, bench): ...
|
|
202
|
+
|
|
203
|
+
Set ``persistent=True`` to keep one connection open across test executions
|
|
204
|
+
(reuse the *same* returned class for every ``Test.execute()`` so they share
|
|
205
|
+
it); see :func:`close_persistent_benchpods`. Extra keyword args are forwarded
|
|
206
|
+
to ``BenchPod(...)`` (``timeout=``, or ``transport=`` to inject a fake
|
|
207
|
+
backend in tests).
|
|
208
|
+
"""
|
|
209
|
+
return type(
|
|
210
|
+
"BoundBenchPodPlug",
|
|
211
|
+
(BenchPodPlug,),
|
|
212
|
+
{
|
|
213
|
+
"connection": connection,
|
|
214
|
+
"pod_kwargs": pod_kwargs,
|
|
215
|
+
"persistent": persistent,
|
|
216
|
+
"health_check": health_check,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: embeddedci-openhtf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenHTF plug for driving an EmbeddedCI BenchPod directly over TCP or serial (no cloud)
|
|
5
|
+
Project-URL: Homepage, https://embeddedci.com
|
|
6
|
+
Project-URL: Repository, https://github.com/embeddedci-com/embeddedci-python
|
|
7
|
+
Project-URL: Issues, https://github.com/embeddedci-com/embeddedci-python/issues
|
|
8
|
+
Author: EmbeddedCI
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: benchpod,embedded,hardware-in-the-loop,manufacturing-test,openhtf,swd
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: embeddedci>=0.2
|
|
22
|
+
Requires-Dist: openhtf>=1.6
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# embeddedci-openhtf
|
|
28
|
+
|
|
29
|
+
> ⚠️ **Experimental.** This package is new and its API may change between
|
|
30
|
+
> releases. Pin a version and expect breaking changes before 1.0.
|
|
31
|
+
|
|
32
|
+
Drive an **EmbeddedCI BenchPod** from [OpenHTF](https://www.openhtf.com/) —
|
|
33
|
+
Google's open-source hardware test framework — connecting **directly** to the pod
|
|
34
|
+
over a TCP socket or serial port. No EmbeddedCI cloud account, OIDC, or web UI is
|
|
35
|
+
required: this package is for teams who want OpenHTF's test sequencing, limits,
|
|
36
|
+
records, and station GUI while talking straight to a pod on their own bench.
|
|
37
|
+
|
|
38
|
+
It's a thin wrapper over the [`embeddedci`](../embeddedci) BenchPod SDK: a single
|
|
39
|
+
plug plus a few phase helpers. The dependency direction is strictly
|
|
40
|
+
**`embeddedci-openhtf` → `embeddedci`**.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install embeddedci-openhtf # pulls in embeddedci + openhtf
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## The plug
|
|
47
|
+
|
|
48
|
+
`BenchPodPlug` opens a `BenchPod` when a test starts and closes it at teardown.
|
|
49
|
+
Bind the connection inline with `benchpod_plug(...)`, or leave it unbound and
|
|
50
|
+
supply it through OpenHTF config / the `BENCHPOD_CONNECTION` env var.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import openhtf as htf
|
|
54
|
+
from embeddedci_openhtf import benchpod_plug
|
|
55
|
+
|
|
56
|
+
bench = benchpod_plug("192.168.1.50:8080") # TCP — or benchpod_plug("/dev/ttyACM0")
|
|
57
|
+
|
|
58
|
+
@htf.plug(bench=bench)
|
|
59
|
+
def power_up(test, bench):
|
|
60
|
+
bench.power_on() # methods proxy to the BenchPod SDK client
|
|
61
|
+
bench.pod.capture_uart(...) # or reach the full client via .pod
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Connection forms (all **direct**, never cloud):
|
|
65
|
+
|
|
66
|
+
| Form | Example |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| TCP `host[:port]` | `benchpod_plug("192.168.1.50:8080")` |
|
|
69
|
+
| Serial device path | `benchpod_plug("/dev/ttyACM0")` / `benchpod_plug("COM5")` |
|
|
70
|
+
| `BENCHPOD_CONNECTION` env | `benchpod_plug()` (unbound) |
|
|
71
|
+
| OpenHTF config | `htf.conf.load(benchpod_connection="...")` then `@htf.plug(bench=BenchPodPlug)` |
|
|
72
|
+
|
|
73
|
+
## Phase helpers
|
|
74
|
+
|
|
75
|
+
Ready-made, fully-decorated phases for the common steps:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from embeddedci_openhtf import benchpod_plug, flash_phase, boot_banner_phase
|
|
79
|
+
|
|
80
|
+
bench = benchpod_plug("192.168.1.50:8080")
|
|
81
|
+
|
|
82
|
+
test = htf.Test(
|
|
83
|
+
flash_phase(bench, file="fw.elf", target="target/stm32f4x.cfg",
|
|
84
|
+
swclk=11, swdio=12, nreset=3), # records flash_ok, attaches openocd.log
|
|
85
|
+
boot_banner_phase(bench, rx=1, tx=2, expect="APP_OK"), # records boot_ok, attaches uart.txt
|
|
86
|
+
)
|
|
87
|
+
test.execute(test_start=lambda: "SN-0001")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
LA channels are 1-12 (the pod has 12 generic logic-analyzer channels and no
|
|
91
|
+
fixed-role pins — wire any DUT signal to any channel and name it here).
|
|
92
|
+
|
|
93
|
+
For anything custom, write a normal phase and use the recorders in
|
|
94
|
+
`embeddedci_openhtf.measurements` (`record_flash`, `record_uart`,
|
|
95
|
+
`record_samples`) to map SDK results onto measurements and attachments.
|
|
96
|
+
|
|
97
|
+
`flash_phase` needs `openocd` on PATH (the pod is the CMSIS-DAP probe; OpenOCD
|
|
98
|
+
runs the flash algorithm from the `target=` config, so every OpenOCD-supported
|
|
99
|
+
MCU works unchanged).
|
|
100
|
+
|
|
101
|
+
### Analog steps
|
|
102
|
+
|
|
103
|
+
The pod's DAC output and ADC input are exposed as analog phases (and low-level
|
|
104
|
+
helpers). **These are TCP-only** — they need the JSON/sample channel that the
|
|
105
|
+
serial console doesn't provide.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from embeddedci_openhtf import (
|
|
109
|
+
benchpod_plug, signal_generate_phase, adc_capture_phase, loopback_measure_phase,
|
|
110
|
+
)
|
|
111
|
+
bench = benchpod_plug("192.168.1.50:8080")
|
|
112
|
+
|
|
113
|
+
test = htf.Test(
|
|
114
|
+
# drive the DAC and capture the ADC together (loopback), assert the round trip
|
|
115
|
+
loopback_measure_phase(bench, waveform="sine", freq=10_000, amplitude=120,
|
|
116
|
+
pp_range=(180, 255), mean_range=(110, 150)),
|
|
117
|
+
# or: free-run a waveform, then snapshot the ADC separately
|
|
118
|
+
signal_generate_phase(bench, waveform="square", freq=1_000, amplitude=100),
|
|
119
|
+
adc_capture_phase(bench, samples=4096, pp_range=(150, 255)),
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each capture/measure phase records `<prefix>_min` / `_max` / `_mean` / `_pp`
|
|
124
|
+
(peak-to-peak) measurements — pass any as `(low, high)` to make it a limit — and
|
|
125
|
+
attaches the raw samples as `adc.json`. The low-level helpers `signal_generate`,
|
|
126
|
+
`signal_stop`, and `measure` are available for custom phases.
|
|
127
|
+
|
|
128
|
+
## Station mode (persistent connection)
|
|
129
|
+
|
|
130
|
+
By default the plug opens a connection per `Test.execute()` and closes it at
|
|
131
|
+
teardown. On a station cycling many DUTs back-to-back, pass `persistent=True` to
|
|
132
|
+
keep **one** connection open across executions (re-checked with a ping each run,
|
|
133
|
+
reconnected if it dropped). Reuse the *same* plug class for every execution, and
|
|
134
|
+
close it once at the end:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from embeddedci_openhtf import benchpod_plug, close_persistent_benchpods
|
|
138
|
+
from openhtf.plugs import user_input
|
|
139
|
+
|
|
140
|
+
bench = benchpod_plug("192.168.1.50:8080", persistent=True)
|
|
141
|
+
test = htf.Test(power_phase(bench, on=True), boot_banner_phase(bench, rx=1, tx=2, expect="APP_OK"))
|
|
142
|
+
try:
|
|
143
|
+
while test.execute(test_start=user_input.prompt_for_test_start()):
|
|
144
|
+
pass # next DUT — same pod connection
|
|
145
|
+
finally:
|
|
146
|
+
close_persistent_benchpods() # also runs automatically at process exit
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Examples
|
|
150
|
+
|
|
151
|
+
* [`examples/flash_and_boot.py`](examples/flash_and_boot.py) — flash over SWD then
|
|
152
|
+
assert the boot banner, over a direct TCP connection.
|
|
153
|
+
* [`examples/serial_smoke.py`](examples/serial_smoke.py) — a no-flash power + UART
|
|
154
|
+
smoke test with a parsed measurement, over a direct serial connection.
|
|
155
|
+
* [`examples/analog_loopback.py`](examples/analog_loopback.py) — DAC→ADC loopback
|
|
156
|
+
signal-path self-test, over a direct TCP connection.
|
|
157
|
+
* [`examples/station.py`](examples/station.py) — a station loop testing many DUTs
|
|
158
|
+
over one **persistent** connection.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# from the repo root
|
|
164
|
+
pip install -e "packages/embeddedci[dev]"
|
|
165
|
+
pip install -e "packages/embeddedci-openhtf[dev]"
|
|
166
|
+
pytest packages/embeddedci-openhtf
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The test suite runs the real OpenHTF executor against an in-memory fake transport
|
|
170
|
+
(`tests/_fake.py`), so it needs no pod and no OpenOCD.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
embeddedci_openhtf/__init__.py,sha256=27fkOUJ1HlBbrGxyBux3hfL-WecGRLxzkMgm6Ptpx4E,2015
|
|
2
|
+
embeddedci_openhtf/analog.py,sha256=UjEA5ygxQiT9Ci7QpNQChjONBY9z2Mj3yYi_JnnFDb0,8070
|
|
3
|
+
embeddedci_openhtf/measurements.py,sha256=WPlYk7TJxXWMUXoW74sf-R0mtqnN2tF6lDEo4Xqe26c,4332
|
|
4
|
+
embeddedci_openhtf/phases.py,sha256=sLe-f2p30Fx4SrbybWxALyp0xw6zaj6Djcf7WF-pIWo,4613
|
|
5
|
+
embeddedci_openhtf/plug.py,sha256=RtiPFAKFgEl7qdRZUXJ5bLqgG4z_WjxND4lkjV2--Yk,8618
|
|
6
|
+
embeddedci_openhtf-0.1.0.dist-info/METADATA,sha256=Sb8M8HFdfa5n73X0D9cQGxXbhobq11WKpIPX05KuXb4,6989
|
|
7
|
+
embeddedci_openhtf-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
embeddedci_openhtf-0.1.0.dist-info/RECORD,,
|