# 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/psu/test_owon_psu.py`](../tests/hardware/psu/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/psu/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/psu/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 <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

```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/psu/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`, …). |
