Added generic joystick.jstest backend

This commit is contained in:
Fabio Manganiello 2021-05-15 23:27:38 +02:00
parent 9eab526e47
commit f296f4b161
6 changed files with 349 additions and 3 deletions
CHANGELOG.md
docs/source
platypush
backend/joystick
message/event

View file

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

View file

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

View file

@ -0,0 +1,5 @@
``platypush.backend.joystick.jstest``
=====================================
.. automodule:: platypush.backend.joystick.jstest
:members:

View file

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

View file

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