diff --git a/documentation/builders/README.md b/documentation/builders/README.md index ed08f5980..512d26ed0 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -26,6 +26,7 @@ * [Soundcards](./components/soundcards/) * [HiFiBerry Boards](./components/soundcards/hifiberry.md) * [RFID Readers](./../developers/rfid/README.md) +* [Event devices (USB and other buttons)](./event-devices.md) ## Web Application diff --git a/documentation/builders/event-devices.md b/documentation/builders/event-devices.md new file mode 100644 index 000000000..8fe5d08e0 --- /dev/null +++ b/documentation/builders/event-devices.md @@ -0,0 +1,120 @@ +# Event devices + +## Background +Event devices are generic input devices that are exposed in `/dev/input`. +This includes USB peripherals (Keyboards, Controllers, Joysticks or Mouse) as well as potentially bluetooth devices. + +A specific usecase for this could be, if a Zero Delay Arcade USB Encoder is used to wire arcade buttons instead of using GPIO pins. + +These device interface support various kinds of input events, such as press, movements and potentially also outputs (eg. rumble, led lights, ...). Currently only the usage of button presses as input is supported. + +This functionality was previously implemented under the name of [USB buttons](https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/develop/components/controls/buttons_usb_encoder/README.md). + +The devices and their button mappings need to be mapped in the configuration file. + +## Configuration + +To configure event devices, first add the plugin as an entry to the module list of your main configuration file ``shared/settings/jukebox.yaml``: + +``` yaml +modules: + named: + event_devices: controls.event_devices +``` + +And add the following section with the plugin specific configuration: +``` yaml +evdev: + enabled: true + config_file: ../../shared/settings/evdev.yaml +``` + +The actual configuration itself is stored in a separate file. In this case in ``../../shared/settings/evdev.yaml``. + +The configuration is structured akin to the configuration of the [GPIO devices](./gpio.md). + +In contrast to `gpio`, multiple devices (eg arcade controllser, keyboards, joysticks, mice, ...) are supported, each with their own `input_devices` (=buttons). `output_devices` or actions other than `on_press` are currently not yet supported. + +``` yaml +devices: # list of devices to listen for + {device nickname}: # config for a specific device + device_name: {device_name} # name of the device + exact: False/True # optional to require exact match. Otherwise it is sufficient that a part of the name matches + input_devices: # list of buttons to listen for for this device + {button nickname}: + type: Button + kwargs: + key_code: {key-code}: # evdev event id + actions: + on_press: # Currently only the on_press action is supported + {rpc_command_definition} # eg `alias: toggle` +``` +The `{device nickname}` is only for your own orientation and can be choosen freely. +For each device you need to figure out the `{device_name}` and the `{event_id}` corresponding to key strokes, as indicated in the sections below. + +### Identifying the `{device_name}` + +The `{device_name}` can be identified using the following Python snippet: + +``` Python +import evdev +devices = [evdev.InputDevice(path) for path in evdev.list_devices()] +for device in devices: + print(device.path, device.name, device.phys) +``` + +The output could be in the style of: + +``` +/dev/input/event1 Dell Dell USB Keyboard usb-0000:00:12.1-2/input0 +/dev/input/event0 Dell USB Optical Mouse usb-0000:00:12.0-2/input0 +``` + +In this example, the `{device_name}` could be `DELL USB Optical Mouse`. +Note that if you use the option `exact: False`, it would be sufficient to add a substring such as `USB Keyboard`. + +### Identifying the `{key-code}` + +The key code for a button press can be determined using the following code snippet: + +``` Python +import evdev +device = evdev.InputDevice('/dev/input/event0') +device.capabilities(verbose=True)[('EV_KEY', evdev.ecodes.EV_KEY)] +``` + +With the `InputDevice` corresponding to the path from the output of the section `{device_name}` (eg. in the example `/dev/input/event0` +would correspond to `Dell Dell USB Keyboard`). + +If the naming is not clear, it is also possible to empirically check for the key code by listening for events: + +``` Python +from evdev import InputDevice, categorize, ecodes +dev = InputDevice('/dev/input/event1') +print(dev) +for event in dev.read_loop(): + if event.type == ecodes.EV_KEY: + print(categorize(event)) +``` +The output could be of the form: +``` +device /dev/input/event1, name "DragonRise Inc. Generic USB Joystick ", phys "usb-3f980000.usb-1.2/input0" +key event at 1672569673.124168, 297 (BTN_BASE4), down +key event at 1672569673.385170, 297 (BTN_BASE4), up +``` + +In this example output, the `{key-code}` would be `297` + +Alternatively, the device could also be setup without a mapping. +Afterwards, when pressing keys, the key codes can be found in the log files. Press various buttons on your device, +while watching the logs with `tail -f shared/logs/app.log`. +Look for entries like `No callback registered for button ...`. + +### Specifying the `{rpc_command_definition}` + +The RPC command follows the regular RPC command rules as defined in the [following documentation](./rpc-commands.md). + + +## Full example config + +A complete configuration example for a USB Joystick controller can be found in the [examples](../../resources/default-settings/evdev.example.yaml). \ No newline at end of file diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 827178aca..19a57e414 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -291,6 +291,20 @@ $ docker run -it --rm \ --name jukebox jukebox ``` +## Testing EVDEV devices in Linux +To test the [event device capabilities](../builders/event-devices.md) in docker, the device needs to be made available to the container. + +### Linux +Mount the device into the container by configuring the appropriate device in a `devices` section of the `jukebox` service in the docker compose file. For example: + +```yaml + jukebox: + ... + devices: + - /dev/input/event3:/dev/input/event3 +``` + + ### Resources #### Mac diff --git a/resources/default-settings/evdev.example.yaml b/resources/default-settings/evdev.example.yaml new file mode 100644 index 000000000..5fbcd57e8 --- /dev/null +++ b/resources/default-settings/evdev.example.yaml @@ -0,0 +1,59 @@ +devices: # A list of evdev devices each containing one or multiple input/output devices + joystick: # A nickname for a device + device_name: DragonRise Inc. Generic USB # Device name + exact: false # If true, the device name must match exactly, otherwise it is sufficient to contain the name + input_devices: + TogglePlayback: + type: Button + kwargs: + key_code: 299 + actions: + on_press: + alias: toggle + NextSong: + type: Button + kwargs: + key_code: 298 + actions: + on_press: + alias: next_song + PrevSong: + type: Button + kwargs: + key_code: 297 + actions: + on_press: + alias: prev_song + VolumeUp: + type: Button + kwargs: + key_code: 296 + actions: + on_press: + alias: change_volume + args: 5 + VolumeDown: + type: Button + kwargs: + key_code: 295 + actions: + on_press: + alias: change_volume + args: -5 + VolumeReset: + type: Button + kwargs: + key_code: 291 + actions: + on_press: + package: volume + plugin: ctrl + method: set_volume + args: [18] + Shutdown: + type: Button + kwargs: + key_code: 292 + actions: + on_press: + alias: shutdown \ No newline at end of file diff --git a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py index 4d17f398e..f03a447cd 100644 --- a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py +++ b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py @@ -49,8 +49,8 @@ def activate(device_name: str, exact: bool = True, open_initial_delay: float = 0 # Do a bit of housekeeping: Delete dead threads listener = list(filter(lambda x: x.is_alive(), listener)) # Check that there is no running thread for this device already - for ll in listener: - if ll.device_request == device_name and ll.is_alive(): + for thread in listener: + if thread.device_request == device_name and thread.is_alive(): logger.debug(f"Button listener thread already active for '{device_name}'") return diff --git a/src/jukebox/components/controls/event_devices/__init__.py b/src/jukebox/components/controls/event_devices/__init__.py new file mode 100644 index 000000000..407a73f14 --- /dev/null +++ b/src/jukebox/components/controls/event_devices/__init__.py @@ -0,0 +1,221 @@ +""" +Plugin to register event_devices (ie USB controllers, keyboards etc) in a + generic manner. + +This effectively does: + + * parse the configured event devices from the evdev.yaml + * setup listen threads + +""" +from __future__ import annotations + +import logging +from typing import Callable +from typing import Tuple + +import jukebox.cfghandler +import jukebox.plugs as plugin +import jukebox.utils +from components.controls.common.evdev_listener import EvDevKeyListener + +logger = logging.getLogger("jb.EventDevice") +cfg_main = jukebox.cfghandler.get_handler("jukebox") +cfg_evdev = jukebox.cfghandler.get_handler("eventdevices") + +# Keep track of all active key event listener threads +# Removal of dead listener threads is done in lazy fashion: +# only on a new connect are dead threads removed +listener: list[EvDevKeyListener] = [] +# Running count of all created listener threads for unique thread naming IDs +listener_cnt = 0 + +#: Indicates that the module is enabled and loaded w/o errors +IS_ENABLED: bool = False +#: The path of the config file the event device configuration was loaded from +CONFIG_FILE: str + +# Constants +_TYPE_BUTTON = 'Button' +_ACTION_ON_PRESS = 'on_press' + +_SUPPORTED_TYPES = [_TYPE_BUTTON] +_SUPPORTED_ACTIONS = {_TYPE_BUTTON: _ACTION_ON_PRESS} + + +@plugin.register +def activate( + device_name: str, + button_callbacks: dict[int, Callable], + exact: bool = True, + mandatory_keys: set[int] | None = None, +): + """Activate an event device listener + + :param device_name: device name + :type device_name: str + :param button_callbacks: mapping of event + code to RPC + :type button_callbacks: dict[int, Callable] + :param exact: Should the device_name match exactly + (default, false) or be a substring of the name? + :type exact: bool, optional + :param mandatory_keys: Mandatory event ids the + device needs to support. Defaults to None + to require all ids from the button_callbacks + :type mandatory_keys: set[int] | None, optional + """ + global listener + global listener_cnt + logger.debug("activate event device: %s", device_name) + # Do a bit of housekeeping: Delete dead threads + listener = list(filter(lambda x: x.is_alive(), listener)) + # Check that there is no running thread for this device already + for thread in listener: + if thread.device_name_request == device_name and thread.is_alive(): + logger.debug( + "Event device listener thread already active for '%s'", + device_name, + ) + return + + listener_cnt += 1 + new_listener = EvDevKeyListener( + device_name_request=device_name, + exact_name=exact, + thread_name=f"EvDevKeyListener-{listener_cnt}", + ) + + listener.append(new_listener) + if button_callbacks is not None: + new_listener.button_callbacks = button_callbacks + if mandatory_keys is not None: + new_listener.mandatory_keys = mandatory_keys + else: + new_listener.mandatory_keys = set(button_callbacks.keys()) + new_listener.start() + + +@plugin.initialize +def initialize(): + """Initialize event device button listener from config + + Initializes event buttons from the main configuration file. + Please see the documentation `builders/event-devices.md` for a specification of the format. + """ + global IS_ENABLED + global CONFIG_FILE + IS_ENABLED = False + enable = cfg_main.setndefault('evdev', 'enable', value=False) + CONFIG_FILE = cfg_main.setndefault('evdev', 'config_file', value='../../shared/settings/evdev.yaml') + if not enable: + return + try: + jukebox.cfghandler.load_yaml(cfg_evdev, CONFIG_FILE) + except Exception as e: + logger.error(f"Disable Event Devices due to error loading evdev config file. {e.__class__.__name__}: {e}") + return + + IS_ENABLED = True + + with cfg_evdev: + for name, config in cfg_evdev.getn( + "devices", + default={}, + ).items(): + logger.debug("activate %s", name) + try: + device_name, exact, button_callbacks = parse_device_config(config) + except Exception as e: + logger.error(f"Error parsing event device config for '{name}'. {e.__class__.__name__}: {e}") + continue + + logger.debug( + f'Call activate with: "{device_name}" and exact: {exact}', + ) + activate( + device_name, + button_callbacks=button_callbacks, + exact=exact, + ) + + +def parse_device_config(config: dict) -> Tuple[str, bool, dict[int, Callable]]: + """Parse the device configuration from the config file + + :param config: The configuration of the device + :type config: dict + :return: The parsed device configuration + :rtype: Tuple[str, bool, dict[int, Callable]] + """ + device_name = config.get("device_name") + if device_name is None: + raise ValueError("'device_name' is required but missing") + exact = bool(config.get("exact", False)) + input_devices = config.get("input_devices", {}) + # Raise warning if not used config present + if 'output_devices' in config: + logger.warning( + "Output devices are not yet supported for event devices", + ) + + # Parse input devices and convert them to button mappings. + # Due to the current implementation of the Event Device Listener, + # only the 'on_press' action is supported. + button_mapping = _input_devices_to_key_mapping(input_devices) + button_callbacks: dict[int, Callable] = {} + for key, action_request in button_mapping.items(): + button_callbacks[key] = jukebox.utils.bind_rpc_command( + action_request, + dereference=False, + logger=logger, + ) + return device_name, exact, button_callbacks + + +def _input_devices_to_key_mapping(input_devices: dict) -> dict: + """Convert input devices to key mapping + + Currently this only supports 'button' input devices with the 'on_press' action. + + :param input_devices: The input devices + :type input_devices: dict + :return: The mapping of key_code to action + :rtype: dict + """ + mapping = {} + for nickname, device in input_devices.items(): + input_type = device.get('type') + if input_type not in _SUPPORTED_TYPES: + logger.warning( + f"Input '{nickname}' device type '{input_type}' is not supported", + ) + continue + + key_code = device.get('kwargs', {}).get('key_code') + if key_code is None: + logger.warning( + f"Input '{nickname}' has no key_code and cannot be mapped.", + ) + continue + + actions = device.get('actions') + + for action_name, action in actions.items(): + if action_name not in _SUPPORTED_ACTIONS[_TYPE_BUTTON]: + logger.warning( + f"Input '{nickname}' has unsupported action '{action_name}'.\n" + f"Currently supported actions: {_SUPPORTED_ACTIONS}", + ) + if action_name == _ACTION_ON_PRESS: + mapping[key_code] = action + + return mapping + + +@plugin.atexit +def atexit(**ignored_kwargs): + global listener + for ll in listener: + ll.stop() + return listener diff --git a/test/evdev/test_evdev_init.py b/test/evdev/test_evdev_init.py new file mode 100644 index 000000000..c37f6ea85 --- /dev/null +++ b/test/evdev/test_evdev_init.py @@ -0,0 +1,185 @@ +""" Tests for the evdev __init__ module +""" +import sys +import unittest +from unittest.mock import patch +from unittest.mock import MagicMock + +# Before importing the module, the jukebox.plugs decorators need to be patched +# to not try to register the plugins +import jukebox.plugs as plugin + + +def dummy_decorator(fkt): + return fkt + + +plugin.register = dummy_decorator +plugin.initialize = dummy_decorator +plugin.atexit = dummy_decorator + +# Mock the jukebox.publishing module to prevent issues with zmq +# which is currently hard to install(see issue #2050) +# and not installed properly for CI +sys.modules['jukebox.publishing'] = MagicMock() + +# Import uses the patched decorators +from components.controls.event_devices import _input_devices_to_key_mapping # noqa: E402 +from components.controls.event_devices import parse_device_config # noqa: E402 + + +class TestInputDevicesToKeyMapping(unittest.TestCase): + def test_mapping_with_supported_input_type_and_key_code(self): + input_devices = { + 'device1': { + 'type': 'Button', + 'kwargs': { + 'key_code': 123 + }, + 'actions': { + 'on_press': 'action1' + } + }, + 'device2': { + 'type': 'Button', + 'kwargs': { + 'key_code': 456 + }, + 'actions': { + 'on_press': 'action2' + } + } + } + + expected_mapping = { + 123: 'action1', + 456: 'action2' + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, expected_mapping) + + def test_mapping_with_missing_type(self): + input_devices = { + 'device1': { + 'kwargs': { + 'key_code': 123 + }, + 'actions': { + 'on_press': 'action1' + } + } + } + + expected_mapping = {} + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, expected_mapping) + + def test_mapping_with_unsupported_input_type(self): + input_devices = { + 'device1': { + 'type': 'unknown', + 'kwargs': { + 'key_code': 'A' + }, + 'actions': { + 'on_press': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + def test_mapping_with_missing_key_code(self): + input_devices = { + 'device1': { + 'type': 'button', + 'kwargs': {}, + 'actions': { + 'on_press': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + def test_mapping_with_unsupported_action(self): + input_devices = { + 'device1': { + 'type': 'button', + 'kwargs': { + 'key_code': 'A' + }, + 'actions': { + 'unknown_action': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + +class TestParseDeviceConfig(unittest.TestCase): + @patch('components.controls.event_devices.jukebox.utils.bind_rpc_command') + def test_parse_device_config(self, bind_rpc_command_mock): + config = { + "device_name": "Test Device", + "exact": True, + "input_devices": { + 'device1': { + 'type': 'Button', + 'kwargs': {'key_code': 123}, + 'actions': { + 'on_press': 'action1' + } + } + } + } + + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, True) + self.assertEqual(button_callbacks, { + 123: bind_rpc_command_mock.return_value, + }) + + def test_parse_device_config_missing_input_devices(self): + config = { + "device_name": "Test Device", + "exact": True + } + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, True) + self.assertEqual(button_callbacks, {}) + + def test_parse_device_config_missing_device_name(self): + config = { + "exact": True, + "input_devices": {} + } + self.assertRaises(ValueError, parse_device_config, config) + + def test_parse_device_config_missing_exact(self): + """Test that the default value for exact is False""" + config = { + "device_name": "Test Device", + "input_devices": {} + } + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, False) + self.assertEqual(button_callbacks, {}) + + +if __name__ == '__main__': + unittest.main()