Documents the new layers introduced over the past several commits.
- docs/19_frame_io_and_alm_helpers.md (new): full reference for the
FrameIO and AlmTester helpers — three access levels (high/mid/low),
full API tables, fixture wiring, cookbook patterns, and §7
describing the four-phase SETUP/PROCEDURE/ASSERT/TEARDOWN test
pattern with the three template flavors plus a §7.4 link to the
PSU+LIN template.
- docs/14_power_supply.md: rewritten and expanded.
§3 cross-platform port resolution (Windows / WSL1 / WSL2 +
usbipd-win / Linux native compatibility table)
§4 auto-detection via idn_substr
§5 session-managed power: contract for tests, must-not list,
what changed in the existing tests
§6 the settle-then-validate pattern: two-delays table (PSU
bench-dependent vs ECU firmware-dependent), copy-paste
example, tuning guidance for ECU_VALIDATION_TIME_S
§6 PSU settling characterization (-m psu_settling)
§7 library API reference table + safe_off_on_close
§9 troubleshooting expanded with WSL2 usbipd-win + dialout
- docs/18_test_catalog.md: voltage-tolerance section refreshed for
the settle-then-validate shape, new "Hardware – PSU settling
(opt-in)" category, new §8 "Hardware-test infrastructure"
documenting conftest.py, frame_io.py, alm_helpers.py,
psu_helpers.py, and both templates.
- docs/05_architecture_overview.md: components list split into
framework core / hardware test layer / artifacts. Mermaid diagram
gained a Hardware-test helpers subgraph showing FrameIO,
AlmTester, rgb_to_pwm, and the templates. Data/control flow
summary describes the session-managed PSU and the helper layer.
- docs/15_report_properties_cheatsheet.md: PSU section split into
per-test (function-scoped rp) and module-scoped (testsuite
property) blocks; added psu_resolved_port, psu_resolved_idn,
psu_settled_s, validation_time_s.
- docs/README.md: links to the new doc 19.
- README.md, TESTING_FRAMEWORK_GUIDE.md: project-structure trees
expanded to show the full current layout — every file and
directory under tests/hardware/ (conftest, helpers, templates,
tests), tests/unit/, config/, docs/, scripts/, and vendor/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
Power Supply (Owon) — control, configuration, tests, and quick demo
This guide covers driving the Owon bench power supply via SCPI over a serial link, plus the cross-platform port resolver and the safety guarantees the controller class provides.
MUM users: the Melexis Universal Master has its own power output on
power_out0and the MUM adapter callspower_up()/power_down()inconnect()/disconnect()automatically. The Owon PSU is not required for the standard MUM flow — leavepower_supply.enabled: false. The Owon remains useful for over/under-voltage scenarios, separate-rail tests, or when running with the deprecated BabyLIN adapter (which has no built-in power).
| Artifact | Path |
|---|---|
| Controller library | ecu_framework/power/owon_psu.py |
| Hardware test | tests/hardware/test_owon_psu.py |
| Quick demo script | vendor/Owon/owon_psu_quick_demo.py |
| Central config | config/test_config.yaml → power_supply |
| Per-machine override | config/owon_psu.yaml or env OWON_PSU_CONFIG |
1. Install dependencies
pip install -r .\requirements.txt
pyserial is the only non-stdlib dep used by the controller.
2. Configure
Settings can live centrally in config/test_config.yaml or be peeled
out into a machine-specific config/owon_psu.yaml (or any path set
via OWON_PSU_CONFIG). The loader merges the per-machine file into
the central power_supply section.
power_supply:
enabled: true
port: COM7 # see §3 for cross-platform behaviour
baudrate: 115200
timeout: 1.0
eol: "\n" # or "\r\n" if your device requires CRLF
parity: N # N|E|O
stopbits: 1 # 1|1.5|2
xonxoff: false
rtscts: false
dsrdtr: false
idn_substr: OWON # optional — see §4 (auto-detection)
do_set: false
set_voltage: 5.0
set_current: 0.1
Field reference
| Field | Default | Meaning |
|---|---|---|
enabled |
false |
Master gate. Tests/utilities skip when false. |
port |
null |
Bench port name. See §3 — works for COM7 or /dev/ttyUSB0 and translates between them. |
baudrate |
115200 |
Serial bit rate. |
timeout |
1.0 |
Read timeout in seconds. |
eol |
"\n" |
Line terminator appended to every command and expected on every response. |
parity |
"N" |
One of N, E, O. Translated to pyserial constants by SerialParams.from_config(). |
stopbits |
1 |
One of 1, 1.5, 2. |
xonxoff / rtscts / dsrdtr |
false |
Flow control flags. |
idn_substr |
null |
Optional substring (case-insensitive) the device's *IDN? must contain to be accepted. Used as the filter when scanning ports for auto-detection. |
do_set |
false |
If true, the hardware test runs the set/measure cycle (sets V/I, enables output briefly, measures, disables). |
set_voltage / set_current |
5.0 / 0.1 |
Setpoints used when do_set: true. |
3. Cross-platform port resolution
A bench config typically names the port the way Windows sees it
(COM7). The resolver lets the same config work on Windows,
Linux, and WSL by trying multiple candidates in priority order.
What the resolver does
resolve_port(configured, *, idn_substr, params) walks four phases
and returns the first port whose *IDN? response is non-empty
(filtered by idn_substr if given):
| Phase | What's tried | Use case |
|---|---|---|
| 1 | configured verbatim |
Windows native — COM7 opens directly. |
| 2 | Cross-platform translation | COM7 ↔ /dev/ttyS6 on WSL1; /dev/ttyS6 ↔ COM7 on Windows. |
| 3 | Linux USB-serial paths | /dev/ttyUSB* and /dev/ttyACM* — covers WSL2 with usbipd-win plus generic Linux USB adapters. Linux/WSL only. |
| 4 | Full scan_ports() |
Last resort — probes every serial port pyserial reports. |
Linux device files that don't exist on disk are skipped without an
open attempt, so the resolver is fast even on machines with many
phantom ttyS* entries.
What works on each platform with port: COM7
| Host | What happens |
|---|---|
| Windows native | Phase 1 hits COM7 directly. |
| WSL1 | Phase 1 fails on COM7, Phase 2 finds /dev/ttyS6 (the COM7 mapping). |
WSL2 + usbipd-win |
Phase 1+2 fail, Phase 3 finds the attached adapter at /dev/ttyUSB0. |
| Linux native (USB adapter) | Phases 1+2 fail, Phase 3 finds /dev/ttyUSB0. |
The resolved port is recorded in the JUnit testsuite properties as
psu_resolved_port (and the IDN as psu_resolved_idn), so report
viewers can see which path was used.
Translation helpers
Useful as building blocks if you need to do the mapping yourself:
from ecu_framework.power import (
windows_com_to_linux, linux_serial_to_windows,
candidate_ports, resolve_port,
)
windows_com_to_linux("COM7") # → "/dev/ttyS6"
windows_com_to_linux("com10") # → "/dev/ttyS9"
linux_serial_to_windows("/dev/ttyS6") # → "COM7"
# What resolve_port will try, in order, for port="COM7" on Linux:
candidate_ports("COM7")
# → ['COM7', '/dev/ttyS6', '/dev/ttyUSB0', '/dev/ttyUSB1', '/dev/ttyACM0', ...]
4. Auto-detection
Leave port empty and set idn_substr to let the resolver scan:
power_supply:
enabled: true
port: # ← empty
idn_substr: OWON # ← required so we don't grab a different SCPI device
...
With no port, Phase 1 and Phase 2 short-circuit; Phase 3 (Linux USB
paths) and Phase 4 (full scan) do the work. The first port whose IDN
contains OWON (case-insensitive) wins.
Tip: without
idn_substr, any device that responds to*IDN?on any port is accepted — fine when the PSU is the only SCPI thing attached, risky otherwise. Always setidn_substrif your bench has other SCPI hardware.
5. Session-managed power (the bench powers the ECU through the PSU)
On benches where the Owon PSU powers the ECU (the MUM only carries LIN traffic), the PSU output must stay on for the entire test session — not just the duration of an individual PSU test. Otherwise every test that runs after a closed PSU connection would brown out the ECU and fail.
The hardware-suite conftest
(tests/hardware/conftest.py)
implements this with three session-scoped fixtures:
| Fixture | Scope | Role |
|---|---|---|
_psu_or_none |
session | Tolerant: opens the PSU once, parks at set_voltage / set_current, enables output. Yields the live OwonPSU or None if unreachable. Closes (with output 0) at session end. |
_psu_powers_bench |
session, autouse | Realizes _psu_or_none. Every hardware test triggers PSU power-up at session start, even tests that don't request psu by name. |
psu |
session | Public fixture for tests that read measurements or perturb voltage. Skips cleanly when the PSU isn't available. |
What this means for tests
Tests should:
- Request
psuif they need to read measurements or change the supply voltage. - Always restore nominal voltage in their
finallyblock — the session fixture won't restore it between tests.
Tests must not:
- Call
psu.set_output(False)— this kills ECU power for every later test in the same session. - Call
psu.close()— the session fixture owns the lifecycle.
What changed in the existing tests
tests/hardware/test_owon_psu.pyis now read-only: it queries*IDN?,output?, and the parsed measurement helpers, but doesn't toggle the output. The previous toggle-and-restore cycle has been deleted because it would brown out the bench mid-session.tests/hardware/_test_case_template_psu_lin.pydrops its localpsufixture and uses the conftest's. Its autouse_park_at_nominalonly restores voltage between tests — it never toggles output.
6. Run the hardware test
Skips cleanly unless power_supply.enabled is true, a port can be
resolved, and the device responds to *IDN?.
pytest -k test_owon_psu_idn_and_optional_set -m hardware -q
What it does:
- Resolves a working port via
resolve_port(...)(cross-platform, IDN-verified). - Queries
*IDN?and the initialoutput?state. - If
do_setis true: sets V/I, enables output, waits, measures, disables output. The measure/disable pair lives in an innertry/finallyso the disable runs even if measurement raises. - Records IDN, before/after output state, setpoints, and parsed measurements as report properties.
- The fixture's
safe_off_on_close=Trueis a backstop — it will sendoutput 0once more when the port closes.
The test follows the four-phase SETUP / PROCEDURE / ASSERT / TEARDOWN pattern from the template because it mutates real bench state.
The settle-then-validate pattern (recommended for any voltage-changing test)
Voltage changes go through two delays — and confusing them is the single most common source of flaky tests:
| Delay | Source | Bench-dependent? |
|---|---|---|
| PSU settling | Owon needs time to slew its output to the new setpoint | Yes — depends on PSU model, load, cable drop. Different up-step / down-step times in practice. |
| ECU validation | Firmware samples its supply rail, debounces, and republishes status on its 10 ms LIN cycle | No (firmware-dependent, but constant for a given build) |
The shared helper tests/hardware/psu_helpers.py
exposes apply_voltage_and_settle() which separates the two cleanly:
from psu_helpers import apply_voltage_and_settle
result = apply_voltage_and_settle(
psu, OVERVOLTAGE_V,
validation_time=ECU_VALIDATION_TIME_S, # firmware budget
)
# By here:
# - PSU output is measurably at OVERVOLTAGE_V (within ±0.10 V)
# - validation_time has elapsed since the rail settled
# So a single status read is unambiguous:
status = fio.read_signal("ALM_Status", "ALMVoltageStatus")
assert status == VOLTAGE_STATUS_OVER
What apply_voltage_and_settle does internally:
psu.set_voltage(1, target_v)— issue the setpoint.- Polls
measure_voltage_v()every 50 ms until the rail is within ±100 mV of target (or raisesAssertionErroron timeout). time.sleep(validation_time)— hold the steady rail.- Returns
{settled_s, validation_s, final_v, trace}for reporting.
The poll-the-meter approach means the function works on any bench without re-tuning sleeps. Up-step and down-step are handled identically — each waits as long as that specific transition takes.
To pick ECU_VALIDATION_TIME_S, run the characterization in §6.1
to learn your PSU's slew time, then add a margin for the firmware's
detection-and-debounce window. Default 1.0 s is conservative for
most automotive ECUs. Tests that change voltage many times should
use the smallest validation time their firmware tolerates.
Characterizing PSU settling time
Voltage-tolerance tests need to wait long enough after a setpoint change for the PSU's output to actually reach the new voltage. The right wait depends on the PSU model and the load. To extract real numbers, run the dedicated characterization test:
pytest -m psu_settling -s
tests/hardware/test_psu_voltage_settling.py walks four
transitions (13 V↔18 V, 13 V↔7 V), polls measure_voltage_v()
every 50 ms until the rail is within ±100 mV of target, and records
settling_time_s plus a downsampled voltage trace per case. The
test is marked psu_settling + slow so it doesn't run on every
-m hardware invocation — it's meant for periodic re-tuning, not
every CI run.
Use the recorded settling times to size constants like
VOLTAGE_DETECT_TIMEOUT in test_overvolt.py: the timeout has to
exceed both the PSU's settling time and the ECU's detection
delay, so add a margin to the larger of the two.
Writing a PSU+LIN test (over/undervoltage etc.)
For tests that combine PSU control with LIN observation — e.g.
overvoltage / undervoltage tolerance — there's a dedicated
copy-paste-ready template at
tests/hardware/_test_case_template_psu_lin.py.
It contains:
- The three module-scoped fixtures (
fio,alm,psu) wired with cross-platform port resolution andsafe_off_on_close=True. - An autouse
_park_at_nominalfixture that parks the PSU atNOMINAL_VOLTAGEand the LED OFF before AND after every test, so failures don't leak supply state between tests. - A
wait_for_voltage_status(fio, target, …)helper that pollsALM_Status.ALMVoltageStatusuntil it matches. - Three flavors:
Flavor Demonstrates A Overvoltage detection — drive PSU above OV threshold, expect ALMVoltageStatus = 0x02, restore.B Undervoltage detection — symmetric for UV ( 0x01).C Parametrized voltage sweep walking (V, expected_status)tuples.
Tune the four constants at the top of the file
(NOMINAL_VOLTAGE, OVERVOLTAGE_V, UNDERVOLTAGE_V,
SET_CURRENT_A) to your ECU's datasheet before running on real
hardware. The defaults are conservative automotive ranges.
7. Library API
from ecu_framework.power import (
SerialParams, OwonPSU, resolve_port,
scan_ports, auto_detect, try_idn_on_port,
)
SerialParams
Plain dataclass for serial-port settings. Build directly, or from the project's PSU config:
params = SerialParams(baudrate=115200, timeout=1.0)
# or
params = SerialParams.from_config(config.power_supply) # translates 'N'/'1' → pyserial constants
OwonPSU
Context-managed controller. Two construction paths:
# Manual:
psu = OwonPSU(port="COM4", params=params, eol="\n")
# From central config (recommended):
psu = OwonPSU.from_config(config.power_supply)
Then either use as a context manager or call open() / close() by
hand. Both forms send output 0 before closing the port if
safe_off_on_close=True (the default).
with OwonPSU.from_config(cfg) as psu:
print(psu.idn()) # *IDN?
psu.set_voltage(1, 5.0) # SOUR:VOLT 5.000
psu.set_current(1, 0.1) # SOUR:CURR 0.100
psu.set_output(True) # output 1
v = psu.measure_voltage_v() # MEAS:VOLT? → float
i = psu.measure_current_a() # MEAS:CURR? → float
is_on = psu.output_is_on() # output? → True/False/None
# safe_off_on_close=True turned the output OFF before the port closed
Method reference
| Method | SCPI sent | Returns |
|---|---|---|
idn() |
*IDN? |
str |
set_voltage(channel, volts) |
SOUR:VOLT <V> |
None. channel is currently ignored — placeholder for multi-channel firmware. |
set_current(channel, amps) |
SOUR:CURR <A> |
None |
set_output(on) |
output 1/output 0 |
None. Note: dialect uses lowercase output, not OUTP ON. |
output_status() |
output? |
Raw str ('ON'/'OFF'/'1'/'0'). |
output_is_on() |
output? |
bool (or None if unparseable). |
measure_voltage() |
MEAS:VOLT? |
Raw str. |
measure_voltage_v() |
MEAS:VOLT? |
float (V) or None. |
measure_current() |
MEAS:CURR? |
Raw str. |
measure_current_a() |
MEAS:CURR? |
float (A) or None. |
query(s) |
s |
Single-line str response (with newline stripped). |
write(s) |
s |
None. No response read. |
Safety: safe_off_on_close
OwonPSU(safe_off_on_close=True) (the default) sends output 0
before the serial port closes. This protects against leaving the
bench powered on after an aborted test, an exception in user code, or
a forgotten manual close. Errors during the safe-off attempt are
swallowed so the close itself always completes.
Pass safe_off_on_close=False only when you specifically need the
output to stay enabled across context-manager boundaries. The
discovery helper try_idn_on_port opts out by default since it
shouldn't drive the bench in either direction.
Discovery helpers
# Probe one port, return its IDN (or "" on failure):
try_idn_on_port("COM7", params)
# Scan every serial port; returns [(port, idn), ...] for responders:
scan_ports(params)
# Pick the first responder matching idn_substr (or first responder if no substring):
auto_detect(params, idn_substr="OWON")
# Cross-platform resolver (recommended): tries the configured port,
# its translation, USB-serial paths, then a full scan. Returns
# (port, idn) or None.
resolve_port("COM7", idn_substr="OWON", params=params)
8. Quick demo script
The quick demo reads OWON_PSU_CONFIG or config/owon_psu.yaml and
performs a short sequence using the same library.
python .\vendor\Owon\owon_psu_quick_demo.py
It also scans ports with *IDN? via scan_ports() to help confirm
which port the device is on before you commit it to the YAML.
9. Troubleshooting
Empty *IDN? / timeouts
- Verify the port and exclusivity — no other program may hold it open.
- Try
eol: "\r\n"if your firmware revision expects CRLF. - Adjust
parityandstopbitsper your device manual. - Power-cycle the PSU and re-attempt — some firmware revisions need a fresh boot before they accept SCPI.
Could not find a working PSU port
The fixture skips with this message when resolve_port returns
None. Things to check, in order:
- Is the device powered and connected?
- Does another process (Putty, Owon's own tool, an old test session) still hold the port?
- Does your user have permission to open the device file? On
Debian-style systems:
sudo usermod -aG dialout $USERand re-login. - WSL2 specifically: USB-serial adapters need
usbipd-winto bind the device into the Linux side. Once attached they appear at/dev/ttyUSB0and the resolver's Phase 3 picks them up automatically. - WSL1: COMx → /dev/ttySn mapping is automatic. If
/dev/ttyS6doesn't exist forCOM7, the bench probably has Windows COM port numbering you weren't expecting — list withls /dev/ttyS*and trylinux_serial_to_windows()to confirm.
Windows COM > 9
Most Python tooling (including pyserial) accepts COM10 directly.
If a third-party tool needs the long form, use \\.\COM10. The
translator in this repo accepts any positive integer.
Flow control
Keep xonxoff, rtscts, dsrdtr set to false unless your specific
PSU model requires otherwise — the Owon family used in this project
doesn't.
10. Related files
| File | Purpose |
|---|---|
ecu_framework/power/owon_psu.py |
Controller library (SerialParams, OwonPSU, resolver helpers). |
tests/hardware/test_owon_psu.py |
Hardware test wired to central config. |
vendor/Owon/owon_psu_quick_demo.py |
Quick demo runner. |
config/owon_psu.example.yaml |
Example per-machine YAML. |
tests/hardware/_test_case_template.py |
Copyable starting point for new hardware tests. |
docs/19_frame_io_and_alm_helpers.md |
The four-phase test pattern and the FrameIO / AlmTester helpers. |
docs/15_report_properties_cheatsheet.md |
Standard rp(...) keys including the PSU ones (psu_idn, psu_resolved_port, …). |