diff --git a/NEWS.adoc b/NEWS.adoc index 51c221fc52..eaa40d6f4b 100644 --- a/NEWS.adoc +++ b/NEWS.adoc @@ -477,6 +477,16 @@ https://github.com/networkupstools/nut/milestone/13 leave their probed measurements in the selected subdriver's state. Configured defaults and overrides are preserved. [issue #3659, PR #3766] + - `nutdrv_hashx` gained an explicitly enabled, experimental USB monitoring + path for the HashX status tunnel observed on an nJoy Keen 2000 with USB + ID `0764:0601`. It issues only status queries, validates and reassembles + length-prefixed replies before publishing telemetry, and preserves genuine + zero-load readings. Developed against passive vendor-software captures, + it was tested once in a controlled AC-mode USB hand-off after an existing + PowerMaster+ session. Cold-start/session requirements, reconnect recovery + and battery/power-event behaviour remain unverified. USB instant commands + and shutdown are unavailable. [PR #3737] + - Introduced a new NUT driver named `ragtech` which provides support for the Ragtech "Easy Pro" family of line-interactive UPS units (also sold under the NEP, TORO, INNERGIE and OneUP brands in Brazil). Devices present a USB diff --git a/data/driver.list.in b/data/driver.list.in index 2daf03dcdd..9ab72b3595 100644 --- a/data/driver.list.in +++ b/data/driver.list.in @@ -1141,6 +1141,7 @@ "nJoy" "ups" "2" "Keen 600" "USB" "blazer_ser port=/dev/ttyUSB0" # https://www.njoy.ro/UPS/keen-600 "nJoy" "ups" "2" "Keen 600" "USB" "nutdrv_qx port=/dev/ttyUSB0" # https://www.njoy.ro/UPS/keen-600 +"nJoy" "ups" "1" "Keen 2000" "USB (0764:0601; experimental AC-mode monitoring only)" "nutdrv_hashx port=auto usb_monitor" # One unit tested after a PowerMaster+ session; cold startup, reconnect and battery operation unverified; no shutdown support "nJoy" "ups" "3" "Aten PRO 3000" "SNMP" "snmp-ups" # At least basic status is served; https://www.njoy.global/product/aten-pro-3000 "Novex" "ups" "1" "NUPS-650" "USB" "blazer_usb protocol=megatec" # http://komp.1k.by/periphery-ups/novex/Novex_NUPS_650-130052.html diff --git a/docs/man/nutdrv_hashx.txt b/docs/man/nutdrv_hashx.txt index 62b0ab29c6..9830ab77a8 100644 --- a/docs/man/nutdrv_hashx.txt +++ b/docs/man/nutdrv_hashx.txt @@ -4,7 +4,7 @@ NUTDRV_HASHX(8) NAME ---- -nutdrv_hashx - Driver for #* protocol serial based UPS equipment +nutdrv_hashx - Driver for #* protocol serial and USB UPS equipment SYNOPSIS -------- @@ -32,14 +32,80 @@ Long story short: if your UPS came with a software called 'PowerMaster+' (or the older 'PowerMaster' and 'PowerGuide'), chances are high that it works with this driver. -Only devices with a serial interface are supported. +Serial devices are supported. An experimental, explicitly enabled USB +monitoring path is available for devices using USB ID `0764:0601` and the +observed HashX status transport, such as an nJoy Keen 2000. USB support has +been developed against passive PowerMaster+ traffic captures and tested +once on one nJoy Keen 2000 in AC mode. That controlled USB hand-off followed +an existing PowerMaster+ session; it does not establish cold-start support. + +The USB ID is also used by other CyberPower-compatible devices. The ID +alone does not establish protocol compatibility. The USB path requires +both `port = auto` and the `usb_monitor` flag, and a complete valid HashX +status response before publishing telemetry. USB matching options such as +`vendorid`, `productid`, `serial`, `bus`, `device` and `busport` can further +restrict selection. Interface and endpoint overrides are not supported by +this experimental path. + +USB MONITORING +-------------- + +Example configuration: + +---- +[hashx_usb] + driver = nutdrv_hashx + port = auto + usb_monitor + vendorid = 0764 + productid = 0601 +---- + +The USB path issues only the `B` status query through interrupt output +endpoint 2 and assembles replies from interrupt input endpoint 1. It +publishes input/output voltage and frequency, load, battery charge and +voltage, estimated runtime, and the existing HashX status mapping. +Incomplete or malformed replies mark data stale instead of inventing zero +values. A genuine complete zero-load reply is preserved. + +In the single controlled AC-mode test, input/output voltage, load, battery +charge and voltage, and estimated runtime agreed with nearby passive +PowerMaster+ telemetry within the comparison tolerances. PowerMaster+ +polling was verified again after the bounded test; the experimental driver +was not left deployed. Historical intermittent zero-load readings with +`usbhid-ups` were not reproduced. The USB tunnel provides an alternative +monitoring path; this test does not establish a correction to the HID decoder. + +Before each status query, the USB path makes a bounded attempt to drain +queued input packets. If the queue cannot be drained, it does not send a +new query and marks data stale. The observed protocol has no transaction +identifier: a reply delayed until after that drain cannot be conclusively +associated with the new query. Freshness across timeouts and reconnects +remains a hardware-test requirement. + +USB monitoring does not send session initialization, settings, battery-test, +beeper or shutdown commands, and does not infer the UPS type, nominal +ratings, model or firmware. Instant commands are not registered in USB +mode. Cold-start/session requirements remain unverified: a successful +status query against an already initialized device does not establish +startup support. The status-bit mapping comes from the serial driver and +has not been verified on this USB device during battery operation. + +The driver claims the USB interface and must have exclusive access. +Arrange a controlled hand-off from PowerMaster+ or another driver before a +hardware test, preserving the existing shutdown protection. Passive +capture tests validate framing and parsing; they do not validate USB +ownership. The single controlled hand-off tested ownership and AC-mode +telemetry on an already initialized device; reconnection, battery operation +and shutdown behaviour remain not yet validated. KNOWN ISSUES AND BUGS --------------------- -The driver has only been tested with a single line-interactive device -(Atlantis Land A03-S1200) so far, thus the protocol reverse engineering -has been currently limited to what such device supports. +The serial protocol reverse engineering was originally limited to a single +line-interactive device (Atlantis Land A03-S1200). The experimental USB path +has separate, limited test coverage on one nJoy Keen 2000 as described +above. Neither establishes support for all features on other devices. Thus the lack of these features: @@ -55,10 +121,10 @@ Thus the lack of these features: * Detection of lack of Neutral line connection. * Detection of UPS generators. * Detection of other Battery / UPS error states. -* Reading of actual Voltage, Frequency, Current, Power factor, Temperature values. +* Reading of actual Current, Power factor, Temperature values. * Reading the status of the battery packs. * Bypass modes usage and detection. -* USB Support (PM+ mentions being it supported, unsure if with the same protocol). +* Full USB support, including startup/session handling and power control. The driver shutdown function is not implemented (while it's supported), so other arrangements must be made to turn off the UPS. diff --git a/docs/nut.dict b/docs/nut.dict index 5f351d8cd9..72d18c7aeb 100644 --- a/docs/nut.dict +++ b/docs/nut.dict @@ -541,6 +541,7 @@ Hajduch Hanno Harnhammar Hasangarayev +HashX Havard Heavner Hessenflow @@ -2858,6 +2859,7 @@ myprivatepassphrase mysecurityname myups myupsname +nJoy nLogic nMONITOR nPOWERDOWNFLAG diff --git a/drivers/Makefile.am b/drivers/Makefile.am index 8cc9bffd48..0a62b7b13a 100644 --- a/drivers/Makefile.am +++ b/drivers/Makefile.am @@ -150,7 +150,7 @@ NUTSW_DRIVERLIST = $(NUTSW_DRIVERLIST_DUMMY_UPS) \ SERIAL_DRIVERLIST = al175 apcmicrolink bcmxcp belkin belkinunv bestfcom \ bestfortress bestuferrups bestups etapro everups \ gamatronic genericups isbmex liebert liebert-esp2 liebert-gxe masterguard metasys \ - mge-utalk microdowell microsol-apc mge-shut nutdrv_hashx oneac optiups powercom powervar_cx_ser rhino \ + mge-utalk microdowell microsol-apc mge-shut oneac optiups powercom powervar_cx_ser rhino \ safenet nutdrv_siemens-sitop solis tripplite tripplitesu upscode2 victronups powerpanel \ blazer_ser ivtscd apcsmart apcsmart-old riello_ser sms_ser bicker_ser ve-direct meanwell_ntu \ ragtech @@ -164,7 +164,7 @@ USB_LIBUSB_DRIVERLIST = usbhid-ups bcmxcp_usb tripplite_usb \ nutdrv_atcl_usb USB_DRIVERLIST = $(USB_LIBUSB_DRIVERLIST) SERIAL_USB_DRIVERLIST = \ - nutdrv_qx + nutdrv_qx nutdrv_hashx NEONXML_DRIVERLIST = netxml-ups MACOSX_DRIVERLIST = macosx-ups MODBUS_DRIVERLIST = phoenixcontact_modbus generic_modbus huawei-ups2000 socomec_jbus adelsystem_cbi apc_modbus must_ep2000pro @@ -276,7 +276,21 @@ microsol_apc_LDADD = $(LDADD_DRIVERS_SERIAL) -lm nhs_ser_SOURCES = nhs_ser.c nhs_ser_LDADD = $(LDADD_DRIVERS_SERIAL) -lm nutdrv_hashx_SOURCES = nutdrv_hashx.c -nutdrv_hashx_LDADD = $(LDADD_DRIVERS_SERIAL) -lm +nutdrv_hashx_LDADD = $(LDADD_DRIVERS) -lm +nutdrv_hashx_CFLAGS = $(AM_CFLAGS) +if WITH_SERIAL +nutdrv_hashx_CFLAGS += -DHASHX_SERIAL +if ENABLE_SHARED_PRIVATE_LIBS +nutdrv_hashx_LDADD += libnutprivate-@NUT_SOURCE_GITREV_SEMVER_UNDERSCORES@-drivers-serial.la +else !ENABLE_SHARED_PRIVATE_LIBS +nutdrv_hashx_LDADD += libdummy_serial.la $(SERLIBS) +endif !ENABLE_SHARED_PRIVATE_LIBS +endif WITH_SERIAL +if WITH_USB +nutdrv_hashx_CFLAGS += -DHASHX_USB +nutdrv_hashx_SOURCES += hashx-usb-protocol.c +nutdrv_hashx_LDADD += $(LDADD_DRIVERS_LIBUSB) +endif WITH_USB oneac_SOURCES = oneac.c optiups_SOURCES = optiups.c powercom_SOURCES = powercom.c @@ -550,6 +564,7 @@ nutdrv_qx_SOURCES += $(NUTDRV_QX_SUBDRIVERS) # distributed by "make dist". dist_noinst_HEADERS = \ + hashx-usb-protocol.h \ apc_modbus.h apc-mib.h apc-iem-mib.h apc-hid.h arduino-hid.h baytech-mib.h baytech-rpc3nc-mib.h bcmxcp.h bcmxcp_ser.h \ bcmxcp_io.h belkin.h belkin-hid.h bestpower-mib.h blazer.h cps-hid.h dstate.h \ dummy-ups.h explore-hid.h failover.h gamatronic.h genericups.h \ diff --git a/drivers/hashx-usb-protocol.c b/drivers/hashx-usb-protocol.c new file mode 100644 index 0000000000..0e6ffc8d17 --- /dev/null +++ b/drivers/hashx-usb-protocol.c @@ -0,0 +1,168 @@ +/* hashx-usb-protocol.c - bounded HashX USB reply framing + * + * Copyright (C) 2026 Network UPS Tools contributors + * + * A passive nJoy Keen 2000 capture provided report 0x28 followed by a + * content-length byte and fragments of a CR-terminated HashX status reply. + * Only declared contents are used; transport padding is ignored. + * This file has no USB, serial or hardware access dependencies. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include "hashx-usb-protocol.h" + +#include + +static int hashx_usb_byte_valid(size_t offset, unsigned char value) +{ + static const char format[] = "#Iddd.dOddd.dLdddBdddVdd.dFdd.dHdd.dRdddS"; + + if (offset < sizeof(format) - 1) { + if (format[offset] == 'd') + return value >= '0' && value <= '9'; + return value == (unsigned char)format[offset]; + } + + /* HashX status bytes carry a common high bit; unlike the preceding + * fields, they are binary and must not be processed with strlen(). */ + if (offset < HASHX_USB_FRAME_SIZE - 1) + return (value & 0x80U) != 0U; + + return offset == HASHX_USB_FRAME_SIZE - 1 && value == '\r'; +} + +static int hashx_usb_charge_valid(const char *data, size_t length) +{ + unsigned int charge; + + if (length < 21) + return 1; + charge = (unsigned int)(data[18] - '0') * 100U + + (unsigned int)(data[19] - '0') * 10U + + (unsigned int)(data[20] - '0'); + return charge <= 100U; +} + +int hashx_usb_status_valid(const char *data, size_t length) +{ + size_t offset; + + if (!data || length != HASHX_USB_FRAME_SIZE) + return 0; + for (offset = 0; offset < length; offset++) { + if (!hashx_usb_byte_valid(offset, (unsigned char)data[offset])) + return 0; + } + + /* Load is intentionally not capped at 100: an overload is valid + * telemetry. A genuine L000 must also remain valid. */ + return hashx_usb_charge_valid(data, length); +} + +void hashx_usb_reply_reset(struct hashx_usb_reply *reply) +{ + if (reply) + memset(reply, 0, sizeof(*reply)); +} + +static int hashx_usb_reply_reject(struct hashx_usb_reply *reply) +{ + hashx_usb_reply_reset(reply); + return -1; +} + +int hashx_usb_reply_append(struct hashx_usb_reply *reply, + const unsigned char *packet, size_t length) +{ + size_t count; + size_t offset; + + if (!reply) + return -1; + if (!packet || length == 0 || length > HASHX_USB_PACKET_SIZE) + return hashx_usb_reply_reject(reply); + if (packet[0] != 0x28U) + return 0; + if (length < 2 || reply->length > HASHX_USB_FRAME_SIZE) + return hashx_usb_reply_reject(reply); + + count = packet[1]; + if (count == 0) + return 0; + if (count > length - 2 || count > HASHX_USB_FRAME_SIZE - reply->length) + return hashx_usb_reply_reject(reply); + + /* Validate each fragment at its final offset. This rejects a new + * '#' while a reply is partial, early CR, embedded NUL and bad fields + * immediately, without joining bytes from different reply cycles. */ + for (offset = 0; offset < count; offset++) { + if (!hashx_usb_byte_valid(reply->length + offset, packet[2 + offset])) + return hashx_usb_reply_reject(reply); + } + memcpy(reply->data + reply->length, packet + 2, count); + reply->length += count; + reply->data[reply->length] = '\0'; + if (!hashx_usb_charge_valid(reply->data, reply->length)) + return hashx_usb_reply_reject(reply); + if (reply->length == HASHX_USB_FRAME_SIZE) { + if (!hashx_usb_status_valid(reply->data, reply->length)) + return hashx_usb_reply_reject(reply); + return 1; + } + return 0; +} + +int hashx_usb_status_query(void *context, + hashx_usb_read_packet_fn read_packet, + hashx_usb_write_packet_fn write_packet, + struct hashx_usb_reply *reply) +{ + unsigned char packet[HASHX_USB_PACKET_SIZE]; + unsigned int attempt; + int count; + int result; + int drained = 0; + + hashx_usb_reply_reset(reply); + if (!reply || !read_packet || !write_packet) + return -1; + + /* Require an observed empty queue before transmitting. A continuous + * stream must exhaust the bounded drain without sending a new query. */ + for (attempt = 0; attempt < 8; attempt++) { + count = read_packet(context, packet, sizeof(packet), 1); + if (count < 0 || count > HASHX_USB_PACKET_SIZE) + return hashx_usb_reply_reject(reply); + if (count == 0) { + drained = 1; + break; + } + } + if (!drained) + return hashx_usb_reply_reject(reply); + + memset(packet, 0, sizeof(packet)); + packet[0] = 0x29U; + packet[1] = 2; + packet[2] = 'B'; + packet[3] = '\r'; + count = write_packet(context, packet, sizeof(packet), 1000); + if (count != HASHX_USB_PACKET_SIZE) + return hashx_usb_reply_reject(reply); + + for (attempt = 0; attempt < 8; attempt++) { + count = read_packet(context, packet, sizeof(packet), 250); + if (count <= 0 || count > HASHX_USB_PACKET_SIZE) + return hashx_usb_reply_reject(reply); + result = hashx_usb_reply_append(reply, packet, (size_t)count); + if (result == 1) + return 1; + if (result < 0) + return hashx_usb_reply_reject(reply); + } + return hashx_usb_reply_reject(reply); +} diff --git a/drivers/hashx-usb-protocol.h b/drivers/hashx-usb-protocol.h new file mode 100644 index 0000000000..df44398bd7 --- /dev/null +++ b/drivers/hashx-usb-protocol.h @@ -0,0 +1,61 @@ +/* hashx-usb-protocol.h - bounded HashX USB reply framing + * + * Copyright (C) 2026 Network UPS Tools contributors + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#ifndef HASHX_USB_PROTOCOL_H +#define HASHX_USB_PROTOCOL_H + +#include + +#define HASHX_USB_PACKET_SIZE 64 +#define HASHX_USB_FRAME_SIZE 48 + +struct hashx_usb_reply { + char data[HASHX_USB_FRAME_SIZE + 1]; + size_t length; +}; + +void hashx_usb_reply_reset(struct hashx_usb_reply *reply); + +/* Return -1 and reset on malformed input, 0 for an incomplete reply or + * an unrelated report, and 1 for a complete recognized status reply. + * Unknown reports and zero-length chunks leave the pending reply intact. + * The caller must bound the receive loop and reset between requests. + * A complete reply retains CR and is followed by an additional NUL. + */ +int hashx_usb_reply_append(struct hashx_usb_reply *reply, + const unsigned char *packet, size_t length); + +/* Validate the observed 48-byte status schema, including its final CR. + * This does not validate device identity, power events or command support. + */ +int hashx_usb_status_valid(const char *data, size_t length); + +/* Packet callbacks must respect the supplied buffer size. Reads return + * 0 for an empty queue/timeout, a negative value on error, or a byte count. + * Writes return the byte count, or a negative value on error. + */ +typedef int (*hashx_usb_read_packet_fn)(void *context, + unsigned char *packet, size_t size, unsigned int timeout_ms); +typedef int (*hashx_usb_write_packet_fn)(void *context, + const unsigned char *packet, size_t size, unsigned int timeout_ms); + +/* Drain at most eight queued packets before sending only the B status + * query, then read at most eight reply packets. Return 1 for a complete + * recognized reply, or -1 with a reset reply on any failure. + * The drain discards already queued replies. Without response sequence + * identifiers it cannot identify a delayed reply arriving after the drain. + * This helper performs no hardware access itself. + */ +int hashx_usb_status_query(void *context, + hashx_usb_read_packet_fn read_packet, + hashx_usb_write_packet_fn write_packet, + struct hashx_usb_reply *reply); + +#endif /* HASHX_USB_PROTOCOL_H */ diff --git a/drivers/nutdrv_hashx.c b/drivers/nutdrv_hashx.c index ff6f7f81b5..a5b906412a 100644 --- a/drivers/nutdrv_hashx.c +++ b/drivers/nutdrv_hashx.c @@ -1,4 +1,4 @@ -/* nutdrv_hashx.c - Driver for serial UPS units with #* protocols +/* nutdrv_hashx.c - Driver for serial and USB UPS units with #* protocols * * Copyright (C) * 2025 Marco Trevisan (TreviƱo) @@ -25,7 +25,13 @@ #include "main.h" #include "nut_stdint.h" #include "powerpanel.h" +#ifdef HASHX_SERIAL #include "serial.h" +#endif +#ifdef HASHX_USB +#include "nut_libusb.h" +#include "hashx-usb-protocol.h" +#endif #include "str.h" #include "upshandler.h" #include @@ -34,8 +40,14 @@ #define ENDCHAR '\r' #define IGNCHARS "" +#if defined(HASHX_USB) && defined(HASHX_SERIAL) +#define DRIVER_NAME "Generic #* USB/Serial driver" +#elif defined(HASHX_USB) +#define DRIVER_NAME "Generic #* USB driver" +#else #define DRIVER_NAME "Generic #* Serial driver" -#define DRIVER_VERSION "0.03" +#endif +#define DRIVER_VERSION "0.04" #define SESSION_ID "OoNUTisAMAZINGoO" #define SESSION_HASH "74279F35A48F5F13" @@ -117,6 +129,7 @@ upsdrv_info_t upsdrv_info = { { NULL } }; +#ifdef HASHX_SERIAL static struct hashx_cmd_t { const char *cmd_name; const char *ups_cmd; @@ -134,6 +147,185 @@ static struct hashx_cmd_t { }; static int hashx_instcmd(const char *cmd_name, const char *extra); +#endif + +static void hashx_apply_status(char *buf, ssize_t transferred); + +#ifdef HASHX_USB +#define HASHX_USB_EP_IN 0x81 +#define HASHX_USB_EP_OUT 0x02 + +static int hashx_is_usb = 0; +static usb_dev_handle *hashx_udev = NULL; +static USBDevice_t hashx_usbdevice; +static USBDeviceMatcher_t *hashx_regex_matcher = NULL; +static USBDeviceMatcher_t *hashx_reopen_matcher = NULL; + +/* The ID is also used by unrelated CyberPower HID devices. USB support + * requires an explicit opt-in and a valid HashX status response. + */ +static usb_device_id_t hashx_usb_ids[] = { + { USB_DEVICE(0x0764, 0x0601), NULL }, + { 0, 0, NULL } +}; + +static int hashx_usb_match(USBDevice_t *device, void *privdata) +{ + NUT_UNUSED_VARIABLE(privdata); + return is_usb_device_supported(hashx_usb_ids, device) == SUPPORTED; +} + +static USBDeviceMatcher_t hashx_device_matcher = { + &hashx_usb_match, + NULL, + NULL +}; + +static void hashx_usb_free_device(void) +{ + free(hashx_usbdevice.Vendor); + free(hashx_usbdevice.Product); + free(hashx_usbdevice.Serial); + free(hashx_usbdevice.Bus); + free(hashx_usbdevice.Device); +#if (defined WITH_USB_BUSPORT) && (WITH_USB_BUSPORT) + free(hashx_usbdevice.BusPort); +#endif + memset(&hashx_usbdevice, 0, sizeof(hashx_usbdevice)); +} + +static int hashx_usb_open(void) +{ + int ret; + USBDeviceMatcher_t *matcher; + + matcher = hashx_reopen_matcher ? hashx_reopen_matcher : hashx_regex_matcher; + hashx_usb_free_device(); + ret = usb_subdriver.open_dev(&hashx_udev, &hashx_usbdevice, matcher, NULL); + if (ret < 1) { + return -1; + } + if (!hashx_reopen_matcher) { + ret = USBNewExactMatcher(&hashx_reopen_matcher, &hashx_usbdevice); + if (ret != 0) { + fatal_with_errno(EXIT_FAILURE, "USBNewExactMatcher"); + } + hashx_reopen_matcher->next = hashx_regex_matcher; + } + return 0; +} + +static void hashx_usb_check_disconnect(int ret) +{ +#if WITH_LIBUSB_1_0 + if (ret != LIBUSB_ERROR_NO_DEVICE) { +#else + if (ret != -ENODEV) { +#endif + return; + } + usb_subdriver.close_dev(hashx_udev); + hashx_udev = NULL; +} + +static int hashx_usb_read_packet(void *context, unsigned char *packet, + size_t length, unsigned int timeout) +{ + int ret; + + if (length > INT_MAX || timeout > INT_MAX) { + return -1; + } + ret = usb_interrupt_read((usb_dev_handle *)context, HASHX_USB_EP_IN, + (usb_ctrl_charbuf)packet, (int)length, (int)timeout); +#if WITH_LIBUSB_1_0 + if (ret == LIBUSB_ERROR_TIMEOUT) { +#else + if (ret == -ETIMEDOUT) { +#endif + return 0; + } + if (ret < 0) { + hashx_usb_check_disconnect(ret); + return -1; + } + return ret; +} + +static int hashx_usb_write_packet(void *context, const unsigned char *packet, + size_t length, unsigned int timeout) +{ + int ret; + + if (length > INT_MAX || timeout > INT_MAX) { + return -1; + } + ret = usb_interrupt_write((usb_dev_handle *)context, HASHX_USB_EP_OUT, + (usb_ctrl_charbuf)packet, (int)length, (int)timeout); + if (ret < 0) { + hashx_usb_check_disconnect(ret); + return -1; + } + return ret; +} + +static ssize_t hashx_usb_status(char *buf, size_t bufsize) +{ + struct hashx_usb_reply reply; + + if (bufsize < HASHX_USB_FRAME_SIZE || (!hashx_udev && hashx_usb_open() < 0)) { + return -1; + } + if (hashx_usb_status_query(hashx_udev, hashx_usb_read_packet, + hashx_usb_write_packet, &reply) != 1 + || !hashx_usb_status_valid(reply.data, reply.length)) { + return -1; + } + + /* The serial receiver excludes CR. Preserve that convention for the + * existing status mapping, including its binary status-byte tail. + */ + memcpy(buf, reply.data, reply.length - 1); + buf[reply.length - 1] = '\0'; + return (ssize_t)(reply.length - 1); +} + +static void hashx_usb_init(void) +{ + char *regex_array[USBMATCHER_REGEXP_ARRAY_LIMIT]; + int ret; + + regex_array[0] = getval("vendorid"); + regex_array[1] = getval("productid"); + regex_array[2] = getval("vendor"); + regex_array[3] = getval("product"); + regex_array[4] = getval("serial"); + regex_array[5] = getval("bus"); + regex_array[6] = getval("device"); +#if (defined WITH_USB_BUSPORT) && (WITH_USB_BUSPORT) + regex_array[7] = getval("busport"); +#else + if (getval("busport")) { + fatalx(EXIT_FAILURE, "busport matching is unavailable in this build"); + } +#endif + ret = USBNewRegexMatcher(&hashx_regex_matcher, regex_array, REG_ICASE | REG_EXTENDED); + if (ret < 0) { + fatal_with_errno(EXIT_FAILURE, "USBNewRegexMatcher"); + } + if (ret > 0) { + fatalx(EXIT_FAILURE, "invalid USB matching expression: %s", regex_array[ret - 1]); + } + hashx_regex_matcher->next = &hashx_device_matcher; + if (hashx_usb_open() < 0) { + fatalx(EXIT_FAILURE, "No matching USB 0764:0601 device available for HashX monitoring"); + } + dstate_setinfo("ups.vendorid", "%04x", hashx_usbdevice.VendorID); + dstate_setinfo("ups.productid", "%04x", hashx_usbdevice.ProductID); +} +#endif /* HASHX_USB */ + +#ifdef HASHX_SERIAL static ssize_t hashx_recv(char *buf, size_t bufsize) { @@ -205,6 +397,7 @@ static int hashx_send_command(const char *command) return status; } +#endif /* HASHX_SERIAL */ /* * This is how PowerMaster+ 1.2.3 does when connecting to the UPS: @@ -250,6 +443,7 @@ static int hashx_send_command(const char *command) void upsdrv_initinfo(void) { +#ifdef HASHX_SERIAL ssize_t transferred; char buf[256]; char *reply; @@ -257,6 +451,25 @@ void upsdrv_initinfo(void) int ival; double dval; size_t i; +#endif + +#ifdef HASHX_USB + if (hashx_is_usb) { + char status_buf[256]; + ssize_t status_length; + + status_length = hashx_usb_status(status_buf, sizeof(status_buf)); + if (status_length < 0) { + fatalx(EXIT_FAILURE, "USB monitoring probe did not return a complete valid HashX status frame"); + } + dstate_setinfo("device.type", "ups"); + dstate_setinfo("driver.parameter.usb_monitor", "enabled"); + hashx_apply_status(status_buf, status_length); + return; + } +#endif + +#ifdef HASHX_SERIAL for (i = 0; ; ++i) { upsdebugx(4, "Checking if device supports the #-protocol... [%" PRIuSIZE "]", i+1); @@ -418,12 +631,44 @@ void upsdrv_initinfo(void) for (i = 0; i < sizeof (hashx_cmd) / sizeof (*hashx_cmd); ++i) { dstate_addcmd(hashx_cmd[i].cmd_name); } +#endif /* HASHX_SERIAL */ } void upsdrv_updateinfo(void) { char buf[256]; ssize_t transferred; + +#ifdef HASHX_USB + if (hashx_is_usb) { + transferred = hashx_usb_status(buf, sizeof(buf)); + if (transferred < 0) { + upslogx(LOG_ERR, "USB HashX status query failed or returned an incomplete/malformed frame"); + dstate_datastale(); + return; + } + hashx_apply_status(buf, transferred); + return; + } +#endif + +#ifdef HASHX_SERIAL + if ((transferred = ser_send(upsfd, COMMAND_GET_STATUS"%c", ENDCHAR)) != 2) { + upslogx(LOG_ERR, "%s: Failed to get status", __func__); + dstate_datastale(); + return; + } + if ((transferred = hashx_recv(buf, sizeof(buf))) <= 0) { + upslogx(LOG_ERR, "%s: unexpected status response: %s", __func__, buf); + dstate_datastale(); + return; + } + hashx_apply_status(buf, transferred); +#endif +} + +static void hashx_apply_status(char *buf, ssize_t transferred) +{ size_t i; char *status; int ret; @@ -438,17 +683,6 @@ void upsdrv_updateinfo(void) char *status_bytes = NULL; size_t status_bytes_size = 0; - if ((transferred = ser_send(upsfd, COMMAND_GET_STATUS"%c", ENDCHAR)) != 2) { - upslogx(LOG_ERR, "%s: Failed to get status", __func__); - dstate_datastale(); - return; - } - if ((transferred = hashx_recv(buf, sizeof(buf))) <= 0) { - upslogx(LOG_ERR, "%s: unexpected status response: %s", __func__, buf); - dstate_datastale(); - return; - } - /* This is in the format: * #I239.0O237.0L007B100V27.0F50.1H50.1R040S\x80\0x84\0xd0\x80\x80\0xc0 * I O L B V F H R S @@ -616,6 +850,7 @@ void upsdrv_updateinfo(void) alarm_commit(); } +#ifdef HASHX_SERIAL static int hashx_instcmd(const char *cmd_name, const char *extra) { size_t i; @@ -643,6 +878,7 @@ static int hashx_instcmd(const char *cmd_name, const char *extra) upslog_INSTCMD_UNKNOWN(cmd_name, extra); return STAT_INSTCMD_UNKNOWN; } +#endif void upsdrv_shutdown(void) { @@ -665,10 +901,35 @@ void upsdrv_tweak_prognames(void) void upsdrv_makevartable(void) { +#ifdef HASHX_USB + addvar(VAR_FLAG, "usb_monitor", "Explicitly enable experimental USB monitoring with port=auto; no power-control commands"); + nut_usb_addvars(); +#endif } void upsdrv_initups(void) { +#ifdef HASHX_USB + if (testvar("usb_monitor")) { + if (strcmp(device_path, "auto") != 0) { + fatalx(EXIT_FAILURE, "USB monitoring requires both port=auto and usb_monitor"); + } + if (getval("usb_config_index") || getval("usb_hid_rep_index") + || getval("usb_hid_desc_index") || getval("usb_hid_ep_in") + || getval("usb_hid_ep_out") || testvar("usb_set_altinterface")) { + fatalx(EXIT_FAILURE, "USB HashX monitoring supports only the observed default interface and endpoints"); + } + hashx_is_usb = 1; + upslogx(LOG_WARNING, "Experimental USB monitoring only: startup sessions and power controls are unverified and unavailable"); + hashx_usb_init(); + return; + } + if (strcmp(device_path, "auto") == 0) { + fatalx(EXIT_FAILURE, "USB monitoring requires explicit usb_monitor opt-in"); + } +#endif + +#ifdef HASHX_SERIAL upsdebugx(3, "Opening device %s", device_path); upsfd = ser_open(device_path); @@ -678,9 +939,26 @@ void upsdrv_initups(void) } ser_set_speed(upsfd, device_path, B2400); +#else + fatalx(EXIT_FAILURE, "Serial support is unavailable; USB requires port=auto and usb_monitor"); +#endif } void upsdrv_cleanup(void) { +#ifdef HASHX_USB + if (hashx_is_usb) { + if (hashx_udev) { + usb_subdriver.close_dev(hashx_udev); + hashx_udev = NULL; + } + USBFreeExactMatcher(hashx_reopen_matcher); + USBFreeRegexMatcher(hashx_regex_matcher); + hashx_usb_free_device(); + return; + } +#endif +#ifdef HASHX_SERIAL ser_close(upsfd, device_path); +#endif } diff --git a/tests/.gitignore b/tests/.gitignore index 56dc9deefe..cae7852744 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -11,6 +11,10 @@ /ecoflow_cdc_protocol_utest /ecoflow_cdc_protocol_utest.log /ecoflow_cdc_protocol_utest.trs +/hashx-usb-protocol.c +/hashx-usb-protocol-test +/hashx-usb-protocol-test.log +/hashx-usb-protocol-test.trs /gpiotest /gpiotest.log /gpiotest.trs diff --git a/tests/Makefile.am b/tests/Makefile.am index 3577f83028..33059f708d 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -164,6 +164,12 @@ ecoflow_cdc_protocol_utest_SOURCES = ecoflow_cdc_protocol_utest.c \ nodist_ecoflow_cdc_protocol_utest_SOURCES = ecoflow-cdc-protocol.c ecoflow_cdc_protocol_utest_LDADD = -lm +TESTS += hashx-usb-protocol-test +hashx_usb_protocol_test_SOURCES = hashx-usb-protocol-test.c \ + $(top_srcdir)/drivers/hashx-usb-protocol.h +nodist_hashx_usb_protocol_test_SOURCES = hashx-usb-protocol.c +hashx_usb_protocol_test_LDADD = + TESTS += nutbooltest nutbooltest_SOURCES = nutbooltest.c #nutbooltest_LDADD = $(NUT_LIBCOMMON) @@ -185,7 +191,7 @@ test_authconf_CFLAGS += $(LIBSSL_CFLAGS) endif WITH_SSL # Separate the .deps of other dirs from this one -LINKED_SOURCE_FILES = cgilib.c hidparser.c ecoflow-cdc-protocol.c +LINKED_SOURCE_FILES = cgilib.c hidparser.c ecoflow-cdc-protocol.c hashx-usb-protocol.c cgilib.c: $(top_srcdir)/clients/cgilib.c test -s '$@' || ln -s -f "$(top_srcdir)/clients/cgilib.c" '$@' @@ -198,6 +204,9 @@ hidparser.c: $(top_srcdir)/drivers/hidparser.c ecoflow-cdc-protocol.c: $(top_srcdir)/drivers/ecoflow-cdc-protocol.c test -s '$@' || ln -s -f "$(top_srcdir)/drivers/ecoflow-cdc-protocol.c" '$@' +hashx-usb-protocol.c: $(top_srcdir)/drivers/hashx-usb-protocol.c + test -s '$@' || ln -s -f "$(top_srcdir)/drivers/hashx-usb-protocol.c" '$@' + if WITH_USB TESTS += getvaluetest getexponenttest-belkin-hid fixreportdesctest-tripplite-hid diff --git a/tests/hashx-usb-protocol-test.c b/tests/hashx-usb-protocol-test.c new file mode 100644 index 0000000000..7a146d49da --- /dev/null +++ b/tests/hashx-usb-protocol-test.c @@ -0,0 +1,443 @@ +/* hashx-usb-protocol-test.c - offline HashX USB framing regression tests + * + * Copyright (C) 2026 Network UPS Tools contributors + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include "hashx-usb-protocol.h" + +#include +#include +#include + +static unsigned int failures = 0; +static unsigned int checks = 0; + +/* Sanitized status frames from the passive Keen 2000 capture. No identity + * strings, unknown vendor replies or USB padding are retained. */ +static const char frame_four[] = + "#I243.0O245.0L004B100V27.1F50.0H50.0R040S" + "\x80\x84\xd0\x80\x80\xc0\r"; +static const char frame_five[] = + "#I243.0O245.0L005B100V27.1F49.9H49.9R040S" + "\x80\x84\xd0\x80\x80\xc0\r"; + +static void check(int condition, const char *description) +{ + checks++; + if (!condition) { + fprintf(stderr, "FAIL: %s\n", description); + failures++; + } +} + +static size_t make_packet(unsigned char *packet, const char *data, size_t length) +{ + memset(packet, 0, HASHX_USB_PACKET_SIZE); + packet[0] = 0x28U; + packet[1] = (unsigned char)length; + memcpy(packet + 2, data, length); + return length + 2; +} + +static void check_reply(const struct hashx_usb_reply *reply, const char *frame) +{ + check(reply->length == HASHX_USB_FRAME_SIZE, "complete reply has exact length"); + check(memcmp(reply->data, frame, HASHX_USB_FRAME_SIZE) == 0, + "assembled reply preserves captured bytes"); + check(reply->data[HASHX_USB_FRAME_SIZE - 1] == '\r', "final CR is retained"); + check(reply->data[HASHX_USB_FRAME_SIZE] == '\0', "NUL follows the final CR"); +} + +static void test_captured_fixtures(void) +{ + struct hashx_usb_reply reply; + unsigned char packet[HASHX_USB_PACKET_SIZE]; + size_t length; + const char *frames[] = { frame_four, frame_five }; + size_t splits[] = { 28, 29 }; + size_t index; + + for (index = 0; index < sizeof(frames) / sizeof(frames[0]); index++) { + hashx_usb_reply_reset(&reply); + length = make_packet(packet, frames[index], splits[index]); + check(hashx_usb_reply_append(&reply, packet, length) == 0, + "captured first chunk is incomplete"); + make_packet(packet, frames[index] + splits[index], + HASHX_USB_FRAME_SIZE - splits[index]); + check(hashx_usb_reply_append(&reply, packet, sizeof(packet)) == 1, + "captured second chunk completes padded USB transfer"); + check_reply(&reply, frames[index]); + } +} + +static void test_all_fragment_boundaries(void) +{ + struct hashx_usb_reply reply; + unsigned char packet[HASHX_USB_PACKET_SIZE]; + size_t split; + size_t length; + + for (split = 1; split < HASHX_USB_FRAME_SIZE; split++) { + hashx_usb_reply_reset(&reply); + length = make_packet(packet, frame_five, split); + check(hashx_usb_reply_append(&reply, packet, length) == 0, + "arbitrary first fragment remains incomplete"); + length = make_packet(packet, frame_five + split, + HASHX_USB_FRAME_SIZE - split); + check(hashx_usb_reply_append(&reply, packet, length) == 1, + "arbitrary second fragment completes status"); + check_reply(&reply, frame_five); + } +} + +static void test_zero_load_overload_and_status_schema(void) +{ + char frame[HASHX_USB_FRAME_SIZE + 1]; + size_t offset; + + memcpy(frame, frame_five, sizeof(frame)); + memcpy(frame + 14, "000", 3); + check(hashx_usb_status_valid(frame, HASHX_USB_FRAME_SIZE), + "genuine zero load is valid"); + memcpy(frame + 14, "125", 3); + check(hashx_usb_status_valid(frame, HASHX_USB_FRAME_SIZE), + "overload above 100 percent is valid"); + memcpy(frame + 18, "101", 3); + check(!hashx_usb_status_valid(frame, HASHX_USB_FRAME_SIZE), + "battery capacity above 100 percent is invalid"); + check(!hashx_usb_status_valid(NULL, HASHX_USB_FRAME_SIZE), "NULL status is invalid"); + check(!hashx_usb_status_valid(frame_five, HASHX_USB_FRAME_SIZE - 1), + "short status is invalid"); + + for (offset = 0; offset < HASHX_USB_FRAME_SIZE; offset++) { + memcpy(frame, frame_five, sizeof(frame)); + frame[offset] = '\0'; + check(!hashx_usb_status_valid(frame, HASHX_USB_FRAME_SIZE), + "embedded NUL cannot pass the strict status schema"); + } +} + +static void test_ignored_packets(void) +{ + struct hashx_usb_reply reply; + struct hashx_usb_reply saved; + unsigned char packet[HASHX_USB_PACKET_SIZE]; + size_t length; + + hashx_usb_reply_reset(&reply); + length = make_packet(packet, frame_five, 29); + check(hashx_usb_reply_append(&reply, packet, length) == 0, "start pending reply"); + saved = reply; + memset(packet, 0, sizeof(packet)); + packet[0] = 0x2cU; + packet[1] = 255U; + check(hashx_usb_reply_append(&reply, packet, sizeof(packet)) == 0, + "unrelated report is ignored"); + check(reply.length == saved.length && memcmp(reply.data, saved.data, sizeof(reply.data)) == 0, + "unrelated report leaves pending bytes unchanged"); + packet[0] = 0x28U; + packet[1] = 0; + check(hashx_usb_reply_append(&reply, packet, sizeof(packet)) == 0, + "empty declared chunk is ignored"); + check(reply.length == saved.length && memcmp(reply.data, saved.data, sizeof(reply.data)) == 0, + "empty chunk leaves pending bytes unchanged"); + length = make_packet(packet, frame_five + 29, HASHX_USB_FRAME_SIZE - 29); + check(hashx_usb_reply_append(&reply, packet, length) == 1, + "interleaved unrelated and empty reports do not corrupt a reply"); + check_reply(&reply, frame_five); +} + +static void test_malformed_chunks_reset(void) +{ + struct hashx_usb_reply reply; + unsigned char packet[HASHX_USB_PACKET_SIZE + 1]; + size_t length; + + hashx_usb_reply_reset(&reply); + check(hashx_usb_reply_append(&reply, NULL, 0) == -1, "missing packet is malformed"); + check(reply.length == 0 && reply.data[0] == '\0', "missing packet resets reply"); + memset(packet, 0, sizeof(packet)); + packet[0] = 0x28U; + check(hashx_usb_reply_append(&reply, packet, 1) == -1, "short chunk header is malformed"); + check(hashx_usb_reply_append(&reply, packet, sizeof(packet)) == -1, + "oversized USB packet is malformed"); + length = make_packet(packet, frame_five, 29); + check(hashx_usb_reply_append(&reply, packet, length - 1) == -1, + "truncated declared content cannot be decoded"); + packet[1] = 63; + check(hashx_usb_reply_append(&reply, packet, HASHX_USB_PACKET_SIZE) == -1, + "declared content larger than packet cannot be decoded"); + length = make_packet(packet, frame_five, 29); + check(hashx_usb_reply_append(&reply, packet, length) == 0, "start reply before malformed continuation"); + packet[2] = '\r'; + check(hashx_usb_reply_append(&reply, packet, length) == -1, + "CR before final status offset is malformed"); + check(reply.length == 0 && reply.data[0] == '\0', "malformed continuation discards pending reply"); + length = make_packet(packet, frame_five, 29); + check(hashx_usb_reply_append(&reply, packet, length) == 0, "start reply before repeated header"); + check(hashx_usb_reply_append(&reply, packet, length) == -1, + "new header while partial cannot join response cycles"); + check(reply.length == 0, "repeated header resets pending state"); + length = make_packet(packet, frame_five + 29, HASHX_USB_FRAME_SIZE - 29); + check(hashx_usb_reply_append(&reply, packet, length) == -1, + "continuation without initial header is malformed"); + length = make_packet(packet, frame_five, HASHX_USB_FRAME_SIZE); + packet[3] = 'X'; + check(hashx_usb_reply_append(&reply, packet, length) == -1, "unrecognized status prefix is malformed"); + reply.length = HASHX_USB_FRAME_SIZE + 1; + check(hashx_usb_reply_append(&reply, packet, length) == -1, + "corrupted accumulator length is rejected before arithmetic"); + check(reply.length == 0, "corrupted accumulator is reset"); + check(hashx_usb_reply_append(NULL, packet, length) == -1, "NULL accumulator is rejected"); +} + +static void test_overflow_and_recovery(void) +{ + struct hashx_usb_reply reply; + unsigned char packet[HASHX_USB_PACKET_SIZE]; + size_t length; + + hashx_usb_reply_reset(&reply); + length = make_packet(packet, frame_five, 29); + check(hashx_usb_reply_append(&reply, packet, length) == 0, "start reply before overflow"); + length = make_packet(packet, frame_five + 28, HASHX_USB_FRAME_SIZE - 28); + check(hashx_usb_reply_append(&reply, packet, length) == -1, "accumulated frame cannot overflow"); + check(reply.length == 0, "overflow resets accumulator"); + length = make_packet(packet, frame_four, HASHX_USB_FRAME_SIZE); + check(hashx_usb_reply_append(&reply, packet, length) == 1, + "new complete reply succeeds after rejected overflow"); + check_reply(&reply, frame_four); +} + +struct mock_packet { + unsigned char data[HASHX_USB_PACKET_SIZE]; + int count; + unsigned int timeout_ms; +}; + +struct mock_transport { + struct mock_packet reads[32]; + size_t read_count; + size_t read_next; + unsigned int writes; + int write_result; +}; + +static void mock_reset(struct mock_transport *mock) +{ + memset(mock, 0, sizeof(*mock)); + mock->write_result = HASHX_USB_PACKET_SIZE; +} + +static void mock_add_read(struct mock_transport *mock, const char *data, + size_t size, int count, unsigned int timeout_ms) +{ + struct mock_packet *event = &mock->reads[mock->read_count++]; + + event->count = count; + event->timeout_ms = timeout_ms; + if (data) + make_packet(event->data, data, size); +} + +static int mock_read(void *context, unsigned char *packet, size_t size, + unsigned int timeout_ms) +{ + struct mock_transport *mock = (struct mock_transport *)context; + const struct mock_packet *event; + + check(size == HASHX_USB_PACKET_SIZE, "mock read uses bounded packet buffer"); + if (mock->read_next >= mock->read_count) { + check(0, "transaction made an unexpected extra read"); + return -1; + } + event = &mock->reads[mock->read_next++]; + check(timeout_ms == event->timeout_ms, "drain and response read timeouts are distinct"); + if (event->count > 0 && event->count <= HASHX_USB_PACKET_SIZE) + memcpy(packet, event->data, (size_t)event->count); + return event->count; +} + +static int mock_write(void *context, const unsigned char *packet, size_t size, + unsigned int timeout_ms) +{ + struct mock_transport *mock = (struct mock_transport *)context; + unsigned char expected[HASHX_USB_PACKET_SIZE]; + + memset(expected, 0, sizeof(expected)); + expected[0] = 0x29U; + expected[1] = 2; + expected[2] = 'B'; + expected[3] = '\r'; + mock->writes++; + check(size == sizeof(expected), "status query writes a full 64-byte packet"); + check(timeout_ms == 1000, "status query has bounded write timeout"); + if (size == sizeof(expected)) + check(memcmp(packet, expected, sizeof(expected)) == 0, + "only B plus CR is sent, with zeroed packet padding"); + return mock->write_result; +} + +static void mock_add_reply(struct mock_transport *mock, const char *frame, + size_t split) +{ + mock_add_read(mock, frame, split, HASHX_USB_PACKET_SIZE, 250); + mock_add_read(mock, frame + split, HASHX_USB_FRAME_SIZE - split, + HASHX_USB_PACKET_SIZE, 250); +} + +static void test_query_discards_queued_old_reply(void) +{ + struct mock_transport mock; + struct hashx_usb_reply reply; + + mock_reset(&mock); + mock_add_read(&mock, frame_five, 29, HASHX_USB_PACKET_SIZE, 1); + mock_add_read(&mock, frame_five + 29, HASHX_USB_FRAME_SIZE - 29, + HASHX_USB_PACKET_SIZE, 1); + mock_add_read(&mock, NULL, 0, 0, 1); + mock_add_reply(&mock, frame_four, 28); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == 1, + "query discards complete old reply and accepts following fresh reply"); + check(mock.writes == 1 && mock.read_next == mock.read_count, + "query consumes expected drain and reply reads only"); + check_reply(&reply, frame_four); +} + +static void test_query_timeout_then_next_drain(void) +{ + struct mock_transport mock; + struct hashx_usb_reply reply; + + mock_reset(&mock); + mock_add_read(&mock, NULL, 0, 0, 1); + mock_add_read(&mock, frame_five, 29, HASHX_USB_PACKET_SIZE, 250); + mock_add_read(&mock, NULL, 0, 0, 250); + mock_add_read(&mock, frame_five + 29, HASHX_USB_FRAME_SIZE - 29, + HASHX_USB_PACKET_SIZE, 1); + mock_add_read(&mock, NULL, 0, 0, 1); + mock_add_reply(&mock, frame_four, 28); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "timeout during a fragmented reply fails the transaction"); + check(reply.length == 0 && reply.data[0] == '\0', + "failed query does not preserve partial or old output data"); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == 1, + "next query drains delayed old tail before accepting fresh reply"); + check(mock.writes == 2 && mock.read_next == mock.read_count, + "two transactions emit exactly two status queries"); + check_reply(&reply, frame_four); +} + +static void test_query_short_and_failed_writes(void) +{ + static const int write_results[] = { -1, 0, 63, 65 }; + struct mock_transport mock; + struct hashx_usb_reply reply; + size_t index; + + for (index = 0; index < sizeof(write_results) / sizeof(write_results[0]); index++) { + mock_reset(&mock); + mock.write_result = write_results[index]; + mock_add_read(&mock, NULL, 0, 0, 1); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "failed or non-full write cannot accept telemetry"); + check(reply.length == 0 && mock.writes == 1 && mock.read_next == 1, + "write failure resets state and stops before response reads"); + } +} + +static void test_query_negative_and_oversized_reads(void) +{ + static const int read_results[] = { -1, 65 }; + struct mock_transport mock; + struct hashx_usb_reply reply; + size_t index; + + for (index = 0; index < sizeof(read_results) / sizeof(read_results[0]); index++) { + mock_reset(&mock); + mock_add_read(&mock, NULL, 0, read_results[index], 1); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "negative or oversized drain read fails before query"); + check(mock.writes == 0 && reply.length == 0, "bad drain cannot send a query"); + mock_reset(&mock); + mock_add_read(&mock, NULL, 0, 0, 1); + mock_add_read(&mock, frame_five, 29, HASHX_USB_PACKET_SIZE, 250); + mock_add_read(&mock, NULL, 0, read_results[index], 250); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "negative or oversized response read fails without accepting partial data"); + check(mock.writes == 1 && reply.length == 0, "bad response resets partial reply"); + } +} + +static void test_query_bounded_drain_and_unknown_replies(void) +{ + struct mock_transport mock; + struct hashx_usb_reply reply; + size_t index; + + mock_reset(&mock); + for (index = 0; index < 8; index++) + mock_add_read(&mock, frame_five, 29, HASHX_USB_PACKET_SIZE, 1); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "continuous queued data exhausts bounded drain"); + check(mock.read_next == 8 && mock.writes == 0 && reply.length == 0, + "drain exhaustion sends no query and accepts no old data"); + + mock_reset(&mock); + mock_add_read(&mock, NULL, 0, 0, 1); + for (index = 0; index < 8; index++) { + mock_add_read(&mock, NULL, 0, HASHX_USB_PACKET_SIZE, 250); + mock.reads[mock.read_count - 1].data[0] = 0x2cU; + } + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "unknown reports exhaust bounded response reads without telemetry"); + check(mock.read_next == 9 && mock.writes == 1 && reply.length == 0, + "unknown replies cannot run an unbounded loop or preserve stale data"); +} + +static void test_query_zero_and_malformed_reply(void) +{ + struct mock_transport mock; + struct hashx_usb_reply reply; + char zero_frame[HASHX_USB_FRAME_SIZE + 1]; + + memcpy(zero_frame, frame_five, sizeof(zero_frame)); + memcpy(zero_frame + 14, "000", 3); + mock_reset(&mock); + mock_add_read(&mock, NULL, 0, 0, 1); + mock_add_reply(&mock, zero_frame, 29); + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == 1, + "valid zero load passes the transaction unchanged"); + check_reply(&reply, zero_frame); + + mock_reset(&mock); + mock_add_read(&mock, NULL, 0, 0, 1); + mock_add_read(&mock, frame_five, 29, HASHX_USB_PACKET_SIZE, 250); + mock.reads[1].data[1] = 63; + check(hashx_usb_status_query(&mock, mock_read, mock_write, &reply) == -1, + "malformed declared chunk fails transaction before mapping"); + check(reply.length == 0 && mock.writes == 1, "malformed transaction has no output frame"); +} + +int main(void) +{ + test_captured_fixtures(); + test_all_fragment_boundaries(); + test_zero_load_overload_and_status_schema(); + test_ignored_packets(); + test_malformed_chunks_reset(); + test_overflow_and_recovery(); + test_query_discards_queued_old_reply(); + test_query_timeout_then_next_drain(); + test_query_short_and_failed_writes(); + test_query_negative_and_oversized_reads(); + test_query_bounded_drain_and_unknown_replies(); + test_query_zero_and_malformed_reply(); + printf("HashX USB protocol: %u checks, %u failures\n", checks, failures); + return failures ? EXIT_FAILURE : EXIT_SUCCESS; +}