# 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_out0` and the MUM adapter calls `power_up()` / > `power_down()` in `connect()` / `disconnect()` automatically. The > Owon PSU is **not required** for the standard MUM flow — leave > `power_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`](../ecu_framework/power/owon_psu.py) | | Hardware test | [`tests/hardware/test_owon_psu.py`](../tests/hardware/test_owon_psu.py) | | Quick demo script | [`vendor/Owon/owon_psu_quick_demo.py`](../vendor/Owon/owon_psu_quick_demo.py) | | Central config | [`config/test_config.yaml`](../config/test_config.yaml) → `power_supply` | | Per-machine override | `config/owon_psu.yaml` or env `OWON_PSU_CONFIG` | --- ## 1. Install dependencies ```powershell 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. ```yaml 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: ```python 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: ```yaml 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 set `idn_substr` if 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`](../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 `psu` if they need to read measurements or change the supply voltage. - Always restore nominal voltage in their `finally` block — 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.py`** is 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.py`** drops its local `psu` fixture and uses the conftest's. Its autouse `_park_at_nominal` only 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?`. ```powershell pytest -k test_owon_psu_idn_and_optional_set -m hardware -q ``` What it does: 1. Resolves a working port via `resolve_port(...)` (cross-platform, IDN-verified). 2. Queries `*IDN?` and the initial `output?` state. 3. If `do_set` is true: sets V/I, enables output, waits, measures, disables output. The measure/disable pair lives in an inner `try`/`finally` so the disable runs even if measurement raises. 4. Records IDN, before/after output state, setpoints, and parsed measurements as report properties. 5. The fixture's `safe_off_on_close=True` is a backstop — it will send `output 0` once more when the port closes. The test follows the four-phase [SETUP / PROCEDURE / ASSERT / TEARDOWN pattern from the template](19_frame_io_and_alm_helpers.md#72-the-four-phase-test-pattern) 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`](../tests/hardware/psu_helpers.py) exposes `apply_voltage_and_settle()` which separates the two cleanly: ```python 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: 1. `psu.set_voltage(1, target_v)` — issue the setpoint. 2. Polls `measure_voltage_v()` every 50 ms until the rail is within ±100 mV of target (or raises `AssertionError` on timeout). 3. `time.sleep(validation_time)` — hold the steady rail. 4. 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: ```powershell 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`](../tests/hardware/_test_case_template_psu_lin.py). It contains: - The three module-scoped fixtures (`fio`, `alm`, `psu`) wired with cross-platform port resolution and `safe_off_on_close=True`. - An autouse `_park_at_nominal` fixture that parks the PSU at `NOMINAL_VOLTAGE` and 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 polls `ALM_Status.ALMVoltageStatus` until 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 ```python 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: ```python 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: ```python # 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). ```python 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 ` | `None`. `channel` is currently ignored — placeholder for multi-channel firmware. | | `set_current(channel, amps)` | `SOUR:CURR ` | `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 ```python # 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. ```powershell 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 `parity` and `stopbits` per 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: 1. Is the device powered and connected? 2. Does another process (Putty, Owon's own tool, an old test session) still hold the port? 3. Does your user have permission to open the device file? On Debian-style systems: `sudo usermod -aG dialout $USER` and re-login. 4. **WSL2 specifically**: USB-serial adapters need [`usbipd-win`](https://learn.microsoft.com/en-us/windows/wsl/connect-usb) to bind the device into the Linux side. Once attached they appear at `/dev/ttyUSB0` and the resolver's Phase 3 picks them up automatically. 5. **WSL1**: COMx → /dev/ttySn mapping is automatic. If `/dev/ttyS6` doesn't exist for `COM7`, the bench probably has Windows COM port numbering you weren't expecting — list with `ls /dev/ttyS*` and try `linux_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`](19_frame_io_and_alm_helpers.md) | The four-phase test pattern and the FrameIO / AlmTester helpers. | | [`docs/15_report_properties_cheatsheet.md`](15_report_properties_cheatsheet.md) | Standard `rp(...)` keys including the PSU ones (`psu_idn`, `psu_resolved_port`, …). |