<div dir="ltr"><div>Hello NUT developers,<br><br>This is a field report plus two proof-of-concept patches that made a previously unreachable UPS work under Windows. It builds directly on the experimental `libwinhid` backend merged in PR #3335, and I hope the findings are useful both to the backend author and to anyone who has hit the "libusb cannot open my HID UPS on Windows" wall.<br><br>Everything below was reproduced on real hardware, with debug logs at each step. I am happy to provide the full logs on request.<br><br>================================================================<br>1. THE SCENARIO<br>================================================================<br><br>  * UPS: Forza SL-1011LCD (line-interactive, 24 V battery pack). USB interface is a Cypress USB-to-Serial bridge, VID 0665 / PID 5161, which despite its name presents itself as a *vendor-defined HID* device (Usage Page 0xFF00), not as a CDC serial port and not as a HID Power Device Class UPS.<br><br>  * The UPS is NOT attached to the monitoring host. It sits in a remote location and is shared over Ethernet by a "Network USB Server M1" appliance (firmware V.2.056.000). nmap identifies the appliance as the SIIG / Monoprice MS NU62P11 print-server family; MAC prefix 00:00:BA (SIIG); TCP 80 (web config), 515 (LPD), 4660 (proprietary USB-over-IP protocol).<br><br>  * Monitoring host: Windows 10 (19045), running the vendor's Windows client, whose kernel driver is `NUServer64.sys`. That client mounts the remote UPS as a local USB device.<br><br>  * Goal: read-only monitoring. The UPS does not power the monitoring host, so no shutdown logic is wanted -- only status visibility (upsc / WinNUT).<br><br>================================================================<br>2. WHAT DID NOT WORK (and why it matters)<br>================================================================<br><br>2.1 All three Zadig backends WinUSB, libusbK and libusb-win32 were each installed on 0665:5161. In every case `libusb_open()` failed. Verified with libusb's own `xusb -d 0665:5161`, which printed:<br><br>        Opening device 0665:5161...<br>          Failed.<br><br>    Since all three Windows drivers are served by the same libusb WinUSB *backend*, this ruled out "wrong driver" as the cause.<br><br>2.2 Patching libusb's hub whitelist. The appliance's virtual hub is enumerated by Windows as `USB\ROOT_HUB_GENERIC&REV_0201`, whose DEVPKEY_Device_Service is `NUServer64`. Following the precedent of VHHUB (VirtualHere) and EUSTUB (Eltima) I rebuilt libusb 1.0.21 (the last release that still carries `hub_driver_names[]`, removed by commit 8d248ab) with "NUSERVER64" appended to that array.<br>    Result: partial. The device count seen by the driver went from 9 to 11 and two new nodes appeared -- so libusb did start walking the virtual bus -- but 0665:5161 still never reached `discovered_devs_append()`, and `listdevs` stayed empty.<br><br>2.3 A physical USB hub between the appliance and the UPS. Idea: give libusb a genuine hub topology to walk.</div><div>
    

