From 6e4f62a3d21b10d2d707f81b82e503e6e00ed4ec Mon Sep 17 00:00:00 2001 From: reklrekl Date: Mon, 19 Oct 2026 00:43:36 +0200 Subject: [PATCH] drivers/ugreen-hid.c: add subdriver for UGREEN US2000 (2b89:ffff) [issue #3779] The US2000 hardcodes several HID feature reports (ups.load and input.voltage are always 1, battery.runtime is 0xFFFF on mains, the PresentStatus bits behind LB are never set) and pushes its real telemetry as a 64-byte Input report with ID 0x71 that nothing in its report descriptor describes, so hid2nut cannot reach it. Keep the standard PDC mappings for the values that are correct and decode the 0x71 frame for the rest: output.current, ups.load, input.voltage, output.voltage, battery.current, battery.charger.status, battery.temperature and battery.type. The frame layout is mapped for the US2000 only. Siblings share 2b89:ffff and push a different layout, so the vendor decoder is gated on the USB product string; an unrecognised device still claims the subdriver and keeps every standard PDC mapping. Also register the VID:PID in the upower hwdb. Tested on a US2000 on mains and on battery; no sibling model available. opencode (Muse Spark 1.3, DeepSeek V4.1 Flash) assisted with the subdriver and the firmware analysis. Signed-off-by: reklrekl --- NEWS.adoc | 16 + data/driver.list.in | 2 + docs/man/usbhid-ups.txt | 1 + docs/nut.dict | 1 + drivers/Makefile.am | 4 +- drivers/ugreen-hid.c | 466 ++++++++++++++++++++++++++++++ drivers/ugreen-hid.h | 27 ++ drivers/usbhid-ups.c | 2 + scripts/upower/95-upower-hid.hwdb | 5 + 9 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 drivers/ugreen-hid.c create mode 100644 drivers/ugreen-hid.h diff --git a/NEWS.adoc b/NEWS.adoc index 51c221fc52..c6f47a5559 100644 --- a/NEWS.adoc +++ b/NEWS.adoc @@ -644,6 +644,22 @@ https://github.com/networkupstools/nut/milestone/13 128 bytes per poll (about 14 MB per day at `pollinterval=1`). It now uses a static buffer like the other conversion functions. [issue #3787, PR #3788] + * Added the `ugreen-hid` subdriver for the Ugreen US2000 (72 W DC UPS, USB + ID `2b89:ffff`). The device was previously unsupported by any subdriver + and no `hid2nut` mapping could reach it: its live telemetry arrives + unprompted as 64-byte HID Input reports with report ID `0x71`, which no + object in its report descriptor describes. The subdriver keeps the + standard PDC feature-report mappings and decodes that frame for + `output.current`, `ups.load`, `input.voltage`, `output.voltage`, + `battery.current`, `battery.charger.status` and `battery.temperature`. + Three feature reports of this firmware are hardcoded dummies (`ups.load` + and `input.voltage` always report `1`; `output.voltage` is a coarse + integer that reads high) and its `PresentStatus` flags are never set, so + `LB` can never be raised from the device; with `battery.charge` live, the + core `ignorelb` option plus `battery.charge.low` provides it instead. + The frame is decoded only for the product strings the subdriver lists; + sibling models on the same USB ID still claim the subdriver to get the + standard mappings, but publish no vendor values. [issue #3779] - `snmp-ups` driver updates: * Corrected the IETF fallback warning so it is emitted for automatic diff --git a/data/driver.list.in b/data/driver.list.in index 2daf03dcdd..3fcdaf9bbd 100644 --- a/data/driver.list.in +++ b/data/driver.list.in @@ -1638,6 +1638,8 @@ "TS Shara" "ups" "4" "(various)" "" "nutdrv_qx" +"UGREEN" "ups" "1" "US2000" "(USB ID 2b89:ffff)" "usbhid-ups" + "UltraMax" "ups" "2" "1000SC" "USB" "nutdrv_qx" # https://github.com/networkupstools/nut/issues/1634 "UNITEK" "ups" "2" "ALPHA 500 IC" "" "blazer_ser" diff --git a/docs/man/usbhid-ups.txt b/docs/man/usbhid-ups.txt index ca44119ffc..fb089b9365 100644 --- a/docs/man/usbhid-ups.txt +++ b/docs/man/usbhid-ups.txt @@ -38,6 +38,7 @@ At the present time, usbhid-ups supports: - some Powercom models, - some PowerWalker models, - some TrippLite models, + - some UGREEN models (US2000), - some Vertiv models (Liebert GXT5). For a more complete list, refer to the NUT hardware compatibility list, diff --git a/docs/nut.dict b/docs/nut.dict index 5f351d8cd9..ce514203f5 100644 --- a/docs/nut.dict +++ b/docs/nut.dict @@ -3650,6 +3650,7 @@ udevadm udq ufw ugen +ugreen ui uid uint diff --git a/drivers/Makefile.am b/drivers/Makefile.am index 8cc9bffd48..b1105813c4 100644 --- a/drivers/Makefile.am +++ b/drivers/Makefile.am @@ -348,7 +348,7 @@ if HAVE_WINDOWS WINHID_IMPL = libwinhid.c endif HAVE_WINDOWS USBHID_UPS_SUBDRIVERS = apc-hid.c arduino-hid.c belkin-hid.c cps-hid.c explore-hid.c \ - liebert-hid.c mge-hid.c powercom-hid.c tripplite-hid.c idowell-hid.c microdowell-hid.c \ + liebert-hid.c mge-hid.c powercom-hid.c tripplite-hid.c ugreen-hid.c idowell-hid.c microdowell-hid.c \ openups-hid.c powervar-hid.c delta_ups-hid.c ecoflow-hid.c ever-hid.c \ legrand-hid.c salicru-hid.c vertiv-hid.c usbhid_ups_SOURCES = usbhid-ups.c libhid.c hidparser.c $(USBHID_UPS_SUBDRIVERS) @@ -558,7 +558,7 @@ dist_noinst_HEADERS = \ main.h mge-hid.h mge-mib.h mge-utalk.h \ mge-xml.h microdowell.h microsol-apc.h microsol-common.h netvision-mib.h netxml-ups.h nut-ipmi.h oneac.h \ powercom.h powerpanel.h powerp-bin.h powerp-txt.h powervar_cx.h raritan-pdu-mib.h \ - safenet.h serial.h sms_ser.h snmp-ups.h solis.h tripplite.h tripplite-hid.h \ + safenet.h serial.h sms_ser.h snmp-ups.h solis.h tripplite.h tripplite-hid.h ugreen-hid.h \ upshandler.h usb-common.h usbhid-ups.h powercom-hid.h compaq-mib.h idowell-hid.h microdowell-hid.h \ apc_common.h apcmicrolink-maps.h apcmicrolink.h apcmicrolink-usb.h apcsmart.h apcsmart_tabs.h apcsmart-old.h apcupsd-ups.h cyberpower-mib.h riello.h openups-hid.h \ delta_ups-mib.h nutdrv_qx.h nutdrv_qx_bestups.h nutdrv_qx_blazer-common.h \ diff --git a/drivers/ugreen-hid.c b/drivers/ugreen-hid.c new file mode 100644 index 0000000000..b2be74f062 --- /dev/null +++ b/drivers/ugreen-hid.c @@ -0,0 +1,466 @@ +/* ugreen-hid.c - subdriver to monitor UGREEN USB/HID UPS devices with NUT + * + * Supports the UGREEN US2000 (72W DC UPS, USB VID:PID 2b89:ffff). + * + * Device behavior (reverse engineered from US2000_TB_V1.2 firmware, + * confirmed by telemetry captures across mains/battery/charge states): + * + * - Standard PDC feature reports answer GET_FEATURE as usual, but several + * are hardcoded dummies in firmware: ups.load is always 1, input.voltage + * is always 1, output.voltage is a coarse integer that reads ~0.8 V high, + * and the LB status bits are never set. + * - Live telemetry arrives unprompted as 64-byte HID input reports with + * report ID 0x71 on the interrupt IN endpoint (about 2 Hz). Subtype 0x01 + * carries ADC channels; subtypes 0x01 and 0x02 share the pack millivolt / + * charge / gauge tail. All volt-class words are millivolts (no offsets); + * current words are milliamps. Only the first 33 bytes are visible + * through the core report buffer (see below); everything this driver + * publishes lies within them. + * - Telemetry needs an exact-size interrupt read (interruptsize 64, + * defaulted in ugreen_claim(); the core 512-byte default never + * completes on this firmware). + * - Pack current arrives as two unsigned magnitudes that the firmware + * holds mutually exclusive: a measured charge current and a pack-side + * discharge magnitude. battery.current carries whichever one is + * nonzero, and battery.charger.status names the direction. + * - Low-battery signaling is intentionally NOT synthesized here: use the + * core "ignorelb" driver option together with "battery.charge.low" + * instead (the charge reading itself is live). + * - Sibling products are known to share this VID:PID but not the + * telemetry framing. Every device on these IDs is therefore claimed + * and gets the standard PDC mappings, while the pushed telemetry is + * decoded only for the product strings listed in + * ugreen_known_products[]. See that array. + * + * Copyright (C) 2026 reklrekl + * + * 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 "config.h" /* must be first */ + +#include + +#include "usbhid-ups.h" +#include "ugreen-hid.h" +#include "dstate.h" +#include "main.h" /* for getval() */ +#include "usb-common.h" + +#define UGREEN_HID_VERSION "UGREEN HID 0.5" + +/* UGREEN */ +#define UGREEN_VENDORID 0x2b89 + +/* Product strings whose telemetry framing this subdriver has decoded. + * Sibling products are known to report on this same VID:PID while + * pushing a different 0x71 frame layout. Decoding one model's field + * layout at another model's offsets would publish plausible-looking + * nonsense, so the telemetry decoder is restricted to the product + * strings listed here. Such a device still claims the subdriver and + * keeps every standard PDC mapping below. Matched on the product prefix + * so that a firmware revision suffix does not disable the decoder. */ +static const char *ugreen_known_products[] = { + "US2000", + NULL +}; + +/* Set from ugreen_claim() and consumed by ugreen_updateinfo(), which + * receives no HIDDevice_t. Holds for the lifetime of the driver process, + * which monitors a single device. */ +static int ugreen_telemetry_known = 0; + +/* USB IDs device table */ +static usb_device_id_t ugreen_usb_device_table[] = { + /* US2000 (72W DC UPS) */ + { USB_DEVICE(UGREEN_VENDORID, 0xffff), NULL }, + + /* Terminating entry */ + { 0, 0, NULL } +}; + +static usb_communication_subdriver_t *usb = &usb_subdriver; + + +/* --------------------------------------------------------------- */ +/* Vendor-specific usage table */ +/* --------------------------------------------------------------- */ + +/* NOTE: the live vendor telemetry (report 0x71) looks like a single + * opaque blob to the HID parser, so it is deliberately NOT mapped + * through the table below. ugreen_updateinfo() cracks the raw report + * bytes out of the core report buffer instead. Only the standard PDC + * feature reports are mapped here. */ +static usage_lkp_t ugreen_usage_lkp[] = { + { NULL, 0 } +}; + +static usage_tables_t ugreen_utab[] = { + ugreen_usage_lkp, + hid_usage_lkp, + NULL, +}; + +/* --------------------------------------------------------------- */ +/* HID2NUT lookup table */ +/* --------------------------------------------------------------- */ + +/* PDC feature-report mappings. Deliberately identical to the Arduino + * PDC mappings, with three exceptions: input.voltage, ups.load and + * output.voltage are NOT mapped here. Reports 0x11 (always 1), 0x07 + * (always 1) and 0x0E (coarse integer, reads ~0.8 V high) are firmware + * dummies; the live values arrive via interrupt report 0x71 and are + * published by ugreen_updateinfo() below. */ +static hid_info_t ugreen_hid2nut[] = { + + /* USB HID PDC defaults */ + { "ups.delay.start", ST_FLAG_RW | ST_FLAG_STRING, 10, "UPS.PowerSummary.DelayBeforeStartup", NULL, DEFAULT_ONDELAY, HU_FLAG_ABSENT, NULL}, + { "ups.delay.shutdown", ST_FLAG_RW | ST_FLAG_STRING, 10, "UPS.PowerSummary.DelayBeforeShutdown", NULL, DEFAULT_OFFDELAY, HU_FLAG_ABSENT, NULL}, + { "ups.timer.start", 0, 0, "UPS.PowerSummary.DelayBeforeStartup", NULL, "%.0f", HU_FLAG_QUICK_POLL, NULL}, + { "ups.timer.shutdown", 0, 0, "UPS.PowerSummary.DelayBeforeShutdown", NULL, "%.0f", HU_FLAG_QUICK_POLL, NULL}, + { "ups.timer.reboot", 0, 0, "UPS.PowerSummary.DelayBeforeReboot", NULL, "%.0f", HU_FLAG_QUICK_POLL, NULL}, + + /* USB HID PDC defaults */ + { "load.off.delay", 0, 0, "UPS.PowerSummary.DelayBeforeShutdown", NULL, DEFAULT_OFFDELAY, HU_TYPE_CMD, NULL }, + { "load.on.delay", 0, 0, "UPS.PowerSummary.DelayBeforeStartup", NULL, DEFAULT_ONDELAY, HU_TYPE_CMD, NULL }, + { "shutdown.stop", 0, 0, "UPS.PowerSummary.DelayBeforeShutdown", NULL, "-1", HU_TYPE_CMD, NULL }, + { "shutdown.reboot", 0, 0, "UPS.PowerSummary.DelayBeforeReboot", NULL, "10", HU_TYPE_CMD, NULL }, + + /* Battery */ + { "battery.type", 0, 0, "UPS.PowerSummary.iDeviceChemistry", NULL, "%s", HU_FLAG_STATIC, stringid_conversion}, + { "battery.voltage.nominal", 0, 0, "UPS.PowerSummary.ConfigVoltage", NULL, "%.2f", HU_FLAG_QUICK_POLL, NULL}, + { "battery.voltage", 0, 0, "UPS.PowerSummary.Voltage", NULL, "%.2f", HU_FLAG_QUICK_POLL, NULL}, + { "battery.runtime", 0, 0, "UPS.PowerSummary.RunTimeToEmpty", NULL, "%.0f", HU_FLAG_QUICK_POLL, NULL}, + { "battery.runtime.low", 0, 0, "UPS.PowerSummary.RemainingTimeLimit", NULL, "%.0f", HU_FLAG_SEMI_STATIC, NULL }, + /* Parse `*Capacity` values assuming `UPS.PowerSummary.CapacityMode` is set to 2, which means the `*Capacity` + * units are percent. */ + { "battery.charge", 0, 0, "UPS.PowerSummary.RemainingCapacity", NULL, "%.0f", 0, NULL }, + { "battery.charge.low", 0, 0, "UPS.PowerSummary.RemainingCapacityLimit", NULL, "%.0f", HU_FLAG_SEMI_STATIC, NULL}, + { "battery.charge.warning", 0, 0, "UPS.PowerSummary.WarningCapacityLimit", NULL, "%.0f", HU_FLAG_SEMI_STATIC, NULL}, + + /* NOTE: no ups.load entry (report 0x07 is a hardcoded 1; the live + * load comes from telemetry 0x71, see ugreen_updateinfo()). */ + /* NOTE: no input.voltage entry (see comment above); the live value + * is published by ugreen_updateinfo(). */ + /* NOTE: no output.voltage entry (report 0x0E is a coarse integer + * that reads high; the live value comes from telemetry 0x71). */ + + /* USB HID PresentStatus Flags */ + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.ACPresent", NULL, NULL, HU_FLAG_QUICK_POLL, online_info}, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.Discharging", NULL, NULL, HU_FLAG_QUICK_POLL, discharging_info}, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.Charging", NULL, NULL, HU_FLAG_QUICK_POLL, charging_info}, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.NeedReplacement", NULL, NULL, 0, replacebatt_info}, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.ShutdownImminent", NULL, NULL, 0, shutdownimm_info }, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.BelowRemainingCapacityLimit", NULL, NULL, HU_FLAG_QUICK_POLL, lowbatt_info }, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.Overload", NULL, NULL, 0, overload_info }, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.RemainingTimeLimitExpired", NULL, NULL, 0, timelimitexpired_info }, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.BatteryPresent", NULL, NULL, 0, nobattery_info }, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.FullyCharged", NULL, NULL, HU_FLAG_QUICK_POLL, fullycharged_info }, + { "BOOL", 0, 0, "UPS.PowerSummary.PresentStatus.FullyDischarged", NULL, NULL, HU_FLAG_QUICK_POLL, depleted_info }, + + /* end of structure. */ + { NULL, 0, 0, NULL, NULL, NULL, 0, NULL } +}; + +/* Vendor telemetry frame (report 0x71) field offsets (subtype 0x01). + * All multi-byte fields are big-endian. Volt-class words are millivolts + * and current words milliamps. Every offset below lies within the + * 33-byte core report slot; fields past byte 32 (temp sensor 2, SOH, + * fullness flag, gauge words) stay dormant until the core sizes the + * slot larger. */ +#define UGREEN_TLM71_SUBTYPE 1 +#define UGREEN_TLM71_OUT_MV 9 +#define UGREEN_TLM71_OUT_MA 11 +#define UGREEN_TLM71_IN_MV 15 +#define UGREEN_TLM71_TEMP_C 17 +#define UGREEN_TLM71_CHG_MA 27 +#define UGREEN_TLM71_DIS_MA 29 +#define UGREEN_TLM71_PACK_MV 31 + +/* ups.load divisor: milliamps over the 72 W / 6 A nameplate rating. */ +#define UGREEN_RATED_OUT_MA 6000 + +/* Last-resort sanity bounds on the decoded words, in the native units. + * The product-string gate above is the primary discriminator; these only + * catch a frame that still parses within the 33-byte slot but carries a + * different field layout, so that a mis-decode is dropped instead of + * published. Generous enough to keep any in-range reading. */ +#define UGREEN_PLAUS_OUT_MV 1000 /* 1 V */ +#define UGREEN_PLAUS_OUT_MV_MAX 20000 /* 20 V */ +#define UGREEN_PLAUS_OUT_MA_MAX (UGREEN_RATED_OUT_MA * 2) +#define UGREEN_PLAUS_IN_MV_MAX 30000 /* 30 V */ + +/* Exact-size interrupt read the 64-byte telemetry frames need; the core + * default (512) never completes on this firmware. */ +#define UGREEN_TLM71_INTERRUPTSIZE 64 + +/* Publish live values cracked out of the pushed vendor telemetry frame. + * Called by the core driver after each successful HID update; the stock + * table above keeps serving the polled feature reports untouched. */ +static void ugreen_updateinfo(void) +{ + unsigned char *f; + size_t len; + int off; + unsigned sub, out_mv, out_ma, in_mv, temp_c, chg_ma, dis_ma, + pack_mv; + const char *status; + + if (!reportbuf) + return; + + /* Only decode telemetry for a product string whose framing we have + * mapped. An unrecognised product on this VID:PID keeps the + * standard PDC mappings and simply publishes no vendor values, + * rather than having another model's field layout read at these + * offsets. */ + if (!ugreen_telemetry_known) { + upsdebugx(4, "%s: product not in the known list, " + "not decoding vendor telemetry", __func__); + return; + } + + len = reportbuf->len[0x71]; + /* The core sizes this slot from the report descriptor (33 bytes + * here) and truncates longer frames; all offsets used below lie + * within the first 33 bytes. */ + if (len < 33) + return; + + f = reportbuf->data[0x71]; + if (!f) + return; + + /* Accept both store layouts: with the report-ID byte (full or + * truncated frames) or payload only (63 bytes). The ID byte alone + * disambiguates; no length check, the slot may hold a prefix. */ + if (f[0] == 0x71) + off = 0; + else if (f[0] == 0x01 || f[0] == 0x02) + off = -1; + else + return; + + pack_mv = ((unsigned)f[off + UGREEN_TLM71_PACK_MV] << 8) + | f[off + UGREEN_TLM71_PACK_MV + 1]; + sub = f[off + UGREEN_TLM71_SUBTYPE]; + + /* Subtype is the framing discriminator within a known product: + * 0x01 carries the ADC block and 0x02 only the shared pack tail on + * the US2000. A different subtype means a different frame layout, + * so nothing below applies to it. */ + if (sub != 0x01 && sub != 0x02) { + upsdebugx(4, "%s: ignoring vendor frame subtype %u", + __func__, sub); + return; + } + + if (sub == 0x01) { + out_mv = ((unsigned)f[off + UGREEN_TLM71_OUT_MV] << 8) + | f[off + UGREEN_TLM71_OUT_MV + 1]; + out_ma = ((unsigned)f[off + UGREEN_TLM71_OUT_MA] << 8) + | f[off + UGREEN_TLM71_OUT_MA + 1]; + in_mv = ((unsigned)f[off + UGREEN_TLM71_IN_MV] << 8) + | f[off + UGREEN_TLM71_IN_MV + 1]; + temp_c = ((unsigned)f[off + UGREEN_TLM71_TEMP_C] << 8) + | f[off + UGREEN_TLM71_TEMP_C + 1]; + chg_ma = ((unsigned)f[off + UGREEN_TLM71_CHG_MA] << 8) + | f[off + UGREEN_TLM71_CHG_MA + 1]; + dis_ma = ((unsigned)f[off + UGREEN_TLM71_DIS_MA] << 8) + | f[off + UGREEN_TLM71_DIS_MA + 1]; + + /* Output voltage is the anchor word of this block: its + * offset is known for the mapped framing, so an + * out-of-band reading means these offsets do not + * describe this frame. Drop the whole block rather than + * publish a mis-decode. */ + if (out_mv < UGREEN_PLAUS_OUT_MV + || out_mv > UGREEN_PLAUS_OUT_MV_MAX + || out_ma > UGREEN_PLAUS_OUT_MA_MAX + || in_mv > UGREEN_PLAUS_IN_MV_MAX) { + upsdebugx(3, "%s: implausible ADC block " + "(out=%u mV, %u mA, in=%u mV), ignoring", + __func__, out_mv, out_ma, in_mv); + return; + } + + /* Input rail reads a ~5 V residual with the brick gone; + * only publish on mains so OB never reports a fake 5 V. + * A sagging-but-present brick still shows (status stays OL). */ + status = dstate_getinfo("ups.status"); + if (!status || !strstr(status, "OB")) { + if (in_mv > 0) + dstate_setinfo("input.voltage", "%.1f", + (double)in_mv / 1000.0); + } else { + dstate_delinfo("input.voltage"); + } + + if (out_mv > 0) + dstate_setinfo("output.voltage", "%.2f", + (double)out_mv / 1000.0); + + /* Output current, milliamps. Linear above ~0.5 A with an + * exact zero; burst-unreliable below (firmware samples the + * switching waveform, the mean sags). */ + dstate_setinfo("output.current", "%.2f", + (double)out_ma / 1000.0); + dstate_setinfo("ups.load", "%.1f", + (double)out_ma * 100.0 / (double)UGREEN_RATED_OUT_MA); + + /* Pack current. The frame carries two separate unsigned + * magnitudes that firmware holds mutually exclusive: + * [27..28] is the measured charge current and [29..30] the + * pack-side discharge magnitude. battery.current is an + * unsigned magnitude in NUT, so it carries whichever of + * the two is nonzero, and battery.charger.status names the + * direction - the standard NUT variable for it, which + * replaces the historic CHRG / DISCHRG status flags. */ + if (chg_ma > 0) { + dstate_setinfo("battery.current", "%.2f", + (double)chg_ma / 1000.0); + dstate_setinfo("battery.charger.status", "%s", + "charging"); + } else if (dis_ma > 0) { + dstate_setinfo("battery.current", "%.2f", + (double)dis_ma / 1000.0); + dstate_setinfo("battery.charger.status", "%s", + "discharging"); + } else { + /* Neither magnitude is nonzero: the frame offers + * no direction, so drop the variable instead of + * guessing one. */ + dstate_setinfo("battery.current", "%.2f", 0.0); + dstate_delinfo("battery.charger.status"); + } + + /* Second temp sensor; follows pack heat, exact placement + * unconfirmed (teardown-gated). */ + dstate_setinfo("battery.temperature", "%u", temp_c); + + if (pack_mv > 0) + dstate_setinfo("battery.voltage", "%.2f", + (double)pack_mv / 1000.0); + + /* Nameplate-proven 2S Li-ion (the PDC iDeviceChemistry + * mapping has nothing to say on this firmware). */ + dstate_setinfo("battery.type", "%s", "Li-ion"); + upsdebugx(3, "%s: sub=01 in=%.1f out=%.2fV %.2fA load=%.1f%% batt=%.2fV chg=%.2fA dis=%.2fA temp=%uC", + __func__, (double)in_mv / 1000.0, + (double)out_mv / 1000.0, (double)out_ma / 1000.0, + (double)out_ma * 100.0 / (double)UGREEN_RATED_OUT_MA, + (double)pack_mv / 1000.0, + (double)chg_ma / 1000.0, (double)dis_ma / 1000.0, + temp_c); + return; + } + + if (sub == 0x02) { + /* No ADC block in this subtype ([15..16] read 0); the pack + * millivolt / charge tail overlaps subtype 0x01. */ + if (pack_mv > 0) + dstate_setinfo("battery.voltage", "%.2f", (double)pack_mv / 1000.0); + upsdebugx(3, "%s: sub=02 battery.voltage=%.2f", + __func__, (double)pack_mv / 1000.0); + } +} + +static const char *ugreen_format_model(HIDDevice_t *hd) { + return hd->Product; +} + +static const char *ugreen_format_mfr(HIDDevice_t *hd) { + return hd->Vendor ? hd->Vendor : "UGREEN"; +} + +static const char *ugreen_format_serial(HIDDevice_t *hd) { + return hd->Serial; +} + +/* this function allows the subdriver to "claim" a device: return 1 if + * the device is supported by this subdriver, else 0. */ +static int ugreen_claim(HIDDevice_t *hd) +{ + int status = is_usb_device_supported(ugreen_usb_device_table, hd); + int i; + + if (status == SUPPORTED) { + /* Decide whether the pushed telemetry framing of this + * product has been mapped. The product strings are read + * from the device before any claim runs. */ + ugreen_telemetry_known = 0; + + if (hd->Product) { + for (i = 0; ugreen_known_products[i]; i++) { + size_t len = strlen(ugreen_known_products[i]); + + if (!strncmp(hd->Product, ugreen_known_products[i], len)) { + ugreen_telemetry_known = 1; + break; + } + } + } + + if (ugreen_telemetry_known) { + upsdebugx(2, "%s: product \"%s\" has mapped telemetry framing", + __func__, hd->Product); + } else { + upsdebugx(2, "%s: product \"%s\" not in the known list; " + "standard mappings only, no vendor telemetry", + __func__, hd->Product ? hd->Product : "(none)"); + } + + /* Telemetry and flashing both live on endpoint 1; make sure + * the core reads interrupts from there by default. */ + if (!getval("usb_hid_ep_in")) { + upsdebugx(1, "%s: defaulting usb_hid_ep_in=1 for this model", __func__); + usb->hid_ep_in = 1; + } + if (!getval("usb_hid_ep_out")) { + upsdebugx(1, "%s: defaulting usb_hid_ep_out=1 for this model", __func__); + usb->hid_ep_out = 1; + } + /* The 64-byte telemetry frames only complete with an + * exact-size interrupt read; the core 512-byte default + * never returns on this firmware. */ + if (!getval("interruptsize") && interrupt_size == 0) { + upsdebugx(1, "%s: defaulting interruptsize=64 for this model", __func__); + interrupt_size = UGREEN_TLM71_INTERRUPTSIZE; + } + return 1; + } + + return 0; +} + +static subdriver_aux_t ugreen_aux = { + NULL, + NULL, + NULL, + ugreen_updateinfo, + NULL +}; + +subdriver_t ugreen_subdriver = { + UGREEN_HID_VERSION, + ugreen_claim, + ugreen_utab, + ugreen_hid2nut, + ugreen_format_model, + ugreen_format_mfr, + ugreen_format_serial, + fix_report_desc, + &ugreen_aux, +}; diff --git a/drivers/ugreen-hid.h b/drivers/ugreen-hid.h new file mode 100644 index 0000000000..f83cc10e4a --- /dev/null +++ b/drivers/ugreen-hid.h @@ -0,0 +1,27 @@ +/* ugreen-hid.h - subdriver to monitor UGREEN USB/HID UPS devices with NUT + * + * Copyright (C) 2026 reklrekl + * + * 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 UGREEN_HID_H +#define UGREEN_HID_H + +#include "usbhid-ups.h" + +extern subdriver_t ugreen_subdriver; + +#endif /* UGREEN_HID_H */ diff --git a/drivers/usbhid-ups.c b/drivers/usbhid-ups.c index 531cd0cf32..41c6f1a668 100644 --- a/drivers/usbhid-ups.c +++ b/drivers/usbhid-ups.c @@ -70,6 +70,7 @@ # include "powervar-hid.h" # include "salicru-hid.h" # include "tripplite-hid.h" +# include "ugreen-hid.h" # include "vertiv-hid.h" #endif /* !SHUT_MODE => USB */ @@ -97,6 +98,7 @@ static subdriver_t *subdriver_list[] = { &powervar_subdriver, &salicru_subdriver, &tripplite_subdriver, + &ugreen_subdriver, &vertiv_subdriver, #endif /* !SHUT_MODE => USB */ NULL diff --git a/scripts/upower/95-upower-hid.hwdb b/scripts/upower/95-upower-hid.hwdb index b4159b4849..a5694e16f8 100644 --- a/scripts/upower/95-upower-hid.hwdb +++ b/scripts/upower/95-upower-hid.hwdb @@ -194,6 +194,11 @@ usb:v2B2DpFFFF* UPOWER_BATTERY_TYPE=ups UPOWER_VENDOR=AEG +# UGREEN +usb:v2B89pFFFF* + UPOWER_BATTERY_TYPE=ups + UPOWER_VENDOR=UGREEN + # Ever usb:v2E51p0000* usb:v2E51pFFFF*