* [PATCH v5 2/3] platform: arm64: Add Lenovo ThinkPad X13s EC driver
2026-10-02 15:42 [PATCH v5 0/3] Lenovo ThinkPad X13s embedded controller support Alex Robinson
2026-10-02 15:42 ` [PATCH v5 1/3] dt-bindings: embedded-controller: Add Lenovo ThinkPad X13s EC Alex Robinson
@ 2026-10-02 15:43 ` Alex Robinson
2026-10-02 15:43 ` [PATCH v5 3/3] arm64: dts: qcom: sc8280xp-x13s: Add embedded controller Alex Robinson
2 siblings, 0 replies; 4+ messages in thread
From: Alex Robinson @ 2026-10-02 15:43 UTC (permalink / raw)
To: Hans de Goede, Ilpo Järvinen
Cc: Bryan O'Donoghue, Rob Herring, Krzysztof Kozlowski,
Conor Dooley, Bjorn Andersson, Konrad Dybcio, Steev Klimaszewski,
platform-driver-x86, linux-arm-msm, devicetree, linux-kernel
Add support for the ThinkPad X13s embedded controller to expose keyboard
backlight control and firmware-driven brightness changes when booting
with Device Tree. Its backlight, event and power-management interfaces
differ from the T14s EC and require separate handling.
Preserve brightness across lid and system-sleep transitions using the
sequences described by the X13s ACPI firmware. Keep temporary suspend
blanking from overwriting the EC's saved lid-restore brightness.
Report firmware-driven brightness changes such as Fn+Space, but do not
report lid-open restoration as a new brightness change, avoiding a
spurious keyboard-backlight on-screen display.
Leave EC wakeup disabled by default because the shared interrupt can
wake the system on lid closure and no selective event mask is known.
This follows earlier X13s EC work by Konrad Dybcio and Steev Klimaszewski.
Assisted-by: LLM
Signed-off-by: Alex Robinson <alex@ironrobin.net>
---
Changes in v5:
- Include dev_printk.h directly for device logging helpers.
- Replace the private suspended/access-blocked flag and event deferral with
disable_irq()/enable_irq() and LED class suspend/resume helpers.
- Flush the LED resume work before re-enabling the event IRQ.
- Preserve the EC lid-restore brightness across PM blanking/restoration.
- Restore lid-open brightness without a spurious hardware-change notification.
diff --git a/MAINTAINERS b/MAINTAINERS
index cc3cae2e378b34aeb705cef654c720ca9f2ba223..86ae65ac93e7575815f34dbc7a690042ad9ca3b0 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -27177,6 +27177,12 @@ S: Maintained
F: Documentation/devicetree/bindings/embedded-controller/lenovo,thinkpad-t14s-ec.yaml
F: drivers/platform/arm64/lenovo-thinkpad-t14s.c
+THINKPAD X13S EMBEDDED CONTROLLER DRIVER
+M: Alex Robinson <alex@ironrobin.net>
+S: Maintained
+F: Documentation/devicetree/bindings/embedded-controller/lenovo,thinkpad-x13s-ec.yaml
+F: drivers/platform/arm64/lenovo-thinkpad-x13s.c
+
THINKPAD LMI DRIVER
M: Mark Pearson <mpearson-lenovo@squebb.ca>
L: platform-driver-x86@vger.kernel.org
diff --git a/drivers/platform/arm64/Kconfig b/drivers/platform/arm64/Kconfig
index e32e01b2a9bdd970d6f2b2bf6b051472c9a16103..eb0b84e5a93733ca16fc871ea94eda30f5760b3c 100644
--- a/drivers/platform/arm64/Kconfig
+++ b/drivers/platform/arm64/Kconfig
@@ -90,6 +90,22 @@ config EC_LENOVO_THINKPAD_T14S
Say M or Y here to include this support.
+config EC_LENOVO_THINKPAD_X13S
+ tristate "Lenovo ThinkPad X13s Embedded Controller driver"
+ depends on ARCH_QCOM || COMPILE_TEST
+ depends on I2C
+ depends on GPIOLIB
+ select NEW_LEDS
+ select LEDS_CLASS
+ select LEDS_BRIGHTNESS_HW_CHANGED
+ help
+ Driver for the embedded controller in the Lenovo ThinkPad X13s.
+ Provides keyboard backlight control, hardware brightness change
+ notifications and system sleep power sequencing.
+
+ To compile this driver as a module, choose M here: the module will
+ be called lenovo-thinkpad-x13s.
+
config EC_QCOM_HAMOA
tristate "Embedded Controller driver for Qualcomm Hamoa/Glymur reference devices"
depends on ARCH_QCOM || COMPILE_TEST
diff --git a/drivers/platform/arm64/Makefile b/drivers/platform/arm64/Makefile
index 7681be4a46e94e7cb80e50bcc5b724cb0961357b..23ed6eef7ce41a5f45d48f5f83b281ebbca94cbb 100644
--- a/drivers/platform/arm64/Makefile
+++ b/drivers/platform/arm64/Makefile
@@ -9,4 +9,5 @@ obj-$(CONFIG_EC_ACER_ASPIRE1) += acer-aspire1-ec.o
obj-$(CONFIG_EC_HUAWEI_GAOKUN) += huawei-gaokun-ec.o
obj-$(CONFIG_EC_LENOVO_YOGA_C630) += lenovo-yoga-c630.o
obj-$(CONFIG_EC_LENOVO_THINKPAD_T14S) += lenovo-thinkpad-t14s.o
+obj-$(CONFIG_EC_LENOVO_THINKPAD_X13S) += lenovo-thinkpad-x13s.o
obj-$(CONFIG_EC_QCOM_HAMOA) += qcom-hamoa-ec.o
diff --git a/drivers/platform/arm64/lenovo-thinkpad-x13s.c b/drivers/platform/arm64/lenovo-thinkpad-x13s.c
new file mode 100644
index 0000000000000000000000000000000000000000..f1ae4c8e67159ebe04ec7aa6521b0ac9ad07a97c
--- /dev/null
+++ b/drivers/platform/arm64/lenovo-thinkpad-x13s.c
@@ -0,0 +1,408 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/* Lenovo ThinkPad X13s embedded controller */
+
+#include <linux/bitfield.h>
+#include <linux/bits.h>
+#include <linux/container_of.h>
+#include <linux/delay.h>
+#include <linux/dev_printk.h>
+#include <linux/device.h>
+#include <linux/err.h>
+#include <linux/gpio/consumer.h>
+#include <linux/i2c.h>
+#include <linux/interrupt.h>
+#include <linux/leds.h>
+#include <linux/lockdep.h>
+#include <linux/module.h>
+#include <linux/mutex.h>
+#include <linux/of.h>
+#include <linux/pm.h>
+#include <linux/pm_wakeup.h>
+#include <linux/slab.h>
+#include <linux/workqueue.h>
+
+#define X13S_EC_CMD_READ 0x02
+#define X13S_EC_CMD_WRITE 0x03
+#define X13S_EC_CMD_EVENT 0xf0
+#define X13S_EC_REG_POWER_STATE 0x80
+#define X13S_EC_POWER_STATE_ENTER 0x55
+#define X13S_EC_POWER_STATE_EXIT 0xaa
+#define X13S_EC_REG_KBD_BACKLIGHT 0xc0
+#define X13S_EC_BACKLIGHT_MASK GENMASK(5, 4)
+#define X13S_EC_REG_KBD_BACKLIGHT_SAVED 0x15
+#define X13S_EC_BACKLIGHT_SAVED_MASK GENMASK(2, 1)
+#define X13S_EC_EVENT_FN_SPACE 0x1f
+#define X13S_EC_EVENT_LID_OPEN 0x52
+
+struct x13s_ec {
+ struct i2c_client *client;
+ struct gpio_desc *power_state;
+ /* Serializes EC transactions and backlight read-modify-write. */
+ struct mutex lock;
+ struct led_classdev led;
+ /* Protected by lock; PM LED changes must preserve the lid-restore value. */
+ bool preserve_saved_brightness;
+};
+
+/*
+ * Each single-message transfer ends in STOP. Hold the bus segment across
+ * both halves so another client cannot interleave them. Keep the 10 ms
+ * post-command settling delay, including on failure.
+ */
+static int x13s_read(struct x13s_ec *ec, u8 cmd, u8 reg, u8 *value)
+{
+ struct i2c_client *client = ec->client;
+ u8 buf[] = { cmd, reg, 0, 1 };
+ struct i2c_msg request = {
+ .addr = client->addr,
+ .flags = I2C_M_STOP,
+ .len = sizeof(buf),
+ .buf = buf,
+ };
+ struct i2c_msg response = {
+ .addr = client->addr,
+ .flags = I2C_M_RD,
+ .len = 1,
+ .buf = value,
+ };
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+
+ i2c_lock_bus(client->adapter, I2C_LOCK_SEGMENT);
+ ret = __i2c_transfer(client->adapter, &request, 1);
+ if (ret == 1)
+ ret = __i2c_transfer(client->adapter, &response, 1);
+ i2c_unlock_bus(client->adapter, I2C_LOCK_SEGMENT);
+ fsleep(10000);
+
+ if (ret < 0)
+ return ret;
+ if (ret != 1)
+ return -EIO;
+
+ return 0;
+}
+
+static int x13s_write(struct x13s_ec *ec, u8 reg, u8 value)
+{
+ u8 buf[] = { X13S_EC_CMD_WRITE, reg, 0, 1, value };
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+
+ ret = i2c_master_send(ec->client, buf, sizeof(buf));
+ fsleep(10000);
+
+ if (ret < 0)
+ return ret;
+ if (ret != sizeof(buf))
+ return -EIO;
+
+ return 0;
+}
+
+static int x13s_brightness(struct x13s_ec *ec)
+{
+ u8 value;
+ int brightness, ret;
+
+ ret = x13s_read(ec, X13S_EC_CMD_READ, X13S_EC_REG_KBD_BACKLIGHT, &value);
+ if (ret)
+ return ret;
+
+ brightness = FIELD_GET(X13S_EC_BACKLIGHT_MASK, value);
+ if (brightness > ec->led.max_brightness)
+ return -EINVAL;
+
+ return brightness;
+}
+
+static enum led_brightness x13s_brightness_get(struct led_classdev *led)
+{
+ struct x13s_ec *ec = container_of(led, struct x13s_ec, led);
+ int ret;
+
+ mutex_lock(&ec->lock);
+ ret = x13s_brightness(ec);
+ mutex_unlock(&ec->lock);
+ return ret;
+}
+
+static int x13s_set_brightness_locked(struct x13s_ec *ec,
+ enum led_brightness brightness)
+{
+ u8 value;
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+ if (brightness > ec->led.max_brightness)
+ return -EINVAL;
+
+ ret = x13s_read(ec, X13S_EC_CMD_READ, X13S_EC_REG_KBD_BACKLIGHT, &value);
+ if (ret)
+ return ret;
+ /* Never interpret or overwrite a reserved current brightness. */
+ if (FIELD_GET(X13S_EC_BACKLIGHT_MASK, value) > ec->led.max_brightness)
+ return -EINVAL;
+ FIELD_MODIFY(X13S_EC_BACKLIGHT_MASK, &value, brightness);
+ return x13s_write(ec, X13S_EC_REG_KBD_BACKLIGHT, value);
+}
+
+/* SCMS(0x20): save the brightness obtained from C0 in the EC. */
+static int x13s_save_brightness_locked(struct x13s_ec *ec,
+ enum led_brightness brightness)
+{
+ u8 value;
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+ ret = x13s_read(ec, X13S_EC_CMD_READ, X13S_EC_REG_KBD_BACKLIGHT_SAVED,
+ &value);
+ if (ret)
+ return ret;
+
+ FIELD_MODIFY(X13S_EC_BACKLIGHT_SAVED_MASK, &value, brightness);
+ return x13s_write(ec, X13S_EC_REG_KBD_BACKLIGHT_SAVED, value);
+}
+
+/* SCMS(0x21): restore C0 from the EC's saved brightness. */
+static int x13s_restore_brightness_locked(struct x13s_ec *ec)
+{
+ u8 value;
+ int brightness, ret;
+
+ lockdep_assert_held(&ec->lock);
+ ret = x13s_read(ec, X13S_EC_CMD_READ, X13S_EC_REG_KBD_BACKLIGHT_SAVED,
+ &value);
+ if (ret)
+ return ret;
+
+ brightness = FIELD_GET(X13S_EC_BACKLIGHT_SAVED_MASK, value);
+ ret = x13s_set_brightness_locked(ec, brightness);
+ return ret ? ret : brightness;
+}
+
+static int x13s_brightness_set(struct led_classdev *led,
+ enum led_brightness brightness)
+{
+ struct x13s_ec *ec = container_of(led, struct x13s_ec, led);
+ int ret;
+
+ guard(mutex)(&ec->lock);
+ ret = x13s_set_brightness_locked(ec, brightness);
+ if (ret)
+ return ret;
+
+ /* PM blanking/restoration must not overwrite the lid-restore value. */
+ if (ec->preserve_saved_brightness)
+ return 0;
+
+ /* Keep software selections across the EC's lid blank/restore cycle. */
+ return x13s_save_brightness_locked(ec, brightness);
+}
+
+static void x13s_refresh_brightness(struct x13s_ec *ec)
+{
+ int brightness, ret;
+
+ lockdep_assert_held(&ec->lock);
+ /* Firmware owns the transition; save its result as _Q1F does. */
+ brightness = x13s_brightness(ec);
+ if (brightness < 0) {
+ dev_err_ratelimited(&ec->client->dev,
+ "Backlight refresh failed: %d\n", brightness);
+ return;
+ }
+ ret = x13s_save_brightness_locked(ec, brightness);
+ if (ret) {
+ dev_err_ratelimited(&ec->client->dev,
+ "Backlight save failed: %d\n", ret);
+ }
+ led_classdev_notify_brightness_hw_changed(&ec->led, brightness);
+}
+
+static void x13s_query_event(struct x13s_ec *ec)
+{
+ u8 event;
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+ /* One query per interrupt, with no event-draining loop or retry. */
+ ret = x13s_read(ec, X13S_EC_CMD_EVENT, 0, &event);
+ if (ret) {
+ dev_err_ratelimited(&ec->client->dev, "Event query failed: %d\n", ret);
+ return;
+ }
+ if (event == X13S_EC_EVENT_FN_SPACE) {
+ x13s_refresh_brightness(ec);
+ } else if (event == X13S_EC_EVENT_LID_OPEN) {
+ ret = x13s_restore_brightness_locked(ec);
+ if (ret < 0) {
+ dev_err_ratelimited(&ec->client->dev,
+ "Backlight lid restore failed: %d\n", ret);
+ return;
+ }
+ ec->led.brightness = ret;
+ }
+}
+
+static irqreturn_t x13s_irq(int irq, void *data)
+{
+ struct x13s_ec *ec = data;
+
+ mutex_lock(&ec->lock);
+ x13s_query_event(ec);
+ mutex_unlock(&ec->lock);
+ return IRQ_HANDLED;
+}
+
+static int x13s_probe(struct i2c_client *client)
+{
+ struct device *dev = &client->dev;
+ struct x13s_ec *ec;
+ int ret;
+
+ if (!i2c_check_functionality(client->adapter, I2C_FUNC_I2C))
+ return -EOPNOTSUPP;
+ if (client->irq <= 0)
+ return dev_err_probe(dev, -EINVAL, "Missing event IRQ\n");
+ if (irq_get_trigger_type(client->irq) != IRQ_TYPE_EDGE_FALLING)
+ return dev_err_probe(dev, -EINVAL, "Expected falling-edge IRQ\n");
+
+ ec = devm_kzalloc(dev, sizeof(*ec), GFP_KERNEL);
+ if (!ec)
+ return -ENOMEM;
+ ec->client = client;
+ ret = devm_mutex_init(dev, &ec->lock);
+ if (ret)
+ return ret;
+ i2c_set_clientdata(client, ec);
+
+ ec->power_state = devm_gpiod_get(dev, "power-state", GPIOD_OUT_HIGH);
+ if (IS_ERR(ec->power_state)) {
+ return dev_err_probe(dev, PTR_ERR(ec->power_state),
+ "Failed to acquire power-state GPIO high\n");
+ }
+
+ ec->led.name = "platform::kbd_backlight";
+ ec->led.max_brightness = 2;
+ ec->led.flags = LED_BRIGHT_HW_CHANGED | LED_RETAIN_AT_SHUTDOWN;
+ ec->led.brightness_get = x13s_brightness_get;
+ ec->led.brightness_set_blocking = x13s_brightness_set;
+ ret = devm_led_classdev_register(dev, &ec->led);
+ if (ret)
+ return dev_err_probe(dev, ret, "Failed to register keyboard backlight\n");
+
+ /* Devres synchronizes the IRQ before unregistering the LED. */
+ ret = devm_request_threaded_irq(dev, client->irq, NULL, x13s_irq,
+ IRQF_ONESHOT, dev_name(dev), ec);
+ if (ret)
+ return dev_err_probe(dev, ret, "Failed to request event IRQ\n");
+
+ /*
+ * As on the T14s, disable wakeup by default since selective EC event
+ * masking is not known. Keep runtime IRQ handling enabled.
+ */
+ device_wakeup_disable(dev);
+
+ return 0;
+}
+
+static int x13s_power_gpio(struct x13s_ec *ec, int value)
+{
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+ ret = gpiod_set_value_cansleep(ec->power_state, value);
+ if (ret) {
+ dev_err(&ec->client->dev, "Failed to set power-state GPIO to %d: %d\n",
+ value, ret);
+ }
+ return ret;
+}
+
+static void x13s_exit_low_power(struct x13s_ec *ec)
+{
+ int ret;
+
+ lockdep_assert_held(&ec->lock);
+ x13s_power_gpio(ec, 1);
+ /* DSDT: GPIO176 high, Sleep(10), then register 0x80 <- 0xaa. */
+ fsleep(10000);
+ ret = x13s_write(ec, X13S_EC_REG_POWER_STATE, X13S_EC_POWER_STATE_EXIT);
+ if (ret)
+ dev_err(&ec->client->dev, "Failed to exit low power: %d\n", ret);
+}
+
+static int x13s_suspend(struct device *dev)
+{
+ struct x13s_ec *ec = dev_get_drvdata(dev);
+ int ret;
+
+ /* The IRQ handler takes lock, so disable it before taking the mutex. */
+ disable_irq(ec->client->irq);
+
+ /* Capture hardware brightness after completing queued LED updates. */
+ flush_work(&ec->led.set_brightness_work);
+ ret = led_update_brightness(&ec->led);
+ if (ret)
+ dev_err(dev, "Failed to save backlight brightness: %d\n", ret);
+
+ mutex_lock(&ec->lock);
+ ec->preserve_saved_brightness = true;
+ mutex_unlock(&ec->lock);
+
+ /* This flushes brightness work, which also takes lock. */
+ led_classdev_suspend(&ec->led);
+
+ guard(mutex)(&ec->lock);
+ /* DSDT: register 0x80 <- 0x55, then GPIO176 low. */
+ ret = x13s_write(ec, X13S_EC_REG_POWER_STATE, X13S_EC_POWER_STATE_ENTER);
+ if (ret)
+ dev_err(dev, "Failed to enter low power: %d\n", ret);
+ else
+ x13s_power_gpio(ec, 0);
+ return 0;
+}
+
+static int x13s_resume(struct device *dev)
+{
+ struct x13s_ec *ec = dev_get_drvdata(dev);
+
+ /* Normal resume runs after the adapter and its GENI parent are ready. */
+ mutex_lock(&ec->lock);
+ x13s_exit_low_power(ec);
+ mutex_unlock(&ec->lock);
+
+ led_classdev_resume(&ec->led);
+ /* Finish restoring brightness before handling a pending lid event. */
+ flush_work(&ec->led.set_brightness_work);
+ mutex_lock(&ec->lock);
+ ec->preserve_saved_brightness = false;
+ mutex_unlock(&ec->lock);
+ enable_irq(ec->client->irq);
+
+ return 0;
+}
+
+static DEFINE_SIMPLE_DEV_PM_OPS(x13s_pm_ops, x13s_suspend, x13s_resume);
+
+static const struct of_device_id x13s_of_match[] = {
+ { .compatible = "lenovo,thinkpad-x13s-ec" },
+ { }
+};
+MODULE_DEVICE_TABLE(of, x13s_of_match);
+
+static struct i2c_driver x13s_driver = {
+ .probe = x13s_probe,
+ .driver = {
+ .name = "thinkpad-x13s-ec",
+ .of_match_table = x13s_of_match,
+ .pm = pm_sleep_ptr(&x13s_pm_ops),
+ },
+};
+module_i2c_driver(x13s_driver);
+
+MODULE_DESCRIPTION("Lenovo ThinkPad X13s Embedded Controller");
+MODULE_LICENSE("GPL");
^ permalink raw reply [flat|nested] 4+ messages in thread