From a5a66e00fcaca8ae8c738e452ff2f2e019fd28d3 Mon Sep 17 00:00:00 2001 From: Jordi-14 Date: Sat, 24 Oct 2026 17:36:04 +0200 Subject: [PATCH] powerpanel: add query-only USB text transport for OLS2000ERT2UA Signed-off-by: Jordi-14 --- NEWS.adoc | 5 + data/driver.list.in | 1 + docs/man/powerpanel.txt | 43 ++++++- docs/nut.dict | 4 +- drivers/Makefile.am | 6 +- drivers/powerp-txt-parser.c | 183 ++++++++++++++++++++++++++++ drivers/powerp-txt-parser.h | 53 ++++++++ drivers/powerp-txt.c | 155 ++++++------------------ drivers/powerp-usb.c | 214 +++++++++++++++++++++++++++++++++ drivers/powerp-usb.h | 10 ++ drivers/powerpanel.c | 59 ++++++++- drivers/powerpanel.h | 2 + tests/Makefile.am | 12 ++ tests/powerpanel-parser-test.c | 63 ++++++++++ tests/powerpanel-usb-test.c | 75 ++++++++++++ tools/nut-usbinfo.pl | 4 + 16 files changed, 763 insertions(+), 126 deletions(-) create mode 100644 drivers/powerp-txt-parser.c create mode 100644 drivers/powerp-txt-parser.h create mode 100644 drivers/powerp-usb.c create mode 100644 drivers/powerp-usb.h create mode 100644 tests/powerpanel-parser-test.c create mode 100644 tests/powerpanel-usb-test.c diff --git a/NEWS.adoc b/NEWS.adoc index 51c221fc52..7820b84cf6 100644 --- a/NEWS.adoc +++ b/NEWS.adoc @@ -506,6 +506,11 @@ https://github.com/networkupstools/nut/milestone/13 `ups.power.nominal`, `ups.realpower.nominal` and the computed `ups.load`. [issue #3500] + - `powerpanel` driver updates: + * Added an opt-in, query-only USB text transport for OLS2000ERT2UA + and decode reported output current, watts and VA. Serial controls + remain unchanged; USB electrical-output commands are not implemented. + - `richcomm_usb` driver updates: * Completed support for NUT standard USB configuration options, and fixed some problems possible when iterating devices. [issue #1768, PR #3477] diff --git a/data/driver.list.in b/data/driver.list.in index 2daf03dcdd..6ffd39e9dc 100644 --- a/data/driver.list.in +++ b/data/driver.list.in @@ -278,6 +278,7 @@ "Cyber Power Systems" "ups" "1" "550SL" "" "genericups upstype=7" "Cyber Power Systems" "ups" "1" "725SL" "" "genericups upstype=7" +"Cyber Power Systems" "ups" "1" "OLS2000ERT2UA" "USB, query-only" "powerpanel transport=usb" "Cyber Power Systems" "ups" "1" "CPS1100AVR" "" "powerpanel" "Cyber Power Systems" "ups" "1" "CPS1200AVR" "" "powerpanel" "Cyber Power Systems" "ups" "1" "CPS1250AVR" "" "powerpanel" diff --git a/docs/man/powerpanel.txt b/docs/man/powerpanel.txt index 8e86742545..d0b6e74306 100644 --- a/docs/man/powerpanel.txt +++ b/docs/man/powerpanel.txt @@ -4,7 +4,7 @@ POWERPANEL(8) NAME ---- -powerpanel - Driver for serial PowerPanel Plus compatible UPS equipment +powerpanel - Driver for PowerPanel Plus compatible UPS equipment SYNOPSIS -------- @@ -28,11 +28,38 @@ If your Cyber Power Systems UPS has a USB port, you may wish to use the linkman:usbhid-ups[8] driver. The linkman:snmp-ups[8] driver supports several network cards via SNMP. +The OLS2000ERT2UA also supports an opt-in USB text transport, requiring a +libusb-1.0 build. This mode provides read-only monitoring, including actual +output current, watts and VA reported by the device. It does not provide UPS +power-off commands or writable settings. Serial operation remains the default. +If no explicit transport is configured, `port = auto` selects USB mode. +Static battery topology and hardware shutdown delays are not reported in USB +mode because their protocol interpretation has not been validated. + +Only one USB device with ID `0764:0601` may be connected for this initial +implementation, and its model must identify as OLS2000ERT2UA. Other models have +not been validated. Do not run another USB driver against the same UPS. + +Example linkman:ups.conf[5] section: + +---- +[myups] + driver = powerpanel + port = auto + transport = usb + protocol = text +---- + EXTRA ARGUMENTS --------------- This driver supports the following optional settings in linkman:ups.conf[5]: +*transport=*['serial,usb']:: +Select the connection transport. The default is serial, except that +`port = auto` selects USB. USB currently supports +only the text protocol and read-only monitoring of OLS2000ERT2UA. + *protocol=*['text,binary']:: Override the default autodetection of the protocol. @@ -48,12 +75,12 @@ Like manufacturer above. *ondelay=*'value':: Time to wait before switching on the UPS (1 - 9999 minutes, 0=indefinite). -Only available with the text protocol driver (see <<_support_status,Support Status>>). +Only available with the serial text protocol driver (see <<_support_status,Support Status>>). *offdelay=*'value':: Time to wait before shutting down the UPS (6 - 600 seconds). Values below 60 seconds will be truncated to 6 seconds intervals, values above 60 seconds to -60 seconds intervals. Only available with the text protocol driver (see +60 seconds intervals. Only available with the serial text protocol driver (see <<_support_status,Support Status>>). VARIABLES @@ -63,6 +90,11 @@ Depending on the type of your UPS unit, some of the following variables may be changed with linkman:upsrw[8]. If the driver can't read a variable from the UPS, it will not be made available. +USB text mode does not mark any settings writable. Where the status reply +contains them, `output.current` is reported in amperes, `ups.realpower` in +watts, and `ups.power` in VA. These values are read from the UPS rather than +estimated from its load percentage. + *input.transfer.high*:: writable: high transfer voltage point in V @@ -84,6 +116,11 @@ COMMANDS Depending on the type of your UPS unit, some of the following commands may be available. +The commands in this section apply to serial connections. USB text mode does +not register instant commands or issue electrical-output controls. In +particular, a USB-mode driver shutdown request fails rather than guessing a +power-off command. + * test.battery.start.quick, test.battery.stop * beeper.enable, beeper.disable, beeper.toggle diff --git a/docs/nut.dict b/docs/nut.dict index 5f351d8cd9..445c090005 100644 --- a/docs/nut.dict +++ b/docs/nut.dict @@ -1,4 +1,4 @@ -personal_ws-1.1 en 3917 utf-8 +personal_ws-1.1 en 3921 utf-8 AAC AAS ABI @@ -365,6 +365,7 @@ EPFCLCD EPIPE EPO EPS +ERT ESC ESS ESV @@ -920,6 +921,7 @@ OFFDURATION OID OIDs OLHVT +OLS OMNIVS OMNIVSINT OMRON diff --git a/drivers/Makefile.am b/drivers/Makefile.am index 8cc9bffd48..8522b8a099 100644 --- a/drivers/Makefile.am +++ b/drivers/Makefile.am @@ -281,8 +281,12 @@ oneac_SOURCES = oneac.c optiups_SOURCES = optiups.c powercom_SOURCES = powercom.c powercom_LDADD = $(LDADD_DRIVERS_SERIAL) -lm -powerpanel_SOURCES = powerpanel.c powerp-bin.c powerp-txt.c +powerpanel_SOURCES = powerpanel.c powerp-bin.c powerp-txt.c powerp-txt-parser.c powerp-txt-parser.h powerpanel_LDADD = $(LDADD_DRIVERS_SERIAL) -lm +if WITH_LIBUSB_1_0 +powerpanel_SOURCES += powerp-usb.c powerp-usb.h +powerpanel_LDADD += $(LIBUSB_LIBS) +endif WITH_LIBUSB_1_0 powervar_cx_ser_SOURCES = powervar_cx_ser.c powervar_cx.c powervar_cx_ser_CFLAGS = $(AM_CFLAGS) -DPVAR_SERIAL=1 rhino_SOURCES = rhino.c diff --git a/drivers/powerp-txt-parser.c b/drivers/powerp-txt-parser.c new file mode 100644 index 0000000000..9657943571 --- /dev/null +++ b/drivers/powerp-txt-parser.c @@ -0,0 +1,183 @@ +/* + * powerp-txt.c - Model specific routines for CyberPower text + * protocol UPSes + * + * Copyright (C) + * 2007 Doug Reynolds + * 2007-2008 Arjen de Korte + * 2012-2016 Timothy Pearson + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ + +#include "common.h" +#include "powerp-txt-parser.h" +#include +#include +#include + +static int parse_current(const char *text, float *out) +{ + char *end; + double value; + errno = 0; + value = strtod(text, &end); + if (end == text || *end || errno == ERANGE || !(value >= 0 && value <= FLT_MAX)) { + return -1; + } + *out = (float)value; + return 0; +} + +static int parse_power(const char *text, int *out) +{ + char *end; + long value; + errno = 0; + value = strtol(text, &end, 10); + if (end == text || *end || errno == ERANGE || value < 0 || value > INT_MAX) { + return -1; + } + *out = (int)value; + return 0; +} + + /* + * Status format from the D query (wire examples): + * READ #I119.0O119.0L000B100T027F060.0S..\r + * #I118.0O118.0L029B100F060.0R0218S..\r + * #I118.1O118.1L13B100V27.5F60.0HF60.0R65Q1.4S\x80\x84\xc0\x88\x80W\r (CST135XLU) + * 01234567890123456789012345678901234567890123 + * 0 1 2 3 4 + */ + +int powpan_parse_status(const char *answer, size_t length, powpan_status_t *status) +{ + size_t i, ofs = 0; + size_t valid = 0; + int code = -1; + char value[32]; + char seen_i_freq = 0; + int seen_flags = 0; + if (!answer || !status || length < 2) { + return -1; + } + memset(status, 0, sizeof(*status)); + if (answer[0] != '#') { + return -1; + } + + for (i = 1; i <= length; i++) { + if (i == length || isalpha((unsigned char)(answer[i]))) { + if (i < length && (code == 'Q' || code == 'J' || code == 'E') + && islower((unsigned char)answer[i])) { + return -1; + } + value[ofs] = '\0'; + valid++; + + switch (code) { + case 'I': + status->i_volt = strtof(value, NULL); + break; + case 'O': + status->o_volt = strtof(value, NULL); + break; + case 'L': + status->o_load = strtol(value, NULL, 10); + break; + case 'B': + status->b_chrg = strtol(value, NULL, 10); + break; + case 'V': + status->b_volt = strtof(value, NULL); + status->has_b_volt = 1; + break; + case 'T': + status->u_temp = strtol(value, NULL, 10); + status->has_u_temp = 1; + break; + case 'F': + status->i_freq = strtof(value, NULL); + seen_i_freq = 1; + break; + case 'H': + status->o_freq = strtof(value, NULL); + status->has_o_freq = 1; + break; + case 'R': + status->runtime = strtol(value, NULL, 10); + status->has_runtime = 1; + break; + case 'Q': + if (status->has_o_current || parse_current(value, &status->o_current) < 0) { + return -1; + } + status->has_o_current = 1; + break; + case 'J': + if (status->has_realpower || parse_power(value, &status->realpower) < 0) { + return -1; + } + status->has_realpower = 1; + break; + case 'E': + if (status->has_power || parse_power(value, &status->power) < 0) { + return -1; + } + status->has_power = 1; + break; + case 'S': + if (ofs < 2) { + return -1; + } + memcpy(&status->flags, value, 2); + seen_flags = 1; + break; + default: + /* We didn't really find valid data */ + valid--; + break; + } + + if (i == length) { + break; + } + code = answer[i]; + ofs = 0; + + /* + * Depending on device/firmware, the output frequency is coded as + * either an H, HF, or a second F (seen once transfered to battery) + */ + if (seen_i_freq && code == 'F') + code = 'H'; + + continue; + } + + if (ofs >= sizeof(value) - 1) { + return -1; + } + value[ofs++] = answer[i]; + } + + /* if we didn't get at least 3 values consider it a failure */ + if (valid < 3 || !seen_flags) { + return -1; + } + + return 0; +} diff --git a/drivers/powerp-txt-parser.h b/drivers/powerp-txt-parser.h new file mode 100644 index 0000000000..55a5d795b3 --- /dev/null +++ b/drivers/powerp-txt-parser.h @@ -0,0 +1,53 @@ +/* + * powerp-txt.c - Model specific routines for CyberPower text + * protocol UPSes + * + * Copyright (C) + * 2007 Doug Reynolds + * 2007-2008 Arjen de Korte + * 2012-2016 Timothy Pearson + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ + +#ifndef POWERP_TXT_PARSER_H +#define POWERP_TXT_PARSER_H +#include + +typedef struct { + float i_volt; + float o_volt; + int o_load; + int b_chrg; + unsigned char has_u_temp; + int u_temp; + float i_freq; + unsigned char flags[2]; + unsigned char has_b_volt; + float b_volt; + unsigned char has_o_freq; + float o_freq; + unsigned char has_runtime; + int runtime; + unsigned char has_o_current; + float o_current; + unsigned char has_realpower; + int realpower; + unsigned char has_power; + int power; +} powpan_status_t; + +int powpan_parse_status(const char *answer, size_t length, powpan_status_t *status); +#endif diff --git a/drivers/powerp-txt.c b/drivers/powerp-txt.c index 9721622932..fb6ea8810e 100644 --- a/drivers/powerp-txt.c +++ b/drivers/powerp-txt.c @@ -33,27 +33,14 @@ #include "nut_stdint.h" #include "powerp-txt.h" +#include "powerp-usb.h" #include -#define POWERPANEL_TEXT_VERSION "Powerpanel-Text 0.65" - -typedef struct { - float i_volt; - float o_volt; - int o_load; - int b_chrg; - unsigned char has_u_temp; - int u_temp; - float i_freq; - unsigned char flags[2]; - unsigned char has_b_volt; - float b_volt; - unsigned char has_o_freq; - float o_freq; - unsigned char has_runtime; - int runtime; -} status_t; +#define POWERPANEL_TEXT_VERSION "Powerpanel-Text 0.66" + +#include "powerp-txt-parser.h" + static long ondelay = 1; /* minutes */ static long offdelay = 60; /* seconds */ @@ -90,6 +77,12 @@ static ssize_t powpan_command(const char *command) { ssize_t ret; +#if WITH_LIBUSB_1_0 + if (powerpanel_usb_mode) { + return powerpanel_usb_command(command, powpan_answer, sizeof(powpan_answer)); + } +#endif + ser_flush_io(upsfd); ret = ser_send_pace(upsfd, UPSDELAY, "%s", command); @@ -237,8 +230,10 @@ static void powpan_initinfo(void) int i; char *s; - dstate_setinfo("ups.delay.start", "%ld", 60 * ondelay); - dstate_setinfo("ups.delay.shutdown", "%ld", offdelay); + if (!powerpanel_usb_mode) { + dstate_setinfo("ups.delay.start", "%ld", 60 * ondelay); + dstate_setinfo("ups.delay.shutdown", "%ld", offdelay); + } /* * NOTE: The reply is already in the buffer, since the P4\r command @@ -262,7 +257,8 @@ static void powpan_initinfo(void) * READ #12.0,002,008.0,00\r * #12,2x1,12,0,1,8\r (CST135XLU) */ - if (powpan_command("P3\r") > 0) { + /* USB battery topology has not been validated; retain serial semantics. */ + if (!powerpanel_usb_mode && powpan_command("P3\r") > 0) { if ((s = strtok(&powpan_answer[1], ",")) != NULL) { dstate_setinfo("battery.voltage.nominal", "%f", strtod(s, NULL)); @@ -320,6 +316,11 @@ static void powpan_initinfo(void) } } + /* USB text support publishes data only; serial controls remain unchanged. */ + if (powerpanel_usb_mode) { + return; + } + for (i = 0; cmdtab[i].cmd != NULL; i++) { dstate_addcmd(cmdtab[i].cmd); } @@ -384,110 +385,19 @@ static void powpan_initinfo(void) upsh.setvar = powpan_setvar; } -static ssize_t powpan_status(status_t *status) +static ssize_t powpan_status(powpan_status_t *status) { - ssize_t ret; - ssize_t i; - ssize_t valid = 0; - int code = -1; - char value[32]; - ssize_t ofs = 0; - char seen_i_freq = 0; - - memset(status, 0, sizeof(status_t)); - - /* - * WRITE D\r - * READ #I119.0O119.0L000B100T027F060.0S..\r - * #I118.0O118.0L029B100F060.0R0218S..\r - * #I118.1O118.1L13B100V27.5F60.0HF60.0R65Q1.4S\x80\x84\xc0\x88\x80W\r (CST135XLU) - * 01234567890123456789012345678901234567890123 - * 0 1 2 3 4 - */ - ret = powpan_command("D\r"); - if (ret <= 0) - return -1; - - if (powpan_answer[0] != '#') { - upsdebugx(2, "Expected start character '#', but got '%c'", powpan_answer[0]); - return -1; - } - - for (i = 1; i <= ret; i++) { - if (i == ret || isalpha((unsigned char)(powpan_answer[i]))) { - value[ofs++] = '\0'; - valid++; - - switch (code) { - case 'I': - status->i_volt = strtof(value, NULL); - break; - case 'O': - status->o_volt = strtof(value, NULL); - break; - case 'L': - status->o_load = strtol(value, NULL, 10); - break; - case 'B': - status->b_chrg = strtol(value, NULL, 10); - break; - case 'V': - status->b_volt = strtof(value, NULL); - status->has_b_volt = 1; - break; - case 'T': - status->u_temp = strtol(value, NULL, 10); - status->has_u_temp = 1; - break; - case 'F': - status->i_freq = strtof(value, NULL); - seen_i_freq = 1; - break; - case 'H': - status->o_freq = strtof(value, NULL); - status->has_o_freq = 1; - break; - case 'R': - status->runtime = strtol(value, NULL, 10); - status->has_runtime = 1; - break; - case 'S': - memcpy(&status->flags, value, 2); - break; - default: - /* We didn't really find valid data */ - valid--; - break; - } - - code = powpan_answer[i]; - ofs = 0; - - /* - * Depending on device/firmware, the output frequency is coded as - * either an H, HF, or a second F (seen once transfered to battery) - */ - if (seen_i_freq && code == 'F') - code = 'H'; - - continue; - } - - value[ofs++] = powpan_answer[i]; - } - - /* if we didn't get at least 3 values consider it a failure */ - if (valid < 3) { + ssize_t ret = powpan_command("D\r"); + if (ret <= 0 || powpan_parse_status(powpan_answer, (size_t)ret, status) < 0) { upsdebugx(4, "Parsing status string failed"); return -1; } - return 0; } static int powpan_updateinfo(void) { - status_t status; + powpan_status_t status; if (powpan_status(&status)) { return -1; @@ -513,6 +423,15 @@ static int powpan_updateinfo(void) if (status.has_runtime) { dstate_setinfo("battery.runtime", "%d", status.runtime*60); } + if (status.has_o_current) { + dstate_setinfo("output.current", "%.1f", status.o_current); + } + if (status.has_realpower) { + dstate_setinfo("ups.realpower", "%d", status.realpower); + } + if (status.has_power) { + dstate_setinfo("ups.power", "%d", status.power); + } status_init(); @@ -562,7 +481,9 @@ static ssize_t powpan_initups(void) upsdebugx(1, "Trying text protocol..."); - ser_set_speed(upsfd, device_path, B2400); + if (!powerpanel_usb_mode) { + ser_set_speed(upsfd, device_path, B2400); + } /* This fails for many devices, so don't bother to complain */ powpan_command("\r\r"); diff --git a/drivers/powerp-usb.c b/drivers/powerp-usb.c new file mode 100644 index 0000000000..baaf744487 --- /dev/null +++ b/drivers/powerp-usb.c @@ -0,0 +1,214 @@ +/* CyberPower HID text transport, documented from observed USB traffic. + * Initial support is deliberately limited to OLS2000ERT2UA queries. + * No vendor implementation is included. GNU GPL version 2 or later. + * + * Observed framing: 64-byte interrupt OUT packets on endpoint 0x02 start + * with report 0x29 and a payload byte count, then CR-terminated text and + * zero padding. Endpoint 0x81 replies use report 0x28 and the same length + * prefix. A reply can span packets, even splitting a numeric field. + * Feature reports 0x25 and 0x27 perform the observed communication setup. + * The application also sends C* messages; those are intentionally excluded + * from this initial query-only transport. + */ +#include "main.h" +#include "powerp-usb.h" + +#if WITH_LIBUSB_1_0 +#include "usb-common.h" + +/* Cyber Power Systems */ +#define POWERPANEL_VENDORID 0x0764 + +static usb_device_id_t powerpanel_usb_device_table[] = { + { USB_DEVICE(POWERPANEL_VENDORID, 0x0601), NULL }, + { 0, 0, NULL } +}; + +static libusb_context *context; +static libusb_device_handle *handle; +static int claimed, detached; + +static int feature(unsigned int id, unsigned char value) +{ + unsigned char data[2]; + data[0] = (unsigned char)id; + data[1] = value; + return libusb_control_transfer(handle, 0x21, 9, (uint16_t)(0x300 | id), + 0, data, sizeof(data), 1000) == 2 ? 0 : -1; +} + +int powerpanel_usb_connected(void) +{ + return handle != NULL; +} + +void powerpanel_usb_cleanup(void) +{ + if (handle) { + if (claimed) { + libusb_release_interface(handle, 0); + } + if (detached) { + libusb_attach_kernel_driver(handle, 0); + } + libusb_close(handle); + handle = NULL; + } + if (context) { + libusb_exit(context); + context = NULL; + } + claimed = detached = 0; +} + +int powerpanel_usb_init(void) +{ + libusb_device **devices = NULL; + libusb_device *candidate = NULL; + struct libusb_device_descriptor descriptor; + unsigned char model[128], packet[64]; + ssize_t count, i; + int matches = 0, result, length; + + powerpanel_usb_cleanup(); + if (libusb_init(&context) != 0) { + return -1; + } + count = libusb_get_device_list(context, &devices); + if (count < 0) { + goto failed; + } + for (i = 0; i < count; i++) { + if (libusb_get_device_descriptor(devices[i], &descriptor) == 0 + && descriptor.idVendor == powerpanel_usb_device_table[0].vendorID + && descriptor.idProduct == powerpanel_usb_device_table[0].productID) { + candidate = devices[i]; + matches++; + } + } + /* Do not choose an arbitrary UPS if more than one shares this USB ID. */ + if (matches != 1 || libusb_open(candidate, &handle) != 0) { + upslogx(LOG_WARNING, "USB text transport requires exactly one accessible 0764:0601 UPS"); + goto failed; + } + if (libusb_get_device_descriptor(candidate, &descriptor) != 0) { + goto failed; + } + result = libusb_get_string_descriptor_ascii(handle, descriptor.iProduct, model, sizeof(model)-1); + if (result < 1) { + goto failed; + } + model[result] = '\0'; + if (strcmp((char *)model, "OLS2000ERT2UA") != 0) { + upslogx(LOG_WARNING, "USB text transport has only been validated for OLS2000ERT2UA"); + goto failed; + } + if (libusb_kernel_driver_active(handle, 0) == 1) { + if (libusb_detach_kernel_driver(handle, 0) != 0) { + goto failed; + } + detached = 1; + } + if (libusb_claim_interface(handle, 0) != 0) { + goto failed; + } + claimed = 1; + /* Observed communication-mode setup, not an electrical output command. */ + if (feature(0x25, 1) < 0) { + goto failed; + } + for (i = 0; i < 4; i++) { + result = libusb_interrupt_transfer(handle, 0x81, packet, sizeof(packet), &length, 100); + if (result == LIBUSB_ERROR_TIMEOUT) { + break; + } + if (result != 0) { + goto failed; + } + } + if (feature(0x27, 0) < 0) { + goto failed; + } + usleep(400000); + if (feature(0x27, 1) < 0) { + goto failed; + } + usleep(400000); + libusb_free_device_list(devices, 1); + return 0; + +failed: + if (devices) { + libusb_free_device_list(devices, 1); + } + powerpanel_usb_cleanup(); + return -1; +} + +static int query_allowed(const char *command) +{ + const char *queries[] = {"D\r", "P1\r", "P2\r", "P3\r", "P4\r", "P5\r", + "P6\r", "P7\r", "P8\r", "P9\r", "\r", "\r\r", NULL}; + size_t i; + for (i = 0; queries[i]; i++) { + if (strcmp(command, queries[i]) == 0) { + return 1; + } + } + return 0; +} + +ssize_t powerpanel_usb_command(const char *command, char *reply, size_t size) +{ + unsigned char packet[64]; + size_t used = 0, i, command_length; + int result, transferred = 0, attempt; + if (!handle || !command || !reply || size < 2 || !query_allowed(command)) { + return -1; + } + command_length = strlen(command); + if (command_length > sizeof(packet)-2) { + return -1; + } + memset(packet, 0, sizeof(packet)); + packet[0] = 0x29; + packet[1] = (unsigned char)command_length; + memcpy(packet+2, command, command_length); + result = libusb_interrupt_transfer(handle, 0x02, packet, sizeof(packet), &transferred, 1000); + if (result != 0 || transferred != (int)sizeof(packet)) { + return -1; + } + /* Bound the complete operation: no unlimited wait for a terminator. */ + for (attempt = 0; attempt < 8; attempt++) { + transferred = 0; + result = libusb_interrupt_transfer(handle, 0x81, packet, sizeof(packet), &transferred, 250); + if (result == LIBUSB_ERROR_TIMEOUT && transferred == 0) { + continue; + } + if (result != 0 && result != LIBUSB_ERROR_TIMEOUT) { + return -1; + } + if (transferred < 2 || packet[0] != 0x28) { + continue; + } + if (packet[1] > sizeof(packet)-2 || (int)packet[1]+2 > transferred) { + return -1; + } + for (i = 0; i < packet[1]; i++) { + unsigned char byte = packet[i+2]; + if (byte == '\r') { + if (i+1 != packet[1]) { + return -1; + } + reply[used] = '\0'; + return (ssize_t)used; + } + if (used >= size-1) { + return -1; + } + reply[used++] = (char)byte; + } + } + return -1; +} +#endif diff --git a/drivers/powerp-usb.h b/drivers/powerp-usb.h new file mode 100644 index 0000000000..0352f0f2b9 --- /dev/null +++ b/drivers/powerp-usb.h @@ -0,0 +1,10 @@ +/* CyberPower HID text transport. GNU GPL version 2 or later. */ +#ifndef POWERP_USB_H +#define POWERP_USB_H + +int powerpanel_usb_init(void); +void powerpanel_usb_cleanup(void); +int powerpanel_usb_connected(void); +ssize_t powerpanel_usb_command(const char *command, char *reply, size_t size); + +#endif diff --git a/drivers/powerpanel.c b/drivers/powerpanel.c index 8a00fde576..c5ec110058 100644 --- a/drivers/powerpanel.c +++ b/drivers/powerpanel.c @@ -26,8 +26,12 @@ #include "powerp-bin.h" #include "powerp-txt.h" +#include "powerp-usb.h" + +int powerpanel_usb_mode = 0; static int mode = 0; +static int reconnecting = 0; static subdriver_t *subdriver[] = { &powpan_binary, @@ -36,7 +40,7 @@ static subdriver_t *subdriver[] = { }; #define DRIVER_NAME "CyberPower text/binary protocol UPS driver" -#define DRIVER_VERSION "0.32" +#define DRIVER_VERSION "0.33" /* driver description structure */ upsdrv_info_t upsdrv_info = { @@ -55,8 +59,15 @@ static int reconnect_ups(void) reconnect_trying(RECONNECT_TRYING); upsdrv_cleanup(); + reconnecting = 1; upsdrv_initups(); - if (INVALID_FD_SER(upsfd)) + reconnecting = 0; +#if WITH_LIBUSB_1_0 + if (powerpanel_usb_mode && !powerpanel_usb_connected()) { + return 0; + } +#endif + if (!powerpanel_usb_mode && INVALID_FD_SER(upsfd)) return 0; reconnect_trying(RECONNECT_UPDATEINFO); @@ -89,8 +100,10 @@ void upsdrv_initinfo(void) dstate_setinfo("ups.serial", "%s", s); } - upsh.instcmd = subdriver[mode]->instcmd; - upsh.setvar = subdriver[mode]->setvar; + if (!powerpanel_usb_mode) { + upsh.instcmd = subdriver[mode]->instcmd; + upsh.setvar = subdriver[mode]->setvar; + } } void upsdrv_updateinfo(void) @@ -129,6 +142,12 @@ void upsdrv_shutdown(void) int i, ret = -1; + if (powerpanel_usb_mode) { + upslogx(LOG_ERR, "USB text transport is read-only; UPS power-off is unsupported"); + set_exit_flag(EF_EXIT_FAILURE); + return; + } + /* * Try to shutdown with delay and automatic reboot if the power * returned in the mean time (newer models support this). @@ -176,6 +195,31 @@ void upsdrv_initups(void) char *version; version = getval("protocol"); + if (getval("transport") && strcasecmp(getval("transport"), "serial") + && strcasecmp(getval("transport"), "usb")) { + fatalx(EXIT_FAILURE, "Unknown transport: expected serial or usb"); + } + if ((getval("transport") && !strcasecmp(getval("transport"), "usb")) + || (!getval("transport") && !strcmp(device_path, "auto"))) { +#if WITH_LIBUSB_1_0 + if (version && strcasecmp(version, "text")) { + fatalx(EXIT_FAILURE, "USB transport supports only the text protocol"); + } + powerpanel_usb_mode = 1; + mode = 1; /* Text subdriver. */ + if (powerpanel_usb_init() == 0 && subdriver[mode]->initups() > 0) { + upslogx(LOG_INFO, "CyberPower USB text protocol detected (query-only)"); + return; + } + powerpanel_usb_cleanup(); + if (reconnecting) { + return; + } + fatalx(EXIT_FAILURE, "CyberPower USB text protocol not found"); +#else + fatalx(EXIT_FAILURE, "USB text transport requires a libusb-1.0 build"); +#endif + } upsfd = ser_open(device_path); if (INVALID_FD_SER(upsfd)) { @@ -221,6 +265,7 @@ void upsdrv_tweak_prognames(void) void upsdrv_makevartable(void) { + addvar(VAR_VALUE, "transport", "Connection transport [serial|usb] (default: serial)"); addvar(VAR_VALUE, "ondelay", "Delay before UPS startup"); addvar(VAR_VALUE, "offdelay", "Delay before UPS shutdown"); @@ -233,6 +278,12 @@ void upsdrv_makevartable(void) void upsdrv_cleanup(void) { upsdebugx(1, "%s: begin", __func__); +#if WITH_LIBUSB_1_0 + if (powerpanel_usb_mode) { + powerpanel_usb_cleanup(); + return; + } +#endif if (VALID_FD_SER(upsfd)) { ser_set_dtr(upsfd, 0); ser_close(upsfd, device_path); diff --git a/drivers/powerpanel.h b/drivers/powerpanel.h index b2b34be0e9..bab452ebdb 100644 --- a/drivers/powerpanel.h +++ b/drivers/powerpanel.h @@ -32,6 +32,8 @@ #define SER_WAIT_SEC 0 #define SER_WAIT_USEC 250000 +extern int powerpanel_usb_mode; + typedef struct { const char *version; /* TODO: Rename: this is a subdriver type: "text" or "binary" */ const char *versionString; /* TODO: Rename: this is the actual subdriver version */ diff --git a/tests/Makefile.am b/tests/Makefile.am index 3577f83028..58217eacca 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -481,3 +481,15 @@ MAINTAINERCLEANFILES = Makefile.in .dirstamp # Helper for only the enabled libs to get built: all-libs-local: $(lib_LTLIBRARIES) $(noinst_LTLIBRARIES) $(EXTRA_LTLIBRARIES) + +# Pure PowerPanel text parser, including USB-observed numeric fields. +TESTS += powerpanel-parser-test +powerpanel_parser_test_SOURCES = powerpanel-parser-test.c ../drivers/powerp-txt-parser.c +powerpanel_parser_test_LDADD = $(NUT_LIBCOMMON) -lm + +if WITH_LIBUSB_1_0 +TESTS += powerpanel-usb-test +powerpanel_usb_test_SOURCES = powerpanel-usb-test.c +powerpanel_usb_test_CFLAGS = $(AM_CFLAGS) $(LIBUSB_CFLAGS) +powerpanel_usb_test_LDADD = $(NUT_LIBCOMMON) $(LIBUSB_LIBS) +endif WITH_LIBUSB_1_0 diff --git a/tests/powerpanel-parser-test.c b/tests/powerpanel-parser-test.c new file mode 100644 index 0000000000..7a95fddc46 --- /dev/null +++ b/tests/powerpanel-parser-test.c @@ -0,0 +1,63 @@ +/* Regression cases from OLS2000ERT2UA USB text captures and documented + * legacy powerpanel replies. No device access or control commands. + * This program is distributed under the GNU GPL version 2 or later. + */ +#include "common.h" +#include "powerp-txt-parser.h" +#include + +static int failed; + +static void check(int condition, const char *message) +{ + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message); + failed++; + } +} + +int main(void) +{ + powpan_status_t status; + const char *frames[] = { + "#I232.5O230.0L013B100V54.7F50.0H50.0R060Q01.1J190E270S\x80\x80\xd0\x88\x80" "W\x81\x81", + "#I233.0O230.0L009B100V54.7F50.0H50.0R069Q00.7J149E191S\x80\x80\xd0\x88\x80" "W\x81\x81P3", + "#I233.0O230.0L012B100V54.7F50.0H50.0R063Q01.0J150E254S\x80\x80\xd0\x88\x80" "W\x81\x81P9", + "#I233.0O230.0L011B100V54.7F50.0H50.0R063Q00.9J200E253S\x80\x80\xd0\x88\x80" "W\x81\x81" + }; + const int watts[] = {190, 149, 150, 200}; + const int va[] = {270, 191, 254, 253}; + const float amps[] = {1.1f, 0.7f, 1.0f, 0.9f}; + const int minutes[] = {60, 69, 63, 63}; + const char *bad[] = { + "#I233O230J149", "#I233O230J149S\x80", + "#I233O230Q-1S\x80\x80", "#I233O230QnanS\x80\x80", + "#I233O230Q1e999S\x80\x80", "#I233O230J999999999999999999999S\x80\x80", + "#I233O230J1.5S\x80\x80", "#I233O230J149J150S\x80\x80", + "#I233O230Q111111111111111111111111111111111111111111111111S\x80\x80" + }; + size_t i; + for (i = 0; i < sizeof(frames) / sizeof(frames[0]); i++) { + check(powpan_parse_status(frames[i], strlen(frames[i]), &status) == 0, "captured status accepted"); + check(status.has_realpower && status.realpower == watts[i], "reported watts decoded"); + check(status.has_power && status.power == va[i], "reported VA decoded"); + check(status.has_o_current && fabs(status.o_current - amps[i]) < 0.001, "reported amperes decoded"); + check(status.has_runtime && status.runtime == minutes[i], "runtime remains in wire minutes"); + } + { + const char legacy[] = "#I119.0O119.0L000B100T027F060.0S\x80\x80"; + check(powpan_parse_status(legacy, sizeof(legacy)-1, &status) == 0, "legacy status accepted"); + check(status.has_u_temp && status.u_temp == 27, "legacy temperature retained"); + check(!status.has_realpower && !status.has_power && !status.has_o_current, "absent fields remain absent"); + } + { + const char legacy[] = "#I118.1O118.1L13B100V27.5F60.0HF60.0R65Q1.4S\x80\x84\xc0\x88\x80" "W"; + check(powpan_parse_status(legacy, sizeof(legacy)-1, &status) == 0, "legacy HF frequency accepted"); + check(status.has_o_freq && fabs(status.o_freq - 60.0f) < 0.001, "legacy output frequency retained"); + } + for (i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + check(powpan_parse_status(bad[i], strlen(bad[i]), &status) < 0, "malformed status rejected"); + } + check(powpan_parse_status(NULL, 0, &status) < 0, "null input rejected"); + return failed ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tests/powerpanel-usb-test.c b/tests/powerpanel-usb-test.c new file mode 100644 index 0000000000..8a637e43ab --- /dev/null +++ b/tests/powerpanel-usb-test.c @@ -0,0 +1,75 @@ +/* USB text framing regression tests: fake transfers, no device access. + * This program is distributed under the GNU GPL version 2 or later. + */ +#include "common.h" +#include "../drivers/powerp-usb.c" + +static int failed, input_index, output_count, timeout_mode, truncated_mode; +static const unsigned char *fragments[4]; +static size_t fragment_sizes[4]; + +int LIBUSB_CALL libusb_interrupt_transfer(libusb_device_handle *device, + unsigned char endpoint, unsigned char *data, int length, + int *transferred, unsigned int timeout) +{ + NUT_UNUSED_VARIABLE(device); + NUT_UNUSED_VARIABLE(timeout); + if (endpoint == 0x02) { + output_count++; + if (length != 64 || data[0] != 0x29 || data[1] != 2 + || data[2] != 'D' || data[3] != '\r') { + failed++; + } + *transferred = length; + return 0; + } + if (timeout_mode || input_index >= 4 || !fragments[input_index]) { + *transferred = 0; + return LIBUSB_ERROR_TIMEOUT; + } + memset(data, 0, (size_t)length); + data[0] = 0x28; + data[1] = (unsigned char)fragment_sizes[input_index]; + memcpy(data+2, fragments[input_index], fragment_sizes[input_index]); + *transferred = truncated_mode ? 2 : 64; + input_index++; + return 0; +} + +static void check(int condition, const char *name) +{ + if (!condition) { + fprintf(stderr, "FAIL: %s\n", name); + failed++; + } +} + +int main(void) +{ + char result[128]; + const char *forbidden[] = {"C\r", "C47:261024000000\r", "T\r", "S01\r", "Z01\r", "D\rC\r", NULL}; + size_t i; + handle = (libusb_device_handle *)(uintptr_t)1; + fragments[0] = (const unsigned char *)"#I233O230Q0."; + fragment_sizes[0] = strlen((const char *)fragments[0]); + fragments[1] = (const unsigned char *)"7J149E191S\x80\x80\r"; + fragment_sizes[1] = strlen((const char *)fragments[1]); + check(powerpanel_usb_command("D\r", result, sizeof(result)) > 0, "fragmented response accepted"); + check(strcmp(result, "#I233O230Q0.7J149E191S\x80\x80") == 0, "fragments reassembled exactly"); + for (i = 0; forbidden[i]; i++) { + int before = output_count; + check(powerpanel_usb_command(forbidden[i], result, sizeof(result)) < 0, "control command rejected"); + check(output_count == before, "rejected command never sent"); + } + input_index = 0; + truncated_mode = 1; + check(powerpanel_usb_command("D\r", result, sizeof(result)) < 0, "truncated frame rejected"); + truncated_mode = 0; + input_index = 0; + check(powerpanel_usb_command("D\r", result, 4) < 0, "response overflow rejected"); + timeout_mode = 1; + check(powerpanel_usb_command("D\r", result, sizeof(result)) < 0, "timeout bounded"); + handle = NULL; + check(powerpanel_usb_command("D\r", result, sizeof(result)) < 0, "disconnected handle rejected"); + return failed ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tools/nut-usbinfo.pl b/tools/nut-usbinfo.pl index 87f517ad19..aa068cc0c5 100755 --- a/tools/nut-usbinfo.pl +++ b/tools/nut-usbinfo.pl @@ -392,6 +392,10 @@ sub find_usbdevs elsif ($nameFile eq "apcmicrolink-usb.c") { $driver="apcmicrolink"; } + # PowerPanel's optional USB text transport shares an existing CPS ID. + elsif ($nameFile eq "powerp-usb.c") { + $driver="powerpanel"; + } # generic matching rule *.c => * elsif ($nameFile =~ /(.+)\.c$/) { $driver=$1;