Koolertron / MHinstek MHS-5200A series dual-channel DDS signal generator + frequency counter driver (covers KKmoon and other rebrands)
Project description
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. Arbitrary waveform upload (ARB0-ARB15) added 2026-06-17 — implemented from wd5gnr's public-domain reference implementation and hardware-verified working (1024-sample uploads to all 16 slots, ~200-300 ms per upload). Sweep commands 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 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
pip install rf-bench-drivers-koolertron
The only runtime dependency is pyserial.
Quick start
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)
# Arbitrary waveform (16 slots: ARB0..ARB15)
import math
sine = [math.sin(2*math.pi*i/1024) for i in range(1024)]
gen.upload_arb_normalized(0, sine) # upload to slot 0
gen.set_waveform(1, Waveform.ARB0) # use it on CH1
# 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.
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:
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) |
| TTL connector | 10-pin header on rear panel (see below) |
TTL connector pinout
The 10-pin header on the rear panel (labelled "TTL-Ext." in the manual, "TTL" on some unit panels) provides digital I/O and power:
| Pin | Function | Notes |
|---|---|---|
| 1 | TTL1 | Synchronized with CH1 (duty cycle follows CH1) |
| 2 | GND | |
| 3 | TTL2 | Synchronized with CH2 (duty cycle follows CH2) |
| 4 | GND | |
| 5 | TTL3 | Synchronized with CH1 (duty cycle follows CH1) |
| 6 | GND | |
| 7 | TTL4 | Synchronized with CH1 (duty cycle follows CH1) |
| 8 | +5V | Power supply output |
| 9 | TTL IN | Frequency counter input (alternative to front-panel EXT IN) |
| 10 | +5V | Power supply output |
TTL Input (pin 9)
Selectable as the counter input source via counter_setup(source_ttl=True) or
measure_frequency_hz(source_ttl=True). Use this for measuring digital/logic-
level signals where TTL threshold detection is more reliable than the analog
front-panel input.
TTL Outputs (pins 1, 3, 5, 7)
Four independent TTL-level outputs synchronized with the main channels:
- TTL1, TTL3, TTL4: All synchronized with CH1. Duty cycle determined by CH1 waveform.
- TTL2: Synchronized with CH2. Duty cycle determined by CH2 waveform.
Phase relationships: When CH1 and CH2 are synchronized (tracking mode), all four TTL outputs synchronize together with phase relationships determined by the CH1/CH2 phase difference setting. The manufacturer describes this as "four variable phase difference of TTL output."
Voltage levels: LOW <0.3V, HIGH 1V–10V (MHS-5200A spec).
Use cases:
- Triggering multiple instruments synchronized to the main generator channels
- Multi-phase clock generation when CH1/CH2 are phase-locked
- Logic-level copies of waveforms for TTL/CMOS interfacing
- Duty-cycle-matched sync outputs
Important: These outputs mirror the main channel waveforms at TTL levels.
They are not independently controllable via the serial protocol — their
behavior is determined by CH1 and CH2 settings. To generate a pure TTL square
wave on CH1/CH2 outputs (maximizing slew rate), use waveform code w05 (CH1)
or w15 (CH2) via the serial protocol.
+5V pins (8, 10) provide power for external logic. Current limit unknown.
Reference: MHS-5200A Operating Manual (2015.05), Section 15: "4-channel TTL output function" and Section 10: "Having four variable phase difference of TTL output." Ships as "TTL-Ext. connector" accessory.
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). Special: Waveform.TTL (wire code w05/w15) switches to TTL digital output mode with maximized slew rate for fast edges. |
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 |
Arbitrary waveforms (ARB0..ARB15)
The MHS-5200A has 16 user-defined arbitrary waveform slots (ARB0 through ARB15), each storing 1024 samples at 8-bit resolution (0-255). After uploading a waveform, select it with set_waveform(channel, Waveform.ARB0 + slot).
| Method | Description |
|---|---|
upload_arb(slot, samples) |
Upload 1024 integers (0-255) to slot 0-15 |
upload_arb_normalized(slot, samples) |
Upload 1024 floats (-1.0 to +1.0) to slot 0-15 |
Example — generate and upload a sine wave:
import math
from rf_bench.koolertron import MHS5200A, Waveform
with MHS5200A() as gen:
# Create normalized sine wave (1024 samples, -1.0 to +1.0)
sine = [math.sin(2 * math.pi * i / 1024) for i in range(1024)]
# Upload to slot 0 (ARB0)
gen.upload_arb_normalized(0, sine)
# Output it on channel 1
gen.set_frequency(1, 100_000)
gen.set_waveform(1, Waveform.ARB0)
gen.output_on()
Example — integer samples (0-255 range):
# Ramp waveform
ramp = [int(i * 255 / 1023) for i in range(1024)]
gen.upload_arb(1, ramp)
gen.set_waveform(1, Waveform.ARB1)
Protocol reference: Arbitrary waveform upload protocol reverse-engineered by Al Williams (wd5gnr) and documented in https://github.com/wd5gnr/mhs5200a (public domain). The setwave5200 shell script in that repository provided the reference implementation from which the wire protocol was understood. This Python implementation is independent.
Upload format: The device receives waveforms as 16 chunks of 64 samples each. Each chunk is sent as :a<slot><chunk>\r\n followed by a comma-separated list of 64 decimal values. The device replies ok\r\n after each chunk. A 10 ms inter-chunk delay is required for reliable uploads (empirically determined from the reference implementation).
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, sweep, and arbitrary waveform upload commands, is included in the
MHS5200AProtocol.pdffile of his open-source reference implementation.Repository: https://github.com/wd5gnr/mhs5200a Document:
MHS5200AProtocol.pdfin that repository. Reference implementation:setwave5200shell script (public domain).This protocol document is the canonical public reference; without it, only the basic 8 set/get commands from standard serial probing would be known. The frequency counter, sweep, arbitrary-waveform upload, and memory-slot commands all come from his reverse-engineering work.
Arbitrary waveform upload: Al Williams'
setwave5200shell script (explicitly marked "Public Domain — use it how you like" in its header) provided the reference implementation from which the wire protocol for uploading 1024-sample waveforms to the device's 16 ARB slots was understood. The upload format (16 chunks of 64 samples,:a<slot><chunk>header, comma-separated decimal values, 10 ms inter-chunk delay) was derived from studying that script and the accompanying AWK processing files. This Python implementation is independent; no code was copied.
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 code5225A5040000. - 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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rf_bench_drivers_koolertron-0.2.0.tar.gz.
File metadata
- Download URL: rf_bench_drivers_koolertron-0.2.0.tar.gz
- Upload date:
- Size: 41.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e59365dd98cec07eb197ee5a4e50a62c4cf00ac00e67dc1033b1a0ec1e238722
|
|
| MD5 |
5a2933afa7f48f046388c1b3e05f71ee
|
|
| BLAKE2b-256 |
b5805ee2a0b24a6c09039e0f852d173c38ba8f1478da811af5c44fdf724da7db
|
File details
Details for the file rf_bench_drivers_koolertron-0.2.0-py3-none-any.whl.
File metadata
- Download URL: rf_bench_drivers_koolertron-0.2.0-py3-none-any.whl
- Upload date:
- Size: 35.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59189a918bf876cd2fa2c1a0c28cdf682ab5c09e6d8d2bd28be9a4ced019e740
|
|
| MD5 |
b49cc555820959f9a040388393a26dfa
|
|
| BLAKE2b-256 |
5d6974283b210ff48f11d08567df75cd4d106a3c6a1d81fd0b2258a89a90e800
|