Linux Input/HID development
 help / color / mirror / Atom feed
* [PATCH v3] HID: Expose LattePanda IOTA UPS as a power_supply device
@ 2026-05-21  3:17 Andrew Maney
  2026-05-21  3:45 ` sashiko-bot
  2026-05-21  6:54 ` Benjamin Tissoires
  0 siblings, 2 replies; 3+ messages in thread
From: Andrew Maney @ 2026-05-21  3:17 UTC (permalink / raw)
  To: jikos; +Cc: bentiss, linux-kernel, linux-input, Andrew Maney

This driver exposes the DFRobot LattePanda IOTA UPS board as a standard
power_supply device, allowing desktop environments and power management
tools such as UPower and systemd-logind to display battery status,
remaining capacity, and charging status without any special
configuration. It also enables automatic suspend or shutdown on low
battery and power profile configuration via any tool that supports the
standard power_supply interface.

The UPS presents itself as an Arduino Leonardo HID device running custom
firmware (VID 0x2341, PID 0x8036). It reports status and capacity via
HID reports 0x07 and 0x0C respectively.

The charge limit (80% or 100%) is configured via a physical DIP switch
on the UPS board and cannot be detected automatically. Userspace can
inform the driver of the configured limit via
charge_control_end_threshold.

---

Changes in v3:
- Deferred power_supply registration to workqueue to avoid blocking probe
- Fixed kernel panic when instantiated via uhid by checking hid_is_usb()
  before dereferencing USB-specific structures

- Fixed ERR_PTR dereference in raw_event by only assigning ups->psu on
  successful registration

- Fixed data race on ups->charge_limit using spin_lock_irqsave()
- Removed TIME_TO_EMPTY_NOW and TIME_TO_FULL_NOW properties to avoid
spurious shutdowns

- Changed plugged-in but not charging state from FULL to NOT_CHARGING
- Used devm_kasprintf() for a unique sysfs name in order to support
multiple devices

- Added POWER_SUPPLY and HIDRAW dependencies to Kconfig
- Used %pe for more human-readable error messages

Changes in v2:
- Rebased on top of the current tree
- Moved vendor and device IDs to drivers/hid/hid-ids.h
- Added Kconfig entry under HID bus support -> Special HID drivers
- Added build rule to drivers/hid/Makefile

Signed-off-by: Andrew Maney <andrewmaney05@gmail.com>
---
 MAINTAINERS                           |   6 +
 drivers/hid/Kconfig                   |  10 +
 drivers/hid/Makefile                  |   1 +
 drivers/hid/hid-ids.h                 |   3 +
 drivers/hid/hid-lattepanda-iota-ups.c | 409 ++++++++++++++++++++++++++
 5 files changed, 429 insertions(+)
 create mode 100644 drivers/hid/hid-lattepanda-iota-ups.c

diff --git a/MAINTAINERS b/MAINTAINERS
index 10e825318..d80721c2c 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -11416,6 +11416,12 @@ F:	include/uapi/linux/hid*
 F:	samples/hid/
 F:	tools/testing/selftests/hid/
 
+HID LATTEPANDA IOTA UPS DRIVER
+M:	Andrew Maney <andrewmaney05@gmail.com>
+L:	linux-input@vger.kernel.org
+S:	Maintained
+F:	drivers/hid/hid-lattepanda-iota-ups.c
+
 HID LOGITECH DRIVERS
 R:	Filipe Laíns <lains@riseup.net>
 L:	linux-input@vger.kernel.org
diff --git a/drivers/hid/Kconfig b/drivers/hid/Kconfig
index ff2f580b6..21ffc2fd0 100644
--- a/drivers/hid/Kconfig
+++ b/drivers/hid/Kconfig
@@ -510,6 +510,16 @@ config HID_KYSONA
 	Say Y here if you have a Kysona M600 mouse
 	and want to be able to read its battery capacity.
 
+config HID_LATTEPANDA_IOTA_UPS
+	tristate "LattePanda IOTA UPS"
+	depends on USB_HID && USB_HIDDEV && X86 && POWER_SUPPLY
+	help
+	Support for the LattePanda IOTA UPS (DFRobot, VID 0x2341 PID 0x8036).
+	Exposes the battery status and capacity via the power_supply interface.
+
+	To compile as a module, choose M here: the module will be
+	called hid-lattepanda-iota-ups.
+
 config HID_UCLOGIC
 	tristate "UC-Logic"
 	depends on USB_HID
