Created
April 28, 2026 10:37
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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()) |
Author
doitian
commented
Apr 28, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment