Metadata-Version: 2.4
Name: rf-bench-drivers-koolertron
Version: 0.1.0
Summary: Koolertron / MHinstek MHS-5200A series dual-channel DDS signal generator + frequency counter driver (covers KKmoon and other rebrands)
Author-email: Jeff Francis <gjfrancis@protonmail.com>
License: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/jfrancis42/rf-bench
Project-URL: Repository, https://github.com/jfrancis42/rf-bench
Keywords: koolertron,mhinstek,mhs5200,mhs-5200a,kkmoon,dds,signal-generator,function-generator,arbitrary-waveform,dual-channel,frequency-counter,sweep,bench-automation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyserial>=3.5
Dynamic: license-file

# rf-bench-drivers-koolertron

> **Status:** Tested 2026-06-08 against a real **MHS-5225A** (raw model code
> `5225A5040000`, CH340 USB-serial) and confirmed working: frequency,
> amplitude, waveform, duty-cycle, offset, phase, attenuator, and
> per-channel set/get; master output enable; built-in frequency counter
> (loopback test confirms ±7 ppm against the unit's commanded frequency);
> period-mode counter; snapshot of full state. Sweep and arb-upload commands
> are implemented from documentation but not yet exercised against hardware.

Python driver for the **Koolertron / MHinstek MHS-5200A series** dual-channel
DDS arbitrary-waveform signal generator with built-in frequency counter and
sweep generator. Sold under many brand names — **Koolertron**, **MHinstek**,
**KKmoon**, and various AliExpress / eBay listings labelled "200MSa/s 12Bit
DDS". The hardware and USB protocol are common to every variant; only the
upper sine-wave frequency limit changes per model suffix:

| Model    | Sine FS  |
|----------|----------|
| MHS-5206A | 6 MHz   |
| MHS-5212A | 12 MHz  |
| MHS-5220A | 20 MHz  |
| MHS-5225A | 25 MHz  |

This driver was written from scratch using the **public protocol document**
listed in [Protocol reference and credits](#protocol-reference-and-credits)
below. No source code from any other implementation was copied or modified;
the wire protocol was verified live against an MHS-5225A on 2026-06-08.

## Installation

```bash
pip install rf-bench-drivers-koolertron
```

The only runtime dependency is `pyserial`.

## Quick start

```python
from rf_bench.koolertron import (
    MHS5200A, Waveform, CounterMode, Gate, Atten,
)

with MHS5200A() as gen:                         # auto-detect CH340 / PL2303
    print(gen.identify())                       # 'MHS-5225A (5040000)'

    # Function generator
    gen.set_frequency(1, 1_000_000)             # CH1: 1 MHz
    gen.set_amplitude(1, 1.0)                   # CH1: 1.0 Vpp into 50 Ω
    gen.set_waveform(1, Waveform.SINE)
    gen.set_frequency(2, 100_000)               # CH2: 100 kHz
    gen.set_waveform(2, Waveform.SQUARE)
    gen.set_duty_cycle(2, 25.0)                 # CH2: 25% duty
    gen.output_on()                             # master enable (BOTH ch)

    # Built-in frequency counter (Ext.IN connector on front)
    hz = gen.measure_frequency_hz(gate=Gate.S10)
    print(f"counter sees {hz:.1f} Hz")

    gen.output_off()
```

## Optional calibration

The driver supports optional per-channel amplitude correction and
frequency offset correction. With no cal file present, the driver works
using the unit's built-in (factory) calibration.

To enable corrections, run the calibration tool at
`projects/signal-sources/koolertron-cal/` to characterise your specific
unit. Results are saved to `~/.koolertron_mhs5200_cal.json` and picked
up automatically by the driver.

```python
with MHS5200A() as gen:
    print(gen.calibration_info())
    # -> {'loaded': True, 'frequency_ppm_offset': 11.77, ...}

    # Frequency is pre-corrected for the unit's TCXO offset
    gen.set_frequency(1, 1_000_000)             # actually ≈ 999_988 Hz
                                                 # at the wire so output is 1 MHz

    # set_amplitude_dbm is amp-corrected per channel and per frequency
    gen.set_amplitude_dbm(1, 25_000_000, 0.0)   # delivers 0 dBm at 25 MHz
                                                 # (compensates ~4.7 dB rolloff)
```

To bypass an existing cal file:

```python
gen = MHS5200A(calibration=False)
```

## Hardware

| Item | Value |
|------|-------|
| Instrument | Koolertron / MHinstek MHS-5206A / 5212A / 5220A / 5225A (and rebrands: KKmoon, et al.) |
| USB chip | QinHeng CH340 (1a86:7523) on older firmware, PL2303 (067b:2303) on newer |
| Serial | 57600 8N1, no flow control, CR LF terminators |
| Per-channel parameters | frequency, amplitude, waveform, duty cycle, offset, phase, output attenuator |
| Master output | Global enable (both channels together — hardware quirk) |
| Counter | EXT IN connector on rear; modes: frequency, count, period, +/− pulse width, duty cycle |
| Sweep | Linear or logarithmic, configurable start/stop/time |
| Memory | 10 setup slots (0..9; slot 0 is the power-on default) |

## API summary

### Identification and configuration

| Method / attribute | Description |
|-------------------|-------------|
| `MHS5200A(port=None, calibration=None)` | Connect; auto-detects CH340 / PL2303 if port omitted. Calibration: `None` = auto-load `~/.koolertron_mhs5200_cal.json` if present; `False` = ignore any cal; `str` = path to specific JSON; `dict` = use as-is. |
| `gen.model`           | Friendly model string, e.g. `"MHS-5225A"` |
| `gen.raw_model`       | Full raw `:r0c` payload, e.g. `"5225A5040000"` |
| `gen.identify()`      | Combined string, e.g. `"MHS-5225A (5040000)"` |
| `gen.calibration_info()` | Dict describing the active calibration, or `{loaded: False}` |
| `gen.port`            | Active serial port path |
| `MHS5200A.find_port()` | Class method returning the first compatible USB-serial port found, or None |

### Per-channel waveform parameters

| Method | Description / units |
|--------|--------------------|
| `set_frequency(ch, hz)` / `get_frequency(ch)` | Hz; pre-corrected by `frequency_ppm_offset` if cal loaded. Wire register: 0.01 Hz steps. |
| `set_amplitude(ch, vpp)`  / `get_amplitude(ch)` | Vpp into 50 Ω (matches scope reading at 50 Ω; front panel shows 2× this open-circuit value). Wire register: 5 mV steps. NOT cal-corrected. |
| `set_amplitude_dbm(ch, freq, dbm)` | Set amplitude as target dBm into 50 Ω at the given frequency. Frequency-aware; uses calibration if loaded. |
| `set_waveform(ch, w)`   / `get_waveform(ch)`  | `Waveform` enum (SINE/SQUARE/TRIANGLE/UP_SAW/DOWN_SAW/ARB0..ARB15) |
| `set_duty_cycle(ch, %)` / `get_duty_cycle(ch)` | Percent (wire: 0.1 % steps) |
| `set_offset(ch, signed)` / `get_offset(ch)` | Signed -120..+120 (wire: 0..240 with 120 = no offset) |
| `set_phase(ch, deg)`    / `get_phase(ch)` | Degrees, 0..359 |
| `set_attenuator(ch, a)` / `get_attenuator(ch)` | `Atten.MINUS_20DB` (-20 dB pad) or `Atten.ZERO_DB` |
| `set_channel_enable(ch, on)` / `get_channel_enable(ch)` | Per-channel enable (use master `output_on/off` for normal use) |

### Master output (global to both channels)

| Method | Description |
|--------|-------------|
| `output_on()`  | Enable output (both channels — global) |
| `output_off()` | Disable output |

### Frequency counter (Ext.IN connector on front)

| Method | Description |
|--------|-------------|
| `counter_setup(mode, gate, source_ttl=False)` | Configure mode + gate window + source |
| `counter_start()` / `counter_stop()` / `counter_reset()` | Run / stop / zero |
| `read_counter()` | Raw counter value (units depend on mode) |
| `read_counter_hz()` | FREQ-mode reading scaled to Hz (firmware 5040000) |
| `measure_frequency_hz(gate, ...)` | One-shot helper: setup + start + poll-until-stable + read + stop. Default gate is `Gate.S10` (10 s), reliable from 10 kHz upward. |

`CounterMode` enum: `FREQ`, `COUNT`, `PULSE_HIGH`, `PULSE_LOW`, `PERIOD`, `DUTY`.
`Gate` enum: `S1` (1 s), `S10` (10 s, default for `measure_frequency_hz`), `S0_1` (100 ms), `S0_01` (10 ms).

**Counter input range (firmware 5040000):**

| Gate | Reliable input range |
|------|----------------------|
| `Gate.S10` | ≥ 10 kHz |
| `Gate.S1`  | ≥ 10 MHz |
| `Gate.S0_1` / `Gate.S0_01` | High-frequency only; not characterised |

Below the listed minimum, the counter often fails to lock; the driver's
`measure_frequency_hz` returns the last value read, but it may be
inaccurate. Caller should sanity-check against the expected input.

### Sweep generator (CH1)

| Method | Description |
|--------|-------------|
| `sweep_setup(start_hz, stop_hz, time_s, log=False)` | Configure |
| `sweep_start()` / `sweep_stop()` / `get_sweep_state()` | Run / stop / query |

### Memory slots

| Method | Description |
|--------|-------------|
| `save_slot(slot=0)` | Save current full setup to slot 0..9 |
| `load_slot(slot=0)` | Load setup; slot 0 is the power-on default |

### Power amplifier (units that have it)

| Method | Description |
|--------|-------------|
| `power_amp(on)` / `get_power_amp()` | Enable/disable optional power amp |

### Other

| Method | Description |
|--------|-------------|
| `snapshot()` | Return a dict with the model + per-channel parameters of both channels |
| `close()`    | Close the serial port (automatic via context manager) |

## Live-test results (2026-06-08)

Confirmed against real **MHS-5225A**, firmware/hardware code `5040000`, USB
adapter CH340 (1a86:7523) on `/dev/ttyUSB0`. CH1 output patched to the rear
EXT IN connector to enable closed-loop counter testing.

```
identify : MHS-5225A (5040000)

TEST 1: CH1 frequency set/get round-trip — 1 Hz to 25 MHz, all OK
TEST 2: CH1 amplitude — 0.5 / 1 / 2.5 / 5 V, all OK
TEST 3: CH1 waveform — SINE/SQUARE/TRIANGLE/UP_SAW/DOWN_SAW, all OK
TEST 4: duty 25 %, offset +30, phase 90°, all OK
TEST 5: CH1=1 MHz / CH2=100 kHz independent — OK
TEST 6: master output on / off — OK (no exception)
TEST 7: frequency counter loopback (CH1 -> EXT IN at 1.234567 MHz):
        measured 1234576.1 Hz   (+7.4 ppm error vs commanded)
TEST 8: counter PERIOD mode at 1.234 MHz: 811 ns (matches 1/1.234 MHz ≈ 810 ns)
TEST 9: snapshot() returns full state for both channels
        0 failures
```

## Protocol reference and credits

This driver's wire protocol is implemented directly from the public reverse-
engineering work of the amateur-radio and electronics community. **All credit
for protocol discovery, reverse-engineering, and the documentation that made
this driver possible belongs to those authors.** Their work is gratefully
acknowledged below; this driver simply exposes their findings through a
clean Python API.

The single most useful resource was:

> **wd5gnr (Al Williams)** — *"Serial Protocol for MHS-5200A"*, document
> version 2, dated 9 August 2015. The complete protocol document, including
> the frequency counter and sweep commands, is included in the
> `MHS5200AProtocol.pdf` file of his open-source reference implementation.
>
> Repository: <https://github.com/wd5gnr/mhs5200a>
> Document:   `MHS5200AProtocol.pdf` in that repository.
>
> This protocol document is the canonical public reference; without it, only
> the basic 8 set/get commands from the standard serial probing would be
> known. The frequency counter, sweep, arbitrary-waveform upload, and memory-
> slot commands all come from his reverse-engineering work.

Other community sources that helped confirm details:

- **eevblog forum thread**: *"MHS-5200A serial protocol reverse engineered"*
  — community teardown identifying the CH340G + STM8 + Lattice MachXO2
  internals, and providing the additional commands (gate value encoding,
  counter mode select) that supplement the wd5gnr document.
  <https://www.eevblog.com/forum/testgear/mhs-5200a-serial-protocol-reverse-engineered/>

- **sigrok wiki — MHINSTEK MHS-5200A page**: the canonical hardware spec
  reference (R-2R-ladder DAC, dual-channel architecture, ~175 MS/s actual
  sample rate vs. the "200 MSa/s" front-panel claim, and the two USB-serial
  vendor IDs in use).
  <https://www.sigrok.org/wiki/MHINSTEK_MHS-5200A>

If you are the author of one of the sources above and feel this credit
should be expanded or reworded, please open an issue on the rf-bench
repository.

## Hardware verified

- **Make/model**: Sold to the author as a KKmoon-branded "200MSa/s 12Bit"
  dual-channel DDS function generator with frequency counter (no model
  number on the front panel).
- **Identifies as**: `MHS-5225A`, raw model code `5225A5040000`.
- **Channel 1 upper sine frequency**: 25 MHz (set/get round-trip OK at 25 MHz).
- **USB chip**: QinHeng CH340 (`1a86:7523`).
- **Confirmed working**: 2026-06-08, all tests passing against
  `/dev/ttyUSB0`.

## License

GPL-3.0-or-later.
