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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any