From 1e27a1f2b901474523a86218a9b3eee56cc127e3 Mon Sep 17 00:00:00 2001 From: Josh Bendavid Date: Tue, 29 Oct 2019 00:59:13 +0100 Subject: [PATCH] Add keyboard_remote trigger on multiple event types and emulate key hold events (#27761) * convert keyboard_remote to async and add possibility to trigger on multiple event types, as well as emulate key hold events * update requirements * cleanup shutdown handling and config handling as well as address other minor comments * cleanup unused return values and debug message formatting * move start and stop event listen to separate coroutine plus minor cleanup * make setup coroutine a function * fix import order and attribute defined outside of init * add to codeowners * update codeowners --- CODEOWNERS | 1 + .../components/keyboard_remote/__init__.py | 407 +++++++++++------- .../components/keyboard_remote/manifest.json | 4 +- requirements_all.txt | 5 +- 4 files changed, 260 insertions(+), 157 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 46ffd1196f7..dac59039935 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -158,6 +158,7 @@ homeassistant/components/jewish_calendar/* @tsvi homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel +homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills homeassistant/components/konnected/* @heythisisnate diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 8b901dcc61e..d4ed6128cbe 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -1,12 +1,11 @@ """Receive signals from a keyboard and use it as a remote control.""" # pylint: disable=import-error -import threading import logging -import os -import time +import asyncio +from evdev import InputDevice, categorize, ecodes, list_devices +import aionotify import voluptuous as vol - import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP @@ -26,6 +25,11 @@ KEYBOARD_REMOTE_CONNECTED = "keyboard_remote_connected" KEYBOARD_REMOTE_DISCONNECTED = "keyboard_remote_disconnected" TYPE = "type" +EMULATE_KEY_HOLD = "emulate_key_hold" +EMULATE_KEY_HOLD_DELAY = "emulate_key_hold_delay" +EMULATE_KEY_HOLD_REPEAT = "emulate_key_hold_repeat" + +DEVINPUT = "/dev/input" CONFIG_SCHEMA = vol.Schema( { @@ -36,11 +40,15 @@ CONFIG_SCHEMA = vol.Schema( { vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, - vol.Optional(TYPE, default="key_up"): vol.All( - cv.string, vol.Any("key_up", "key_down", "key_hold") + vol.Optional(TYPE, default=["key_up"]): vol.All( + cv.ensure_list, [vol.In(KEY_VALUE)] ), + vol.Optional(EMULATE_KEY_HOLD, default=False): cv.boolean, + vol.Optional(EMULATE_KEY_HOLD_DELAY, default=0.250): float, + vol.Optional(EMULATE_KEY_HOLD_REPEAT, default=0.033): float, } - ) + ), + cv.has_at_least_one_key(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP), ], ) }, @@ -48,165 +56,256 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): +async def async_setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - keyboard_remote = KeyboardRemote(hass, config) - - def _start_keyboard_remote(_event): - keyboard_remote.run() - - def _stop_keyboard_remote(_event): - keyboard_remote.stop() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_keyboard_remote) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_keyboard_remote) + remote = KeyboardRemote(hass, config) + remote.setup() return True -class KeyboardRemoteThread(threading.Thread): - """This interfaces with the inputdevice using evdev.""" - - def __init__(self, hass, device_name, device_descriptor, key_value): - """Construct a thread listening for events on one device.""" - self.hass = hass - self.device_name = device_name - self.device_descriptor = device_descriptor - self.key_value = key_value - - if self.device_descriptor: - self.device_id = self.device_descriptor - else: - self.device_id = self.device_name - - self.dev = self._get_keyboard_device() - if self.dev is not None: - _LOGGER.debug("Keyboard connected, %s", self.device_id) - else: - _LOGGER.debug( - "Keyboard not connected, %s. " "Check /dev/input/event* permissions", - self.device_id, - ) - - id_folder = "/dev/input/by-id/" - - if os.path.isdir(id_folder): - from evdev import InputDevice, list_devices - - device_names = [ - InputDevice(file_name).name for file_name in list_devices() - ] - _LOGGER.debug( - "Possible device names are: %s. " - "Possible device descriptors are %s: %s", - device_names, - id_folder, - os.listdir(id_folder), - ) - - threading.Thread.__init__(self) - self.stopped = threading.Event() - self.hass = hass - - def _get_keyboard_device(self): - """Get the keyboard device.""" - from evdev import InputDevice, list_devices - - if self.device_name: - devices = [InputDevice(file_name) for file_name in list_devices()] - for device in devices: - if self.device_name == device.name: - return device - elif self.device_descriptor: - try: - device = InputDevice(self.device_descriptor) - except OSError: - pass - else: - return device - return None - - def run(self): - """Run the loop of the KeyboardRemote.""" - from evdev import categorize, ecodes - - if self.dev is not None: - self.dev.grab() - _LOGGER.debug("Interface started for %s", self.dev) - - while not self.stopped.isSet(): - # Sleeps to ease load on processor - time.sleep(0.05) - - if self.dev is None: - self.dev = self._get_keyboard_device() - if self.dev is not None: - self.dev.grab() - self.hass.bus.fire( - KEYBOARD_REMOTE_CONNECTED, - { - DEVICE_DESCRIPTOR: self.device_descriptor, - DEVICE_NAME: self.device_name, - }, - ) - _LOGGER.debug("Keyboard re-connected, %s", self.device_id) - else: - continue - - try: - event = self.dev.read_one() - except OSError: # Keyboard Disconnected - self.dev = None - self.hass.bus.fire( - KEYBOARD_REMOTE_DISCONNECTED, - { - DEVICE_DESCRIPTOR: self.device_descriptor, - DEVICE_NAME: self.device_name, - }, - ) - _LOGGER.debug("Keyboard disconnected, %s", self.device_id) - continue - - if not event: - continue - - if event.type is ecodes.EV_KEY and event.value is self.key_value: - _LOGGER.debug(categorize(event)) - self.hass.bus.fire( - KEYBOARD_REMOTE_COMMAND_RECEIVED, - { - KEY_CODE: event.code, - DEVICE_DESCRIPTOR: self.device_descriptor, - DEVICE_NAME: self.device_name, - }, - ) - - class KeyboardRemote: - """Sets up one thread per device.""" + """Manage device connection/disconnection using inotify to asynchronously monitor.""" def __init__(self, hass, config): - """Construct a KeyboardRemote interface object.""" - self.threads = [] + """Create handlers and setup dictionaries to keep track of them.""" + self.hass = hass + self.handlers_by_name = {} + self.handlers_by_descriptor = {} + self.active_handlers_by_descriptor = {} + self.watcher = None + self.monitor_task = None + for dev_block in config: - device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) - device_name = dev_block.get(DEVICE_NAME) - key_value = KEY_VALUE.get(dev_block.get(TYPE, "key_up")) + handler = self.DeviceHandler(hass, dev_block) + descriptor = dev_block.get(DEVICE_DESCRIPTOR) + if descriptor is not None: + self.handlers_by_descriptor[descriptor] = handler + else: + name = dev_block.get(DEVICE_NAME) + self.handlers_by_name[name] = handler - if device_descriptor is not None or device_name is not None: - thread = KeyboardRemoteThread( - hass, device_name, device_descriptor, key_value + def setup(self): + """Listen for Home Assistant start and stop events.""" + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self.async_start_monitoring + ) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_stop_monitoring + ) + + async def async_start_monitoring(self, event): + """Start monitoring of events and devices. + + Start inotify watching for events, start event monitoring for those already + connected, and start monitoring for device connection/disconnection. + """ + + # start watching + self.watcher = aionotify.Watcher() + self.watcher.watch( + alias="devinput", + path=DEVINPUT, + flags=aionotify.Flags.CREATE + | aionotify.Flags.ATTRIB + | aionotify.Flags.DELETE, + ) + await self.watcher.setup(self.hass.loop) + + # add initial devices (do this AFTER starting watcher in order to + # avoid race conditions leading to missing device connections) + initial_start_monitoring = set() + descriptors = list_devices(DEVINPUT) + for descriptor in descriptors: + dev, handler = self.get_device_handler(descriptor) + + if handler is None: + continue + + self.active_handlers_by_descriptor[descriptor] = handler + initial_start_monitoring.add(handler.async_start_monitoring(dev)) + + if initial_start_monitoring: + await asyncio.wait(initial_start_monitoring) + + self.monitor_task = self.hass.async_create_task(self.async_monitor_devices()) + + async def async_stop_monitoring(self, event): + """Stop and cleanup running monitoring tasks.""" + + _LOGGER.debug("Cleanup on shutdown") + + if self.monitor_task is not None: + if not self.monitor_task.done(): + self.monitor_task.cancel() + await self.monitor_task + + handler_stop_monitoring = set() + for handler in self.active_handlers_by_descriptor.values(): + handler_stop_monitoring.add(handler.async_stop_monitoring()) + + if handler_stop_monitoring: + await asyncio.wait(handler_stop_monitoring) + + def get_device_handler(self, descriptor): + """Find the correct device handler given a descriptor (path).""" + + # devices are often added and then correct permissions set after + try: + dev = InputDevice(descriptor) + except (OSError, PermissionError): + return (None, None) + + handler = None + if descriptor in self.handlers_by_descriptor: + handler = self.handlers_by_descriptor[descriptor] + elif dev.name in self.handlers_by_name: + handler = self.handlers_by_name[dev.name] + + return (dev, handler) + + async def async_monitor_devices(self): + """Monitor asynchronously for device connection/disconnection or permissions changes.""" + + try: + while True: + event = await self.watcher.get_event() + descriptor = f"{DEVINPUT}/{event.name}" + + descriptor_active = descriptor in self.active_handlers_by_descriptor + + if (event.flags & aionotify.Flags.DELETE) and descriptor_active: + handler = self.active_handlers_by_descriptor[descriptor] + del self.active_handlers_by_descriptor[descriptor] + await handler.async_stop_monitoring() + elif ( + (event.flags & aionotify.Flags.CREATE) + or (event.flags & aionotify.Flags.ATTRIB) + ) and not descriptor_active: + dev, handler = self.get_device_handler(descriptor) + if handler is None: + continue + self.active_handlers_by_descriptor[descriptor] = handler + await handler.async_start_monitoring(dev) + except asyncio.CancelledError: + return + + class DeviceHandler: + """Manage input events using evdev with asyncio.""" + + def __init__(self, hass, dev_block): + """Fill configuration data.""" + + self.hass = hass + + key_types = dev_block.get(TYPE) + + self.key_values = set() + for key_type in key_types: + self.key_values.add(KEY_VALUE[key_type]) + + self.emulate_key_hold = dev_block.get(EMULATE_KEY_HOLD) + self.emulate_key_hold_delay = dev_block.get(EMULATE_KEY_HOLD_DELAY) + self.emulate_key_hold_repeat = dev_block.get(EMULATE_KEY_HOLD_REPEAT) + self.monitor_task = None + self.dev = None + + async def async_keyrepeat(self, path, name, code, delay, repeat): + """Emulate keyboard delay/repeat behaviour by sending key events on a timer.""" + + await asyncio.sleep(delay) + while True: + self.hass.bus.async_fire( + KEYBOARD_REMOTE_COMMAND_RECEIVED, + {KEY_CODE: code, DEVICE_DESCRIPTOR: path, DEVICE_NAME: name}, ) - self.threads.append(thread) + await asyncio.sleep(repeat) - def run(self): - """Run all event listener threads.""" - for thread in self.threads: - thread.start() + async def async_start_monitoring(self, dev): + """Start event monitoring task and issue event.""" + if self.monitor_task is None: + self.dev = dev + self.monitor_task = self.hass.async_create_task( + self.async_monitor_input(dev) + ) + self.hass.bus.async_fire( + KEYBOARD_REMOTE_CONNECTED, + {DEVICE_DESCRIPTOR: dev.path, DEVICE_NAME: dev.name}, + ) + _LOGGER.debug("Keyboard (re-)connected, %s", dev.name) - def stop(self): - """Stop all event listener threads.""" - for thread in self.threads: - thread.stopped.set() + async def async_stop_monitoring(self): + """Stop event monitoring task and issue event.""" + if self.monitor_task is not None: + try: + self.dev.ungrab() + except OSError: + pass + # monitoring of the device form the event loop and closing of the + # device has to occur before cancelling the task to avoid + # triggering unhandled exceptions inside evdev coroutines + asyncio.get_event_loop().remove_reader(self.dev.fileno()) + self.dev.close() + if not self.monitor_task.done(): + self.monitor_task.cancel() + await self.monitor_task + self.monitor_task = None + self.hass.bus.async_fire( + KEYBOARD_REMOTE_DISCONNECTED, + {DEVICE_DESCRIPTOR: self.dev.path, DEVICE_NAME: self.dev.name}, + ) + _LOGGER.debug("Keyboard disconnected, %s", self.dev.name) + self.dev = None + + async def async_monitor_input(self, dev): + """Event monitoring loop. + + Monitor one device for new events using evdev with asyncio, + start and stop key hold emulation tasks as needed. + """ + + repeat_tasks = {} + + try: + _LOGGER.debug("Start device monitoring") + dev.grab() + async for event in dev.async_read_loop(): + if event.type is ecodes.EV_KEY: + if event.value in self.key_values: + _LOGGER.debug(categorize(event)) + self.hass.bus.async_fire( + KEYBOARD_REMOTE_COMMAND_RECEIVED, + { + KEY_CODE: event.code, + DEVICE_DESCRIPTOR: dev.path, + DEVICE_NAME: dev.name, + }, + ) + + if ( + event.value == KEY_VALUE["key_down"] + and self.emulate_key_hold + ): + repeat_tasks[event.code] = self.hass.async_create_task( + self.async_keyrepeat( + dev.path, + dev.name, + event.code, + self.emulate_key_hold_delay, + self.emulate_key_hold_repeat, + ) + ) + elif event.value == KEY_VALUE["key_up"]: + if event.code in repeat_tasks: + repeat_tasks[event.code].cancel() + del repeat_tasks[event.code] + except (OSError, PermissionError, asyncio.CancelledError): + # cancel key repeat tasks + for task in repeat_tasks.values(): + task.cancel() + + if repeat_tasks: + await asyncio.wait(repeat_tasks.values()) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index 6172de132bb..25b8bfa682a 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -3,8 +3,8 @@ "name": "Keyboard remote", "documentation": "https://www.home-assistant.io/integrations/keyboard_remote", "requirements": [ - "evdev==0.6.1" + "evdev==1.1.2", "aionotify==0.2.0" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@bendavid"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5679d126e2d..a1db7bbc1f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -169,6 +169,9 @@ aiolifx==0.6.7 # homeassistant.components.lifx aiolifx_effects==0.2.2 +# homeassistant.components.keyboard_remote +aionotify==0.2.0 + # homeassistant.components.notion aionotion==1.1.0 @@ -477,7 +480,7 @@ epsonprinter==0.0.9 eternalegypt==0.0.10 # homeassistant.components.keyboard_remote -# evdev==0.6.1 +# evdev==1.1.2 # homeassistant.components.evohome evohome-async==0.3.4b1