mirror of
https://github.com/coolsnowwolf/lede.git
synced 2025-07-28 19:46:58 +08:00
1143 lines
28 KiB
Diff
1143 lines
28 KiB
Diff
From 55af457be82d0853857bab148c649a328aa58ffa Mon Sep 17 00:00:00 2001
|
|
From: Hector Martin <marcan@marcan.st>
|
|
Date: Fri, 8 Jul 2022 02:11:21 +0900
|
|
Subject: [PATCH 130/171] hid: Add Apple DockChannel HID transport driver
|
|
|
|
Apple M2 devices have an MTP coprocessor embedded in the SoC that
|
|
handles HID for the integrated touchpad/keyboard, and communicates
|
|
over the DockChannel interface. This driver implements this new
|
|
interface.
|
|
|
|
Signed-off-by: Hector Martin <marcan@marcan.st>
|
|
---
|
|
drivers/hid/Kconfig | 2 +
|
|
drivers/hid/Makefile | 2 +
|
|
drivers/hid/dockchannel-hid/Kconfig | 14 +
|
|
drivers/hid/dockchannel-hid/Makefile | 6 +
|
|
drivers/hid/dockchannel-hid/dockchannel-hid.c | 1058 +++++++++++++++++
|
|
5 files changed, 1082 insertions(+)
|
|
create mode 100644 drivers/hid/dockchannel-hid/Kconfig
|
|
create mode 100644 drivers/hid/dockchannel-hid/Makefile
|
|
create mode 100644 drivers/hid/dockchannel-hid/dockchannel-hid.c
|
|
|
|
diff --git a/drivers/hid/Kconfig b/drivers/hid/Kconfig
|
|
index 1dd6697e7c59..bd47caba1413 100644
|
|
--- a/drivers/hid/Kconfig
|
|
+++ b/drivers/hid/Kconfig
|
|
@@ -1324,4 +1324,6 @@ source "drivers/hid/surface-hid/Kconfig"
|
|
|
|
source "drivers/hid/spi-hid/Kconfig"
|
|
|
|
+source "drivers/hid/dockchannel-hid/Kconfig"
|
|
+
|
|
endmenu
|
|
diff --git a/drivers/hid/Makefile b/drivers/hid/Makefile
|
|
index a720e68fe1d7..2c90409d515c 100644
|
|
--- a/drivers/hid/Makefile
|
|
+++ b/drivers/hid/Makefile
|
|
@@ -158,3 +158,5 @@ obj-$(CONFIG_AMD_SFH_HID) += amd-sfh-hid/
|
|
obj-$(CONFIG_SURFACE_HID_CORE) += surface-hid/
|
|
|
|
obj-$(CONFIG_SPI_HID_APPLE_CORE) += spi-hid/
|
|
+
|
|
+obj-$(CONFIG_HID_DOCKCHANNEL) += dockchannel-hid/
|
|
diff --git a/drivers/hid/dockchannel-hid/Kconfig b/drivers/hid/dockchannel-hid/Kconfig
|
|
new file mode 100644
|
|
index 000000000000..8a81d551a83d
|
|
--- /dev/null
|
|
+++ b/drivers/hid/dockchannel-hid/Kconfig
|
|
@@ -0,0 +1,14 @@
|
|
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
|
|
+menu "DockChannel HID support"
|
|
+ depends on APPLE_DOCKCHANNEL
|
|
+
|
|
+config HID_DOCKCHANNEL
|
|
+ tristate "HID over DockChannel transport layer for Apple Silicon SoCs"
|
|
+ default ARCH_APPLE
|
|
+ depends on APPLE_DOCKCHANNEL && INPUT && OF && HID
|
|
+ help
|
|
+ Say Y here if you use an M2 or later Apple Silicon based laptop.
|
|
+ The keyboard and touchpad are HID based devices connected via the
|
|
+ proprietary DockChannel interface.
|
|
+
|
|
+endmenu
|
|
diff --git a/drivers/hid/dockchannel-hid/Makefile b/drivers/hid/dockchannel-hid/Makefile
|
|
new file mode 100644
|
|
index 000000000000..7dba766b047f
|
|
--- /dev/null
|
|
+++ b/drivers/hid/dockchannel-hid/Makefile
|
|
@@ -0,0 +1,6 @@
|
|
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
|
|
+#
|
|
+# Makefile for DockChannel HID transport drivers
|
|
+#
|
|
+
|
|
+obj-$(CONFIG_HID_DOCKCHANNEL) += dockchannel-hid.o
|
|
diff --git a/drivers/hid/dockchannel-hid/dockchannel-hid.c b/drivers/hid/dockchannel-hid/dockchannel-hid.c
|
|
new file mode 100644
|
|
index 000000000000..aaa7d357c6fa
|
|
--- /dev/null
|
|
+++ b/drivers/hid/dockchannel-hid/dockchannel-hid.c
|
|
@@ -0,0 +1,1058 @@
|
|
+/*
|
|
+ * SPDX-License-Identifier: GPL-2.0 OR MIT
|
|
+ *
|
|
+ * Apple DockChannel HID transport driver
|
|
+ *
|
|
+ * Copyright The Asahi Linux Contributors
|
|
+ */
|
|
+#include <asm/unaligned.h>
|
|
+#include <linux/delay.h>
|
|
+#include <linux/device.h>
|
|
+#include <linux/dma-mapping.h>
|
|
+#include <linux/firmware.h>
|
|
+#include <linux/gpio/consumer.h>
|
|
+#include <linux/hid.h>
|
|
+#include <linux/slab.h>
|
|
+#include <linux/soc/apple/dockchannel.h>
|
|
+#include <linux/of.h>
|
|
+#include "../hid-ids.h"
|
|
+
|
|
+#define COMMAND_TIMEOUT 1000
|
|
+
|
|
+#define MAX_INTERFACES 16
|
|
+
|
|
+/* Data + checksum */
|
|
+#define MAX_PKT_SIZE (0xffff + 4)
|
|
+
|
|
+#define DCHID_CHANNEL_CMD 0x11
|
|
+#define DCHID_CHANNEL_REPORT 0x12
|
|
+
|
|
+struct dchid_hdr {
|
|
+ u8 hdr_len;
|
|
+ u8 channel;
|
|
+ __le16 length;
|
|
+ u8 seq;
|
|
+ u8 iface;
|
|
+ __le16 pad;
|
|
+} __packed;
|
|
+
|
|
+#define IFACE_COMM 0
|
|
+
|
|
+#define FLAGS_GROUP GENMASK(7, 6)
|
|
+#define FLAGS_REQ GENMASK(5, 0)
|
|
+
|
|
+#define GROUP_INPUT 0
|
|
+#define GROUP_OUTPUT 1
|
|
+#define GROUP_CMD 2
|
|
+
|
|
+#define REQ_SET_REPORT 0
|
|
+#define REQ_GET_REPORT 1
|
|
+
|
|
+struct dchid_subhdr {
|
|
+ u8 flags;
|
|
+ u8 unk;
|
|
+ __le16 length;
|
|
+ __le32 retcode;
|
|
+} __packed;
|
|
+
|
|
+#define EVENT_GPIO_CMD 0xa0
|
|
+#define EVENT_INIT 0xf0
|
|
+#define EVENT_READY 0xf1
|
|
+
|
|
+struct dchid_init_hdr {
|
|
+ u8 type;
|
|
+ u8 unk1;
|
|
+ u8 unk2;
|
|
+ u8 iface;
|
|
+ char name[16];
|
|
+} __packed;
|
|
+
|
|
+#define INIT_HID_DESCRIPTOR 0
|
|
+#define INIT_GPIO_REQUEST 1
|
|
+#define INIT_TERMINATOR 2
|
|
+
|
|
+#define CMD_RESET_INTERFACE 0x40
|
|
+#define CMD_SEND_FIRMWARE 0x95
|
|
+#define CMD_ENABLE_INTERFACE 0xb4
|
|
+#define CMD_ACK_GPIO_CMD 0xa1
|
|
+
|
|
+struct dchid_init_block_hdr {
|
|
+ __le16 type;
|
|
+ __le16 subtype;
|
|
+ __le16 length;
|
|
+} __packed;
|
|
+
|
|
+#define MAX_GPIO_NAME 32
|
|
+
|
|
+struct dchid_gpio_request {
|
|
+ __le16 unk;
|
|
+ __le16 id;
|
|
+ char name[MAX_GPIO_NAME];
|
|
+} __packed;
|
|
+
|
|
+struct dchid_gpio_cmd {
|
|
+ u8 type;
|
|
+ u8 iface;
|
|
+ u8 gpio;
|
|
+ u8 unk;
|
|
+ u8 cmd;
|
|
+} __packed;
|
|
+
|
|
+struct dchid_gpio_ack {
|
|
+ u8 type;
|
|
+ __le32 retcode;
|
|
+ u8 cmd[];
|
|
+} __packed;
|
|
+
|
|
+#define STM_REPORT_ID 0x10
|
|
+#define STM_REPORT_SERIAL 0x11
|
|
+#define STM_REPORT_KEYBTYPE 0x14
|
|
+
|
|
+#define KEYBOARD_TYPE_ANSI 0
|
|
+#define KEYBOARD_TYPE_ISO 1
|
|
+#define KEYBOARD_TYPE_JIS 2
|
|
+
|
|
+struct dchid_stm_id {
|
|
+ u8 unk;
|
|
+ __le16 vendor_id;
|
|
+ __le16 product_id;
|
|
+ __le16 version_number;
|
|
+ u8 unk2;
|
|
+ u8 unk3;
|
|
+ u8 keyboard_type;
|
|
+ u8 serial_length;
|
|
+ /* Serial follows, but we grab it with a different report. */
|
|
+} __packed;
|
|
+
|
|
+#define FW_MAGIC 0x46444948
|
|
+#define FW_VER 1
|
|
+
|
|
+struct fw_header {
|
|
+ __le32 magic;
|
|
+ __le32 version;
|
|
+ __le32 hdr_length;
|
|
+ __le32 data_length;
|
|
+ __le32 iface_offset;
|
|
+} __packed;
|
|
+
|
|
+struct dchid_work {
|
|
+ struct work_struct work;
|
|
+ struct dchid_iface *iface;
|
|
+
|
|
+ struct dchid_hdr hdr;
|
|
+ u8 data[];
|
|
+};
|
|
+
|
|
+struct dchid_iface {
|
|
+ struct dockchannel_hid *dchid;
|
|
+ struct hid_device *hid;
|
|
+
|
|
+ int index;
|
|
+ const char *name;
|
|
+ const struct device_node *of_node;
|
|
+
|
|
+ uint8_t tx_seq;
|
|
+ uint8_t rx_seq;
|
|
+ bool deferred;
|
|
+ bool open;
|
|
+
|
|
+ void *hid_desc;
|
|
+ size_t hid_desc_len;
|
|
+
|
|
+ struct gpio_desc *gpio;
|
|
+ int gpio_id;
|
|
+
|
|
+ struct mutex out_mutex;
|
|
+ u32 out_flags;
|
|
+ int out_report;
|
|
+ u32 retcode;
|
|
+ void *resp_buf;
|
|
+ size_t resp_size;
|
|
+ struct completion out_complete;
|
|
+};
|
|
+
|
|
+struct dockchannel_hid {
|
|
+ struct device *dev;
|
|
+ struct dockchannel *dc;
|
|
+
|
|
+ bool id_ready;
|
|
+ struct dchid_stm_id device_id;
|
|
+ char serial[64];
|
|
+
|
|
+ struct dchid_iface *comm;
|
|
+ struct dchid_iface *ifaces[MAX_INTERFACES];
|
|
+
|
|
+ u8 pkt_buf[MAX_PKT_SIZE];
|
|
+
|
|
+ struct workqueue_struct *wq;
|
|
+};
|
|
+
|
|
+static struct dchid_iface *
|
|
+dchid_get_interface(struct dockchannel_hid *dchid, int index, const char *name)
|
|
+{
|
|
+ struct dchid_iface *iface;
|
|
+
|
|
+ if (index >= MAX_INTERFACES) {
|
|
+ dev_err(dchid->dev, "Interface index %d out of range\n", index);
|
|
+ return NULL;
|
|
+ }
|
|
+
|
|
+ if (dchid->ifaces[index])
|
|
+ return dchid->ifaces[index];
|
|
+
|
|
+ iface = devm_kzalloc(dchid->dev, sizeof(struct dchid_iface), GFP_KERNEL);
|
|
+ if (!iface)
|
|
+ return NULL;
|
|
+
|
|
+ iface->index = index;
|
|
+ iface->name = devm_kstrdup(dchid->dev, name, GFP_KERNEL);
|
|
+ iface->dchid = dchid;
|
|
+ iface->out_report= -1;
|
|
+ init_completion(&iface->out_complete);
|
|
+ mutex_init(&iface->out_mutex);
|
|
+
|
|
+ dev_info(dchid->dev, "Initializing interface %s\n", iface->name);
|
|
+
|
|
+ /* Comm is not a HID subdevice */
|
|
+ if (!strcmp(name, "comm")) {
|
|
+ dchid->ifaces[index] = iface;
|
|
+ return iface;
|
|
+ }
|
|
+
|
|
+ iface->of_node = of_get_child_by_name(dchid->dev->of_node, name);
|
|
+ if (!iface->of_node) {
|
|
+ dev_warn(dchid->dev, "No OF node for subdevice %s, ignoring.", name);
|
|
+ return NULL;
|
|
+ }
|
|
+
|
|
+ dchid->ifaces[index] = iface;
|
|
+ return iface;
|
|
+}
|
|
+
|
|
+static u32 dchid_checksum(void *p, size_t length)
|
|
+{
|
|
+ u32 sum = 0;
|
|
+
|
|
+ while (length >= 4) {
|
|
+ sum += get_unaligned_le32(p);
|
|
+ p += 4;
|
|
+ length -= 4;
|
|
+ }
|
|
+
|
|
+ WARN_ON_ONCE(length);
|
|
+ return sum;
|
|
+}
|
|
+
|
|
+static int dchid_send(struct dchid_iface *iface, u32 flags, void *msg, size_t size)
|
|
+{
|
|
+ u32 checksum = 0xffffffff;
|
|
+ size_t wsize = round_down(size, 4);
|
|
+ size_t tsize = size - wsize;
|
|
+ int ret;
|
|
+ struct {
|
|
+ struct dchid_hdr hdr;
|
|
+ struct dchid_subhdr sub;
|
|
+ } __packed h;
|
|
+
|
|
+ memset(&h, 0, sizeof(h));
|
|
+ h.hdr.hdr_len = sizeof(h.hdr);
|
|
+ h.hdr.channel = DCHID_CHANNEL_CMD;
|
|
+ h.hdr.length = round_up(size, 4) + sizeof(h.sub);
|
|
+ h.hdr.seq = iface->tx_seq;
|
|
+ h.hdr.iface = iface->index;
|
|
+ h.sub.flags = flags;
|
|
+ h.sub.length = size;
|
|
+
|
|
+ ret = dockchannel_send(iface->dchid->dc, &h, sizeof(h));
|
|
+ if (ret < 0)
|
|
+ return ret;
|
|
+ checksum -= dchid_checksum(&h, sizeof(h));
|
|
+
|
|
+ ret = dockchannel_send(iface->dchid->dc, msg, wsize);
|
|
+ if (ret < 0)
|
|
+ return ret;
|
|
+ checksum -= dchid_checksum(msg, wsize);
|
|
+
|
|
+ if (tsize) {
|
|
+ u8 tail[4] = {0, 0, 0, 0};
|
|
+
|
|
+ memcpy(tail, msg + wsize, tsize);
|
|
+ ret = dockchannel_send(iface->dchid->dc, tail, sizeof(tail));
|
|
+ if (ret < 0)
|
|
+ return ret;
|
|
+ checksum -= dchid_checksum(tail, sizeof(tail));
|
|
+ }
|
|
+
|
|
+ ret = dockchannel_send(iface->dchid->dc, &checksum, sizeof(checksum));
|
|
+ if (ret < 0)
|
|
+ return ret;
|
|
+
|
|
+ return 0;
|
|
+}
|
|
+
|
|
+static int dchid_cmd(struct dchid_iface *iface, u32 type, u32 req,
|
|
+ void *data, size_t size, void *resp_buf, size_t resp_size)
|
|
+{
|
|
+ int ret;
|
|
+ int report_id = *(u8*)data;
|
|
+
|
|
+ mutex_lock(&iface->out_mutex);
|
|
+
|
|
+ WARN_ON(iface->out_report != -1);
|
|
+ iface->out_report = report_id;
|
|
+ iface->out_flags = FIELD_PREP(FLAGS_GROUP, type) | FIELD_PREP(FLAGS_REQ, req);
|
|
+ iface->resp_buf = resp_buf;
|
|
+ iface->resp_size = resp_size;
|
|
+ reinit_completion(&iface->out_complete);
|
|
+
|
|
+ ret = dchid_send(iface, iface->out_flags, data, size);
|
|
+ if (ret < 0)
|
|
+ goto done;
|
|
+
|
|
+ if (!wait_for_completion_timeout(&iface->out_complete, msecs_to_jiffies(1000))) {
|
|
+ dev_err(iface->dchid->dev, "output report 0x%x to iface %d (%s) timed out\n",
|
|
+ report_id, iface->index, iface->name);
|
|
+ ret = -ETIMEDOUT;
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ ret = iface->resp_size;
|
|
+ if (iface->retcode) {
|
|
+ dev_err(iface->dchid->dev,
|
|
+ "output report 0x%x to iface %d (%s) failed with err 0x%x\n",
|
|
+ report_id, iface->index, iface->name, iface->retcode);
|
|
+ ret = -EIO;
|
|
+ }
|
|
+
|
|
+done:
|
|
+ iface->tx_seq++;
|
|
+ iface->out_report = -1;
|
|
+ iface->out_flags = 0;
|
|
+ iface->resp_buf = NULL;
|
|
+ iface->resp_size = 0;
|
|
+ mutex_unlock(&iface->out_mutex);
|
|
+ return ret;
|
|
+}
|
|
+
|
|
+static int dchid_comm_cmd(struct dockchannel_hid *dchid, void *cmd, size_t size)
|
|
+{
|
|
+ return dchid_cmd(dchid->comm, GROUP_CMD, REQ_SET_REPORT, cmd, size, NULL, 0);
|
|
+}
|
|
+
|
|
+static int dchid_enable_interface(struct dchid_iface *iface)
|
|
+{
|
|
+ u8 msg[] = { CMD_ENABLE_INTERFACE, iface->index };
|
|
+
|
|
+ return dchid_comm_cmd(iface->dchid, msg, sizeof(msg));
|
|
+}
|
|
+
|
|
+static int dchid_reset_interface(struct dchid_iface *iface, int state)
|
|
+{
|
|
+ u8 msg[] = { CMD_RESET_INTERFACE, 1, iface->index, state };
|
|
+
|
|
+ return dchid_comm_cmd(iface->dchid, msg, sizeof(msg));
|
|
+}
|
|
+
|
|
+static int dchid_send_firmware(struct dchid_iface *iface, void *firmware, size_t size)
|
|
+{
|
|
+ struct {
|
|
+ u8 cmd;
|
|
+ u8 unk1;
|
|
+ u8 unk2;
|
|
+ u8 iface;
|
|
+ u64 addr;
|
|
+ u32 size;
|
|
+ } __packed msg = {
|
|
+ .cmd = CMD_SEND_FIRMWARE,
|
|
+ .unk1 = 2,
|
|
+ .unk2 = 0,
|
|
+ .iface = iface->index,
|
|
+ .size = size,
|
|
+ };
|
|
+ dma_addr_t addr;
|
|
+ void *buf = dmam_alloc_coherent(iface->dchid->dev, size, &addr, GFP_KERNEL);
|
|
+
|
|
+ if (IS_ERR_OR_NULL(buf))
|
|
+ return buf ? PTR_ERR(buf) : -ENOMEM;
|
|
+
|
|
+ msg.addr = addr;
|
|
+ memcpy(buf, firmware, size);
|
|
+ wmb();
|
|
+
|
|
+ return dchid_comm_cmd(iface->dchid, &msg, sizeof(msg));
|
|
+}
|
|
+
|
|
+static int dchid_get_firmware(struct dchid_iface *iface, void **firmware, size_t *size)
|
|
+{
|
|
+ int ret;
|
|
+ const char *fw_name;
|
|
+ const struct firmware *fw;
|
|
+ struct fw_header *hdr;
|
|
+ u8 *fw_data;
|
|
+
|
|
+ ret = of_property_read_string(iface->of_node, "firmware-name", &fw_name);
|
|
+ if (ret) {
|
|
+ /* Firmware is only for some devices */
|
|
+ *firmware = NULL;
|
|
+ *size = 0;
|
|
+ return 0;
|
|
+ }
|
|
+
|
|
+ ret = request_firmware(&fw, fw_name, iface->dchid->dev);
|
|
+ if (ret)
|
|
+ return ret;
|
|
+
|
|
+ hdr = (struct fw_header *)fw->data;
|
|
+
|
|
+ if (hdr->magic != FW_MAGIC || hdr->version != FW_VER ||
|
|
+ hdr->hdr_length < sizeof(*hdr) || hdr->hdr_length > fw->size ||
|
|
+ (hdr->hdr_length + (size_t)hdr->data_length) > fw->size ||
|
|
+ hdr->iface_offset >= hdr->data_length) {
|
|
+ dev_warn(iface->dchid->dev, "%s: invalid firmware header\n",
|
|
+ fw_name);
|
|
+ ret = -EINVAL;
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ fw_data = devm_kmemdup(iface->dchid->dev, fw->data + hdr->hdr_length,
|
|
+ hdr->data_length, GFP_KERNEL);
|
|
+ if (!fw_data) {
|
|
+ ret = -ENOMEM;
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ if (hdr->iface_offset)
|
|
+ fw_data[hdr->iface_offset] = iface->index;
|
|
+
|
|
+ *firmware = fw_data;
|
|
+ *size = hdr->data_length;
|
|
+
|
|
+done:
|
|
+ release_firmware(fw);
|
|
+ return ret;
|
|
+}
|
|
+
|
|
+
|
|
+static int dchid_start(struct hid_device *hdev)
|
|
+{
|
|
+ return 0;
|
|
+};
|
|
+
|
|
+static void dchid_stop(struct hid_device *hdev)
|
|
+{
|
|
+ /* no-op, we don't know what the shutdown commands are, if any */
|
|
+}
|
|
+
|
|
+static int dchid_open(struct hid_device *hdev)
|
|
+{
|
|
+ struct dchid_iface *iface = hdev->driver_data;
|
|
+
|
|
+ iface->open = true;
|
|
+ return 0;
|
|
+}
|
|
+
|
|
+static void dchid_close(struct hid_device *hdev)
|
|
+{
|
|
+ struct dchid_iface *iface = hdev->driver_data;
|
|
+
|
|
+ iface->open = false;
|
|
+}
|
|
+
|
|
+static int dchid_parse(struct hid_device *hdev)
|
|
+{
|
|
+ struct dchid_iface *iface = hdev->driver_data;
|
|
+
|
|
+ return hid_parse_report(hdev, iface->hid_desc, iface->hid_desc_len);
|
|
+}
|
|
+
|
|
+/* Note: buf excludes report number! For ease of fetching strings/etc. */
|
|
+static int dchid_get_report_cmd(struct dchid_iface *iface, u8 reportnum, void *buf, size_t len)
|
|
+{
|
|
+ int ret = dchid_cmd(iface, GROUP_CMD, REQ_GET_REPORT, &reportnum, 1, buf, len);
|
|
+
|
|
+ return ret <= 0 ? ret : ret - 1;
|
|
+}
|
|
+
|
|
+/* Note: buf includes report number! */
|
|
+static int dchid_set_report(struct dchid_iface *iface, void *buf, size_t len)
|
|
+{
|
|
+ return dchid_cmd(iface, GROUP_OUTPUT, REQ_SET_REPORT, buf, len, NULL, 0);
|
|
+}
|
|
+
|
|
+static int dchid_raw_request(struct hid_device *hdev,
|
|
+ unsigned char reportnum, __u8 *buf, size_t len,
|
|
+ unsigned char rtype, int reqtype)
|
|
+{
|
|
+ struct dchid_iface *iface = hdev->driver_data;
|
|
+
|
|
+ switch (reqtype) {
|
|
+ case HID_REQ_GET_REPORT:
|
|
+ buf[0] = reportnum;
|
|
+ return dchid_cmd(iface, GROUP_OUTPUT, REQ_GET_REPORT, &reportnum, 1, buf + 1, len - 1);
|
|
+ case HID_REQ_SET_REPORT:
|
|
+ return dchid_set_report(iface, buf, len);
|
|
+ default:
|
|
+ return -EIO;
|
|
+ }
|
|
+
|
|
+ return 0;
|
|
+}
|
|
+
|
|
+static struct hid_ll_driver dchid_ll = {
|
|
+ .start = &dchid_start,
|
|
+ .stop = &dchid_stop,
|
|
+ .open = &dchid_open,
|
|
+ .close = &dchid_close,
|
|
+ .parse = &dchid_parse,
|
|
+ .raw_request = &dchid_raw_request,
|
|
+};
|
|
+
|
|
+static void dchid_init_interface(struct dchid_iface *iface)
|
|
+{
|
|
+ void *fw;
|
|
+ size_t size;
|
|
+
|
|
+ iface->deferred = false;
|
|
+
|
|
+ /* Enable interface (general) */
|
|
+ if (dchid_enable_interface(iface) < 0)
|
|
+ return;
|
|
+
|
|
+ /* Look to see if we need firmware */
|
|
+ if (dchid_get_firmware(iface, &fw, &size) < 0)
|
|
+ return;
|
|
+
|
|
+ /* Only multi-touch has firmware */
|
|
+ if (!fw || !size)
|
|
+ return;
|
|
+
|
|
+ /* Send it to the device */
|
|
+ if (dchid_send_firmware(iface, fw, size) < 0)
|
|
+ return;
|
|
+
|
|
+ /* After loading firmware, multi-touch needs a reset */
|
|
+ dchid_reset_interface(iface, 0);
|
|
+ dchid_reset_interface(iface, 2);
|
|
+}
|
|
+
|
|
+static void dchid_handle_descriptor(struct dchid_iface *iface, void *hid_desc, size_t desc_len)
|
|
+{
|
|
+ if (iface->hid) {
|
|
+ dev_warn(iface->dchid->dev, "Tried to initialize already started interface %s!\n",
|
|
+ iface->name);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ iface->hid_desc = devm_kmemdup(iface->dchid->dev, hid_desc, desc_len, GFP_KERNEL);
|
|
+ if (!iface->hid_desc)
|
|
+ return;
|
|
+
|
|
+ iface->hid_desc_len = desc_len;
|
|
+
|
|
+ /* We need to enable STM first, since it'll give us the device IDs */
|
|
+ if (iface->dchid->id_ready || !strcmp(iface->name, "stm"))
|
|
+ dchid_init_interface(iface);
|
|
+ else
|
|
+ iface->deferred = true;
|
|
+}
|
|
+
|
|
+static void dchid_handle_ready(struct dockchannel_hid *dchid, void *data, size_t length)
|
|
+{
|
|
+ struct hid_device *hid;
|
|
+ struct dchid_iface *iface;
|
|
+ int ret;
|
|
+ u8 *pkt = data;
|
|
+ u8 index;
|
|
+ int i;
|
|
+
|
|
+ if (length < 2) {
|
|
+ dev_err(dchid->dev, "Bad length for ready message: %ld\n", length);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ index = pkt[1];
|
|
+
|
|
+ if (index >= MAX_INTERFACES) {
|
|
+ dev_err(dchid->dev, "Got ready notification for bad iface %d\n", index);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ iface = dchid->ifaces[index];
|
|
+ if (!iface) {
|
|
+ dev_err(dchid->dev, "Got ready notification for unknown iface %d\n", index);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ if (iface->hid) {
|
|
+ dev_warn(iface->dchid->dev, "Interface %s already ready!\n",
|
|
+ iface->name);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ /* When STM is ready, grab global device info */
|
|
+ if (!strcmp(iface->name, "stm")) {
|
|
+ ret = dchid_get_report_cmd(iface, STM_REPORT_ID, &dchid->device_id,
|
|
+ sizeof(dchid->device_id));
|
|
+ if (ret < sizeof(dchid->device_id)) {
|
|
+ dev_warn(iface->dchid->dev, "Failed to get device ID from STM!\n");
|
|
+ /* Fake it and keep going. Things might still work... */
|
|
+ memset(&dchid->device_id, 0, sizeof(dchid->device_id));
|
|
+ dchid->device_id.vendor_id = HOST_VENDOR_ID_APPLE;
|
|
+ }
|
|
+ ret = dchid_get_report_cmd(iface, STM_REPORT_SERIAL, dchid->serial,
|
|
+ sizeof(dchid->serial) - 1);
|
|
+ if (ret < 0) {
|
|
+ dev_warn(iface->dchid->dev, "Failed to get serial from STM!\n");
|
|
+ dchid->serial[0] = 0;
|
|
+ }
|
|
+
|
|
+ dchid->id_ready = true;
|
|
+ for (i = 0; i < MAX_INTERFACES; i++)
|
|
+ if (dchid->ifaces[i] && dchid->ifaces[i]->deferred)
|
|
+ dchid_init_interface(dchid->ifaces[i]);
|
|
+
|
|
+ }
|
|
+
|
|
+ hid = hid_allocate_device();
|
|
+ if (IS_ERR(hid))
|
|
+ return;
|
|
+
|
|
+ snprintf(hid->name, sizeof(hid->name), "Apple MTP %s", iface->name);
|
|
+ snprintf(hid->phys, sizeof(hid->phys), "%s.%d (%s)",
|
|
+ dev_name(iface->dchid->dev), iface->index, iface->name);
|
|
+ strscpy(hid->uniq, dchid->serial, sizeof(hid->uniq));
|
|
+
|
|
+ hid->ll_driver = &dchid_ll;
|
|
+ hid->bus = BUS_HOST;
|
|
+ hid->vendor = dchid->device_id.vendor_id;
|
|
+ hid->product = dchid->device_id.product_id;
|
|
+ hid->version = dchid->device_id.version_number;
|
|
+ hid->type = HID_TYPE_OTHER;
|
|
+ if (!strcmp(iface->name, "multi-touch")) {
|
|
+ hid->type = HID_TYPE_SPI_MOUSE;
|
|
+ } else if (!strcmp(iface->name, "keyboard")) {
|
|
+ hid->type = HID_TYPE_SPI_KEYBOARD;
|
|
+
|
|
+ /* These country codes match what earlier Apple HID keyboards did */
|
|
+ switch (dchid->device_id.keyboard_type) {
|
|
+ case KEYBOARD_TYPE_ANSI:
|
|
+ hid->country = 33; // US-English
|
|
+ break;
|
|
+
|
|
+ case KEYBOARD_TYPE_ISO:
|
|
+ hid->country = 13; // ISO
|
|
+ break;
|
|
+
|
|
+ case KEYBOARD_TYPE_JIS:
|
|
+ hid->country = 15; // Japan
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ hid->dev.parent = iface->dchid->dev;
|
|
+ hid->driver_data = iface;
|
|
+
|
|
+ ret = hid_add_device(hid);
|
|
+ if (ret < 0) {
|
|
+ hid_destroy_device(hid);
|
|
+ dev_warn(iface->dchid->dev, "Failed to register hid device %s", iface->name);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ iface->hid = hid;
|
|
+}
|
|
+
|
|
+static void dchid_request_gpio(struct dchid_iface *iface, int id, const char *name)
|
|
+{
|
|
+ char prop_name[MAX_GPIO_NAME + 16];
|
|
+
|
|
+ dev_info(iface->dchid->dev, "Requesting GPIO %s#%d: %s\n", iface->name, id, name);
|
|
+
|
|
+ if (iface->gpio) {
|
|
+ dev_err(iface->dchid->dev, "Cannot request more than one GPIO per interface!\n");
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ snprintf(prop_name, sizeof(prop_name), "apple,%s-gpios", name);
|
|
+
|
|
+ iface->gpio = devm_gpiod_get_from_of_node(iface->dchid->dev,
|
|
+ iface->of_node, prop_name, 0,
|
|
+ GPIOD_OUT_LOW, name);
|
|
+
|
|
+ if (IS_ERR_OR_NULL(iface->gpio)) {
|
|
+ dev_err(iface->dchid->dev, "Failed to request GPIO %s\n", prop_name);
|
|
+ iface->gpio = NULL;
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ iface->gpio_id = id;
|
|
+}
|
|
+
|
|
+static void dchid_handle_init(struct dockchannel_hid *dchid, void *data, size_t length)
|
|
+{
|
|
+ struct dchid_init_hdr *hdr = data;
|
|
+ struct dchid_iface *iface;
|
|
+ struct dchid_init_block_hdr *blk;
|
|
+
|
|
+ if (length < sizeof(*hdr))
|
|
+ return;
|
|
+
|
|
+ iface = dchid_get_interface(dchid, hdr->iface, hdr->name);
|
|
+ if (!iface)
|
|
+ return;
|
|
+
|
|
+ data += sizeof(*hdr);
|
|
+ length -= sizeof(*hdr);
|
|
+
|
|
+ while (length > sizeof(*blk)) {
|
|
+ blk = data;
|
|
+ data += sizeof(*blk);
|
|
+ length -= sizeof(*blk);
|
|
+
|
|
+ if (blk->length > length)
|
|
+ return;
|
|
+ switch (blk->type) {
|
|
+ case INIT_HID_DESCRIPTOR:
|
|
+ dchid_handle_descriptor(iface, data, blk->length);
|
|
+ break;
|
|
+
|
|
+ case INIT_GPIO_REQUEST: {
|
|
+ struct dchid_gpio_request *req = data;
|
|
+
|
|
+ if (sizeof(*req) > length)
|
|
+ return;
|
|
+ dchid_request_gpio(iface, req->id, req->name);
|
|
+ break;
|
|
+ }
|
|
+
|
|
+ case INIT_TERMINATOR:
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ data += blk->length + sizeof(*blk);
|
|
+ length -= blk->length + sizeof(*blk);
|
|
+ }
|
|
+}
|
|
+
|
|
+static void dchid_handle_gpio(struct dockchannel_hid *dchid, void *data, size_t length)
|
|
+{
|
|
+ struct dchid_gpio_cmd *cmd = data;
|
|
+ struct dchid_iface *iface;
|
|
+ u32 retcode = 0xe000f00d; /* Give it a random Apple-style error code */
|
|
+ struct dchid_gpio_ack *ack;
|
|
+
|
|
+ if (length < sizeof(*cmd))
|
|
+ return;
|
|
+
|
|
+ if (cmd->iface >= MAX_INTERFACES || !(iface = dchid->ifaces[cmd->iface])) {
|
|
+ dev_err(dchid->dev, "Got GPIO command for bad inteface %d\n", cmd->iface);
|
|
+ goto err;
|
|
+ }
|
|
+
|
|
+ if (!iface->gpio || cmd->gpio != iface->gpio_id) {
|
|
+ dev_err(dchid->dev, "Got GPIO command for bad GPIO %s#%d\n",
|
|
+ iface->name, cmd->gpio);
|
|
+ goto err;
|
|
+ }
|
|
+
|
|
+ dev_info(dchid->dev, "GPIO command: %s#%d: %d\n", iface->name, cmd->gpio, cmd->cmd);
|
|
+
|
|
+ switch (cmd->cmd) {
|
|
+ case 3:
|
|
+ /* Pulse. */
|
|
+ gpiod_set_value_cansleep(iface->gpio, 1);
|
|
+ msleep(10); /* Random guess... */
|
|
+ gpiod_set_value_cansleep(iface->gpio, 0);
|
|
+ retcode = 0;
|
|
+ break;
|
|
+ default:
|
|
+ dev_err(dchid->dev, "Unknown GPIO command %d\n", cmd->cmd );
|
|
+ break;
|
|
+ }
|
|
+
|
|
+err:
|
|
+ /* Ack it */
|
|
+ ack = kzalloc(sizeof(*ack) + length, GFP_KERNEL);
|
|
+ if (!ack)
|
|
+ return;
|
|
+
|
|
+ ack->type = CMD_ACK_GPIO_CMD;
|
|
+ ack->retcode = retcode;
|
|
+ memcpy(ack->cmd, data, length);
|
|
+
|
|
+ if (dchid_comm_cmd(dchid, ack, sizeof(*ack) + length) < 0)
|
|
+ dev_err(dchid->dev, "Failed to ACK GPIO command\n");
|
|
+
|
|
+ kfree(ack);
|
|
+}
|
|
+
|
|
+static void dchid_handle_event(struct dockchannel_hid *dchid, void *data, size_t length)
|
|
+{
|
|
+ u8 *p = data;
|
|
+ switch (*p) {
|
|
+ case EVENT_INIT:
|
|
+ dchid_handle_init(dchid, data, length);
|
|
+ break;
|
|
+ case EVENT_READY:
|
|
+ dchid_handle_ready(dchid, data, length);
|
|
+ break;
|
|
+ case EVENT_GPIO_CMD:
|
|
+ dchid_handle_gpio(dchid, data, length);
|
|
+ break;
|
|
+ }
|
|
+}
|
|
+
|
|
+static void dchid_handle_report(struct dchid_iface *iface, void *data, size_t length)
|
|
+{
|
|
+ struct dockchannel_hid *dchid = iface->dchid;
|
|
+
|
|
+ if (!iface->hid) {
|
|
+ dev_warn(dchid->dev, "Report received but %s is not initialized!\n", iface->name);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ if (!iface->open)
|
|
+ return;
|
|
+
|
|
+ hid_input_report(iface->hid, HID_INPUT_REPORT, data, length, 1);
|
|
+}
|
|
+
|
|
+static void dchid_packet_work(struct work_struct *ws)
|
|
+{
|
|
+ struct dchid_work *work = container_of(ws, struct dchid_work, work);
|
|
+ struct dchid_subhdr *shdr = (void *)work->data;
|
|
+ struct dockchannel_hid *dchid = work->iface->dchid;
|
|
+ int type = FIELD_GET(FLAGS_GROUP, shdr->flags);
|
|
+ u8 *payload = work->data + sizeof(*shdr);
|
|
+
|
|
+ if (shdr->length + sizeof(*shdr) > work->hdr.length) {
|
|
+ dev_err(dchid->dev, "Bad sub header length (%d > %ld)\n",
|
|
+ shdr->length, work->hdr.length - sizeof(*shdr));
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ switch (type) {
|
|
+ case GROUP_INPUT:
|
|
+ if (work->hdr.iface == IFACE_COMM)
|
|
+ dchid_handle_event(dchid, payload, shdr->length);
|
|
+ else
|
|
+ dchid_handle_report(work->iface, payload, shdr->length);
|
|
+ break;
|
|
+ default:
|
|
+ dev_err(dchid->dev, "Received unknown packet type %d\n", type);
|
|
+ break;
|
|
+ }
|
|
+
|
|
+ kfree(work);
|
|
+}
|
|
+
|
|
+static void dchid_handle_ack(struct dchid_iface *iface, struct dchid_hdr *hdr, void *data)
|
|
+{
|
|
+ struct dchid_subhdr *shdr = (void *)data;
|
|
+ u8 *payload = data + sizeof(*shdr);
|
|
+
|
|
+ if (shdr->length + sizeof(*shdr) > hdr->length) {
|
|
+ dev_err(iface->dchid->dev, "Bad sub header length (%d > %ld)\n",
|
|
+ shdr->length, hdr->length - sizeof(*shdr));
|
|
+ return;
|
|
+ }
|
|
+ if (shdr->flags != iface->out_flags) {
|
|
+ dev_err(iface->dchid->dev,
|
|
+ "Received unexpected flags 0x%x on ACK channel (expected 0x%x)\n",
|
|
+ shdr->flags, iface->out_flags);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ if (shdr->length < 1) {
|
|
+ dev_err(iface->dchid->dev, "Received length 0 output report ack\n");
|
|
+ return;
|
|
+ }
|
|
+ if (iface->tx_seq != hdr->seq) {
|
|
+ dev_err(iface->dchid->dev, "Received ACK with bad seq (expected %d, got %d)\n",
|
|
+ iface->rx_seq, hdr->seq);
|
|
+ return;
|
|
+ }
|
|
+ if (iface->out_report != payload[0]) {
|
|
+ dev_err(iface->dchid->dev, "Received ACK with bad report (expected %d, got %d\n",
|
|
+ iface->out_report, payload[0]);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ if (iface->resp_buf && iface->resp_size)
|
|
+ memcpy(iface->resp_buf, payload + 1, min((size_t)shdr->length - 1, iface->resp_size));
|
|
+
|
|
+ iface->resp_size = shdr->length;
|
|
+ iface->out_report = -1;
|
|
+ iface->retcode = shdr->retcode;
|
|
+ complete(&iface->out_complete);
|
|
+}
|
|
+
|
|
+static void dchid_handle_packet(void *cookie, size_t avail)
|
|
+{
|
|
+ struct dockchannel_hid *dchid = cookie;
|
|
+ struct dchid_hdr hdr;
|
|
+ struct dchid_work *work;
|
|
+ struct dchid_iface *iface;
|
|
+ u32 checksum;
|
|
+
|
|
+ if (dockchannel_recv(dchid->dc, &hdr, sizeof(hdr)) != sizeof(hdr)) {
|
|
+ dev_err(dchid->dev, "Read failed (header)\n");
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ if (hdr.hdr_len != sizeof(hdr)) {
|
|
+ dev_err(dchid->dev, "Bad header length %d\n", hdr.hdr_len);
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ if (dockchannel_recv(dchid->dc, dchid->pkt_buf, hdr.length + 4) != (hdr.length + 4)) {
|
|
+ dev_err(dchid->dev, "Read failed (body)\n");
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ checksum = dchid_checksum(&hdr, sizeof(hdr));
|
|
+ checksum += dchid_checksum(dchid->pkt_buf, hdr.length + 4);
|
|
+
|
|
+ if (checksum != 0xffffffff) {
|
|
+ dev_err(dchid->dev, "Checksum mismatch (iface %d): 0x%08x != 0xffffffff\n",
|
|
+ hdr.iface, checksum);
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+
|
|
+ if (hdr.iface >= MAX_INTERFACES) {
|
|
+ dev_err(dchid->dev, "Bad iface %d\n", hdr.iface);
|
|
+ }
|
|
+
|
|
+ iface = dchid->ifaces[hdr.iface];
|
|
+
|
|
+ if (!iface) {
|
|
+ dev_err(dchid->dev, "Received packet for uninitialized iface %d\n", hdr.iface);
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ switch (hdr.channel) {
|
|
+ case DCHID_CHANNEL_CMD:
|
|
+ dchid_handle_ack(iface, &hdr, dchid->pkt_buf);
|
|
+ goto done;
|
|
+ case DCHID_CHANNEL_REPORT:
|
|
+ break;
|
|
+ default:
|
|
+ dev_warn(dchid->dev, "Unknown channel 0x%x, treating as report...\n",
|
|
+ hdr.channel);
|
|
+ break;
|
|
+ }
|
|
+
|
|
+ if (hdr.seq != iface->rx_seq) {
|
|
+ dev_err(dchid->dev, "Received packet out of sequence (expected %d, got %d)\n",
|
|
+ iface->rx_seq, hdr.seq);
|
|
+ goto done;
|
|
+ }
|
|
+
|
|
+ iface->rx_seq++;
|
|
+
|
|
+ work = kzalloc(sizeof(*work) + hdr.length, GFP_KERNEL);
|
|
+ if (!work)
|
|
+ return;
|
|
+
|
|
+ work->hdr = hdr;
|
|
+ work->iface = iface;
|
|
+ memcpy(work->data, dchid->pkt_buf, hdr.length);
|
|
+ INIT_WORK(&work->work, dchid_packet_work);
|
|
+
|
|
+ queue_work(dchid->wq, &work->work);
|
|
+
|
|
+done:
|
|
+ dockchannel_await(dchid->dc, dchid_handle_packet, dchid, sizeof(struct dchid_hdr));
|
|
+}
|
|
+
|
|
+static int dockchannel_hid_probe(struct platform_device *pdev)
|
|
+{
|
|
+ struct device *dev = &pdev->dev;
|
|
+ struct dockchannel_hid *dchid;
|
|
+ struct device_node *child;
|
|
+ struct property *prop;
|
|
+ bool defer = false;
|
|
+
|
|
+ /*
|
|
+ * First make sure all the GPIOs are available, in cased we need to defer.
|
|
+ * This is necessary because MTP will request them by name later, and by then
|
|
+ * it's too late to defer the probe.
|
|
+ */
|
|
+
|
|
+ for_each_child_of_node(dev->of_node, child) {
|
|
+ for_each_property_of_node(child, prop) {
|
|
+ size_t len = strlen(prop->name);
|
|
+ struct gpio_desc *gpio;
|
|
+
|
|
+ if (len < 12 || strncmp("apple,", prop->name, 6) ||
|
|
+ strcmp("-gpios", prop->name + len - 6))
|
|
+ continue;
|
|
+
|
|
+ gpio = gpiod_get_from_of_node(child, prop->name, 0, GPIOD_ASIS,
|
|
+ prop->name);
|
|
+ if (IS_ERR_OR_NULL(gpio)) {
|
|
+ if (PTR_ERR(gpio) == EPROBE_DEFER) {
|
|
+ defer = true;
|
|
+ of_node_put(child);
|
|
+ return -EPROBE_DEFER;
|
|
+ }
|
|
+ } else {
|
|
+ gpiod_put(gpio);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ dchid = devm_kzalloc(dev, sizeof(*dchid), GFP_KERNEL);
|
|
+ if (!dchid)
|
|
+ return -ENOMEM;
|
|
+
|
|
+
|
|
+ dchid->dev = &pdev->dev;
|
|
+ dchid->dc = dockchannel_init(pdev);
|
|
+ if (IS_ERR_OR_NULL(dchid->dc)) {
|
|
+ return -PTR_ERR(dchid->dc);
|
|
+ }
|
|
+
|
|
+ dchid->comm = dchid_get_interface(dchid, IFACE_COMM, "comm");
|
|
+ if (!dchid->comm) {
|
|
+ dev_err(dchid->dev, "Failed to initialize comm interface");
|
|
+ return -EIO;
|
|
+ }
|
|
+
|
|
+ dchid->wq = alloc_ordered_workqueue("dockchannel-hid-report", WQ_MEM_RECLAIM);
|
|
+ if (!dchid->wq)
|
|
+ return -ENOMEM;
|
|
+
|
|
+ dev_info(dchid->dev, "initialized, awaiting packets\n");
|
|
+ dockchannel_await(dchid->dc, dchid_handle_packet, dchid, sizeof(struct dchid_hdr));
|
|
+
|
|
+ return 0;
|
|
+}
|
|
+
|
|
+static int dockchannel_hid_remove(struct platform_device *pdev)
|
|
+{
|
|
+ BUG_ON(1);
|
|
+ return 0;
|
|
+}
|
|
+
|
|
+static const struct of_device_id dockchannel_hid_of_match[] = {
|
|
+ { .compatible = "apple,dockchannel-hid" },
|
|
+ {},
|
|
+};
|
|
+MODULE_DEVICE_TABLE(of, dockchannel_hid_of_match);
|
|
+MODULE_FIRMWARE("apple/tpmtfw-*.bin");
|
|
+
|
|
+static struct platform_driver dockchannel_hid_driver = {
|
|
+ .driver = {
|
|
+ .name = "dockchannel-hid",
|
|
+ .of_match_table = dockchannel_hid_of_match,
|
|
+ },
|
|
+ .probe = dockchannel_hid_probe,
|
|
+ .remove = dockchannel_hid_remove,
|
|
+};
|
|
+module_platform_driver(dockchannel_hid_driver);
|
|
+
|
|
+MODULE_DESCRIPTION("Apple DockChannel HID transport driver");
|
|
+MODULE_AUTHOR("Hector Martin <marcan@marcan.st>");
|
|
+MODULE_LICENSE("Dual MIT/GPL");
|
|
--
|
|
2.34.1
|
|
|