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/kaiterra/* @Michsior14
homeassistant/components/keba/* @dannerph homeassistant/components/keba/* @dannerph
homeassistant/components/keenetic_ndms2/* @foxel homeassistant/components/keenetic_ndms2/* @foxel
homeassistant/components/keyboard_remote/* @bendavid
homeassistant/components/knx/* @Julius2342 homeassistant/components/knx/* @Julius2342
homeassistant/components/kodi/* @armills homeassistant/components/kodi/* @armills
homeassistant/components/konnected/* @heythisisnate homeassistant/components/konnected/* @heythisisnate

View File

@ -1,12 +1,11 @@
"""Receive signals from a keyboard and use it as a remote control.""" """Receive signals from a keyboard and use it as a remote control."""
# pylint: disable=import-error # pylint: disable=import-error
import threading
import logging import logging
import os import asyncio
import time
from evdev import InputDevice, categorize, ecodes, list_devices
import aionotify
import voluptuous as vol import voluptuous as vol
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP 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" KEYBOARD_REMOTE_DISCONNECTED = "keyboard_remote_disconnected"
TYPE = "type" 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( CONFIG_SCHEMA = vol.Schema(
{ {
@ -36,11 +40,15 @@ CONFIG_SCHEMA = vol.Schema(
{ {
vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string,
vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string,
vol.Optional(TYPE, default="key_up"): vol.All( vol.Optional(TYPE, default=["key_up"]): vol.All(
cv.string, vol.Any("key_up", "key_down", "key_hold") 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.""" """Set up the keyboard_remote."""
config = config.get(DOMAIN) config = config.get(DOMAIN)
keyboard_remote = KeyboardRemote(hass, config) remote = KeyboardRemote(hass, config)
remote.setup()
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)
return True return True
class KeyboardRemoteThread(threading.Thread): class KeyboardRemote:
"""This interfaces with the inputdevice using evdev.""" """Manage device connection/disconnection using inotify to asynchronously monitor."""
def __init__(self, hass, device_name, device_descriptor, key_value): def __init__(self, hass, config):
"""Construct a thread listening for events on one device.""" """Create handlers and setup dictionaries to keep track of them."""
self.hass = hass self.hass = hass
self.device_name = device_name self.handlers_by_name = {}
self.device_descriptor = device_descriptor self.handlers_by_descriptor = {}
self.key_value = key_value self.active_handlers_by_descriptor = {}
self.watcher = None
self.monitor_task = None
if self.device_descriptor: for dev_block in config:
self.device_id = self.device_descriptor handler = self.DeviceHandler(hass, dev_block)
descriptor = dev_block.get(DEVICE_DESCRIPTOR)
if descriptor is not None:
self.handlers_by_descriptor[descriptor] = handler
else: else:
self.device_id = self.device_name name = dev_block.get(DEVICE_NAME)
self.handlers_by_name[name] = handler
self.dev = self._get_keyboard_device() def setup(self):
if self.dev is not None: """Listen for Home Assistant start and stop events."""
_LOGGER.debug("Keyboard connected, %s", self.device_id)
else: self.hass.bus.async_listen_once(
_LOGGER.debug( EVENT_HOMEASSISTANT_START, self.async_start_monitoring
"Keyboard not connected, %s. " "Check /dev/input/event* permissions", )
self.device_id, self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self.async_stop_monitoring
) )
id_folder = "/dev/input/by-id/" async def async_start_monitoring(self, event):
"""Start monitoring of events and devices.
if os.path.isdir(id_folder): Start inotify watching for events, start event monitoring for those already
from evdev import InputDevice, list_devices connected, and start monitoring for device connection/disconnection.
"""
device_names = [ # start watching
InputDevice(file_name).name for file_name in list_devices() self.watcher = aionotify.Watcher()
] self.watcher.watch(
_LOGGER.debug( alias="devinput",
"Possible device names are: %s. " path=DEVINPUT,
"Possible device descriptors are %s: %s", flags=aionotify.Flags.CREATE
device_names, | aionotify.Flags.ATTRIB
id_folder, | aionotify.Flags.DELETE,
os.listdir(id_folder),
) )
await self.watcher.setup(self.hass.loop)
threading.Thread.__init__(self) # add initial devices (do this AFTER starting watcher in order to
self.stopped = threading.Event() # avoid race conditions leading to missing device connections)
self.hass = hass initial_start_monitoring = set()
descriptors = list_devices(DEVINPUT)
for descriptor in descriptors:
dev, handler = self.get_device_handler(descriptor)
def _get_keyboard_device(self): if handler is None:
"""Get the keyboard device.""" continue
from evdev import InputDevice, list_devices
if self.device_name: self.active_handlers_by_descriptor[descriptor] = handler
devices = [InputDevice(file_name) for file_name in list_devices()] initial_start_monitoring.add(handler.async_start_monitoring(dev))
for device in devices:
if self.device_name == device.name: if initial_start_monitoring:
return device await asyncio.wait(initial_start_monitoring)
elif self.device_descriptor:
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: try:
device = InputDevice(self.device_descriptor) 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},
)
await asyncio.sleep(repeat)
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)
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: except OSError:
pass pass
else: # monitoring of the device form the event loop and closing of the
return device # device has to occur before cancelling the task to avoid
return None # triggering unhandled exceptions inside evdev coroutines
asyncio.get_event_loop().remove_reader(self.dev.fileno())
def run(self): self.dev.close()
"""Run the loop of the KeyboardRemote.""" if not self.monitor_task.done():
from evdev import categorize, ecodes self.monitor_task.cancel()
await self.monitor_task
if self.dev is not None: self.monitor_task = None
self.dev.grab() self.hass.bus.async_fire(
_LOGGER.debug("Interface started for %s", self.dev) KEYBOARD_REMOTE_DISCONNECTED,
{DEVICE_DESCRIPTOR: self.dev.path, DEVICE_NAME: self.dev.name},
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) _LOGGER.debug("Keyboard disconnected, %s", self.dev.name)
else: self.dev = None
continue
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: try:
event = self.dev.read_one() _LOGGER.debug("Start device monitoring")
except OSError: # Keyboard Disconnected dev.grab()
self.dev = None async for event in dev.async_read_loop():
self.hass.bus.fire( if event.type is ecodes.EV_KEY:
KEYBOARD_REMOTE_DISCONNECTED, if event.value in self.key_values:
{
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)) _LOGGER.debug(categorize(event))
self.hass.bus.fire( self.hass.bus.async_fire(
KEYBOARD_REMOTE_COMMAND_RECEIVED, KEYBOARD_REMOTE_COMMAND_RECEIVED,
{ {
KEY_CODE: event.code, KEY_CODE: event.code,
DEVICE_DESCRIPTOR: self.device_descriptor, DEVICE_DESCRIPTOR: dev.path,
DEVICE_NAME: self.device_name, DEVICE_NAME: dev.name,
}, },
) )
if (
class KeyboardRemote: event.value == KEY_VALUE["key_down"]
"""Sets up one thread per device.""" and self.emulate_key_hold
):
def __init__(self, hass, config): repeat_tasks[event.code] = self.hass.async_create_task(
"""Construct a KeyboardRemote interface object.""" self.async_keyrepeat(
self.threads = [] dev.path,
for dev_block in config: dev.name,
device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) event.code,
device_name = dev_block.get(DEVICE_NAME) self.emulate_key_hold_delay,
key_value = KEY_VALUE.get(dev_block.get(TYPE, "key_up")) self.emulate_key_hold_repeat,
if device_descriptor is not None or device_name is not None:
thread = KeyboardRemoteThread(
hass, device_name, device_descriptor, key_value
) )
self.threads.append(thread) )
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()
def run(self): if repeat_tasks:
"""Run all event listener threads.""" await asyncio.wait(repeat_tasks.values())
for thread in self.threads:
thread.start()
def stop(self):
"""Stop all event listener threads."""
for thread in self.threads:
thread.stopped.set()

View File

@ -3,8 +3,8 @@
"name": "Keyboard remote", "name": "Keyboard remote",
"documentation": "https://www.home-assistant.io/integrations/keyboard_remote", "documentation": "https://www.home-assistant.io/integrations/keyboard_remote",
"requirements": [ "requirements": [
"evdev==0.6.1" "evdev==1.1.2", "aionotify==0.2.0"
], ],
"dependencies": [], "dependencies": [],
"codeowners": [] "codeowners": ["@bendavid"]
} }

View File

@ -169,6 +169,9 @@ aiolifx==0.6.7
# homeassistant.components.lifx # homeassistant.components.lifx
aiolifx_effects==0.2.2 aiolifx_effects==0.2.2
# homeassistant.components.keyboard_remote
aionotify==0.2.0
# homeassistant.components.notion # homeassistant.components.notion
aionotion==1.1.0 aionotion==1.1.0
@ -477,7 +480,7 @@ epsonprinter==0.0.9
eternalegypt==0.0.10 eternalegypt==0.0.10
# homeassistant.components.keyboard_remote # homeassistant.components.keyboard_remote
# evdev==0.6.1 # evdev==1.1.2
# homeassistant.components.evohome # homeassistant.components.evohome
evohome-async==0.3.4b1 evohome-async==0.3.4b1