Skip to content

Instantly share code, notes, and snippets.

@doitian
Created April 28, 2026 10:37
Show Gist options
  • Select an option

  • Save doitian/b16bb10cc08d7eeef6563e8b78697f0f to your computer and use it in GitHub Desktop.

Select an option

Save doitian/b16bb10cc08d7eeef6563e8b78697f0f to your computer and use it in GitHub Desktop.
Pure-stdlib Windows HID sniffer for Ulanzi D200X (vid 2207 pid 0019); helps reverse-engineer the official Ulanzi app's startup handshake to unlock 4th-row + encoder input. Companion to https://github.com/doitian/ulanzi-studio-niri
#!/usr/bin/env python3
"""Sniff Ulanzi D200X HID input on Windows while the official app is running.
Pure-stdlib: uses ctypes against setupapi.dll + hid.dll (both ship with
Windows). No pip install, no admin needed for HID-level reads.
================================================================================
Goal
================================================================================
Capture what the official Ulanzi Stream Controller app does that makes the
D200X start emitting HID input reports for the 4th-row hardware buttons
(pos 14/15) and the three rotary encoders. On Linux those controls are
silent on /dev/hidraw* until *something* enables them; we suspect a
vendor-specific opcode the app sends on connect.
================================================================================
What this script does (input direction only)
================================================================================
Opens every HID interface the D200X exposes (vid 0x2207 / pid 0x0019) with
shared access and prints every input report. Run it BEFORE the official
app, then launch the official app and exercise the 4th-row controls. If
the app successfully unlocks input we'll see frames here that we never see
on Linux.
Limitation: this only captures device->host. It does NOT see what the app
writes to the device, which is the half we actually need to figure out the
"enable" opcode. For that you need USBPcap (kernel-level USB sniffer):
1. Install USBPcap from https://desowin.org/usbpcap/ (admin, one-time)
2. Reboot if prompted; install Wireshark.
3. With the D200X plugged in, run from a regular cmd prompt:
"C:\\Program Files\\USBPcap\\USBPcapCMD.exe" -d \\\\.\\USBPcapN -o ulanzi.pcap
(Pick N by checking which USBPcapN sees the 2207:0019 device.)
4. Launch the official Ulanzi app; let it connect.
5. Press both hardware buttons; click + twist each encoder.
6. Quit the official app; Ctrl+C the USBPcap window.
7. Open ulanzi.pcap in Wireshark, filter: usb.idVendor == 0x2207
8. Share the .pcap (or paste hex of host->device URBs around connect time
and any device->host URBs that follow your input).
Run:
py sniff_official_app_windows.py [seconds]
seconds = optional run duration; default = run until Ctrl+C.
"""
from __future__ import annotations
import ctypes
import ctypes.wintypes as wt
import datetime
import sys
import threading
import time
VID = 0x2207
PID = 0x0019
# ---------------------------------------------------------------------------- WinAPI bindings
setupapi = ctypes.windll.setupapi
hid_dll = ctypes.windll.hid
kernel32 = ctypes.windll.kernel32
INVALID_HANDLE_VALUE = wt.HANDLE(-1).value
GENERIC_READ = 0x80000000
GENERIC_WRITE = 0x40000000
FILE_SHARE_READ = 0x00000001
FILE_SHARE_WRITE = 0x00000002
OPEN_EXISTING = 3
FILE_FLAG_OVERLAPPED = 0x40000000
DIGCF_PRESENT = 0x02
DIGCF_DEVICEINTERFACE = 0x10
ERROR_NO_MORE_ITEMS = 259
class GUID(ctypes.Structure):
_fields_ = [
("Data1", wt.DWORD),
("Data2", wt.WORD),
("Data3", wt.WORD),
("Data4", ctypes.c_ubyte * 8),
]
class SP_DEVICE_INTERFACE_DATA(ctypes.Structure):
_fields_ = [
("cbSize", wt.DWORD),
("InterfaceClassGuid", GUID),
("Flags", wt.DWORD),
("Reserved", ctypes.c_void_p),
]
class SP_DEVICE_INTERFACE_DETAIL_DATA_W(ctypes.Structure):
_fields_ = [
("cbSize", wt.DWORD),
("DevicePath", wt.WCHAR * 1),
]
class HIDD_ATTRIBUTES(ctypes.Structure):
_fields_ = [
("Size", wt.ULONG),
("VendorID", wt.USHORT),
("ProductID", wt.USHORT),
("VersionNumber", wt.USHORT),
]
class HIDP_CAPS(ctypes.Structure):
_fields_ = [
("Usage", wt.USHORT),
("UsagePage", wt.USHORT),
("InputReportByteLength", wt.USHORT),
("OutputReportByteLength", wt.USHORT),
("FeatureReportByteLength", wt.USHORT),
("Reserved", wt.USHORT * 17),
("NumberLinkCollectionNodes", wt.USHORT),
("NumberInputButtonCaps", wt.USHORT),
("NumberInputValueCaps", wt.USHORT),
("NumberInputDataIndices", wt.USHORT),
("NumberOutputButtonCaps", wt.USHORT),
("NumberOutputValueCaps", wt.USHORT),
("NumberOutputDataIndices", wt.USHORT),
("NumberFeatureButtonCaps", wt.USHORT),
("NumberFeatureValueCaps", wt.USHORT),
("NumberFeatureDataIndices", wt.USHORT),
]
hid_dll.HidD_GetHidGuid.argtypes = [ctypes.POINTER(GUID)]
hid_dll.HidD_GetHidGuid.restype = None
setupapi.SetupDiGetClassDevsW.argtypes = [
ctypes.POINTER(GUID),
wt.LPCWSTR,
wt.HWND,
wt.DWORD,
]
setupapi.SetupDiGetClassDevsW.restype = wt.HANDLE
setupapi.SetupDiEnumDeviceInterfaces.argtypes = [
wt.HANDLE,
ctypes.c_void_p,
ctypes.POINTER(GUID),
wt.DWORD,
ctypes.POINTER(SP_DEVICE_INTERFACE_DATA),
]
setupapi.SetupDiEnumDeviceInterfaces.restype = wt.BOOL
setupapi.SetupDiGetDeviceInterfaceDetailW.argtypes = [
wt.HANDLE,
ctypes.POINTER(SP_DEVICE_INTERFACE_DATA),
ctypes.c_void_p,
wt.DWORD,
ctypes.POINTER(wt.DWORD),
ctypes.c_void_p,
]
setupapi.SetupDiGetDeviceInterfaceDetailW.restype = wt.BOOL
setupapi.SetupDiDestroyDeviceInfoList.argtypes = [wt.HANDLE]
setupapi.SetupDiDestroyDeviceInfoList.restype = wt.BOOL
kernel32.CreateFileW.argtypes = [
wt.LPCWSTR,
wt.DWORD,
wt.DWORD,
ctypes.c_void_p,
wt.DWORD,
wt.DWORD,
wt.HANDLE,
]
kernel32.CreateFileW.restype = wt.HANDLE
kernel32.CloseHandle.argtypes = [wt.HANDLE]
kernel32.CloseHandle.restype = wt.BOOL
kernel32.ReadFile.argtypes = [
wt.HANDLE,
ctypes.c_void_p,
wt.DWORD,
ctypes.POINTER(wt.DWORD),
ctypes.c_void_p,
]
kernel32.ReadFile.restype = wt.BOOL
hid_dll.HidD_GetAttributes.argtypes = [wt.HANDLE, ctypes.POINTER(HIDD_ATTRIBUTES)]
hid_dll.HidD_GetAttributes.restype = wt.BOOL
hid_dll.HidD_GetPreparsedData.argtypes = [wt.HANDLE, ctypes.POINTER(ctypes.c_void_p)]
hid_dll.HidD_GetPreparsedData.restype = wt.BOOL
hid_dll.HidD_FreePreparsedData.argtypes = [ctypes.c_void_p]
hid_dll.HidD_FreePreparsedData.restype = wt.BOOL
hid_dll.HidP_GetCaps.argtypes = [ctypes.c_void_p, ctypes.POINTER(HIDP_CAPS)]
hid_dll.HidP_GetCaps.restype = wt.LONG
# ---------------------------------------------------------------------------- enumeration
def enumerate_hid_paths() -> list[str]:
"""Return the device-interface paths for every HID device on the system."""
guid = GUID()
hid_dll.HidD_GetHidGuid(ctypes.byref(guid))
info = setupapi.SetupDiGetClassDevsW(
ctypes.byref(guid), None, None, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE
)
if info == INVALID_HANDLE_VALUE or info is None:
return []
paths: list[str] = []
try:
idx = 0
while True:
iface = SP_DEVICE_INTERFACE_DATA()
iface.cbSize = ctypes.sizeof(SP_DEVICE_INTERFACE_DATA)
ok = setupapi.SetupDiEnumDeviceInterfaces(
info, None, ctypes.byref(guid), idx, ctypes.byref(iface)
)
if not ok:
err = ctypes.GetLastError()
if err == ERROR_NO_MORE_ITEMS:
break
break
idx += 1
required = wt.DWORD(0)
setupapi.SetupDiGetDeviceInterfaceDetailW(
info, ctypes.byref(iface), None, 0, ctypes.byref(required), None
)
size = required.value
if size <= 0:
continue
buf = (ctypes.c_byte * size)()
detail = ctypes.cast(buf, ctypes.POINTER(SP_DEVICE_INTERFACE_DETAIL_DATA_W))
# cbSize is 8 on x64, 6 on x86 (DWORD + WCHAR with alignment).
detail[0].cbSize = 8 if ctypes.sizeof(ctypes.c_void_p) == 8 else 6
ok = setupapi.SetupDiGetDeviceInterfaceDetailW(
info, ctypes.byref(iface), buf, size, None, None
)
if not ok:
continue
# Path string starts at offset of DevicePath inside the struct.
path_offset = SP_DEVICE_INTERFACE_DETAIL_DATA_W.DevicePath.offset
path = ctypes.wstring_at(ctypes.addressof(buf) + path_offset)
paths.append(path)
finally:
setupapi.SetupDiDestroyDeviceInfoList(info)
return paths
def open_device(path: str, *, for_read: bool = True) -> int | None:
access = GENERIC_READ if for_read else (GENERIC_READ | GENERIC_WRITE)
handle = kernel32.CreateFileW(
path,
access,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
0,
None,
)
if handle == INVALID_HANDLE_VALUE or handle is None:
return None
return handle
def device_attrs(handle: int) -> tuple[int, int] | None:
attrs = HIDD_ATTRIBUTES()
attrs.Size = ctypes.sizeof(attrs)
if not hid_dll.HidD_GetAttributes(handle, ctypes.byref(attrs)):
return None
return (attrs.VendorID, attrs.ProductID)
def device_caps(handle: int) -> HIDP_CAPS | None:
pp = ctypes.c_void_p()
if not hid_dll.HidD_GetPreparsedData(handle, ctypes.byref(pp)):
return None
try:
caps = HIDP_CAPS()
if hid_dll.HidP_GetCaps(pp, ctypes.byref(caps)) != 0x00110000: # HIDP_STATUS_SUCCESS
return None
return caps
finally:
hid_dll.HidD_FreePreparsedData(pp)
# ---------------------------------------------------------------------------- main
def reader_loop(idx: int, path: str, stop: threading.Event) -> None:
handle = open_device(path, for_read=True)
if handle is None:
err = ctypes.GetLastError()
print(f"iface[{idx}] open failed err={err} ({path})", flush=True)
return
caps = device_caps(handle)
read_size = caps.InputReportByteLength if caps else 1024
if read_size <= 0:
read_size = 1024
buf = (ctypes.c_ubyte * read_size)()
bytes_read = wt.DWORD(0)
print(
f"iface[{idx}] reading {read_size}-byte input reports from {path}",
flush=True,
)
try:
while not stop.is_set():
ok = kernel32.ReadFile(
handle, buf, read_size, ctypes.byref(bytes_read), None
)
if not ok:
err = ctypes.GetLastError()
if stop.is_set():
break
print(f"iface[{idx}] ReadFile err={err}", flush=True)
time.sleep(0.5)
continue
n = bytes_read.value
if n <= 0:
continue
data = bytes(buf[:n])
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
preview = data[:64].hex()
tail = "..." if n > 64 else ""
print(f"{ts} iface[{idx}] len={n} hex={preview}{tail}", flush=True)
finally:
kernel32.CloseHandle(handle)
def main() -> int:
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 0.0
matches: list[str] = []
for path in enumerate_hid_paths():
h = open_device(path, for_read=True)
if h is None:
continue
try:
attrs = device_attrs(h)
finally:
kernel32.CloseHandle(h)
if attrs is None:
continue
vid, pid = attrs
if vid == VID and pid == PID:
matches.append(path)
if not matches:
print(f"No HID device with vid=0x{VID:04x} pid=0x{PID:04x} found.")
print("Plug in the D200X and try again.")
return 1
print(f"Found {len(matches)} D200X HID interface(s):")
for i, p in enumerate(matches):
print(f" [{i}] {p}")
stop = threading.Event()
threads: list[threading.Thread] = []
for i, p in enumerate(matches):
t = threading.Thread(target=reader_loop, args=(i, p, stop), daemon=True)
t.start()
threads.append(t)
print()
print("listening for input reports.")
print("now: launch the official Ulanzi Stream Controller app and let it connect.")
print("then: press both hardware buttons (pos 14/15), click each encoder,")
print(" twist each encoder a few clicks in each direction.")
print("press Ctrl+C when done (or wait for the timeout if you passed one).")
print()
deadline = time.monotonic() + duration if duration > 0 else None
try:
while True:
if deadline is not None and time.monotonic() >= deadline:
break
time.sleep(0.2)
except KeyboardInterrupt:
pass
finally:
stop.set()
# Reads block on ReadFile; closing the handles in the worker on exit
# is enough since the threads are daemons.
print("\ndone")
return 0
if __name__ == "__main__":
sys.exit(main())
@doitian
Copy link
Copy Markdown
Author

doitian commented Apr 28, 2026

python .\sniff_official_app_windows.py.txt
Found 1 D200X HID interface(s):
  [0] \\?\hid#vid_2207&pid_0019&mi_00#a&155b8aad&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}

listening for input reports.
iface[0] reading 1025-byte input reports from \\?\hid#vid_2207&pid_0019&mi_00#a&155b8aad&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}
now: launch the official Ulanzi Stream Controller app and let it connect.
then: press both hardware buttons (pos 14/15), click each encoder,
      twist each encoder a few clicks in each direction.
press Ctrl+C when done (or wait for the timeout if you passed one).

18:39:29.624 iface[0] len=1025 hex=007c7c0303f60300007b0a202020202253657269616c4e756d626572223a20223032443034413034355533363734363330222c0a20202020224476657273696f...
18:39:30.216 iface[0] len=1025 hex=007c7c0303f60300007b0a202020202253657269616c4e756d626572223a20223032443034413034355533363734363330222c0a20202020224476657273696f...
18:39:33.158 iface[0] len=1025 hex=007c7c0303f60300007b0a202020202253657269616c4e756d626572223a20223032443034413034355533363734363330222c0a20202020224476657273696f...
18:39:36.138 iface[0] len=1025 hex=007c7c0101f603000001110201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:36.143 iface[0] len=1025 hex=007c7c0101f603000001110200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:37.070 iface[0] len=1025 hex=007c7c0101f603000001110202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:37.555 iface[0] len=1025 hex=007c7c0101f603000001110203000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:38.647 iface[0] len=1025 hex=007c7c0101f603000001120201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:38.652 iface[0] len=1025 hex=007c7c0101f603000001120200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:39.548 iface[0] len=1025 hex=007c7c0101f603000001130201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:39.553 iface[0] len=1025 hex=007c7c0101f603000001130200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:40.026 iface[0] len=1025 hex=007c7c0101f603000001130203000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:40.896 iface[0] len=1025 hex=007c7c0101f6030000010f0101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:41.093 iface[0] len=1025 hex=007c7c0101f6030000010f0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:42.058 iface[0] len=1025 hex=007c7c0101f603000001100101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:42.199 iface[0] len=1025 hex=007c7c0101f603000001100100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:43.216 iface[0] len=1025 hex=007c7c0101f6030000010f0101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:43.437 iface[0] len=1025 hex=007c7c0101f6030000010f0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:44.042 iface[0] len=1025 hex=007c7c0101f603000001100101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:44.189 iface[0] len=1025 hex=007c7c0101f603000001100100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:46.088 iface[0] len=1025 hex=007c7c0101f603000001000101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:46.223 iface[0] len=1025 hex=007c7c0101f603000001000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:48.548 iface[0] len=1025 hex=007c7c0101f603000001100101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:48.694 iface[0] len=1025 hex=007c7c0101f603000001100100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:50.393 iface[0] len=1025 hex=007c7c0101f6030000010f0101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:50.545 iface[0] len=1025 hex=007c7c0101f6030000010f0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:52.370 iface[0] len=1025 hex=007c7c0101f603000001100101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...
18:39:52.537 iface[0] len=1025 hex=007c7c0101f603000001100100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment