3 Commits

Author SHA1 Message Date
Thomas Ales dff7048429 Merge fix-scope-channel-coupling: the scope could not be connected to
set_channel_coupling() looped over CHANNEL_COUPLING.items(), so it called
.upper() on the list half of each pair and raised "'list' object has no
attribute 'upper'" before looking at its argument.  configure_channels()
sets the coupling on every channel as part of connecting, so no IP would
work.  Fixed to .values(), with a test that drives the real instrument
class rather than the FakeScope that stubbed the setters out.
2026-09-08 09:45:33 -05:00
Thomas Ales 43337bfa67 Scope: set_channel_coupling() iterated items() and raised on every call
Connecting to the oscilloscope failed with "'list' object has no attribute
'upper'" whatever the IP was.  configure_channels() runs on connect and
sets the coupling on all four channels; set_channel_coupling() looped over
CHANNEL_COUPLING.items(), so `variants` was the whole ('AC', ['AC']) pair
and the comprehension called .upper() on the list half.  It raised before
looking at the value it was given, so no coupling could ever be accepted.

The tuple unpacking was dropped from `for long_form, variants in ...items()`
in 709dc529 without changing .items() to .values(); the four sibling
validators (acquire mode, trigger slope, trigger mode, data encoding) all
use .values().

The suite missed it because tests/fakes.py FakeScope replaces these setters
with recording stubs, so nothing exercised the real class.  The new test
drives TektronixOscilloscopeBase itself with only the socket replaced,
covering the connect-time path and the other validated setters alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:17:23 -05:00
Thomas Ales 92bbf02c8e Merge dev-helios-current-setpoint: the panel's diode current was a register
The current field read 32 mA and would not move.  32 was LCE — bit 5, door
switch open — landing in the diode current field: replies are CRLF framed
and padded with blank lines, the reader stopped at CR, and the LF left
behind cost a full port timeout on the next read.  A query that spends its
deadline blocked gives up while its own reply is still on the wire, the
next flush cuts a line in half, and "     32" is what the fragment reads
as.  The laser itself was holding 100 mA and took a write of 900 mA first
time.

Lines are framed on CR, LF or CRLF now, out of a buffer that is cleared
along with the port; only CSR and HSR may answer without naming
themselves; writes are read back and retried, as the manual asks; and the
spin box belongs to the operator, with the laser's own value beside it.
A status sweep went from 8.6 s to 356 ms on the way through.
2026-09-08 08:59:13 -05:00
2 changed files with 72 additions and 1 deletions
+1 -1
View File
@@ -292,7 +292,7 @@ class TektronixOscilloscopeBase:
# Validate coupling # Validate coupling
valid = False valid = False
for variants in self.CHANNEL_COUPLING.items(): for variants in self.CHANNEL_COUPLING.values():
if coupling_upper in [v.upper() for v in variants]: if coupling_upper in [v.upper() for v in variants]:
valid = True valid = True
break break
+71
View File
@@ -0,0 +1,71 @@
"""TektronixOscilloscopeBase: the SCPI setters that validate their input.
These run against the real class with only the socket replaced. The scan
tests use FakeScope, which stubs the setters out entirely — so a setter
could raise on every call and nothing in the suite would notice, which is
what happened: set_channel_coupling() iterated CHANNEL_COUPLING.items()
instead of .values(), called .upper() on the list half of each pair, and
raised AttributeError for any coupling at all. configure_channels() runs
on connect, so the scope could not be connected to.
"""
import pytest
from core.scope_sras import SRAS_CHANNELS, configure_channels
from hardware.tektronix_base import TektronixOscilloscopeBase
class RecordingScope(TektronixOscilloscopeBase):
"""The real instrument class with the wire replaced by a list."""
def __init__(self):
super().__init__(resource_name="192.0.2.1")
self._connected = True
self.written: list[str] = []
def write(self, command):
self.written.append(command)
@pytest.fixture
def scope():
return RecordingScope()
def test_connect_time_channel_setup_reaches_the_wire(scope):
"""The regression: every command configure_channels() sends must go out."""
configure_channels(scope)
for ch, profile in SRAS_CHANNELS.items():
assert f"SELect:CH{ch} ON" in scope.written
assert f"CH{ch}:COUPling {profile.coupling}" in scope.written
assert f"CH{ch}:TERmination {profile.termination_ohm}" in scope.written
assert f"CH{ch}:SCAle {profile.scale_v_div}" in scope.written
@pytest.mark.parametrize("coupling", ["DC", "AC", "dc", "ac"])
def test_channel_coupling_accepts_both_modes_in_any_case(scope, coupling):
scope.set_channel_coupling(1, coupling)
assert scope.written == [f"CH1:COUPling {coupling}"]
def test_channel_coupling_rejects_an_unknown_mode(scope):
"""Rejection has to be the documented ValueError, not an AttributeError
raised while building the list of valid options."""
with pytest.raises(ValueError, match="Invalid coupling mode"):
scope.set_channel_coupling(1, "GND")
assert scope.written == []
# The other setters share this shape; a table keeps them honest together.
@pytest.mark.parametrize("setter,good,bad,sent", [
("set_acquire_mode", "AVERAGE", "SMOOTH", "ACQuire:MODe AVERAGE"),
("set_acquire_mode", "SAM", "SMOOTH", "ACQuire:MODe SAM"),
("set_trigger_slope", "RISE", "SIDEWAYS", "TRIGger:A:EDGE:SLOpe RISE"),
("set_trigger_mode", "NORMAL", "SOMETIMES", "TRIGger:A:MODe NORMAL"),
])
def test_validated_setters_take_valid_values_and_reject_the_rest(
scope, setter, good, bad, sent):
getattr(scope, setter)(good)
assert scope.written == [sent]
with pytest.raises(ValueError):
getattr(scope, setter)(bad)