diff --git a/drivers/hid/Makefile b/drivers/hid/Makefile
index 0597fd6a4..d7ad3fc8f 100644
--- a/drivers/hid/Makefile
+++ b/drivers/hid/Makefile
@@ -74,6 +74,7 @@ obj-$(CONFIG_HID_KENSINGTON)	+= hid-kensington.o
 obj-$(CONFIG_HID_KEYTOUCH)	+= hid-keytouch.o
 obj-$(CONFIG_HID_KYE)		+= hid-kye.o
 obj-$(CONFIG_HID_KYSONA)	+= hid-kysona.o
+obj-$(CONFIG_HID_LATTEPANDA_IOTA_UPS)	+= hid-lattepanda-iota-ups.o
 obj-$(CONFIG_HID_LCPOWER)	+= hid-lcpower.o
 obj-$(CONFIG_HID_LENOVO)	+= hid-lenovo.o
 obj-$(CONFIG_HID_LENOVO_GO)	+= hid-lenovo-go.o
diff --git a/drivers/hid/hid-ids.h b/drivers/hid/hid-ids.h
index 4657d96fb..6ded2c943 100644
--- a/drivers/hid/hid-ids.h
+++ b/drivers/hid/hid-ids.h
@@ -859,6 +859,9 @@
 #define USB_DEVICE_ID_LD_HYBRID		0x2090
 #define USB_DEVICE_ID_LD_HEATCONTROL	0x20A0
 
+#define USB_VENDOR_ID_LATTEPANDA_IOTA	0x2341
+#define USB_DEVICE_ID_LATTEPANDA_IOTA_UPS	0x8036
+
 #define USB_VENDOR_ID_LENOVO		0x17ef
 #define USB_DEVICE_ID_LENOVO_TPKBD	0x6009
 #define USB_DEVICE_ID_LENOVO_CUSBKBD	0x6047
