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
This commit is contained in:
Josh Bendavid 2019-10-29 00:59:13 +01:00 committed by Martin Hjelmare
parent 0ef99934b7
commit 1e27a1f2b9
4 changed files with 260 additions and 157 deletions

View File

@ -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

View File

@ -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())

View File

@ -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"]
}

View File

@ -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