mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
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:
parent
0ef99934b7
commit
1e27a1f2b9
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user