Add USB serial transport for HMC472A attenuator control
HMC472ASerial class implements usb-serial-json-v1 protocol over the ESP32-S3's native USB CDC port. Auto-detection scans /dev/ttyACM* and probes with the identify command to find the right port. --attenuator flag now defaults to 'auto' (USB first, HTTP fallback). Also accepts direct serial port paths or HTTP URLs for explicit control.
This commit is contained in:
parent
d117782dcf
commit
7f1e0cf0d7
@ -10,9 +10,9 @@ detectable signal, and BPSK mode 9 behavior.
|
|||||||
Hardware setup:
|
Hardware setup:
|
||||||
NanoVNA CH0 → DC Blocker → HMC472A → SMA-to-F → SkyWalker-1
|
NanoVNA CH0 → DC Blocker → HMC472A → SMA-to-F → SkyWalker-1
|
||||||
|
|
||||||
The HMC472A (0-31.5 dB, 0.5 dB steps) is controlled via its ESP32-S2 REST
|
The HMC472A (0-31.5 dB, 0.5 dB steps) is controlled via USB serial (preferred)
|
||||||
API. The NanoVNA provides CW at a fixed frequency, controlled either via
|
or REST API. The NanoVNA provides CW at a fixed frequency, controlled either
|
||||||
mcnanovna or manually. The SkyWalker-1 measures AGC, SNR, and lock status.
|
via mcnanovna or manually. The SkyWalker-1 measures AGC, SNR, and lock status.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python rf_testbench.py agc-linearity --freq 1200
|
python rf_testbench.py agc-linearity --freq 1200
|
||||||
@ -88,6 +88,80 @@ class HMC472A:
|
|||||||
return self._get("/config")
|
return self._get("/config")
|
||||||
|
|
||||||
|
|
||||||
|
# --- HMC472A USB serial client ---
|
||||||
|
|
||||||
|
class HMC472ASerial:
|
||||||
|
"""Control the HMC472A digital attenuator via USB CDC serial.
|
||||||
|
|
||||||
|
Uses the usb-serial-json-v1 protocol: one JSON object per newline-
|
||||||
|
terminated line in each direction. Requires pyserial.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||||
|
import serial
|
||||||
|
self.ser = serial.Serial(port, baudrate, timeout=timeout)
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.ser and self.ser.is_open:
|
||||||
|
self.ser.close()
|
||||||
|
|
||||||
|
def _cmd(self, command: dict) -> dict:
|
||||||
|
line = json.dumps(command, separators=(",", ":")) + "\n"
|
||||||
|
self.ser.write(line.encode())
|
||||||
|
self.ser.flush()
|
||||||
|
resp_line = self.ser.readline()
|
||||||
|
if not resp_line:
|
||||||
|
raise TimeoutError("no response from HMC472A")
|
||||||
|
resp = json.loads(resp_line)
|
||||||
|
if not resp.get("ok"):
|
||||||
|
raise RuntimeError(resp.get("error", "unknown error"))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return self._cmd({"cmd": "status"})
|
||||||
|
|
||||||
|
def set_db(self, attenuation_db: float) -> dict:
|
||||||
|
clamped = max(0.0, min(31.5, attenuation_db))
|
||||||
|
rounded = round(clamped * 2) / 2
|
||||||
|
return self._cmd({"cmd": "set", "db": rounded})
|
||||||
|
|
||||||
|
def config(self) -> dict:
|
||||||
|
return self._cmd({"cmd": "config"})
|
||||||
|
|
||||||
|
def identify(self) -> dict:
|
||||||
|
return self._cmd({"cmd": "identify"})
|
||||||
|
|
||||||
|
|
||||||
|
def detect_hmc472a_serial() -> str | None:
|
||||||
|
"""Scan /dev/ttyACM* ports for an HMC472A responding to identify.
|
||||||
|
|
||||||
|
Returns the port path if found, None otherwise.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ports = sorted(glob.glob("/dev/ttyACM*"))
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(port, 115200, timeout=0.5)
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.write(b'{"cmd":"identify"}\n')
|
||||||
|
ser.flush()
|
||||||
|
resp_line = ser.readline()
|
||||||
|
ser.close()
|
||||||
|
if resp_line:
|
||||||
|
resp = json.loads(resp_line)
|
||||||
|
if resp.get("device") == "hmc472a-attenuator":
|
||||||
|
return port
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class MockSkyWalker1:
|
class MockSkyWalker1:
|
||||||
"""Lightweight mock SkyWalker-1 for rf_testbench testing."""
|
"""Lightweight mock SkyWalker-1 for rf_testbench testing."""
|
||||||
|
|
||||||
@ -650,12 +724,14 @@ examples:
|
|||||||
%(prog)s freq-accuracy --freqs 1000,1200,1400
|
%(prog)s freq-accuracy --freqs 1000,1200,1400
|
||||||
%(prog)s mds --freq 1200
|
%(prog)s mds --freq 1200
|
||||||
%(prog)s bpsk-probe --freq 1200
|
%(prog)s bpsk-probe --freq 1200
|
||||||
%(prog)s band-flatness --nanovna auto --attenuator http://10.0.0.50
|
%(prog)s band-flatness --attenuator /dev/ttyACM1
|
||||||
|
%(prog)s agc-linearity --attenuator http://attenuator.local --freq 1200
|
||||||
|
|
||||||
hardware setup:
|
hardware setup:
|
||||||
NanoVNA CH0 → DC Blocker → HMC472A (0-31.5 dB) → SMA-to-F → SkyWalker-1
|
NanoVNA CH0 → DC Blocker → HMC472A (0-31.5 dB) → SMA-to-F → SkyWalker-1
|
||||||
|
|
||||||
The HMC472A is controlled via its ESP32-S2 REST API.
|
The HMC472A is controlled via USB serial (preferred) or HTTP REST API.
|
||||||
|
Use --attenuator auto (default) to auto-detect USB, falling back to HTTP.
|
||||||
The NanoVNA provides CW, controlled via mcnanovna or manually.
|
The NanoVNA provides CW, controlled via mcnanovna or manually.
|
||||||
LNB power is disabled (direct L-band input mode).
|
LNB power is disabled (direct L-band input mode).
|
||||||
""",
|
""",
|
||||||
@ -667,10 +743,10 @@ hardware setup:
|
|||||||
help="CSV output file path")
|
help="CSV output file path")
|
||||||
parser.add_argument("--cal", type=str, default=None,
|
parser.add_argument("--cal", type=str, default=None,
|
||||||
help="Path loss calibration CSV (NanoVNA S21 sweep)")
|
help="Path loss calibration CSV (NanoVNA S21 sweep)")
|
||||||
parser.add_argument("--attenuator", type=str,
|
parser.add_argument("--attenuator", type=str, default="auto",
|
||||||
default="http://attenuator.local",
|
help="HMC472A connection: 'auto' (USB then HTTP), "
|
||||||
help="HMC472A REST API base URL "
|
"/dev/ttyACMx (USB serial), or http://host (REST) "
|
||||||
"(default: http://attenuator.local)")
|
"(default: auto)")
|
||||||
parser.add_argument("--nanovna", choices=["auto", "manual"],
|
parser.add_argument("--nanovna", choices=["auto", "manual"],
|
||||||
default="auto",
|
default="auto",
|
||||||
help="NanoVNA control mode (default: auto via mcnanovna)")
|
help="NanoVNA control mode (default: auto via mcnanovna)")
|
||||||
@ -717,6 +793,51 @@ hardware setup:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_attenuator(target: str):
|
||||||
|
"""Connect to HMC472A via auto-detect, USB serial, or HTTP REST.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: "auto", a serial port path (/dev/ttyACM*), or an HTTP URL.
|
||||||
|
"""
|
||||||
|
# Auto-detect: try USB serial first, fall back to HTTP
|
||||||
|
if target == "auto":
|
||||||
|
port = detect_hmc472a_serial()
|
||||||
|
if port:
|
||||||
|
print(f"HMC472A: auto-detected USB serial on {port}")
|
||||||
|
target = port
|
||||||
|
else:
|
||||||
|
print("HMC472A: no USB device found, trying HTTP...")
|
||||||
|
target = "http://attenuator.local"
|
||||||
|
|
||||||
|
# USB serial path
|
||||||
|
if target.startswith("/dev/"):
|
||||||
|
try:
|
||||||
|
atten = HMC472ASerial(target)
|
||||||
|
info = atten.identify()
|
||||||
|
print(f"HMC472A: USB serial on {target} "
|
||||||
|
f"(v{info.get('version', '?')}, "
|
||||||
|
f"protocol {info.get('protocol', '?')})")
|
||||||
|
return atten
|
||||||
|
except ImportError:
|
||||||
|
print("HMC472A: pyserial not installed (pip install pyserial)")
|
||||||
|
sys.exit(1)
|
||||||
|
except (OSError, TimeoutError) as e:
|
||||||
|
print(f"HMC472A: cannot open {target} ({e})")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# HTTP REST API
|
||||||
|
atten = HMC472A(target)
|
||||||
|
try:
|
||||||
|
cfg = atten.config()
|
||||||
|
print(f"HMC472A: HTTP on {target} ({cfg.get('hostname', '?')}, "
|
||||||
|
f"v{cfg.get('version', '?')})")
|
||||||
|
return atten
|
||||||
|
except (URLError, OSError) as e:
|
||||||
|
print(f"HMC472A: cannot reach {target} ({e})")
|
||||||
|
print(" Use --attenuator /dev/ttyACMx (USB) or http://host (HTTP)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@ -729,15 +850,7 @@ def main():
|
|||||||
atten = MockHMC472A()
|
atten = MockHMC472A()
|
||||||
print("HMC472A: mock mode")
|
print("HMC472A: mock mode")
|
||||||
else:
|
else:
|
||||||
atten = HMC472A(args.attenuator)
|
atten = _connect_attenuator(args.attenuator)
|
||||||
try:
|
|
||||||
cfg = atten.config()
|
|
||||||
print(f"HMC472A: connected ({cfg.get('hostname', '?')}, "
|
|
||||||
f"v{cfg.get('version', '?')})")
|
|
||||||
except (URLError, OSError) as e:
|
|
||||||
print(f"HMC472A: cannot reach {args.attenuator} ({e})")
|
|
||||||
print(" Check network connection or use --attenuator <url>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Set up NanoVNA
|
# Set up NanoVNA
|
||||||
nanovna = None
|
nanovna = None
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user