Result: the port number changed (&0&1 -> &0&4, so the hub was physically there) but the appliance *flattens* the topology before redirecting: the UPS is still presented as hanging directly off ROOT_HUB_GENERIC at depth 0. No change in behaviour.<br><br>2.4 Native HID driver + libusb. With Zadig removed and the device back on `hidusb.sys`, libusb cannot see it at all ("libusb1: No appropriate HID device found"), because HIDClass owns it exclusively.<br><br>2.5 Appliance-side options. The appliance firmware exposes only network/firmware/password settings -- no serial-over-TCP or raw-socket mode. The only firmware image I could find in public archives is older than the installed one. There is no Linux client for this protocol.<br>    Net effect: a classic catch-22. The transport the appliance *does* grant (HID) was not the one NUT uses (libusb); the one NUT uses was the one the appliance denies.<br><br>================================================================<br>3. HOW THE WINDOW WAS FOUND<br>================================================================<br><br>Two pieces of evidence pointed the same way.<br><br>3.1 The PnP parent chain<br>        Forza (HIDClass)<br>          -> EST_Server  : USB\ROOT_HUB_GENERIC&Rev_0201\...<br>          -> System      : ROOT\SYSTEM\0002<br>          -> HTREE\ROOT\0<br><br>    The "hub" is not part of any USB host controller subtree; it is a synthetic node under ROOT\SYSTEM. `Win32_USBControllerDevice` does not list the UPS at all.<br><br>3.2 Decompiling NUServer64.sys (Ghidra)<br>    This was the decisive step. The driver is not a passive redirector:<br><br>      * It is a *bus driver* that fabricates PDOs:<br>        `CreatePdo`, `PDO_QueryDeviceRelations()`, `PDO_QueryDeviceId()`,<br>        `PDO_QueryBusInformation()`. The topology flattening is produced here, in the client driver -- not by the appliance firmware.<br><br>      * It implements a complete, operable USB stack:<br>        `SelectConfiguration_PDO()`, `SelectInterface_PDO()`,<br>        `BulkOrInterruptTransfer_FDO()`, `ClassInterface()`,<br>        `AbortPipe()`, `ResetPipe()`, `GetPipeInfoStruct()`.<br><br>      * It exposes `USB_BUS_INTERFACE_USBDI` and `USB_BUS_INTERFACE_HUB`, and for unknown interfaces it *passes the IRP through* (it forwards via `IofCallDriver` and returns the incoming Status) rather than rejecting it.<br><br>      * Crucially, it contains `UsbHidGetDeviceDescriptor()` and a dedicated `UserModeUsbIoCtls()` routine.<br><br>    Conclusion: the device is fully operable -- through the *native Windows HID path*, in user mode. libusb's WinUSB backend was simply asking for something this synthetic PDO does not serve. That is exactly the gap the `libwinhid` backend from PR #3335 fills, so I built NUT master with it.<br><br>================================================================<br>4. FIRST SUCCESS: usbhid-ups -x winhid<br>================================================================<br><br>    usbhid-ups.exe -a ups -x winhid -x explore \<br>                   -x vendorid=0665 -x productid=5161 -DDDDD<br><br>    Using experimental winhid backend: USB communication driver<br>        (Windows HID API) 0.11<br>    winhid_collect_caps_and_optional_descriptor: HidP report lengths<br>        Input=9 Output=9 Feature=0<br>    nut_winhid_open: accepted HID device<br>        '\\?\hid#vid_0665&pid_5161#...' (0665/5161)<br><br>The device opened -- through the appliance -- with zero open errors, after every libusb route had failed. The 72-byte report descriptor is:<br><br>    06 00 FF   Usage Page (Vendor Defined 0xFF00)<br>    09 01      Usage (0x01)<br>    A1 01      Collection (Application)<br>      ...<br>      75 08    Report Size (8)<br>      95 08    Report Count (8)<br>      81 02    Input  (Data,Var,Abs)   <- 8 data bytes<br>      ...<br>      91 02    Output (Data,Var,Abs)   <- 8 data bytes<br><br>That is precisely the Megatec/Qx transport: 8-byte command out, reply read back in 8-byte chunks. (It matches both the well-known Cypress 0665:5161 reverse-engineering write-up and the read pattern nutdrv_qx/cypress produces on a locally attached unit),`usbhid-ups` itself of course could not claim the device -- the usages are vendor-defined, not HID PDC ("not found in lookup table"). The UPS needs `nutdrv_qx`. So the remaining work was to give `nutdrv_qx` access to the same transport.<br><br>================================================================<br>5. PATCHES<br>================================================================<br><br>Base: NUT master, `2.8.5.1079-1079+g59fad7a97`<br>Toolchain: MSYS2 MINGW64, gcc 16.1.0<br>configure: --with-usb=yes --with-serial=auto --with-dev=auto<br>           --with-modbus=auto --without-snmp --disable-docs<br><br>Line numbers below are from that checkout and are approximate; the anchors quoted with each hunk are unambiguous.<br><br>----------------------------------------------------------------<br>5.1 drivers/nutdrv_qx.c -- select the winhid backend<br>----------------------------------------------------------------<br><br>A pleasant surprise: the abstraction was already there. `cypress_command()` carries these two lines *as comments*, right above hardcoded libusb calls:<br><br>    /* ret = usb->set_report(udev, 0, (unsigned char *)&tmp[i], 8); */<br>    /* ret = usb->get_interrupt(udev, (unsigned char *)&buf[i], 8, 1000); */<br><br>`usb_communication_subdriver_t` (drivers/nut_libusb.h, lines 52-117) declares `set_report` and `get_interrupt` with exactly those signatures, and `libwinhid.h:219` exports `winhid_subdriver` of that same type. So<br>the port is mostly a matter of using the abstraction again.<br><br>(a) include -- around line 50, inside the existing `#ifdef QX_USB`, immediately after `#include "nut_libusb.h"`:<br><br>        #ifdef QX_USB<br>        #   include "nut_libusb.h" /* also includes "usb-common.h" */<br>    +   #     ifdef WIN32<br>    +   #               include "libwinhid.h"<br>    +   #   endif   /* WIN32 */<br><br>(b) new flag -- in `upsdrv_makevartable()`, around line 3699, after the existing `noscanlangid` entry:<br><br>    +        addvar(VAR_FLAG, "winhid",<br>    +           "Use the experimental native Windows HID backend instead of libusb (WIN32 only)");<br><br>(c) backend selection -- around line 4104, immediately before `ret = usb->open_dev(&udev, &usbdevice, regex_matcher, NULL);` (mirrors what usbhid-ups.c does at lines 1737-1751):<br><br>    +#ifdef WIN32<br>    +            if (testvar("winhid")) {<br>    +                     usb = &winhid_subdriver;<br>    +                   dstate_setinfo("driver.version.usb", "winhid-%s (Windows HID API)",<br>    +                                usb->version);<br>    +                      upslogx(LOG_INFO, "Using experimental winhid backend: %s %s",<br>    +                                usb->name, usb->version);<br>    +                }<br>    +#else /* !WIN32 */<br>    +           if (testvar("winhid")) {<br>    +                     upslogx(LOG_WARNING, "winhid is only supported on WIN32 builds; ignoring option");<br>    +           }<br>    +#endif        /* WIN32 */<br>                ret = usb->open_dev(&udev, &usbdevice, regex_matcher, NULL);<br><br>    (`nutdrv_qx.c:724` already has `static usb_communication_subdriver_t<br>    *usb = &usb_subdriver;`, so nothing else is needed.)<br><br>(d) route the transport through the abstraction -- three call sites inside `cypress_command()` (lines ~743-850):<br><br>    line ~770 (stale-report flush, 0665:5161 quirk path):<br>    -               ret = usb_interrupt_read(udev, 0x81,<br>    -                   (usb_ctrl_charbuf)tmp, 8, CYPRESS_0665_5161_FLUSH_TIMEOUT);<br>    +            ret = usb->get_interrupt(udev,<br>    +                      (usb_ctrl_charbuf)tmp, 8, CYPRESS_0665_5161_FLUSH_TIMEOUT);<br><br>    line ~797 (send):<br>    -         ret = usb_control_msg(udev,<br>    -                    USB_ENDPOINT_OUT + USB_TYPE_CLASS + USB_RECIP_INTERFACE,<br>    -                       0x09, 0x200, 0,<br>    -                        (usb_ctrl_charbuf)&tmp[i], 8, 5000);<br>    +               ret = usb->set_report(udev, 0, (usb_ctrl_charbuf)&tmp[i], 8);<br><br>    line ~820 (read):<br>    -                ret = usb_interrupt_read(udev,<br>    -                 0x81,<br>    -                  (usb_ctrl_charbuf)&buf[i], 8, reply_timeout);<br>    +              ret = usb->get_interrupt(udev, (usb_ctrl_charbuf)&buf[i], 8, reply_timeout);<br><br>    Note that the replaced control transfer was bmRequestType 0x21, bRequest 0x09, wValue 0x0200 -- i.e. a HID SET_REPORT (Output) -- and the interrupt read on 0x81 is a HID input report. The cypress subdriver was already speaking HID; it was only expressing it in libusb primitives.<br><br>----------------------------------------------------------------<br>5.2 drivers/libwinhid.c -- implement nut_winhid_set_report()<br>----------------------------------------------------------------<br><br>`nut_winhid_set_report()` (around line 2330) was a stub:<br><br>        upsdebugx(2, "%s: not implemented in phase-1 backend", __func__);<br>        return LIBUSB_ERROR_NOT_SUPPORTED;<br><br>With the port above in place, every Qx subdriver claim failed with "Operation not supported or unimplemented on this platform (-12)". Implementing it required five small additions plus the function body.<br><br>(a) ~line 94, immediately before the `pHidD_GetIndexedString` typedef:<br><br>    +typedef BOOLEAN (WINAPI *pHidD_SetOutputReport)(<br>    + HANDLE HidDeviceObject,<br>    +        PVOID ReportBuffer,<br>    +    ULONG ReportBufferLength);<br><br>(b) ~line 194, in the resolved-API struct, after `HidD_GetFeature`:<br><br>    +    pHidD_SetOutputReport HidD_SetOutputReport;<br><br>(c) ~line 318, after the HidD_GetFeature resolve:<br><br>    +     WINHID_RESOLVE(g_winhid_api.HidD_SetOutputReport, g_winhid_api.hid_mod, "HidD_SetOutputReport");<br><br>(d) ~line 233, in `winhid_dev_ctx_t`, after `input_report_len`:<br><br>    +        size_t output_report_len;<br><br>(e) ~line 1998, next to the existing caps assignments:<br><br>    +  ctx->output_report_len = (size_t)caps.OutputReportByteLength;<br><br>(f) the function body (~lines 2339-2410), replacing the four NUT_UNUSED_VARIABLE() calls and the stub return:<br><br>         winhid_dev_ctx_t *ctx;<br>      size_t write_size;<br>          unsigned char *tmp;<br>         DWORD err;<br><br>        if (!sdev || !raw_buf || ReportSize < 1) {<br>               return 0;<br>           }<br><br>         ctx = (winhid_dev_ctx_t *)sdev;<br>     if (!ctx->handle || ctx->handle == INVALID_HANDLE_VALUE) {<br>                    return LIBUSB_ERROR_NO_DEVICE;<br>      }<br><br>         /* Windows expects a full-length output report whose first byte is<br>           * the report ID.  Prefer the length advertised by HidP caps; fall<br>          * back to the caller's payload size plus one byte for the ID. */<br>       write_size = ctx->output_report_len;<br>     if (write_size < (size_t)ReportSize + 1) {<br>               write_size = (size_t)ReportSize + 1;<br>        }<br><br>         if (write_size < 2 || write_size > (size_t)USB_CTRL_CHARBUFSIZE_MAX) {<br>                return LIBUSB_ERROR_INVALID_PARAM;<br>          }<br><br>         tmp = (unsigned char *)calloc(write_size, 1);<br>       if (!tmp) {<br>                 return LIBUSB_ERROR_NO_MEM;<br>         }<br><br>         tmp[0] = (unsigned char)(ReportId & 0xffU);<br>     memcpy(tmp + 1, raw_buf, (size_t)ReportSize);<br><br>     if (!g_winhid_api.HidD_SetOutputReport(ctx->handle, tmp, (ULONG)write_size)) {<br>                   err = GetLastError();<br>               if (!ctx->use_overlapped_io) {<br>                           DWORD written = 0;<br>                          if (WriteFile(ctx->handle, tmp, (DWORD)write_size, &written, NULL)) {<br>                                free(tmp);<br>                                  return (int)ReportSize;<br>                     }<br>                           err = GetLastError();<br>               }<br>                   free(tmp);<br>                  return winhid_map_winerr_to_libusb(err);<br>            }<br><br>         free(tmp);<br>          return (int)ReportSize;<br><br>    (Returning `ReportSize`, not `write_size`, matters: callers such as `cypress_command()` advance their loop index by the return value)<br><br>----------------------------------------------------------------<br>5.3 drivers/libwinhid.c -- report-ID offset on the read paths<br>----------------------------------------------------------------<br><br>This one is, I believe, a genuine bug in the current backend and is worth reviewing independently of everything above.<br><br>With 5.2 in place the UPS answered, but the payload was corrupt. Debug output showed every 8-byte chunk beginning with 0x00:<br><br>    send: QS<br>    read [  0]: 00 23 72 01 20 34 20 73<br>    read [  8]: 00 20 34 20 07 20 4f e9<br>    ...<br><br>That leading 0x00 is the *report ID*, not data. The read helpers copied `copy_len` bytes starting at offset 0 of the report buffer, so each chunk delivered [ID + 7 data bytes] and silently dropped the 8th data byte -- one byte lost per chunk, which mangles the whole frame. (It also explains replies like `00 4e 0d`, which is really "N\r", the standard Megatec "not supported")<br><br>Three sites, all fixed the same way -- skip the leading ID byte:<br><br>  * `winhid_get_interrupt_overlapped()`, immediate-completion path<br>    (~lines 2525-2529):<br><br>    -                    copy_len = (size_t)got;<br>    +                        copy_len = ((size_t)got > 0) ? ((size_t)got - 1) : 0;<br>                           if (copy_len > (size_t)bufsize) {<br>                               copy_len = (size_t)bufsize;<br>                        }<br>    -                      memcpy(buf, ctx->read_buf, copy_len);<br>    +                       memcpy(buf, ctx->read_buf + 1, copy_len);<br><br>  * `winhid_get_interrupt_overlapped()`, deferred-completion path<br>    (~lines 2550-2554): identical change.<br><br>  * `winhid_get_input_report_control()` (~lines 2607-2611):<br><br>    -      copy_len = ((size_t)bufsize < query_size) ? (size_t)bufsize : query_size;<br>    +   copy_len = (query_size > 0) ? (query_size - 1) : 0;<br>    + if (copy_len > (size_t)bufsize) {<br>    +           copy_len = (size_t)bufsize;<br>    +    }<br>    -      memcpy(buf, tmp, copy_len);<br>    +    memcpy(buf, tmp + 1, copy_len);<br><br>    I deliberately did not touch `nut_winhid_get_report()`, which serves feature reports for usbhid-ups and whose current behaviour works.<br>    If the ID-stripping convention should be uniform across the backend, that call site probably deserves a look too -- I did not want to change behaviour I could not test.<br><br>================================================================<br>6. RESULT<br>================================================================<br><br>    nutdrv_qx.exe -a ups -x winhid -x subdriver=cypress \<br>                  -x vendorid=0665 -x productid=5161 -DDDDD -d 1<br><br>    Using experimental winhid backend: USB communication driver<br>        (Windows HID API) 0.11<br>    nut_winhid_open: accepted HID device '...vid_0665&pid_5161...'<br>    send: M<br>    read: T<br>    send: QS<br>    read: (32 bytes) => 23 72 01 20 34 20 72 01 ...<br>    subdriver_matcher: Trying protocol Voltronic-QS-Hex 0.12: claim succeeded<br>    Using protocol: Voltronic-QS-Hex 0.12<br><br>    battery.charge: 100<br>    battery.voltage: 27.06<br>    battery.voltage.nominal: 24.0<br>    driver.flag.winhid: enabled<br>    driver.version.data: Voltronic-QS-Hex 0.12<br>    driver.version.usb: winhid-0.11 (Windows HID API)<br>    input.voltage: 116.2<br>    output.voltage: 116.2<br>    output.frequency: 60.0<br>    ups.firmware.aux: PM-T<br>    ups.load: 7<br>    ups.status: OL<br>    ups.type: offline / line interactive<br><br>Working ups.conf section:<br><br>    [ups]<br>        driver    = nutdrv_qx<br>        port      = auto<br>        vendorid  = 0665<br>        productid = 5161<br>        subdriver = cypress<br>        protocol  = voltronic-qs-hex<br>        winhid<br>        desc      = "Forza SL-1011LCD (remote, via USB-over-Ethernet)"<br><br>The device must be left on the stock Windows HID driver -- no Zadig, no WinUSB. That is the whole point: the backend uses the path the appliance actually grants.<br><br>Note that the claiming subdriver is Voltronic-QS-**Hex**, not Voltronic-QS: this unit answers "T" to `M` (ups.firmware.aux "PM-T") and returns the QS frame in hex form.<br><br>================================================================<br>7. OPERATIONAL NOTE (may interest other users)<br>================================================================<br><br>On Windows, the NUT service runs in session 0, while the vendor's USB client that mounts the remote device runs in the interactive user session. Consequently `upsd` and `upsmon` start fine as services but the driver cannot see the device from session 0. My workaround is to keep upsd/upsmon under the NUT service and start `nutdrv_qx` from a Scheduled Task triggered at logon (delayed 1 minute so the USB client is up, repeating every 5 minutes with "do not start a new instance" as a watchdog).<br><br>Since this UPS does not power the monitoring host, upsmon is configured purely as an observer:<br><br>    MONITOR ups@localhost 0 upsmon <pass> secondary<br>    MINSUPPLIES 0<br>    POLLFAIL_LOG_THROTTLE_MAX 100<br><br>with `MAXAGE 30` and `ALLOW_NO_DEVICE true` in upsd.conf, because the appliance is itself powered by the very UPS being monitored: during a real outage the appliance dies and the data legitimately goes stale. (the power value of 0 is what guarantees a remote outage can never shut down the monitoring host.)<br><br>================================================================<br>8. STATUS OF THESE PATCHES<br>================================================================<br><br>These are proof-of-concept changes made to solve a concrete problem, not polished submissions. Specifically:<br><br>  * 5.1 only routes `cypress_command()` through the abstraction. Other Qx subdriver command functions in nutdrv_qx.c still call libusb directly and carry the same commented-out abstract calls; they would need the same treatment for full coverage.<br>  * 5.2 has been exercised only with 8-byte output reports on one device.</div><div>  * 5.3 is the change I would most like reviewed by the libwinhid author, since it alters behaviour for every winhid user, and the right convention (strip the ID in the backend vs. in the callers) is a design decision I am not in a position to make.<br><br>I am happy to rework any of this into proper pull requests, to test alternative approaches on this hardware, or to provide the complete debug logs, the decompiled driver findings, or the HID report descriptor dumps.<br><br>Thanks to the author of PR #3335 -- without that backend this device would simply not be monitorable on Windows.<br><br>Best regards,<br>Sebastián Holzapfel</div></div>