A macOS desktop application for the Agilent U2702A USB oscilloscope, built with PySide6 and PyQtGraph. Uses an ESP32-S3 as a USB bridge to bypass Apple Silicon USB driver limitations.
- Real-time dual-channel waveform display with dark theme
- Keysight-inspired front panel layout (graph left, controls right)
- Custom rotary knobs (drag to adjust, click to enter exact value)
- Per-channel V/div, offset, coupling, and probe controls
- Horizontal T/div and position controls
- Edge trigger with level, source, slope, sweep, and coupling
- Software trigger alignment (trigger edge at center marker)
- Live measurements: Vpp, Vmin, Vmax, Vrms, Vmean, Freq, Period, Rise, Fall, Duty
- Measurement toggle buttons with per-channel readouts
- Cursor system (time, voltage, or both) with drag-to-position and readout bar
- Measurement click-to-cursor: click a measurement value to set cursors at key positions
- Measurement hover highlights on waveform
- Probe system: 1x / 10x / 100x / 1000x / Custom with probe badges on GND markers
- Probe compensation guidance dialog
- Digital multimeter mode (DC, AC RMS, AC+DC RMS) with large readout
- Current measurement mode (I = V/R via shunt resistor)
- DMM Hold, Relative (delta), and Range Lock
- Session save/load (JSON), auto-save on exit, auto-restore on startup
- Recent Sessions menu, QSettings persistence
- Export waveform data to CSV or JSON (with metadata + measurements)
- Export graph as PNG (dark/light) or PDF (A4 landscape)
- Unified Export dialog with Data and Graph tabs
- Drag-to-zoom with Cmd+Z undo
- Per-channel GND markers, trigger level indicator, trigger position marker
- Connection dialog with CP2102N auto-detection
- SCPI Tester tool (shared connection)
- Settings dialog (channel colors, probe attenuation, knob scroll toggle)
Mac (Python/PySide6) <-- Serial (2Mbps) --> ESP32-S3 <-- USB Host --> U2702A
GUI + Processing CP2102N UART Firmware USBTMC
The ESP32-S3-DevKitC-1 was chosen because it has two USB connectors:
| Connector | Label on PCB | Connects to | Purpose |
|---|---|---|---|
| USB (top) | USB | Computer (Mac) | CP2102N UART bridge — serial communication at 2 Mbps |
| USB OTG (bottom) | USB OTG | U2702A oscilloscope | Native USB Host — boots scope + USBTMC data transfer |
The U2702A boot-mode firmware violates USB 2.0 spec (no config descriptor). macOS Apple Silicon's USB stack permanently blocks the device after the first timeout. The ESP32-S3 acts as a USB-to-serial bridge, completely bypassing macOS USB.
instrument/— Hardware communication (serial bridge, SCPI protocol)processing/— Signal analysis (ADC-to-voltage, measurements, trigger detection)gui/— PySide6 display (waveform, controls, panels)
- ESP32-S3-DevKitC-1 (~$8) with firmware flashed (see
firmware/) - Agilent U2702A oscilloscope
- USB-A to Micro-USB OTG cable (to connect scope to ESP32 USB OTG port)
- USB-C cable (to connect ESP32 USB port to Mac)
- Wire wrap / solder bridge between the two USB VCC pins (see below)
The ESP32-S3-DevKitC-1 has two separate USB connectors with independent power rails. The U2702A requires 5V power from the USB Host (ESP32 OTG port) to boot. By default, the OTG port does not supply 5V.
You must connect the VCC (5V) pin from the CP2102N USB connector to the VCC pin of the USB OTG connector with a short wire wrap or solder bridge. This routes the Mac's USB 5V power through to the scope.
ESP32-S3-DevKitC-1
┌──────────────────┐
│ [USB] ← to Mac │ ← CP2102N UART (serial data + 5V power source)
│ VCC ─────┐ │
│ wire │ ← Wire wrap: connect the two VCC pins
│ VCC ─────┘ │
│ [USB OTG] ← to │ ← Native USB Host (USBTMC to scope)
│ U2702A scope │
└──────────────────┘
┌─────────┐ USB-C ┌────────────────┐ USB OTG ┌───────────┐
│ Mac │ ──────────── │ ESP32-S3 │ ──────────── │ U2702A │
│ (macOS) │ 2 Mbps │ DevKitC-1 │ USBTMC │ Scope │
│ │ UART │ │ USB Host │ │
└─────────┘ └────────────────┘ └───────────┘
↑ ↑ ↑ ↑
python3 gui/main.py USB port USB OTG port Micro-USB
(to Mac) (to scope)
- Python 3.11+
- macOS (tested on Apple Silicon)
pip install PySide6 pyqtgraph pyserial numpypython3 gui/main.pyThe connection dialog opens automatically. Select the CP2102N serial port and click Connect.
Download the latest artifact for your OS from the
Actions tab → most recent green
build run → Artifacts section. Tagged releases (v*) also publish
a GitHub Release with the same bundles.
| OS | Artifact | Run it |
|---|---|---|
| macOS (Apple Silicon) | LTB-macos-arm64.zip |
unzip, open LTB.app (see macOS note below) |
| Windows x64 | LTB-windows-x64.zip |
unzip, double-click LTB.exe |
| Linux x64 | LTB-linux-x64.tar.gz |
tar -xzf …, run LTB/LTB |
macOS Gatekeeper note. The CI build isn't signed with an Apple Developer ID (costs $99/yr), so the first time you launch it macOS refuses with "Apple could not verify …is free of malware". Clear the quarantine flag once per download:
xattr -dr com.apple.quarantine ~/Downloads/LTB.app
open ~/Downloads/LTB.appOr right-click LTB.app → hold Option → Open → Open anyway.
The app will launch and remember the decision.
pip install -r requirements-build.txt
python scripts/build_app.py --clean --testsOutput lands in dist/:
- macOS:
dist/LTB.app(also a rawdist/LTBbinary) - Windows:
dist/LTB/LTB.exe - Linux:
dist/LTB/LTB
Flags: --onefile produces a single-file executable instead of a
bundle; --clean wipes build/ and dist/ first; --tests runs
the headless test suite and fails the build if anything breaks.
The same driver objects the GUI uses are reachable from a plain Python script with no Qt loaded:
from automation import LabSession
with LabSession() as lab:
# Any registered model; demo=True works without hardware.
scope = lab.open("U2702A", demo=True)
scope.apply_setting("channel.1.vdiv", 0.5)
result = scope.acquire()
print(result.waveforms[0].measurements["vpp"])
# Multiple instruments in one session — the lab-bench case.
dmm = lab.open("Generic-DMM", demo=True)
gen = lab.open("33120A", demo=True)
gen.apply_setting("waveform", "SIN")
gen.apply_setting("frequency", 1000.0)
gen.apply_setting("amplitude", 2.0)
gen.apply_setting("output_enabled", True)
print(dmm.acquire().primary, "V")
print(gen.acquire().setpoints["waveform"])| Model | Kind | Transport | Driver |
|---|---|---|---|
| Agilent U2702A | Oscilloscope (2ch, 8-bit, 1256 samples) | USB via ESP32-S3 bridge | instrument/drivers/u2702a.py |
| Generic-DMM | Digital multimeter (SCPI MEAS:*?) |
Any serial | instrument/drivers/generic_dmm.py |
| Agilent 33120A | Function generator (15 MHz, sine/square/tri/ramp/noise/DC) | RS-232 via USB-RS232 adapter | instrument/drivers/agilent_33120a.py |
Adding a new instrument is typically: write a preset in config/presets/,
a driver in instrument/drivers/, a layout in gui/layouts/ if its UI
needs to be different, and register both in config/__init__.py and
automation/session.py. See the existing three for the pattern.
Two GitHub Actions workflows live under .github/workflows/:
tests.yml— runs the headless test suite on every PR tomainand every push tomain. Matrix covers Python 3.11 and 3.12.build.yml— on every push tomain(and onv*tags) builds the standalone app for macOS arm64, Windows x64, and Linux x64 in parallel, uploads the bundles as workflow artifacts, and on tag pushes publishes a GitHub Release with the three archives attached.
CI runs automatically, but blocking merges on red CI is a repo setting that has to be clicked in GitHub:
- Repo → Settings → Branches
- Add a branch protection rule for
main - Enable Require a pull request before merging
- Enable Require status checks to pass before merging and select
the
testsjob (both matrix entries) as required - Optional but recommended: Require branches to be up to date before merging
No code change can enforce this — only a repo admin in the Settings tab. The workflow runs either way; protection just prevents merging a failing branch.
gui/
main.py # Entry point
main_window.py # Main oscilloscope window, signal wiring
waveform_widget.py # PyQtGraph plot, graticule, markers, cursors
channel_panel.py # Per-channel Keysight-style columns
timebase_panel.py # Horizontal T/div + Position
trigger_panel.py # Trigger controls
measurement_bar.py # Measurement toggle buttons + readouts
acquisition_worker.py # QThread SCPI streaming + trigger alignment
knob_widget.py # Custom rotary knob (drag + click popup)
connection_dialog.py # Serial port connection dialog
settings_dialog.py # Colors, probes, knob scroll
utility_panel.py # Autoscale, measurements, cursor mode, DMM
cursor_readout.py # Cursor readout panel (delta T, delta V)
export_dialog.py # Unified export (CSV/JSON + PNG/PDF graph)
probe_comp_dialog.py # Probe compensation guidance
theme.py # Dark theme, SI formatting, channel colors
scpi_tester.py # SCPI command tester tool
session.py # Session save/load/restore
instrument/
serial_bridge.py # Thread-safe serial bridge client
protocol.py # SCPI command definitions (61 commands)
processing/
waveform.py # WaveformData, ADC conversion, trigger detection
measurements.py # Vpp, Vrms, frequency, rise/fall, duty cycle
autoscale.py # Auto-range V/div, T/div, offset
export.py # CSV and JSON export functions
firmware/
src/main.c # ESP32-S3 firmware entry point
src/u2702a_boot.c # HCD boot sequence (PID 0x2818 → 0x2918)
src/usb_host.c # USB Host daemon + client tasks
src/usbtmc.c # USBTMC bulk transfer layer
src/serial_bridge.c # UART command dispatch
See VERSIONING.md for the full roadmap and feature checklist.
| Version | Status | Description |
|---|---|---|
| 0.1.x | Complete | Foundation: ESP32 bridge, SCPI tester, serial client |
| 0.2.x | Complete | Oscilloscope GUI: controls, waveforms, basic measurements |
| 0.3.x | Complete | Trigger system: edge trigger, modes, drag-to-zoom |
| 0.4.x | Complete | Measurements: rise/fall, duty cycle, cursors |
| 0.5.x | Complete | Probe system: 1x-1000x, custom, compensation |
| 0.6.x | Complete | Multimeter mode: DMM display, current measurement |
| 0.7.x | Complete | Session files: save/load/restore, auto-save |
| 0.8.x | Current | Export: CSV, JSON, PNG, PDF graph rendering |
| 1.0.0 | Planned | Protocol decoders, light theme, .app bundle |
This project is under active development. Expect breaking changes between alpha versions.
The current focus is data export and graph rendering (0.8.x). Upcoming milestones:
| Milestone | Goal |
|---|---|
| 0.8.x — Export & Data | CSV/JSON export, PNG/PDF graph, waveform averaging, reference traces |
| 1.0.0 — Release | Protocol decoders (UART, SPI, I2C), keyboard shortcuts, light theme, .app bundle |
Post-1.0 ideas: FFT/math functions, XY mode, persistence display, network/LAN bridge, mask testing, plugin system for other oscilloscopes.
See VERSIONING.md for the full feature checklist.
Contributions are welcome! The modular three-layer architecture makes it possible to add support for other oscilloscopes by implementing the instrument interface. See VERSIONING.md for the modular architecture requirements.
This project is licensed under the GNU General Public License v3.0 — see LICENSE for details.
Copyright (C) 2026 Luca Bresch