Skip to content

Commit ed7d8e3

Browse files
authored
Device setup: fixture-owned port lifecycle (realsenseai#15281)
2 parents a773d90 + e85540f commit ed7d8e3

11 files changed

Lines changed: 473 additions & 187 deletions

unit-tests/conftest.py

Lines changed: 216 additions & 54 deletions
Large diffs are not rendered by default.

unit-tests/infra-tests/e2e/e2e_conftest.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# Fake pyrealsense2 — track log_to_console calls so tests can verify --rslog
1616
import json as _json
1717
_tracking_log = os.path.join(os.path.dirname(os.path.abspath(__file__)), '_tracking.json')
18-
_tracking = {"rslog_calls": [], "query_kwargs": [], "enable_only_calls": []}
18+
_tracking = {"rslog_calls": [], "query_kwargs": [], "enable_only_calls": [], "disable_calls": []}
1919
def _save_tracking():
2020
with open(_tracking_log, 'w') as _f:
2121
_json.dump(_tracking, _f)
@@ -76,21 +76,37 @@ def _mock_get(sn):
7676
_dev.by_spec = _mock_by_spec
7777
_dev.get = _mock_get
7878
_dev._device_by_sn = {sn: FakeDevice(sn, n) for sn, n in _sn_map.items()}
79-
_dev.hub = None
79+
# Default: model a hub-equipped bench with a truthy sentinel. Port ops are mocked above, so the
80+
# object is never actually used. init_hub is stubbed so the real one doesn't probe (absent)
81+
# hardware and reset this back to None. A scenario test file can patch devices.hub /
82+
# devices.any_port_powered at import time (before fixtures run) to model a hub-less bench or a
83+
# port left powered -- see pytest-hubless-setup.py and pytest-port-already-on.py.
84+
_dev.hub = object()
85+
_dev.init_hub = lambda: None
8086
_dev._context = None
8187
def _mock_query(**kw):
8288
_tracking["query_kwargs"].append(kw)
8389
_save_tracking()
8490
_dev.query = _mock_query
8591
_dev.map_unknown_ports = lambda: None
8692
_dev.wait_until_all_ports_disabled = lambda: None
93+
# Hub hardware port-state probe used by the conftest recycle decision. Default OFF (the previous
94+
# teardown powered the device down -> setup just enables). A scenario test file patches this to
95+
# True to model a port left powered by a skipped teardown -> setup recycles it clean.
96+
_dev.any_port_powered = lambda serials: False
8797

88-
# Track enable_only calls so tests can verify hub port behavior
89-
def _mock_enable_only(serials, recycle=True):
90-
_tracking["enable_only_calls"].append({"serials": list(serials), "recycle": recycle})
98+
# Track enable_only / disable calls so tests can verify hub port behavior
99+
def _mock_enable_only(serials, recycle=True, timeout=None, disable_other_ports=False):
100+
_tracking["enable_only_calls"].append(
101+
{"serials": list(serials), "recycle": recycle, "disable_other_ports": disable_other_ports})
91102
_save_tracking()
92103
_dev.enable_only = _mock_enable_only
93104

105+
def _mock_disable(serials, wait=True):
106+
_tracking.setdefault("disable_calls", []).append({"serials": list(serials), "wait": wait})
107+
_save_tracking()
108+
_dev.disable = _mock_disable
109+
94110
# exec() the REAL conftest.py
95111
_conftest_path = os.path.join(_unit_tests_dir, 'conftest.py')
96112
with open(_conftest_path) as _f:
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Model a hub-less bench (e.g. Jetson). Patch devices.hub to None at import -- this runs during
2+
# collection, before module_device_setup -- so the conftest recycle decision takes the no-hub
3+
# branch: teardown-disable is a no-op there, so setup recycles via enable_only(recycle=True)
4+
# (which falls back to hardware_reset on a real hub-less machine).
5+
import rspy.devices as _devices
6+
_devices.hub = None
7+
8+
import pytest
9+
10+
11+
@pytest.mark.device("D455")
12+
def test_d455(module_device_setup):
13+
assert module_device_setup == '111'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Model a device whose hub port was left powered by a skipped/crashed teardown. Patch
2+
# any_port_powered -> True at import (before module_device_setup runs) so the conftest recycle
3+
# decision takes the "already powered" branch: setup recycles the device clean (recycle=True)
4+
# instead of reusing a possibly-bad state.
5+
import rspy.devices as _devices
6+
_devices.any_port_powered = lambda serials: True
7+
8+
import pytest
9+
10+
11+
@pytest.mark.device("D455")
12+
def test_d455(module_device_setup):
13+
assert module_device_setup == '111'

unit-tests/infra-tests/helpers.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,16 @@ def make_device_marker(name, *patterns):
138138
_E2E_CONFTEST = os.path.join(_E2E_DIR, 'e2e_conftest.py')
139139

140140

141-
def run_e2e(test_filename, *extra_pytest_args):
141+
def run_e2e(test_filename, *extra_pytest_args, env=None):
142142
"""Run a pytest subprocess on a static test file from e2e/.
143143
144144
Copies e2e_conftest.py and the test file to a temp dir for isolation
145145
from the parent unit-tests/conftest.py. No content is generated — both
146146
files are static and checked into the repo.
147147
148+
:param env: optional dict of extra environment variables for the subprocess
149+
(e.g. {'E2E_NO_HUB': '1'} to model a hub-less bench).
150+
148151
Returns (returncode, stdout, tracking) where tracking is a dict with:
149152
- enable_only_calls: list of {serials, recycle} dicts
150153
- rslog_calls: list of {level} dicts
@@ -156,13 +159,15 @@ def run_e2e(test_filename, *extra_pytest_args):
156159
shutil.copy(_E2E_CONFTEST, os.path.join(tmpdir, 'conftest.py'))
157160
shutil.copy(os.path.join(_E2E_DIR, test_filename), os.path.join(tmpdir, test_filename))
158161

159-
env = os.environ.copy()
160-
env['INFRA_UNIT_TESTS_DIR'] = os.path.normpath(os.path.join(_E2E_DIR, '..', '..')) # unit-tests/
162+
sub_env = os.environ.copy()
163+
sub_env['INFRA_UNIT_TESTS_DIR'] = os.path.normpath(os.path.join(_E2E_DIR, '..', '..')) # unit-tests/
164+
if env:
165+
sub_env.update(env)
161166

162167
p = subprocess.run(
163168
[sys.executable, "-m", "pytest", test_filename, "-v", *extra_pytest_args],
164169
cwd=tmpdir,
165-
env=env,
170+
env=sub_env,
166171
stdout=subprocess.PIPE,
167172
stderr=subprocess.STDOUT,
168173
universal_newlines=True,
@@ -176,7 +181,7 @@ def run_e2e(test_filename, *extra_pytest_args):
176181

177182
tracking_file = os.path.join(tmpdir, '_tracking.json')
178183
tracking = json.loads(open(tracking_file).read()) if os.path.exists(tracking_file) else {
179-
"enable_only_calls": [], "rslog_calls": [], "query_kwargs": []
184+
"enable_only_calls": [], "rslog_calls": [], "query_kwargs": [], "disable_calls": []
180185
}
181186

182187
return p.returncode, p.stdout, tracking

unit-tests/infra-tests/test_e2e_cli_options.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ def test_retries(self):
7979
calls = tracking["enable_only_calls"]
8080
# Two enable_only calls: initial module-fixture creation + post-retry-teardown re-creation.
8181
assert len(calls) == 2
82-
assert all(c['recycle'] is True for c in calls)
82+
assert all(c['recycle'] is False for c in calls)
83+
# the device recycle now comes from the teardown-disable between attempts, not recycle=True
84+
assert len(tracking["disable_calls"]) >= 1
8385

8486
def test_retries_recreate_module_fixture(self):
8587
"""Module-scoped fixtures must be torn down and re-instantiated between
@@ -101,23 +103,27 @@ def test_retries_on_setup_error(self):
101103
assert rc == 0
102104

103105
def test_repeat(self):
104-
"""--repeat 3 should repeat the test 3 times, recycling the device each time."""
106+
"""--repeat 3 repeats the test 3 times; each pass power-cycles the device via
107+
teardown-disable + setup-enable (setup itself uses recycle=False)."""
105108
rc, out, tracking = run_e2e("pytest-device-setup.py", "-k", "test_d455 and not excluded",
106109
"--repeat", "3")
107110
assert_outcomes(out, passed=3)
108111
calls = tracking["enable_only_calls"]
109112
assert len(calls) == 3
110-
assert all(c['recycle'] is True for c in calls)
113+
assert all(c['recycle'] is False for c in calls)
114+
assert len(tracking["disable_calls"]) >= 1 # teardown disables each pass -> the cycle
111115

112116
def test_repeat_no_reset(self):
113117
"""--repeat 3 --no-reset should repeat without recycling."""
114118
rc, out, tracking = run_e2e("pytest-device-setup.py", "-k", "test_d455 and not excluded",
115119
"--repeat", "3", "--no-reset")
116120
assert_outcomes(out, passed=3)
117121
calls = tracking["enable_only_calls"]
118-
# First run enables without recycle, subsequent runs skip enable_only entirely
119-
assert len(calls) == 1
120-
assert calls[0]['recycle'] is False
122+
# --no-reset re-enables each pass but never disables on teardown, so the device is
123+
# NOT power-cycled (left on) -- the distinguishing behavior vs the default path.
124+
assert len(calls) == 3
125+
assert all(c['recycle'] is False for c in calls)
126+
assert tracking["disable_calls"] == []
121127

122128
def test_device_nonexistent(self):
123129
"""--device D999 with no matching device should produce 0 parametrized instances."""

unit-tests/infra-tests/test_e2e_port_management.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,40 @@
1717
class TestDevicePortManagement:
1818

1919
def test_device_marker_enables_correct_port(self):
20-
"""@device('D455') should call enable_only(['111'], recycle=True)."""
20+
"""@device('D455') enables the device (recycle=False -- the power cycle is
21+
teardown-disable + setup-enable, so setup itself doesn't recycle)."""
2122
rc, out, tracking = run_e2e("pytest-device-setup.py", "-k", "test_d455 and not excluded")
2223
assert_outcomes(out, passed=1)
2324
assert len(tracking["enable_only_calls"]) == 1
2425
assert tracking["enable_only_calls"][0]['serials'] == ['111']
26+
assert tracking["enable_only_calls"][0]['recycle'] is False
27+
28+
def test_hubless_recycles_via_hw_reset(self):
29+
"""On a hub-less bench (e.g. Jetson) teardown-disable is a no-op, so setup MUST recycle --
30+
enable_only(recycle=True) falls back to hardware_reset. pytest-hubless-setup.py patches
31+
devices.hub to None to exercise the no-hub branch of the conftest recycle decision."""
32+
rc, out, tracking = run_e2e("pytest-hubless-setup.py")
33+
assert_outcomes(out, passed=1)
34+
assert len(tracking["enable_only_calls"]) == 1
35+
assert tracking["enable_only_calls"][0]['recycle'] is True
36+
37+
def test_port_already_on_recycles(self):
38+
"""With a hub, setup expects the device OFF (prev teardown disabled it). If a required port
39+
is still powered -- a skipped/crashed teardown -- setup recycles it clean instead of reusing
40+
a stale state. pytest-port-already-on.py patches any_port_powered -> True for this branch."""
41+
rc, out, tracking = run_e2e("pytest-port-already-on.py")
42+
assert_outcomes(out, passed=1)
43+
assert len(tracking["enable_only_calls"]) == 1
2544
assert tracking["enable_only_calls"][0]['recycle'] is True
2645

2746
def test_device_each_enables_one_port_per_test(self):
28-
"""@device_each('D400*') should call enable_only once per device, each with recycle=True."""
47+
"""@device_each('D400*') should call enable_only once per device (recycle=False)."""
2948
rc, out, tracking = run_e2e("pytest-each-setup.py", "-k", "test_d400 and not d999")
3049
assert_outcomes(out, passed=3)
3150
assert len(tracking["enable_only_calls"]) == 3
3251
serials_enabled = [c['serials'][0] for c in tracking["enable_only_calls"]]
3352
assert set(serials_enabled) == {'111', '222', '777'}
34-
assert all(c['recycle'] is True for c in tracking["enable_only_calls"])
53+
assert all(c['recycle'] is False for c in tracking["enable_only_calls"])
3554
assert all(len(c['serials']) == 1 for c in tracking["enable_only_calls"])
3655

3756
def test_second_test_same_device_no_recycle(self):

unit-tests/py/rspy/acroname.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,28 @@ def get_port_by_location(self, usb_location):
332332
# We don't know how to get the port from these yet!
333333
return None # int(match.group(2))
334334
else:
335-
split_location = [int(x) for x in usb_location.split('.')]
335+
# Windows LocationInformation fields are hex (e.g. "0000.000d.0000.003.002...").
336+
# Parse base-16 so fields like "000d" don't raise; only the last two non-zero
337+
# indices (acroname sub-hub port + device port) are used and those are small
338+
# (<=4), so hex vs decimal is identical for them. Unparseable -> defer to
339+
# map_unknown_ports() enumeration.
340+
try:
341+
split_location = [int(x, 16) for x in usb_location.split('.')]
342+
except ValueError:
343+
log.d(f'could not parse usb location {usb_location!r}; deferring to port mapping')
344+
return None
336345
# lambda helper to return the last 2 non-zero numbers, used when connecting using an additional hub
337346
# ex: laptop -> hub -> acroname
338347
get_last_two_digits = lambda array: tuple(
339348
reversed(list(reversed([i for i in array if i != 0]))[:2]))
340349
# only the last two digits are necessary
341-
first_index, second_index = get_last_two_digits(split_location)
350+
last_two = get_last_two_digits(split_location)
351+
if len(last_two) < 2:
352+
# fewer than two non-zero fields (e.g. all-zero or single-field location):
353+
# not enough to identify a sub-hub+port pair -> defer to port mapping
354+
log.d(f'usb location {usb_location!r} has <2 non-zero fields; deferring to port mapping')
355+
return None
356+
first_index, second_index = last_two
342357

343358
return get_port_from_usb(first_index, second_index)
344359
else:
@@ -373,6 +388,9 @@ def get_port_by_location(self, usb_location):
373388
match = re.search(r'^(\d+)\.(\d+)', usb_location[len(port) + 1:])
374389
if match:
375390
return get_port_from_usb(int(match.group(1)), int(match.group(2)))
391+
# no known acroname hub prefixes this location (or sub-ports unparseable) -- log so a
392+
# misconfigured intermediate hub is diagnosable, then defer to map_unknown_ports()
393+
log.d( f'usb location {usb_location!r} not under a known acroname hub; deferring to port mapping' )
376394

377395
specs = None
378396
def discover(retries = 0):
@@ -414,7 +432,13 @@ def get_port_from_usb(first_usb_index, second_usb_index ):
414432
(3, 2): 6,
415433
(3, 1): 7,
416434
}
417-
return acroname_port_usb_map[(first_usb_index, second_usb_index)]
435+
# .get() -> None for an unmapped topology so the caller falls back to map_unknown_ports().
436+
# Log the miss (pre-PR this raised KeyError which the caller logged) so an operator with a
437+
# misconfigured intermediate hub has a signal explaining why a device ended up unmapped.
438+
port = acroname_port_usb_map.get( (first_usb_index, second_usb_index) )
439+
if port is None:
440+
log.d( f'unmapped acroname topology ({first_usb_index},{second_usb_index}); deferring to port mapping' )
441+
return port
418442

419443

420444

0 commit comments

Comments
 (0)