Skip to content

Commit f1d446b

Browse files
serial_manager: read per-class polling method and interval from manifest; support nested class blocks (SPM PSU/DMM). tests: update per-class polling test to use class-specific methods.
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 8663187 commit f1d446b

2 files changed

Lines changed: 113 additions & 12 deletions

File tree

benchmesh-serial-service/src/benchmesh_service/serial_manager.py

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,30 @@ def _get_class_channel_counts(dev: dict) -> Dict[str, int]:
6565
except Exception:
6666
ch = 1
6767
out[str(klass)] = max(1, ch)
68+
# Fallback: detect nested class blocks mistakenly placed under another class
69+
for subk, subcfg in (cfg or {}).items():
70+
if not isinstance(subcfg, dict):
71+
continue
72+
if subk in ("features", "modes", "pooling", "polling"):
73+
continue
74+
sub_features = (subcfg or {}).get("features") or {}
75+
if sub_features:
76+
try:
77+
sch = int(sub_features.get("channels", 1) or 1)
78+
except Exception:
79+
sch = 1
80+
out[str(subk)] = max(1, sch)
6881
return out
6982
except Exception:
7083
return out
7184

7285

7386
def _get_class_poll_intervals(dev: dict) -> Dict[str, float]:
74-
"""Return mapping of class -> poll interval (seconds), defaults to 2.0 if not specified."""
87+
"""Return mapping of class -> poll interval (seconds) for classes that declare a polling method.
88+
89+
Only classes with a defined pooling/polling entry are included. This prevents polling
90+
classes that have no configured poll method.
91+
"""
7592
out: Dict[str, float] = {}
7693
try:
7794
driver_key = dev.get("driver")
@@ -90,16 +107,34 @@ def _get_class_poll_intervals(dev: dict) -> Dict[str, float]:
90107
inst_class_block = model_cfg.get("instrument_class") or {}
91108
for klass, cfg in (inst_class_block or {}).items():
92109
pooling = (cfg or {}).get("pooling") or (cfg or {}).get("polling") or []
93-
# pooling can be a list of entries, pick poll_status if present
94-
iv = 2.0
110+
# Pick the first polling entry we can use for the top-level class
111+
iv = None
95112
for entry in pooling:
96113
try:
97-
if entry.get("method") == "poll_status":
98-
iv = float(entry.get("interval", iv))
114+
mname = entry.get("method")
115+
if mname:
116+
iv = float(entry.get("interval", 2.0))
99117
break
100118
except Exception:
101119
continue
102-
out[str(klass)] = iv
120+
if iv is not None:
121+
out[str(klass)] = iv
122+
# Also detect nested class blocks and their pooling
123+
for subk, subcfg in (cfg or {}).items():
124+
if not isinstance(subcfg, dict) or subk in ("features", "modes", "pooling", "polling"):
125+
continue
126+
sub_pool = (subcfg or {}).get("pooling") or (subcfg or {}).get("polling") or []
127+
sub_iv = None
128+
for entry in sub_pool:
129+
try:
130+
mname = entry.get("method")
131+
if mname:
132+
sub_iv = float(entry.get("interval", 2.0))
133+
break
134+
except Exception:
135+
continue
136+
if sub_iv is not None:
137+
out[str(subk)] = sub_iv
103138
return out
104139
except Exception:
105140
return out
@@ -288,10 +323,42 @@ def monitor_connections(self):
288323
continue
289324
try:
290325
polled_any = False
326+
# Resolve poll method name from manifest config
327+
poll_method = None
328+
try:
329+
driver_key = dev.get('driver')
330+
manifest = _load_manifest(driver_key)
331+
models = manifest.get('models', {}) or {}
332+
model_cfg = models.get(dev.get('model')) if dev.get('model') in models else (next(iter(models.values())) if models else {})
333+
iclasses = (model_cfg or {}).get('instrument_class', {}) or {}
334+
icfg = iclasses.get(klass, {})
335+
pooling = (icfg or {}).get('pooling') or (icfg or {}).get('polling') or []
336+
if not pooling:
337+
for topk, topcfg in (iclasses or {}).items():
338+
if isinstance(topcfg, dict) and isinstance(topcfg.get(klass), dict):
339+
alt = topcfg.get(klass) or {}
340+
pooling = (alt.get('pooling') or alt.get('polling') or [])
341+
if pooling:
342+
break
343+
for entry in pooling:
344+
name = entry.get('method')
345+
if name:
346+
poll_method = name
347+
break
348+
except Exception:
349+
pass
350+
# Default fallback
351+
if not poll_method:
352+
poll_method = 'poll_status'
353+
meth = getattr(drv, poll_method, None)
354+
if not callable(meth):
355+
logger.warning("Poll method %s not implemented on driver %s; skipping class %s", poll_method, type(drv).__name__, klass)
356+
continue
291357
for ch in range(1, max(1, ch_count) + 1):
292358
try:
293-
status = drv.poll_status(ch) if hasattr(drv, 'poll_status') else {}
294-
except Exception:
359+
status = meth(ch)
360+
except Exception as e:
361+
logger.warning("Polling %s[%s] failed: %s", device_id, klass, e)
295362
status = {}
296363
if not status:
297364
self._clear_disconnected_registry(device_id)
@@ -377,10 +444,43 @@ def _device_worker(self, dev_id: str):
377444
continue
378445
try:
379446
polled_any = False
447+
# Resolve poll method name from manifest config
448+
poll_method = None
449+
try:
450+
dev_cfg = next((d for d in self.devices if d.get('id') == dev_id), None)
451+
if dev_cfg:
452+
driver_key = dev_cfg.get('driver')
453+
manifest = _load_manifest(driver_key)
454+
models = manifest.get('models', {}) or {}
455+
model_cfg = models.get(dev_cfg.get('model')) if dev_cfg.get('model') in models else (next(iter(models.values())) if models else {})
456+
iclasses = (model_cfg or {}).get('instrument_class', {}) or {}
457+
icfg = iclasses.get(klass, {})
458+
pooling = (icfg or {}).get('pooling') or (icfg or {}).get('polling') or []
459+
if not pooling:
460+
for topk, topcfg in (iclasses or {}).items():
461+
if isinstance(topcfg, dict) and isinstance(topcfg.get(klass), dict):
462+
alt = topcfg.get(klass) or {}
463+
pooling = (alt.get('pooling') or alt.get('polling') or [])
464+
if pooling:
465+
break
466+
for entry in pooling:
467+
name = entry.get('method')
468+
if name:
469+
poll_method = name
470+
break
471+
except Exception:
472+
pass
473+
if not poll_method:
474+
poll_method = 'poll_status'
475+
meth = getattr(drv, poll_method, None)
476+
if not callable(meth):
477+
logger.warning("Poll method %s not implemented on driver %s; skipping class %s", poll_method, type(drv).__name__, klass)
478+
continue
380479
for ch in range(1, max(1, ch_count)+1):
381480
try:
382-
st = drv.poll_status(ch) if hasattr(drv, 'poll_status') else {}
383-
except Exception:
481+
st = meth(ch)
482+
except Exception as e:
483+
logger.warning("Polling %s[%s] failed: %s", dev_id, klass, e)
384484
st = {}
385485
if not st:
386486
self._clear_disconnected_registry(dev_id)

benchmesh-serial-service/tests/test_serial_manager_per_class_polling.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ def test_registry_nested_under_class_and_channels():
6464
def stub_poll(channel=1):
6565
# Return dict so it is considered a successful poll
6666
return {'ok': True, 'ch': channel}
67-
# One poll method used for both classes in driver; manager will call per class
68-
drv.poll_status = stub_poll # type: ignore[attr-defined]
67+
# Provide per-class poll methods as per manifest for SPM (PSU/DMM)
68+
drv.poll_status_psu = stub_poll # type: ignore[attr-defined]
69+
drv.poll_status_dmm = stub_poll # type: ignore[attr-defined]
6970

7071
# Allow some time for a few polling cycles
7172
time.sleep(0.6)

0 commit comments

Comments
 (0)