Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions NEWS.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions data/driver.list.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 73 additions & 7 deletions docs/man/nutdrv_hashx.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------
Expand Down Expand Up @@ -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:

Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/nut.dict
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ Hajduch
Hanno
Harnhammar
Hasangarayev
HashX
Havard
Heavner
Hessenflow
Expand Down Expand Up @@ -2858,6 +2859,7 @@ myprivatepassphrase
mysecurityname
myups
myupsname
nJoy
nLogic
nMONITOR
nPOWERDOWNFLAG
Expand Down
21 changes: 18 additions & 3 deletions drivers/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
168 changes: 168 additions & 0 deletions drivers/hashx-usb-protocol.c
Original file line number Diff line number Diff line change
@@ -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 <string.h>

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);
}
Loading