This commit is contained in:
david rice
2026-05-06 15:57:48 +01:00
parent 395e9d6a43
commit 0edb95d7e1
30 changed files with 2493 additions and 0 deletions

7
hardware/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Instrument I/O — VXI-11 to scope/PSU and HTTP REST to the i.MX target."""
from hardware.psu import PSUController
from hardware.scope import ScopeController
from hardware.target import TargetController
__all__ = ["PSUController", "ScopeController", "TargetController"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

58
hardware/psu.py Normal file
View File

@@ -0,0 +1,58 @@
"""Siglent SPD3303X-E PSU controller over VXI-11 / SCPI.
Drives the display 3.3 V rail so the master loop can power-cycle the PCB
between captures.
"""
from __future__ import annotations
import logging
import time
import vxi11
from config import (
PSU_CHANNEL_DISPLAY,
PSU_DISPLAY_CURRENT,
PSU_DISPLAY_VOLTAGE,
PSU_POWER_CYCLE_DELAY_S,
)
log = logging.getLogger(__name__)
class PSUController:
def __init__(self, ip: str) -> None:
self.ip = ip
self._inst = vxi11.Instrument(ip)
idn = self._inst.ask("*IDN?").strip()
log.info("PSU connected: %s", idn)
self.idn = idn
ch = PSU_CHANNEL_DISPLAY
self._inst.write(f"CH{ch}:VOLTage {PSU_DISPLAY_VOLTAGE}")
self._inst.write(f"CH{ch}:CURRent {PSU_DISPLAY_CURRENT}")
self.output_off()
def output_on(self) -> None:
self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},ON")
def output_off(self) -> None:
self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},OFF")
def power_cycle(self, delay_s: float = PSU_POWER_CYCLE_DELAY_S) -> None:
self.output_off()
time.sleep(delay_s)
self.output_on()
def measure(self) -> dict:
ch = PSU_CHANNEL_DISPLAY
voltage = float(self._inst.ask(f"MEASure:VOLTage? CH{ch}"))
current = float(self._inst.ask(f"MEASure:CURRent? CH{ch}"))
return {"voltage_v": voltage, "current_a": current}
def close(self) -> None:
try:
self._inst.close()
except Exception:
pass

133
hardware/scope.py Normal file
View File

@@ -0,0 +1,133 @@
"""Keysight DSO80204B scope controller over VXI-11 / SCPI.
Configures channels for MIPI D-PHY probing (50 Ω DC, 19.2× attenuation),
arms a single trigger, and downloads ASCII waveforms with absolute
timestamps reconstructed from the preamble.
CRITICAL: All four channels must be DC50 — the 910R+50R divider only gives
the documented 19.2× ratio with 50 Ω termination.
"""
from __future__ import annotations
import io
import logging
import time
import pandas as pd
import vxi11
from config import (
PROBE_ATTENUATION,
SCOPE_CHANNELS,
SCOPE_POINTS,
SCOPE_TIMEBASE,
TRIGGER_CHANNEL,
TRIGGER_LEVEL_V,
TRIGGER_SLOPE,
)
log = logging.getLogger(__name__)
class ScopeController:
def __init__(self, ip: str) -> None:
self.ip = ip
self._inst = vxi11.Instrument(ip)
idn = self._inst.ask("*IDN?").strip()
log.info("Scope connected: %s", idn)
self.idn = idn
def setup(self) -> None:
i = self._inst
i.write("*RST")
time.sleep(1.0)
i.write(":STOP")
for label, ch in SCOPE_CHANNELS.items():
i.write(f":CHANnel{ch}:DISPlay ON")
i.write(f":CHANnel{ch}:INPut DC50")
i.write(f":CHANnel{ch}:PROBe {PROBE_ATTENUATION}")
i.write(f":CHANnel{ch}:SCALe 0.05")
i.write(f":CHANnel{ch}:OFFSet 0.0")
i.write(f":CHANnel{ch}:LABel '{label}'")
i.write(f":TIMebase:SCALe {SCOPE_TIMEBASE:.3E}")
i.write(":TIMebase:POSition 0")
i.write(":TIMebase:REFerence CENTer")
i.write(":TRIGger:MODE EDGE")
i.write(f":TRIGger:EDGE:SOURce CHANnel{TRIGGER_CHANNEL}")
i.write(f":TRIGger:EDGE:SLOPe {TRIGGER_SLOPE}")
i.write(f":TRIGger:EDGE:LEVel {TRIGGER_LEVEL_V}")
i.write(":TRIGger:SWEep NORMal")
i.write(":ACQuire:MODE RTIMe")
i.write(":ACQuire:INTerpolate ON")
i.write(f":ACQuire:POINts {SCOPE_POINTS}")
i.write(":DISPlay:LAYout STACKed")
def arm_single(self) -> None:
self._inst.write(":SINGle")
def wait_for_trigger(self, timeout_s: float = 30.0) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
ter = int(self._inst.ask(":TER?").strip())
except ValueError:
ter = 0
if ter == 1:
return True
time.sleep(0.1)
return False
def _read_preamble(self, channel: int) -> dict:
self._inst.write(f":WAVeform:SOURce CHANnel{channel}")
raw = self._inst.ask(":WAVeform:PREamble?").strip()
parts = raw.split(",")
return {
"format": int(parts[0]),
"type": int(parts[1]),
"points": int(parts[2]),
"count": int(parts[3]),
"x_increment": float(parts[4]),
"x_origin": float(parts[5]),
"x_reference": float(parts[6]),
"y_increment": float(parts[7]),
"y_origin": float(parts[8]),
"y_reference": float(parts[9]),
}
def download_waveform(self, channel: int) -> pd.DataFrame:
preamble = self._read_preamble(channel)
self._inst.write(":WAVeform:FORMat ASCii")
self._inst.write(":WAVeform:STReaming ON")
self._inst.write(f":WAVeform:SOURce CHANnel{channel}")
raw = self._inst.ask(":WAVeform:DATA?")
if raw.startswith("#"):
n_digits = int(raw[1])
raw = raw[2 + n_digits :]
voltages = pd.read_csv(io.StringIO(raw), header=None).iloc[0].astype(float).values
n = len(voltages)
x_inc = preamble["x_increment"]
x_origin = preamble["x_origin"]
times = x_origin + x_inc * pd.Series(range(n), dtype="float64")
return pd.DataFrame({"time_s": times, "voltage_v": voltages})
def download_all(self) -> dict[str, pd.DataFrame]:
return {
label: self.download_waveform(ch) for label, ch in SCOPE_CHANNELS.items()
}
def close(self) -> None:
try:
self._inst.close()
except Exception:
pass

53
hardware/target.py Normal file
View File

@@ -0,0 +1,53 @@
"""HTTP REST client for the i.MX 8M Mini target.
Talks to the Flask server in `server/app.py`. The target must run that server
with appropriate privileges to access /sys/kernel/debug, memtool, and i2c-2.
"""
from __future__ import annotations
import logging
import requests
log = logging.getLogger(__name__)
class TargetController:
def __init__(self, ip: str, port: int, timeout_s: float = 10.0) -> None:
self.base_url = f"http://{ip}:{port}"
self.timeout = timeout_s
probe = requests.get(f"{self.base_url}/registers", timeout=timeout_s)
probe.raise_for_status()
log.info("Target reachable at %s", self.base_url)
def _get(self, path: str) -> dict:
r = requests.get(f"{self.base_url}{path}", timeout=self.timeout)
r.raise_for_status()
return r.json()
def _put(self, path: str, payload: dict) -> dict:
r = requests.put(f"{self.base_url}{path}", json=payload, timeout=self.timeout)
r.raise_for_status()
return r.json()
def get_dsim_registers(self) -> dict:
return self._get("/registers")
def get_sn65_registers(self) -> dict:
return self._get("/sn65_registers")
def get_sn65_settling(self) -> dict:
return self._get("/sn65_settling")
def display_on(self) -> dict:
return self._put("/display", {"state": "on"})
def display_off(self) -> dict:
return self._put("/display", {"state": "off"})
def video_start(self, mode: str = "static-pink") -> dict:
return self._put("/video", {"action": "start", "mode": mode})
def video_stop(self) -> dict:
return self._put("/video", {"action": "stop"})