From f296f4b1612cba0d78afc24451e818d74da8bec6 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 15 May 2021 23:27:38 +0200 Subject: [PATCH] Added generic `joystick.jstest` backend --- CHANGELOG.md | 4 + docs/source/backends.rst | 1 + .../platypush/backend/joystick.jstest.rst | 5 + .../{joystick.py => joystick/__init__.py} | 0 platypush/backend/joystick/jstest.py | 262 ++++++++++++++++++ platypush/message/event/joystick.py | 80 +++++- 6 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 docs/source/platypush/backend/joystick.jstest.rst rename platypush/backend/{joystick.py => joystick/__init__.py} (100%) create mode 100644 platypush/backend/joystick/jstest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb3f6936..ea52e8253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ Given the high speed of development in the first phase, changes are being report - Support for custom MQTT timeout on all the `zwavejs2mqtt` calls. +- Added generic joystick backend `backend.joystick.jstest` which uses `jstest` from the + standard `joystick` system package to read the state of joysticks not compatible with + `python-inputs`. + ### Changed - `switch.switchbot` plugin renamed to `switchbot.bluetooth` plugin, while the new plugin diff --git a/docs/source/backends.rst b/docs/source/backends.rst index 5a48aee4b..df729be9f 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -32,6 +32,7 @@ Backends platypush/backend/http.poll.rst platypush/backend/inotify.rst platypush/backend/joystick.rst + platypush/backend/joystick.jstest.rst platypush/backend/kafka.rst platypush/backend/light.hue.rst platypush/backend/linode.rst diff --git a/docs/source/platypush/backend/joystick.jstest.rst b/docs/source/platypush/backend/joystick.jstest.rst new file mode 100644 index 000000000..cb4dad584 --- /dev/null +++ b/docs/source/platypush/backend/joystick.jstest.rst @@ -0,0 +1,5 @@ +``platypush.backend.joystick.jstest`` +===================================== + +.. automodule:: platypush.backend.joystick.jstest + :members: diff --git a/platypush/backend/joystick.py b/platypush/backend/joystick/__init__.py similarity index 100% rename from platypush/backend/joystick.py rename to platypush/backend/joystick/__init__.py diff --git a/platypush/backend/joystick/jstest.py b/platypush/backend/joystick/jstest.py new file mode 100644 index 000000000..ae4f41403 --- /dev/null +++ b/platypush/backend/joystick/jstest.py @@ -0,0 +1,262 @@ +import json +import os +import re +import subprocess +import time +from typing import Optional, List + +from platypush.backend.joystick import JoystickBackend +from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, JoystickStateEvent, \ + JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent + + +class JoystickState: + def __init__(self, axes: List[int], buttons: List[bool]): + self.axes = axes + self.buttons = buttons + + def __str__(self): + return json.dumps(self.__dict__) + + def __eq__(self, obj): + return obj.axes == self.axes and obj.buttons == self.buttons + + def __sub__(self, obj) -> dict: + diff = { + 'axes': { + axis: obj.axes[axis] + for axis in range(len(self.axes)) + if self.axes[axis] != obj.axes[axis] + }, + 'buttons': { + button: obj.buttons[button] + for button in range(len(self.buttons)) + if self.buttons[button] != obj.buttons[button] + }, + } + + return { + k: v for k, v in diff.items() if v + } + + +class JoystickJstestBackend(JoystickBackend): + """ + This backend can be used to intercept events from a joystick device if the device does not work with the standard + :class:`platypush.backend.joystick.JoystickBackend` backend (this may especially happen with some Bluetooth + joysticks that don't support the ``ioctl`` requests used by ``inputs``). + + This backend only works on Linux and it requires the ``joystick`` package to be installed. + + Instructions on Debian-based distros:: + + # apt-get install joystick + + Instructions on Arch-based distros:: + + # pacman -S joyutils + + To test if your joystick is compatible, connect it to your device, check for its path (usually under + ``/dev/input/js*``) and run:: + + $ jstest /dev/input/js[n] + + Triggers: + + * :class:`platypush.message.event.joystick.JoystickConnectedEvent` when the joystick is connected. + * :class:`platypush.message.event.joystick.JoystickDisconnectedEvent` when the joystick is disconnected. + * :class:`platypush.message.event.joystick.JoystickStateEvent` when the state of the joystick (i.e. some of its + axes or buttons values) changes. + * :class:`platypush.message.event.joystick.JoystickButtonPressedEvent` when a joystick button is pressed. + * :class:`platypush.message.event.joystick.JoystickButtonReleasedEvent` when a joystick button is released. + * :class:`platypush.message.event.joystick.JoystickAxisEvent` when an axis value of the joystick changes. + + """ + + js_axes_regex = re.compile(r'Axes:\s+(((\d+):\s*([\-\d]+)\s*)+)') + js_buttons_regex = re.compile(r'Buttons:\s+(((\d+):\s*(on|off)\s*)+)') + js_axis_regex = re.compile(r'^\s*(\d+):\s*([\-\d]+)\s*(.*)') + js_button_regex = re.compile(r'^\s*(\d+):\s*(on|off)\s*(.*)') + + def __init__(self, + device: str = '/dev/input/js0', + jstest_path: str = '/usr/bin/jstest', + **kwargs): + """ + :param device: Path to the joystick device (default: ``/dev/input/js0``). + :param jstest_path: Path to the ``jstest`` executable that comes with the ``joystick`` system package + (default: ``/usr/bin/jstest``). + """ + super().__init__(**kwargs) + + self.device_path = device + self.jstest_path = jstest_path + self._process: Optional[subprocess.Popen] = None + self._state: Optional[JoystickState] = None + + def _wait_ready(self): + self.logger.info(f'Waiting for joystick device on {self.device_path}') + + while not self.should_stop() or not os.path.exists(self.device_path): + time.sleep(1) + + if self.should_stop(): + return + + self.bus.post(JoystickConnectedEvent(device=self.device)) + + def _read_states(self): + while not self.should_stop(): + yield self._get_state() + + def _get_state(self) -> JoystickState: + axes = [] + buttons = [] + line = '' + + while not self.should_stop(): + ch = self._process.stdout.read(1).decode() + if not ch: + continue + + if ch in ['\r', '\n']: + line = '' + continue + + line += ch + if line.endswith('Axes: '): + break + + while not self.should_stop() and len(axes) < len(self._state.axes): + ch = ' ' + while ch == ' ': + ch = self._process.stdout.read(1).decode() + + self._process.stdout.read(len(f'{len(axes)}')) + value = '' + + while not self.should_stop(): + ch = self._process.stdout.read(1).decode() + if ch == ' ': + if not value: + continue + break + + if ch == ':': + break + + value += ch + + if value: + axes.append(int(value)) + + line = '' + + while not self.should_stop(): + ch = self._process.stdout.read(1).decode() + if not ch: + continue + + line += ch + if line.endswith('Buttons: '): + break + + while not self.should_stop() and len(buttons) < len(self._state.buttons): + ch = ' ' + while ch == ' ': + ch = self._process.stdout.read(1).decode() + + self._process.stdout.read(len(f'{len(buttons)}')) + value = '' + + while not self.should_stop(): + ch = self._process.stdout.read(1).decode() + if ch == ' ': + continue + + value += ch + if value in ['on', 'off']: + buttons.append(value == 'on') + break + + return JoystickState(axes=axes, buttons=buttons) + + def _initialize(self): + while not self.should_stop() and not self._state: + line = b'' + ch = None + + while ch not in [b'\r', b'\n']: + ch = self._process.stdout.read(1) + line += ch + + line = line.decode().strip() + if not (line and line.startswith('Axes:')): + continue + + re_axes = self.js_axes_regex.search(line) + re_buttons = self.js_buttons_regex.search(line) + + if not (re_axes and re_buttons): + return + + state = { + 'axes': [], + 'buttons': [], + } + + axes = re_axes.group(1) + while axes: + m = self.js_axis_regex.search(axes) + state['axes'].append(int(m.group(2))) + axes = m.group(3) + + buttons = re_buttons.group(1) + while buttons: + m = self.js_button_regex.search(buttons) + state['buttons'].append(m.group(2) == 'on') + buttons = m.group(3) + + self._state = JoystickState(**state) + + def _process_state(self, state: JoystickState): + diff = self._state - state + if not diff: + return + + self.bus.post(JoystickStateEvent(device=self.device, **state.__dict__)) + + for button, pressed in diff.get('buttons', {}).items(): + evt_class = JoystickButtonPressedEvent if pressed else JoystickButtonReleasedEvent + self.bus.post(evt_class(device=self.device, button=button)) + + for axis, value in diff.get('buttons', {}).items(): + self.bus.post(JoystickAxisEvent(device=self.device, axis=axis, value=value)) + + self._state = state + + def run(self): + super().run() + + try: + while not self.should_stop(): + self._wait_ready() + + with subprocess.Popen( + [self.jstest_path, '--normal', self.device_path], + stdout=subprocess.PIPE) as self._process: + self.logger.info('Device opened') + self._initialize() + + for state in self._read_states(): + self._process_state(state) + + if not os.path.exists(self.device_path): + self.logger.warning(f'Connection to {self.device_path} lost') + self.bus.post(JoystickDisconnectedEvent(self.device_path)) + break + finally: + self._process = None + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/event/joystick.py b/platypush/message/event/joystick.py index 4aca2e7c9..08e37a544 100644 --- a/platypush/message/event/joystick.py +++ b/platypush/message/event/joystick.py @@ -1,9 +1,12 @@ +from abc import ABC +from typing import List + from platypush.message.event import Event class JoystickEvent(Event): """ - Event triggered upon joystick event + Generic joystick event. """ def __init__(self, code, state, *args, **kwargs): @@ -11,12 +14,83 @@ class JoystickEvent(Event): :param code: Event code, usually the code of the source key/handle :type code: str - :param state: State of the triggering element. Can be 0/1 for a button, -1/0/1 for an axis, a discrete integer for an analog input etc. + :param state: State of the triggering element. Can be 0/1 for a button, -1/0/1 for an axis, a discrete integer + for an analog input etc. :type state: int """ super().__init__(*args, code=code, state=state, **kwargs) -# vim:sw=4:ts=4:et: +class _JoystickEvent(Event, ABC): + """ + Base joystick event class. + """ + def __init__(self, device: str, **kwargs): + super().__init__(device=device, **kwargs) + + +class JoystickConnectedEvent(_JoystickEvent): + """ + Event triggered upon joystick connection. + """ + + +class JoystickDisconnectedEvent(_JoystickEvent): + """ + Event triggered upon joystick disconnection. + """ + + +class JoystickStateEvent(_JoystickEvent): + """ + Event triggered when the state of the joystick changes. + """ + + def __init__(self, *args, axes: List[int], buttons: List[bool], **kwargs): + """ + :param axes: Joystick axes values, as a list of integer values. + :param buttons: Joystick buttons values, as a list of boolean values (True for pressed, False for released). + """ + super().__init__(*args, axes=axes, buttons=buttons, **kwargs) + + +class JoystickButtonPressedEvent(_JoystickEvent): + """ + Event triggered when a joystick button is pressed. + """ + + def __init__(self, *args, button: int, **kwargs): + """ + :param button: Button index. + """ + super().__init__(*args, button=button, **kwargs) + + +class JoystickButtonReleasedEvent(_JoystickEvent): + """ + Event triggered when a joystick button is released. + """ + + def __init__(self, *args, button: int, **kwargs): + """ + :param button: Button index. + """ + super().__init__(*args, button=button, **kwargs) + + +class JoystickAxisEvent(_JoystickEvent): + """ + Event triggered when an axis value of the joystick changes. + """ + + def __init__(self, *args, axis: int, value: int, **kwargs): + """ + :param axis: Axis index. + :param value: Axis value. + """ + super().__init__(*args, axis=axis, value=value, **kwargs) + + +# vim:sw=4:ts=4:et: