From 9b2201a4388525c899f10247831ecccb20645e13 Mon Sep 17 00:00:00 2001 From: Borys Lykah Date: Sat, 29 Oct 2022 14:46:35 -0600 Subject: [PATCH] Initial hotpluggable keyboard handling (#9540) * Added a new plugin external-keyboard. It listens to USB events. When keyboard is plugged in or plugged out, it updates device and input configuration accordingly. * Added new fake events UsbDevicePlugIn and UsbDevicePlugOut that are emitted when a device is connected to a book reader that plays the role of USB host. The usage of the existing events UsbPlugIn and UsbPlugOut has not changed - they are used when a reader is connected to a host. The koreader-base has a related PR for those events. * Did a small refactoring of initialization for the modules FocusManager and InputText. They check device keyboard capabilities on their when the module is first loaded and store it. Some of the initialization code has been extracted into functions, so that we can re-initialize them when keyboard is (dis)connected. * Initial implementation centered around text input, and tested with USB keyboards on devices with OTG support. * Said OTG shenanigans are so far supported on devices with debugfs & the chipidea driver, or sunxi devices. --- frontend/device/input.lua | 23 +- .../ui/elements/filemanager_menu_order.lua | 1 + frontend/ui/elements/reader_menu_order.lua | 1 + frontend/ui/uimanager.lua | 47 +++ frontend/ui/widget/focusmanager.lua | 45 ++- frontend/ui/widget/inputtext.lua | 24 +- plugins/externalkeyboard.koplugin/_meta.lua | 6 + .../event_map_keyboard.lua | 72 ++++ .../find-keyboard.lua | 102 ++++++ plugins/externalkeyboard.koplugin/main.lua | 325 ++++++++++++++++++ 10 files changed, 626 insertions(+), 20 deletions(-) create mode 100644 plugins/externalkeyboard.koplugin/_meta.lua create mode 100644 plugins/externalkeyboard.koplugin/event_map_keyboard.lua create mode 100644 plugins/externalkeyboard.koplugin/find-keyboard.lua create mode 100644 plugins/externalkeyboard.koplugin/main.lua diff --git a/frontend/device/input.lua b/frontend/device/input.lua index 80a5e41a3..ccb781f46 100644 --- a/frontend/device/input.lua +++ b/frontend/device/input.lua @@ -176,6 +176,14 @@ local Input = { }, }, + fake_event_set = { + IntoSS = true, OutOfSS = true, + UsbPlugIn = true, UsbPlugOut = true, + Charging = true, NotCharging = true, + WakeupFromSuspend = true, ReadyToSuspend = true, + UsbDevicePlugIn = true, UsbDevicePlugOut = true, + }, + -- NOTE: When looking at the device in Portrait mode, that's assuming PgBack is on TOP, and PgFwd on the BOTTOM rotation_map = { [framebuffer.ORIENTATION_PORTRAIT] = {}, @@ -194,6 +202,7 @@ local Input = { Ctrl = false, Shift = false, Sym = false, + Meta = false, }, -- repeat state: @@ -256,6 +265,8 @@ function Input:init() self.event_map[10021] = "NotCharging" self.event_map[10030] = "WakeupFromSuspend" self.event_map[10031] = "ReadyToSuspend" + self.event_map[10040] = "UsbDevicePlugIn" + self.event_map[10041] = "UsbDevicePlugOut" -- user custom event map local custom_event_map_location = string.format( @@ -527,11 +538,7 @@ function Input:handleKeyBoardEv(ev) keycode = self.rotation_map[self.device.screen:getRotationMode()][keycode] end - -- fake events - if keycode == "IntoSS" or keycode == "OutOfSS" - or keycode == "UsbPlugIn" or keycode == "UsbPlugOut" - or keycode == "Charging" or keycode == "NotCharging" - or keycode == "WakeupFromSuspend" or keycode == "ReadyToSuspend" then + if self.fake_event_set[keycode] then return keycode end @@ -638,11 +645,7 @@ function Input:handlePowerManagementOnlyEv(ev) return keycode end - -- Fake events - if keycode == "IntoSS" or keycode == "OutOfSS" - or keycode == "UsbPlugIn" or keycode == "UsbPlugOut" - or keycode == "Charging" or keycode == "NotCharging" - or keycode == "WakeupFromSuspend" or keycode == "ReadyToSuspend" then + if self.fake_event_set[keycode] then return keycode end diff --git a/frontend/ui/elements/filemanager_menu_order.lua b/frontend/ui/elements/filemanager_menu_order.lua index 59b074c07..eb2eb83bd 100644 --- a/frontend/ui/elements/filemanager_menu_order.lua +++ b/frontend/ui/elements/filemanager_menu_order.lua @@ -43,6 +43,7 @@ local order = { }, device = { "keyboard_layout", + "external_keyboard", "font_ui_fallbacks", "----------------------------", "time", diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index 87d17f74b..399fcf4b1 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -86,6 +86,7 @@ local order = { }, device = { "keyboard_layout", + "external_keyboard", "font_ui_fallbacks", "----------------------------", "time", diff --git a/frontend/ui/uimanager.lua b/frontend/ui/uimanager.lua index 660dd0e73..19917c430 100644 --- a/frontend/ui/uimanager.lua +++ b/frontend/ui/uimanager.lua @@ -56,6 +56,13 @@ function UIManager:init() Power = function(input_event) Device:onPowerEvent(input_event) end, + -- This is for OTG input devices + UsbDevicePlugIn = function() + self:broadcastEvent(Event:new("UsbDevicePlugIn")) + end, + UsbDevicePlugOut = function() + self:broadcastEvent(Event:new("UsbDevicePlugOut")) + end, } self.poweroff_action = function() self._entered_poweroff_stage = true @@ -329,6 +336,46 @@ function UIManager:tickAfterNext(action) end --]] +function UIManager:debounce(seconds, immediate, action) + -- Ported from underscore.js + local args = nil + local previous_call_at = nil + local is_scheduled = false + local result = nil + + local scheduled_action + scheduled_action = function() + local passed_from_last_call = time:now() - previous_call_at + if seconds > passed_from_last_call then + self:scheduleIn(seconds - passed_from_last_call, scheduled_action) + is_scheduled = true + else + is_scheduled = false + if not immediate then + result = action(unpack(args)) + end + if not is_scheduled then + -- This check is needed because action can recursively call debounced_action_wrapper + args = nil + end + end + end + local debounced_action_wrapper = function(...) + args = table.pack(...) + previous_call_at = time:now() + if not is_scheduled then + self:scheduleIn(seconds, scheduled_action) + is_scheduled = true + if immediate then + result = action(unpack(args)) + end + end + return result + end + + return debounced_action_wrapper +end + --[[-- Unschedules a previously scheduled task. diff --git a/frontend/ui/widget/focusmanager.lua b/frontend/ui/widget/focusmanager.lua index 947a7dc6d..e967cdc6d 100644 --- a/frontend/ui/widget/focusmanager.lua +++ b/frontend/ui/widget/focusmanager.lua @@ -32,11 +32,17 @@ local FocusManager = InputContainer:extend{ movement_allowed = { x = true, y = true }, } --- Only build the default mappings once, we'll make copies during instantiation. -local KEY_EVENTS = {} -local BUILTIN_KEY_EVENTS = {} -local EXTRA_KEY_EVENTS = {} -do +-- Only build the default mappings once on initialization, or when an external keyboard is (dis-)/connected. +-- We'll make copies during instantiation. +local KEY_EVENTS +local BUILTIN_KEY_EVENTS +local EXTRA_KEY_EVENTS + +local function populateEventMappings() + KEY_EVENTS = {} + BUILTIN_KEY_EVENTS = {} + EXTRA_KEY_EVENTS = {} + if Device:hasDPad() then local event_keys = {} -- these will all generate the same event, just with different arguments @@ -93,6 +99,8 @@ do end end +populateEventMappings() + function FocusManager:_init() InputContainer._init(self) @@ -250,6 +258,33 @@ function FocusManager:onFocusMove(args) return true end +function FocusManager:onPhysicalKeyboardConnected() + -- Re-initialize with new keys info. + populateEventMappings() + -- We can't just call FocusManager._init because it will *reset* the mappings, losing our widget-specific ones (if any), + -- and it'll call InputContainer._init, which *also* resets the touch zones. + -- Instead, we'll just do a merge ourselves. + util.tableMerge(self.key_events, KEY_EVENTS) + -- populateEventMappings replaces these, so, update our refs + self.builtin_key_events = BUILTIN_KEY_EVENTS + self.extra_key_events = EXTRA_KEY_EVENTS +end + +function FocusManager:onPhysicalKeyboardDisconnected() + local prev_key_events = KEY_EVENTS + populateEventMappings() + + -- Remove what disappeared from KEY_EVENTS from self.key_events (if any). + -- NOTE: This is slightly overkill, we could very well live with a few unreachable mappings for the rest of this widget's life ;). + for k, _ in pairs(prev_key_events) do + if not KEY_EVENTS[k] then + self.key_events[k] = nil + end + end + self.builtin_key_events = BUILTIN_KEY_EVENTS + self.extra_key_events = EXTRA_KEY_EVENTS +end + -- constant, used to reset focus widget after layout recreation -- not send Unfocus event FocusManager.NOT_UNFOCUS = 1 diff --git a/frontend/ui/widget/inputtext.lua b/frontend/ui/widget/inputtext.lua index 1595cfbea..faa3555b5 100644 --- a/frontend/ui/widget/inputtext.lua +++ b/frontend/ui/widget/inputtext.lua @@ -72,9 +72,7 @@ function InputText:initEventListener() end function InputText:onFocus() end function InputText:onUnfocus() end --- only use PhysicalKeyboard if the device does not have touch screen -if Device:isTouchDevice() or Device:hasDPad() then - Keyboard = require("ui/widget/virtualkeyboard") +local function initTouchEvents() if Device:isTouchDevice() then function InputText:initEventListener() self.ges_events = { @@ -284,6 +282,9 @@ if Device:isTouchDevice() or Device:hasDPad() then return false end end +end + +local function initDPadEvents() if Device:hasDPad() then function InputText:onFocus() -- Event called by the focusmanager @@ -302,10 +303,23 @@ if Device:isTouchDevice() or Device:hasDPad() then return true end end -else - Keyboard = require("ui/widget/physicalkeyboard") end +-- only use PhysicalKeyboard if the device does not support touch input +function InputText.initInputEvents() + FocusManagerInstance = nil + + if Device:isTouchDevice() or Device:hasDPad() then + Keyboard = require("ui/widget/virtualkeyboard") + initTouchEvents() + initDPadEvents() + else + Keyboard = require("ui/widget/physicalkeyboard") + end +end + +InputText.initInputEvents() + function InputText:checkTextEditability() -- The split of the 'text' string to a table of utf8 chars may not be -- reversible to the same string, if 'text' comes from a binary file diff --git a/plugins/externalkeyboard.koplugin/_meta.lua b/plugins/externalkeyboard.koplugin/_meta.lua new file mode 100644 index 000000000..f8624d66e --- /dev/null +++ b/plugins/externalkeyboard.koplugin/_meta.lua @@ -0,0 +1,6 @@ +local _ = require("gettext") +return { + name = "externalkeyboard", + fullname = _("External Keyboard"), + description = _([[Manages USB OTG and configures keyboard.]]), +} diff --git a/plugins/externalkeyboard.koplugin/event_map_keyboard.lua b/plugins/externalkeyboard.koplugin/event_map_keyboard.lua new file mode 100644 index 000000000..35e646c72 --- /dev/null +++ b/plugins/externalkeyboard.koplugin/event_map_keyboard.lua @@ -0,0 +1,72 @@ +--[[ +event map for full-size keyboards. +--]] + +return { + [2] = "1", [3] = "2", [4] = "3", [5] = "4", [6] = "5", [7] = "6", [8] = "7", [9] = "8", [10] = "9", [11] = "0", + [16] = "Q", [17] = "W", [18] = "E", [19] = "R", [20] = "T", [21] = "Y", [22] = "U", [23] = "I", [24] = "O", [25] = "P", + [30] = "A", [31] = "S", [32] = "D", [33] = "F", [34] = "G", [35] = "H", [36] = "J", [37] = "K", [38] = "L", [39] = ":", [40] = "'", + [44] = "Z", [45] = "X", [46] = "C", [47] = "V", [48] = "B", [49] = "N", [50] = "M", [51] = ",", [52] = ".", [53] = "/", + + [14] = "Backspace", + [15] = "Tab", + [28] = "Press", -- Enter + [29] = "LCtrl", + [42] = "Shift", -- Left Shift + [43] = "\\", + [54] = "Shift", -- Right Shift + [56] = "LAlt", + [58] = "CapsLock", + [57] = " ", + [59] = "F1", + [60] = "F2", + [61] = "F3", + [62] = "F4", + [63] = "F5", + [64] = "F6", + [65] = "F7", + [66] = "F8", + [67] = "F9", + [68] = "F10", + [69] = "NumLock", + [70] = "ScrollLock", + + [71] = "KP7", + [72] = "KP8", + [73] = "KP9", + [74] = "KPMinus", + [75] = "KP4", + [76] = "KP5", + [77] = "KP6", + [78] = "KPPlus", + [79] = "KP1", + [80] = "KP2", + [81] = "KP3", + [82] = "KP0", + [87] = "F11", + [88] = "F12", + [96] = "Press", -- KPEnter + + [97] = "Ctrl", -- Right Ctrl + [98] = "Home", + [99] = "PrintScr", -- Also SysRq + [100] = "RAlt", + [102] = "Home", + [103] = "Up", + [104] = "LPgBack", -- PageUp + [105] = "Left", + [106] = "Right", + [107] = "End", + [108] = "Down", + [109] = "LPgFwd", -- PageDown + [110] = "Ins", + [111] = "Del", + [114] = "VMinus", + [115] = "VPlus", + [116] = "Power", + [119] = "Pause", + [125] = "LMeta", -- Meta, Win, Cmd, etc. + [126] = "RMeta", + [127] = "Compose", + [139] = "Menu", +} diff --git a/plugins/externalkeyboard.koplugin/find-keyboard.lua b/plugins/externalkeyboard.koplugin/find-keyboard.lua new file mode 100644 index 000000000..01ef5b07b --- /dev/null +++ b/plugins/externalkeyboard.koplugin/find-keyboard.lua @@ -0,0 +1,102 @@ +local bit = require("bit") +local ffi = require("ffi") +local lfs = require("libs/libkoreader-lfs") + +-- Constants from the linux kernel input-event-codes.h +local KEY_UP = 103 +local BTN_DPAD_UP = 0x220 + +local FindKeyboard = {} + +local function count_set_bits(n) + -- Brian Kernighan's algorithm + local count = 0 + while n ~= 0 do + count = count + 1 + n = bit.band(n, n - 1) + end + return count +end + +local function capabilities_str_to_long_bitmap_array(str) + -- The format for capabilities is at include/linux/mod_devicetable.h. + -- They are long's split by spaces. See linux/drivers/input/input.c::input_print_bitmap. + local long_bitmap_arr = {} + for c in str:gmatch "([0-9a-fA-F]+)" do + local long_bitmap = tonumber(c, 16) + table.insert(long_bitmap_arr, 1, long_bitmap) + end + return long_bitmap_arr +end + +local function count_set_bits_in_array(arr) + local count = 0 + for __, number in ipairs(arr) do + local count_in_number = count_set_bits(number) + count = count + count_in_number + end + + return count +end + +local function is_capabilities_bit_set(long_bitmap_arr, bit_offset) + local long_bitsize = ffi.sizeof("long") * 8 + local arr_index = math.floor(bit_offset / long_bitsize) + local long_mask = bit.lshift(1, bit_offset % long_bitsize) + local long_bitmap = long_bitmap_arr[arr_index + 1] -- Array index starts from 1 in Lua + + if long_bitmap then + return bit.band(long_bitmap, long_mask) ~= 0 + else + return false + end +end + +local function read_key_capabilities(sys_event_path) + local key_path = sys_event_path .. "/device/capabilities/key" + local file = io.open(key_path, "r") + if not file then + -- This should not happen - the kernel creates key capabilities file for all devices. + return nil + end + local keys_bitmap_str = file:read("l") + file:close() + + return capabilities_str_to_long_bitmap_array(keys_bitmap_str) +end + +local function analyze_key_capabilities(long_bitmap_arr) + -- The heuristic is that a keyboard has at least as many keys as there are alphabet letters and some more. + local keyboard_min_number_keys = 64 + local keys_count = count_set_bits_in_array(long_bitmap_arr) + + local is_keyboard = keys_count >= keyboard_min_number_keys + local has_dpad = is_capabilities_bit_set(long_bitmap_arr, KEY_UP) or + is_capabilities_bit_set(long_bitmap_arr, BTN_DPAD_UP) + + return { + is_keyboard = is_keyboard, + has_dpad = has_dpad, + } +end + +function FindKeyboard:find() + local keyboards = {} + for event_file_name in lfs.dir("/sys/class/input/") do + if event_file_name:match("event.*") then + local capabilities_long_bitmap_arr = read_key_capabilities("/sys/class/input/" .. event_file_name) + if capabilities_long_bitmap_arr then + local keyboard_info = analyze_key_capabilities(capabilities_long_bitmap_arr) + if keyboard_info.is_keyboard then + table.insert(keyboards, { + event_path = "/dev/input/" .. event_file_name, + has_dpad = keyboard_info.has_dpad + }) + end + end + end + end + return keyboards +end + +return FindKeyboard diff --git a/plugins/externalkeyboard.koplugin/main.lua b/plugins/externalkeyboard.koplugin/main.lua new file mode 100644 index 000000000..479633eab --- /dev/null +++ b/plugins/externalkeyboard.koplugin/main.lua @@ -0,0 +1,325 @@ +local Event = require("ui/event") +local FindKeyboard = require("find-keyboard") +local Device = require("device") +local InfoMessage = require("ui/widget/infomessage") +local InputText = require("ui/widget/inputtext") +local lfs = require("libs/libkoreader-lfs") +local logger = require("logger") +local UIManager = require("ui/uimanager") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local event_map_keyboard = require("event_map_keyboard") +local util = require("util") +local _ = require("gettext") + +-- The include/linux/usb/role.h calls the USB roles "host" and "device". +local USB_ROLE_DEVICE = "device" +local USB_ROLE_HOST = "host" +-- The Chipidea driver calls them "host" and "gadget". +-- This plugin sticks to Linux naming except when interacting with drivers. +local CHIPIDEA_TO_USB = { + host = USB_ROLE_HOST, + gadget = USB_ROLE_DEVICE, +} +local USB_TO_CHIPIDEA = { + [USB_ROLE_HOST] = "host", + [USB_ROLE_DEVICE] = "gadget", +} +-- sunxi just adds a "usb_" prefix +local SUNXI_TO_USB = { + usb_host = USB_ROLE_HOST, + usb_device = USB_ROLE_DEVICE, +} +local USB_TO_SUNXI = { + [USB_ROLE_HOST] = "usb_host", + [USB_ROLE_DEVICE] = "usb_device", +} + +-- This path exists on Kobo Clara and newer. Other devices w/ Chipidea drivers should have it too. +-- Also, the kernel must be compiled with CONFIG_DEBUG_FS and the debugfs must be mounted (we'll ensure the latter). +local OTG_CHIPIDEA_ROLE_PATH = "/sys/kernel/debug/ci_hdrc.0/role" +-- This one is for devices on a sunxi SoC (tested on a B300, as found on the Kobo Elipsa & Sage). +-- It does not require debugfs, but the point is moot as debugfs is mounted by default on those, +-- as Nickel relies on it for PM interaction with the display driver. +local OTG_SUNXI_ROLE_PATH = "/sys/devices/platform/soc/usbc0/otg_role" + +local function setupDebugFS() + local mounts = io.open("/proc/mounts", "re") + if not mounts then + return false + end + + local found = false + for line in mounts:lines() do + if line:find("^none /sys/kernel/debug debugfs") then + found = true + break + end + end + mounts:close() + + if not found then + if os.execute("mount -t debugfs none /sys/kernel/debug") ~= 0 then + logger.warn("ExternalKeyboard: Failed to mount debugfs") + return false + end + end + + return true +end + +if lfs.attributes("/sys/kernel/debug", "mode") == "directory" then + -- This should be in init() but the check must come first. So this part of initialization is here. + -- It is quick and harmless enough to be in a check. + if not setupDebugFS() then + return { disabled = true } + end + if lfs.attributes(OTG_CHIPIDEA_ROLE_PATH, "mode") ~= "file" and + lfs.attributes(OTG_SUNXI_ROLE_PATH, "mode") ~= "file" then + return { disabled = true } + end +else + return { disabled = true } +end + +local function yes() return true end +local function no() return false end -- luacheck: ignore + +local ExternalKeyboard = WidgetContainer:extend{ + name = "external_keyboard", + is_doc_only = false, + original_device_values = nil, + keyboard_fds = {}, +} + +function ExternalKeyboard:init() + self.ui.menu:registerToMainMenu(self) + + -- Check if we should go with the sunxi otg manager, or the chipidea driver... + if lfs.attributes(OTG_SUNXI_ROLE_PATH, "mode") == "file" then + self.getOTGRole = self.sunxiGetOTGRole + self.setOTGRole = self.sunxiSetOTGRole + else + self.getOTGRole = self.chipideaGetOTGRole + self.setOTGRole = self.chipideaSetOTGRole + end + + local role = self:getOTGRole() + logger.dbg("ExternalKeyboard: role", role) + + if role == USB_ROLE_DEVICE and G_reader_settings:isTrue("external_keyboard_otg_mode_on_start") then + self:setOTGRole(USB_ROLE_HOST) + role = USB_ROLE_HOST + end + if role == USB_ROLE_HOST then + self:findAndSetupKeyboard() + end +end + +function ExternalKeyboard:addToMainMenu(menu_items) + menu_items.external_keyboard = { + text = _("External Keyboard"), + sub_item_table = { + { + text = _("Enable OTG mode to connect peripherals"), + keep_menu_open = true, + checked_func = function() + return self:getOTGRole() == USB_ROLE_HOST + end, + callback = function(touchmenu_instance) + local role = self:getOTGRole() + local new_role = (role == USB_ROLE_DEVICE) and USB_ROLE_HOST or USB_ROLE_DEVICE + self:setOTGRole(new_role) + touchmenu_instance:updateItems() + end, + }, + { + text = _("Always enable OTG mode"), + keep_menu_open = true, + checked_func = function() + return G_reader_settings:isTrue("external_keyboard_otg_mode_on_start") + end, + callback = function(touchmenu_instance) + G_reader_settings:flipNilOrFalse("external_keyboard_otg_mode_on_start") + end, + }, + { + text = _("Help"), + keep_menu_open = true, + callback = function() + self:showHelp() + end, + }, + } + } +end + +function ExternalKeyboard:chipideaGetOTGRole() + local role = USB_ROLE_DEVICE + local file = io.open(OTG_CHIPIDEA_ROLE_PATH, "re") + + -- Do not throw exception if the file for role does not exist. + -- If it does not exist, the USB must be in the default device mode. + if file then + local chipidea_role = file:read("l") + file:close() + return CHIPIDEA_TO_USB[chipidea_role] or role + end + return role +end + +function ExternalKeyboard:sunxiGetOTGRole() + local file = io.open(OTG_SUNXI_ROLE_PATH, "re") + + -- File should always be present + if file then + local sunxi_role = file:read("l") + file:close() + return SUNXI_TO_USB[sunxi_role] + end +end + +function ExternalKeyboard:getOTGRole() end + +function ExternalKeyboard:chipideaSetOTGRole(role) + -- Writing role to file will fail if the role is the same as the current role. + -- Check current role before calling. + logger.dbg("ExternalKeyboard:chipideaSetOTGRole setting to", role) + local file = io.open(OTG_CHIPIDEA_ROLE_PATH, "we") + if file then + file:write(USB_TO_CHIPIDEA[role]) + file:close() + end +end + +function ExternalKeyboard:sunxiSetOTGRole(role) + -- Sunxi being what it is, there's no sanity check at all, it'll happily reset USB to set the same role again. + logger.dbg("ExternalKeyboard:sunxiSetOTGRole setting to", role) + local file = io.open(OTG_SUNXI_ROLE_PATH, "we") + if file then + file:write(USB_TO_SUNXI[role]) + file:close() + end +end + +function ExternalKeyboard:setOTGRole(role) end + +-- Keep in mind this fires every time we tear down the FM or Reader, too. +-- Then again, so does init when the new view spins up, +-- which ensures everything works out when switching between the FM & the Reader, +-- as long as `external_keyboard_otg_mode_on_start` is enabled. +function ExternalKeyboard:onCloseWidget() + logger.dbg("ExternalKeyboard:onCloseWidget") + local role = self:getOTGRole() + if role == USB_ROLE_HOST then + self:setOTGRole(USB_ROLE_DEVICE) + end +end + +ExternalKeyboard.onUsbDevicePlugIn = UIManager:debounce(0.5, false, function(self) + self:findAndSetupKeyboard() +end) + +ExternalKeyboard.onUsbDevicePlugOut = UIManager:debounce(0.5, false, function(self) + logger.dbg("ExternalKeyboard: onUsbDevicePlugOut") + local is_any_disconnected = false + -- Check that a keyboard really was disconnected. Another USB device could've been unplugged. + for event_path, fd in pairs(ExternalKeyboard.keyboard_fds) do + local event_file_attrs = lfs.attributes(event_path, "mode") + logger.dbg("ExternalKeyboard: checked if event file exists. path:", event_path, "file mode:", tostring(event_file_attrs)) + if event_file_attrs == nil then + is_any_disconnected = true + end + end + + if not is_any_disconnected then + return + end + + logger.dbg("ExternalKeyboard: USB keyboard was disconnected") + + ExternalKeyboard.keyboard_fds = {} + if ExternalKeyboard.original_device_values then + Device.input.event_map = ExternalKeyboard.original_device_values.event_map + Device.keyboard_layout = ExternalKeyboard.original_device_values.keyboard_layout + Device.hasKeyboard = ExternalKeyboard.original_device_values.hasKeyboard + Device.hasDPad = ExternalKeyboard.original_device_values.hasDPad + ExternalKeyboard.original_device_values = nil + end + + -- Broadcasting events throught UIManager would only get to InputText if there is an active widget on the window stack. + -- So, calling a static function is the only choice. + -- InputText.setKeyboard(require("ui/widget/virtualkeyboard")) + -- Update the existing input widgets. It must be issued after the static state of InputText is updated. + InputText.initInputEvents() + UIManager:broadcastEvent(Event:new("PhysicalKeyboardDisconnected")) +end) + +-- The keyboard events with the same key codes would override the original events. +-- That may cause embedded buttons to lose their original function and produce letters. +-- Can we tell from which device a key press comes? The koreader-base passes values of input_event which do not have file descriptors. +function ExternalKeyboard:findAndSetupKeyboard() + local keyboards = FindKeyboard:find() + local is_new_keyboard_setup = false + local has_dpad_func = Device.hasDPad + + -- A USB keyboard may be recognized as several devices under a hub. And several of them may + -- have keyboard capabilities set. Yet, only one would emit the events. The solution is to open all of them. + for __, keyboard_info in ipairs(keyboards) do + logger.dbg("ExternalKeyboard:findAndSetupKeyboard found event path", keyboard_info.event_path, "has_dpad", keyboard_info.has_dpad) + -- Check if the event file already was open. + if ExternalKeyboard.keyboard_fds[keyboard_info.event_path] == nil then + local ok, fd = pcall(Device.input.open, keyboard_info.event_path) + if not ok then + UIManager:show(InfoMessage:new{ + text = "Error opening the keyboard device " .. keyboard_info.event_path .. ":\n" .. tostring(fd), + }) + return + end + + is_new_keyboard_setup = true + ExternalKeyboard.keyboard_fds[keyboard_info.event_path] = fd + + if keyboard_info.has_dpad then + has_dpad_func = yes + end + end + end + + if is_new_keyboard_setup then + -- The setting for input_invert_page_turn_keys wouldn't mess up the new event map. Device module applies it on initialization, not dynamically. + ExternalKeyboard.original_device_values = { + event_map = Device.input.event_map, + keyboard_layout = Device.keyboard_layout, + hasKeyboard = Device.hasKeyboard, + hasDPad = Device.hasDPad, + } + + -- Using a new table avoids mutating the original event map. + local event_map = {} + util.tableMerge(event_map, Device.input.event_map) + util.tableMerge(event_map, event_map_keyboard) + Device.input.event_map = event_map + Device.hasKeyboard = yes + Device.hasDPad = has_dpad_func + + UIManager:show(InfoMessage:new{ + text = _("Keyboard connected"), + timeout = 1, + }) + InputText.initInputEvents() + UIManager:broadcastEvent(Event:new("PhysicalKeyboardConnected")) + end +end + +function ExternalKeyboard:showHelp() + UIManager:show(InfoMessage:new { + text = _([[ +Note that in the OTG mode the device would not be recognized as a USB drive by a computer. + +Troubleshooting: +- If the keyboard is not recognized after plugging it in, try switching the USB mode to regular and back to OTG again. +]]), + }) +end + +return ExternalKeyboard