diff --git a/drivers/hid/hid-lattepanda-iota-ups.c b/drivers/hid/hid-lattepanda-iota-ups.c
new file mode 100644
index 000000000..f5d522695
--- /dev/null
+++ b/drivers/hid/hid-lattepanda-iota-ups.c
@@ -0,0 +1,409 @@
+// SPDX-License-Identifier: GPL-2.0
+#include <linux/power_supply.h>
+#include <linux/completion.h>
+#include <linux/workqueue.h>
+#include <linux/module.h>
+#include <linux/spinlock.h>
+#include <linux/hid.h>
+#include <linux/usb.h>
+#include "hid-ids.h"
+
+#define REPORT_ID_CAPACITY	0x0C
+#define REPORT_ID_STATUS	0x07
+
+#define STATUS_DISCHARGING	BIT(1)
+#define STATUS_PLUGGED_IN	BIT(0)
+#define STATUS_CHARGING		BIT(2)
+
+MODULE_AUTHOR("Andrew Maney");
+MODULE_DESCRIPTION("LattePanda IOTA UPS power supply driver");
+MODULE_LICENSE("GPL");
+
+struct iota_ups {
+	struct power_supply_desc psu_desc;
+	struct power_supply *psu;
+	struct hid_device *hiddev;
+	spinlock_t lock; /* Protects cached HID report values */
+
+	/* Cached values that are updated from HID reports */
+	bool plugged_in;
+	char serial[64];
+	int charge_limit;
+	int psu_status;
+	int capacity;
+
+	/*
+	 * Wait for both status and capacity reports before registering
+	 * with the power_supply core, so initial values are correct and
+	 * not erroneous.
+	 */
+	struct completion got_initial_data;
+	struct work_struct register_work;
+	bool got_capacity;
+	bool data_ready;
+	bool got_status;
+};
+
+static enum power_supply_property iota_ups_properties[] = {
+	POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD,
+	POWER_SUPPLY_PROP_SERIAL_NUMBER,
+	POWER_SUPPLY_PROP_MANUFACTURER,
+	POWER_SUPPLY_PROP_MODEL_NAME,
+	POWER_SUPPLY_PROP_TECHNOLOGY,
+	POWER_SUPPLY_PROP_CAPACITY,
+	POWER_SUPPLY_PROP_PRESENT,
+	POWER_SUPPLY_PROP_ONLINE,
+	POWER_SUPPLY_PROP_STATUS,
+	POWER_SUPPLY_PROP_SCOPE,
+};
+
+static const struct hid_device_id iota_ups_devices[] = {
+	{ HID_USB_DEVICE(USB_VENDOR_ID_LATTEPANDA_IOTA,
+		USB_DEVICE_ID_LATTEPANDA_IOTA_UPS) },
+	{ }
+};
+MODULE_DEVICE_TABLE(hid, iota_ups_devices);
+
+static int iota_ups_get_property(struct power_supply *supply,
+				 enum power_supply_property psp,
+				 union power_supply_propval *val)
+{
+	struct iota_ups *ups = power_supply_get_drvdata(supply);
+	unsigned long flags;
+
+	spin_lock_irqsave(&ups->lock, flags);
+
+	switch (psp) {
+	case POWER_SUPPLY_PROP_STATUS:
+		val->intval = ups->psu_status;
+		break;
+
+	/* Remaining capacity as a percentage from 0 to 100 */
+	case POWER_SUPPLY_PROP_CAPACITY:
+		val->intval = ups->capacity;
+		break;
+
+	/* The UPS is always present if the driver is loaded */
+	case POWER_SUPPLY_PROP_PRESENT:
+		val->intval = 1;
+		break;
+
+	/* Whether mains power is connected */
+	case POWER_SUPPLY_PROP_ONLINE:
+		val->intval = ups->plugged_in ? 1 : 0;
+		break;
+
+	/*
+	 * The UPS board supplies power to the IOTA and any
+	 * peripherals connected to it, therefore its scope
+	 * is system-wide.
+	 */
+	case POWER_SUPPLY_PROP_SCOPE:
+		val->intval = POWER_SUPPLY_SCOPE_SYSTEM;
+		break;
+
+	/* V1.0 only accepts 18650 Li-ion cells */
+	case POWER_SUPPLY_PROP_TECHNOLOGY:
+		val->intval = POWER_SUPPLY_TECHNOLOGY_LION;
+		break;
+
+	/* 80% or 100%, configured via a DIP switch on the UPS board */
+	case POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD:
+		val->intval = ups->charge_limit;
+		break;
+
+	case POWER_SUPPLY_PROP_MANUFACTURER:
+		val->strval = "DFRobot";
+		break;
+
+	case POWER_SUPPLY_PROP_MODEL_NAME:
+		val->strval = "LattePanda IOTA UPS";
+		break;
+
+	/* Retrieved from the USB descriptor */
+	case POWER_SUPPLY_PROP_SERIAL_NUMBER:
+		val->strval = ups->serial;
+		break;
+
+	default:
+		spin_unlock_irqrestore(&ups->lock, flags);
+		return -EINVAL;
+	}
+
+	spin_unlock_irqrestore(&ups->lock, flags);
+	return 0;
+}
+
+static int iota_ups_set_property(struct power_supply *supply,
+				 enum power_supply_property psp,
+				 const union power_supply_propval *val)
+{
+	struct iota_ups *ups = power_supply_get_drvdata(supply);
+
+	if (psp == POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD) {
+		unsigned long flags;
+
+		/*
+		 * V1.0 supports 80% and 100% charge limits only, which is
+		 * set via a DIP switch on the board. This property allows
+		 * userspace to inform the driver which limit is configured.
+		 */
+		if (val->intval != 80 && val->intval != 100)
+			return -EINVAL;
+
+		spin_lock_irqsave(&ups->lock, flags);
+		ups->charge_limit = val->intval;
+		spin_unlock_irqrestore(&ups->lock, flags);
+		return 0;
+	}
+
+	return -EINVAL;
+}
+
+static int iota_ups_property_is_writable(struct power_supply *supply,
+					 enum power_supply_property psp)
+{
+	return psp == POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD;
+}
+
+static int iota_ups_raw_event(struct hid_device *hdev,
+			      struct hid_report *report,
+			      u8 *data, int size)
+{
+	struct iota_ups *ups = hid_get_drvdata(hdev);
+	unsigned long flags;
+	bool changed = false;
+
+	/* All of the UPS's reports are at least 2 bytes */
+	if (size < 2)
+		return 0;
+
+	spin_lock_irqsave(&ups->lock, flags);
+
+	switch (data[0]) {
+	case REPORT_ID_STATUS: {
+		u8 status = data[1];
+		int new_status;
+		bool plugged_in = !!(status & STATUS_PLUGGED_IN);
+
+		/*
+		 * The UPS status is determined as follows:
+		 * Battery full:
+		 *	UPS is plugged in
+		 *	Battery is at full capacity
+		 *
+		 * Battery charging:
+		 *	UPS is plugged in
+		 *	Battery is not at full capacity
+		 *
+		 * Battery discharging:
+		 *	UPS is not plugged in
+		 *
+		 * Battery not charging:
+		 *	UPS is plugged in
+		 *	UPS has halted charging for some reason
+		 *
+		 * Unknown:
+		 *	None of the above conditions are met
+		 */
+		if (status & STATUS_CHARGING) {
+			if (ups->capacity >= ups->charge_limit)
+				new_status = POWER_SUPPLY_STATUS_FULL;
+			else
+				new_status = POWER_SUPPLY_STATUS_CHARGING;
+
+		} else if (status & STATUS_DISCHARGING) {
+			new_status = POWER_SUPPLY_STATUS_DISCHARGING;
+
+		} else if (plugged_in) {
+			new_status = POWER_SUPPLY_STATUS_NOT_CHARGING;
+
+		} else {
+			new_status = POWER_SUPPLY_STATUS_UNKNOWN;
+		}
+
+		if (new_status != ups->psu_status ||
+		    plugged_in != ups->plugged_in) {
+			ups->plugged_in = plugged_in;
+			ups->psu_status = new_status;
+			changed = true;
+		}
+
+		ups->got_status = true;
+		break;
+	}
+
+	case REPORT_ID_CAPACITY: {
+		int new_cap = clamp((int)data[1], 0, 100);
+
+		if (new_cap != ups->capacity) {
+			ups->capacity = new_cap;
+			changed = true;
+		}
+
+		ups->got_capacity = true;
+		break;
+	}
+	}
+
+	/*
+	 * Signal that the UPS is ready to be registered because we have
+	 * received both capacity and status reports.
+	 */
+	if (!ups->data_ready && ups->got_status && ups->got_capacity) {
+		ups->data_ready = true;
+		complete(&ups->got_initial_data);
+	}
+
+	spin_unlock_irqrestore(&ups->lock, flags);
+
+	/*
+	 * Notify the power_supply core outside the spinlock to avoid
+	 * a deadlock; power_supply_changed() may call back into
+	 * get_property() which acquires the same lock.
+	 */
+	if (changed && ups->psu)
+		power_supply_changed(ups->psu);
+
+	return 0;
+}
+
+static void iota_ups_register_work(struct work_struct *work)
+{
+	struct iota_ups *ups = container_of(work, struct iota_ups, register_work);
+	struct power_supply_config psu_config = {};
+	struct power_supply *psu;
+
+	/*
+	 * Wait for both status and capacity reports before registering.
+	 * The device sends reports every ~1 second, so 3 seconds is safe.
+	 * We wait here in order to prevent registration in an unknown
+	 * state, since this could cause emergency shutdowns or other
+	 * undesired effects.
+	 */
+	wait_for_completion_timeout(&ups->got_initial_data,
+				    msecs_to_jiffies(3000));
+
+	/* Configure the UPS's power supply properties */
+	ups->psu_desc.name = devm_kasprintf(&ups->hiddev->dev, GFP_KERNEL,
+					    "lattepanda-iota-ups.%s",
+					    dev_name(&ups->hiddev->dev));
+
+	if (!ups->psu_desc.name) {
+		hid_err(ups->hiddev, "failed to allocate power supply name\n");
+		return;
+	}
+
+	ups->psu_desc.property_is_writeable = iota_ups_property_is_writable;
+	ups->psu_desc.num_properties	    = ARRAY_SIZE(iota_ups_properties);
+	ups->psu_desc.get_property	    = iota_ups_get_property;
+	ups->psu_desc.set_property	    = iota_ups_set_property;
+	ups->psu_desc.properties	    = iota_ups_properties;
+	ups->psu_desc.type		    = POWER_SUPPLY_TYPE_BATTERY;
+	psu_config.drv_data		    = ups;
+
+	/* Register the UPS as a power_supply device */
+	psu = devm_power_supply_register(&ups->hiddev->dev, &ups->psu_desc, &psu_config);
+	if (IS_ERR(psu)) {
+		hid_err(ups->hiddev, "power supply registration failed: %pe\n", psu);
+		return;
+	}
+
+	/*
+	 * Finally, notify the power_supply core so userspace reads the correct
+	 * initial state immediately after registration.
+	 */
+	ups->psu = psu;
+	power_supply_changed(ups->psu);
+	hid_info(ups->hiddev, "LattePanda IOTA UPS registered as a power_supply device\n");
+}
+
+static int iota_ups_probe(struct hid_device *hdev,
+			  const struct hid_device_id *id)
+{
+	struct iota_ups *ups;
+	int ret;
+
+	ups = devm_kzalloc(&hdev->dev, sizeof(*ups), GFP_KERNEL);
+	if (!ups)
+		return -ENOMEM;
+
+	ups->hiddev = hdev;
+	ups->psu_status = POWER_SUPPLY_STATUS_UNKNOWN;
+
+	/* 50% is a safe default if wait_for_completion_timeout() times out. */
+	ups->capacity = 50;
+
+	/*
+	 * Default to 100% to prevent unexpected shutdowns.
+	 * Userspace can update this via charge_control_end_threshold.
+	 */
+	ups->charge_limit = 100;
+
+	init_completion(&ups->got_initial_data);
+	spin_lock_init(&ups->lock);
+	hid_set_drvdata(hdev, ups);
+
+	/*
+	 * Retrieve the UPS's serial number from the USB descriptor. If the device is not
+	 * a USB device, we can use the unique device identifier as the serial number.
+	 */
+	if (hid_is_usb(hdev)) {
+		struct usb_device *udev = to_usb_device(hdev->dev.parent->parent);
+
+		if (udev->serial)
+			strscpy(ups->serial, udev->serial, sizeof(ups->serial));
+		else
+			strscpy(ups->serial, "Unknown", sizeof(ups->serial));
+	} else {
+		if (*hdev->uniq)
+			strscpy(ups->serial, hdev->uniq, sizeof(ups->serial));
+		else
+			strscpy(ups->serial, "Unknown", sizeof(ups->serial));
+	}
+
+	ret = hid_parse(hdev);
+	if (ret) {
+		hid_err(hdev, "HID parse failed: %pe\n", ERR_PTR(ret));
+		return ret;
+	}
+
+	ret = hid_hw_start(hdev, HID_CONNECT_HIDRAW);
+	if (ret) {
+		hid_err(hdev, "HID hw start failed: %pe\n", ERR_PTR(ret));
+		return ret;
+	}
+
+	ret = hid_hw_open(hdev);
+	if (ret) {
+		hid_err(hdev, "HID hw open failed: %pe\n", ERR_PTR(ret));
+		goto err_stop;
+	}
+
+	/* Probe for the UPS in a worker queue so we don't halt the enumeration thread */
+	INIT_WORK(&ups->register_work, iota_ups_register_work);
+	schedule_work(&ups->register_work);
+	return 0;
+
+err_stop:
+	hid_hw_stop(hdev);
+	return ret;
+}
+
+static void iota_ups_remove(struct hid_device *hdev)
+{
+	struct iota_ups *ups = hid_get_drvdata(hdev);
+
+	cancel_work_sync(&ups->register_work);
+	hid_hw_close(hdev);
+	hid_hw_stop(hdev);
+}
+
+static struct hid_driver iota_ups_driver = {
+	.name = "lattepanda-iota-ups",
+	.id_table = iota_ups_devices,
+	.probe = iota_ups_probe,
+	.remove = iota_ups_remove,
+	.raw_event = iota_ups_raw_event,
+};
+module_hid_driver(iota_ups_driver);
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 3+ messages in thread

end of thread, other threads:[~2026-05-21  6:54 UTC | newest]

Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-05-21  3:17 [PATCH v3] HID: Expose LattePanda IOTA UPS as a power_supply device Andrew Maney
2026-05-21  3:45 ` sashiko-bot
2026-05-21  6:54 ` Benjamin Tissoires

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox