#!/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())