HUB MOON v1.2.0

Documentation · Manual

moondrop_control.py

Every flag, what it takes and what it prints. The first connected supported device is used automatically; --pid names one explicitly where it matters. Examples below use python3 moondrop_control.py — if you installed the package, hub-moon is the same program.

Interfacev1.2.0
Bands
8 (index 0–7)
Gain
±12 dB
Frequency
20 Hz – 20 kHz
Q
0.1 – 10
Filter types
6
Pre-gain
−12 … 0 dB
Writes
flash by default
Exclusive modes
--json · -i
01

Synopsis

Read and write flags compose, and are applied in a fixed order with --save-flash last — so one invocation can preview a change and then persist it. --json and -i are exclusive modes and ignore the rest.

Composition
$ python3 moondrop_control.py --set-peq 0 peaking 1000 -3 1 --no-flash --save-flash
#                            └─ written live to the DSP ─┘  └─ then committed ─┘
Two names, one program

python3 moondrop_control.py runs the file directly — the form to use from a clone. hub-moon is the installed console entry point, and hub-moon-gui is the windowed one that goes straight to the desktop app.

02

Discovery and state

--list

Every connected Moondrop device, with vendor and product ID and the hidraw path. Unsupported-but-known devices are listed with a note rather than hidden.

--info

Device name, firmware version, the active EQ profile number, pre-gain, global gain and the band count. Read-only.

--get-peq

Dump every PEQ slot as it currently reads back from the DSP — filter type, frequency, gain and Q per band.

--registry

This file's own device table as JSON, including each device's product_uuid. Opens no device.

--json

The full device state on stdout, as one JSON object — the shape a front-end should read rather than reimplementing the registry. Exclusive mode.

--pid HEX

Name a device explicitly instead of taking the first connected one, e.g. --pid 011d. Also selects which device's library --presets fetches.

--json · full device state
$ python3 moondrop_control.py --json
{
  "ok": true,
  "device_name": "DAWN PRO2",
  "product_id": 285,
  "firmware": "1.5",
  "active_eq_profile": 9,
  "peq_index": 7,
  "supports_pregain": true,
  "pregain": -4.80078125,
  "global_gain": 0.0,
  "bands": 8,
  "filters": [
    { "index": 0, "frequency": 60, "q": 0.30078125, "gain": -4.5, "type": "peaking" },
    { "index": 1, "frequency": 1500, "q": 0.6015625, "gain": -1.5, "type": "peaking" },
    ...
  ]
}
03

Writing

--set-peq INDEX TYPE FREQ GAIN Q

One band. Index is 0-based and must be inside the device's band count; the types are below. A filter the firmware cannot represent is refused with the largest gain that would fit — see §10.

--set-pregain DB

Headroom, in dB. Boosting bands adds gain the DAC has to find somewhere; this is where it comes from. Three devices report no pre-gain support — the flag warns and writes anyway, since whether the firmware ignores it is untested.

--set-globalgain DB

The DAC's global output offset, independent of the EQ.

--set-eq-index N

Select the active EQ profile slot. Custom PEQ is profile 7 on every supported device except the E.S. combo, which uses 4. Read §12 before drawing conclusions from this number.

TYPEFilterUses
peakingPeaking / bellThe general case — lift or cut a band around a centre frequency
low_shelfLow shelfEverything below a corner, by a fixed amount
high_shelfHigh shelfEverything above a corner. The type most likely to hit the coefficient ceiling
low_passLow passRoll the top off entirely above the corner
high_passHigh passRoll the bottom off entirely below the corner
disabledDisabledSwitch the slot off without touching the others
Gains and one band
$ python3 moondrop_control.py --set-pregain -3.5
$ python3 moondrop_control.py --set-globalgain 0.0
$ python3 moondrop_control.py --set-eq-index 7

# index · type · frequency (Hz) · gain (dB) · Q
$ python3 moondrop_control.py --set-peq 0 peaking 1000 -3.0 1.0
$ python3 moondrop_control.py --set-peq 7 disabled 20000 0 0.7
04

Live, or flashed

Writes go to flash by default. --no-flash applies them to the DSP only, so you can audition a change and walk away from it — unplug the DAC and it is gone.

--no-flash

Apply to the DSP without persisting. Verified: a --no-flash write did not survive a physical unplug/replug, while a flashed one came back byte-identical.

--save-flash

Commit whatever is currently in the DSP to flash. Applied last, so it can be combined with a --no-flash write in the same command.

Why a re-read is not an undo

The DSP reports whatever was written to it last, not what is in flash — so reading the device back after a live edit returns the edit, not the saved state. That is why the desktop app keeps its own snapshot for revert, and why a backup file is worth more than a re-read.

05

Backup, restore and import

--export-json FILE

A full device snapshot: every band, pre-gain and global gain. Take one before experimenting.

--import-json FILE

Write a snapshot back. Bands outside the device's band count are skipped with a note; an unsupported filter type is an error rather than a guess.

--import-rew FILE

AutoEQ / REW exported filter text. Maps PK, LS, HS, LP and HP, honours the Preamp line as pre-gain, and disables any bands the file does not define.

Round trip
$ python3 moondrop_control.py --export-json profile.json
$ python3 moondrop_control.py --import-json profile.json
$ python3 moondrop_control.py --import-rew filters.txt   # REW / AutoEQ
06

Community presets

Moondrop Hub carries about 59,700 user-made curves from 19,900 authors. Reading needs no account, no key and no token, so these two flags browse it — and only ever read. Publishing, liking and favouriting need a login and are deliberately not implemented.

--presets

The library for the connected device, as JSON. Add --pid 011d to name a device instead.

--presets --search TEXT

Filter locally over the whole index — title, author and description.

--presets --refresh

Bypass the day-long cache and refetch the index.

--preset UUID

Pull one config down and print it as bands you can apply.

One result
$ python3 moondrop_control.py --presets --search "harman" | jq '.presets[0]'
{
  "uuid": "4ba6fbe4-6a97-48f1-b487-9d2a640ee30c",
  "title": "水月雨aria2 模拟入耳式耳机使用哈曼2019在HEAD acoustics第三代人工头曲线",
  "author": "rockyuan",
  "downloads": 31072,
  "likes": 604,
  "file": "peq-config-file/fQ0QdflTTrx27gduE14KQpeq.txt"
}
  • You get your whole device family's presets, not just your model. The server pools by the app's sharedConfigGroupId, so a DAWN PRO2 sees ~6,900 curves (its own 1,270 plus every other FreeDSP-family device).
  • The index is cached for a day under ~/.cache/hub_moon/. It has to be: the API has no pagination at all — productUuid is the only parameter it honours, and page, limit and sortBy return zero rows rather than being ignored. The smallest possible request is the entire ~3.6 MB index for your device, so --search runs locally over all of it.
  • Neither flag opens the DAC — strace-verified, zero /dev/hidraw opens — so browsing cannot collide with a GUI that is mid-write.
  • Published presets carry no pre-gain, unlike AutoEQ. A loud community curve will clip unless you set your own headroom.
  • Bands with no filterType become peaking, which is what the official app does, and it is the common case: most published bands omit the field.
  • The product-UUID table is hardcoded, because the API's own products/all reports pid: null for all 102 products — --registry reports the UUIDs it uses.
07

Diagnostics and modes

--stream-status

Hardware-level ALSA stream diagnostics — the sample rate and bit format the card is actually running, and the rates it supports. Reads /proc/asound, so Linux only; everything else here is cross-platform through hidapi.

-i

An interactive tuning panel in the terminal: the same controls, live. Exclusive mode. Written, but never exercised against real hardware.

--gui

Opens the desktop app. You do not normally need it: install a package and open Hub Moon from your launcher, or run hub-moon with no arguments at all — both do this. The flag exists for the case where you are already in a shell. Needs the [gui] extra; the toolkit is lazy-imported so the plain CLI keeps its footprint.

08

Building a front-end on this

The CLI is designed to be driven. --json reports the full device state, and everything device-shaped comes from there rather than being reimplemented in the caller: band count, which slot custom PEQ lives on, whether the device supports pre-gain at all. This file stays the single source of truth for the device registry.

The rule that shapes everything else

Only one process can usefully hold the hidraw at a time — two readers pick up each other's replies. So anything passive (a tray icon, a status pill) must not be the second one. --registry prints the device table and touches no hardware, so a front-end can recognise a DAC from USB IDs the system already knows and never open the device. --presets and --preset are hardware-free for the same reason.

Identifying the DAC from the system side

PipeWire is not the source of truth for playback, so there are two paths:

  • Normally the sink node carries alsa.components = USB35d8:011d — the USB pair, no name-matching needed — and it updates reactively on hotplug.
  • A bit-perfect player (SONE, TIDAL) opens the card directly through exclusive ALSA. PipeWire never sees that stream, so defaultAudioSink will happily report "Speaker" while the music is physically going through the DAC — and PipeWire may hold no node at all for a card it cannot open. The fallback asks the kernel instead: whoever holds /proc/asound/card*/pcm*p/sub* outside pipewire, identified by that card's /proc/asound/cardN/usbid — the same 35d8:011d pair, available whether or not PipeWire has any idea the device exists.

What the desktop app does

It is the worked example: it imports moondrop_control and calls the hardware-tested engine rather than reimplementing the protocol, runs all HID I/O on one worker thread so a read never interleaves with a write, and gives the community library and the file dialogs threads of their own.

09

Notes

  • The DSP runs at a fixed 96 kHz internal sample rate; biquad coefficients are computed against that regardless of the playback rate.
  • Coefficients use the standard Bristow-Johnson formulas, packed as Q2.30 signed 32-bit integers in the layout [b0, b1, b2, -a1, -a2]. This matches the official web app's packing function exactly.
  • --stream-status reads /proc/asound and is Linux-only. Everything else is cross-platform through hidapi.
  • --import-rew reads REW's exported filter text: it maps PK/LS/HS/LP/HP, honours the Preamp line, and disables any bands the file does not define. Filters past the device's band count are skipped with a note.
  • Read/write flags compose and are applied in a fixed order, with --save-flash last. --json and -i are exclusive modes and ignore the rest.
10

Filters this hardware cannot represent

Q2.30 spans only [−2, 2), and some otherwise reasonable filters need coefficients outside it. This tool refuses those with an error naming the safe ceiling. Two cases.

CoefficientWhenExample
b1 < −2 A high_shelf above roughly +5 dB (at 8 kHz / Q=0.7 the ceiling is about +4.7 dB), or a high_shelf with a corner below roughly 200 Hz at any gain high_shelf 150 6 0.7
b0 > 2 Any type at high gain, low Q and high frequency peaking 20000 12 0.3
The official app allows +12 dB and does not clamp

Its JavaScript packs coefficients with bitwise operators, which wrap modulo 2³² instead of failing — so past these limits it silently programs a filter unrelated to the curve it draws. A +6 dB shelf's b1 wraps from −2.303 to +1.697, flipping sign. This tool rejects rather than reproduce that. What the firmware would actually do with a wrapped coefficient is untested.

11

Protocol notes

Findings from reverse-engineering the official web app, verified against its JavaScript and — where marked — against real hardware. The full write-up, including hosts, endpoints and the product-UUID table, is in the repository.

  • Coefficient packing is Q2.30, layout [b0, b1, b2, -a1, -a2], scaled by 2³⁰ and computed against a fixed 96 kHz DSP rate. Confirmed byte-for-byte against the web app's own packing function, and corroborated by an independent reimplementation.
  • The device registry is transcribed from the app, correcting a scrambled name/ID mapping: 0x011D is DAWN PRO2 (confirmed against real hardware), 0x43DA is MOONRIVER 3, 0x011B is Rays. "Rays Pro" does not exist. E.S. combo uses custom-PEQ profile slot 4; every other supported device uses 7.
  • Old Fashioned (0x0122) is detected but refused. It does not use biquad coefficients at all — it writes PEQ through device registers as int8 gain ×10, uint16 frequency and int16 Q ×1000, exposes 5 bands, and reports no pre-gain or global-gain support.
  • HID replies must be matched to their request. A response echoes the command and sub-command it answers at bytes 1–2. Commands that never reply would otherwise leave the next read picking up the previous command's report, shifting every subsequent read by one and silently returning another register's data.
  • The active EQ profile is not a PEQ-mode indicator — see below.
12

"I changed the EQ and nothing happened"

On a DAWN PRO2 the equaliser is toggled on the hardware: press both volume buttons to switch between the default (no EQ) mode and custom EQ. If your edits are inaudible, check that first — PEQ writes only affect the sound in custom-EQ mode.

That toggle is not reflected in any register we could find: sweeping every readable sub-command (0–254) returns byte-identical data in both modes, so this tool cannot tell you which mode you are in. --info reports the active EQ profile, but on firmware 1.5 that reads 9 in both modes while PEQ writes are audible in custom EQ regardless. Do not read anything into that number.

For the record

The official app assumes otherwise — it gates PEQ on readEQIndex() === peqIndex (7 for this device), which does not describe firmware 1.5. That check would report "not in PEQ mode" even while custom EQ is plainly working.

13

Disclaimer

Unofficial and not affiliated with Moondrop. The USB HID protocol here was reverse engineered from the official web app with the assistance of AI. The coefficient packing and PEQ byte layout have since been checked against that app's own JavaScript and match it exactly, but the command set is still inferred from observed behaviour rather than any documented spec — treat it as a best-effort reconstruction that works on the hardware it was tested against, not as authoritative.

This script is tested with the Moondrop DAWN PRO2 only, which works as intended though further ironing is necessary. Other devices require testing by people who own them — see what has actually been exercised.

It writes to your DAC's flash

Export a backup with --export-json before experimenting.