From fc1d9ad3e61b31549abbe5e5fb0c4c49f2f07c41 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <info@fabiomanganiello.com>
Date: Mon, 17 May 2021 14:51:53 +0200
Subject: [PATCH] Added joystick.linux backend

---
 CHANGELOG.md                                  |   3 +
 docs/source/backends.rst                      |   1 +
 .../platypush/backend/joystick.linux.rst      |   5 +
 docs/source/platypush/plugins/pwm.pca9685.rst |   5 +
 docs/source/plugins.rst                       |   1 +
 platypush/backend/joystick/jstest.py          |   4 +
 platypush/backend/joystick/linux.py           | 186 ++++++++++++++++++
 platypush/message/event/joystick.py           |  28 ++-
 8 files changed, 225 insertions(+), 8 deletions(-)
 create mode 100644 docs/source/platypush/backend/joystick.linux.rst
 create mode 100644 docs/source/platypush/plugins/pwm.pca9685.rst
 create mode 100644 platypush/backend/joystick/linux.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3f5ba48..8fb102dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,9 @@ Given the high speed of development in the first phase, changes are being report
   
 - Added PWM PCA9685 plugin.
 
+- Added Linux native joystick plugin, ``backend.joystick.linux``, for the cases where
+  ``python-inputs`` doesn't work and ``jstest`` is too slow.
+
 ### 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 df729be9..f9f9edb9 100644
--- a/docs/source/backends.rst
+++ b/docs/source/backends.rst
@@ -33,6 +33,7 @@ Backends
     platypush/backend/inotify.rst
     platypush/backend/joystick.rst
     platypush/backend/joystick.jstest.rst
+    platypush/backend/joystick.linux.rst
     platypush/backend/kafka.rst
     platypush/backend/light.hue.rst
     platypush/backend/linode.rst
diff --git a/docs/source/platypush/backend/joystick.linux.rst b/docs/source/platypush/backend/joystick.linux.rst
new file mode 100644
index 00000000..b4f2a638
--- /dev/null
+++ b/docs/source/platypush/backend/joystick.linux.rst
@@ -0,0 +1,5 @@
+``platypush.backend.joystick.linux``
+====================================
+
+.. automodule:: platypush.backend.joystick.linux
+    :members:
diff --git a/docs/source/platypush/plugins/pwm.pca9685.rst b/docs/source/platypush/plugins/pwm.pca9685.rst
new file mode 100644
index 00000000..7a054831
--- /dev/null
+++ b/docs/source/platypush/plugins/pwm.pca9685.rst
@@ -0,0 +1,5 @@
+``platypush.plugins.pwm.pca9685``
+=================================
+
+.. automodule:: platypush.plugins.pwm.pca9685
+    :members:
diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst
index 5af5c7d1..033c869e 100644
--- a/docs/source/plugins.rst
+++ b/docs/source/plugins.rst
@@ -104,6 +104,7 @@ Plugins
     platypush/plugins/ping.rst
     platypush/plugins/printer.cups.rst
     platypush/plugins/pushbullet.rst
+    platypush/plugins/pwm.pca9685.rst
     platypush/plugins/qrcode.rst
     platypush/plugins/redis.rst
     platypush/plugins/rtorrent.rst
diff --git a/platypush/backend/joystick/jstest.py b/platypush/backend/joystick/jstest.py
index 7ec87443..e7d3547e 100644
--- a/platypush/backend/joystick/jstest.py
+++ b/platypush/backend/joystick/jstest.py
@@ -51,6 +51,10 @@ class JoystickJstestBackend(Backend):
 
     This backend only works on Linux and it requires the ``joystick`` package to be installed.
 
+    **NOTE**: This backend can be quite slow, since it has to run another program (``jstest``) and parse its output.
+    Consider it as a last resort if your joystick works with neither :class:`platypush.backend.joystick.JoystickBackend`
+    nor :class:`platypush.backend.joystick.JoystickLinuxBackend`.
+
     Instructions on Debian-based distros::
 
         # apt-get install joystick
diff --git a/platypush/backend/joystick/linux.py b/platypush/backend/joystick/linux.py
new file mode 100644
index 00000000..f7155bb7
--- /dev/null
+++ b/platypush/backend/joystick/linux.py
@@ -0,0 +1,186 @@
+import array
+import struct
+import time
+from fcntl import ioctl
+from typing import IO
+
+from platypush.backend import Backend
+from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, \
+    JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent
+
+
+class JoystickLinuxBackend(Backend):
+    """
+    This backend intercepts events from joystick devices through the native Linux API implementation.
+
+    It is loosely based on https://gist.github.com/rdb/8864666, which itself uses the
+    `Linux kernel joystick API <https://www.kernel.org/doc/Documentation/input/joystick-api.txt>`_ to interact with
+    the devices.
+
+    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.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.
+
+    """
+
+    # These constants were borrowed from linux/input.h
+    axis_names = {
+        0x00: 'x',
+        0x01: 'y',
+        0x02: 'z',
+        0x03: 'rx',
+        0x04: 'ry',
+        0x05: 'rz',
+        0x06: 'throttle',
+        0x07: 'rudder',
+        0x08: 'wheel',
+        0x09: 'gas',
+        0x0a: 'brake',
+        0x10: 'hat0x',
+        0x11: 'hat0y',
+        0x12: 'hat1x',
+        0x13: 'hat1y',
+        0x14: 'hat2x',
+        0x15: 'hat2y',
+        0x16: 'hat3x',
+        0x17: 'hat3y',
+        0x18: 'pressure',
+        0x19: 'distance',
+        0x1a: 'tilt_x',
+        0x1b: 'tilt_y',
+        0x1c: 'tool_width',
+        0x20: 'volume',
+        0x28: 'misc',
+    }
+
+    button_names = {
+        0x120: 'trigger',
+        0x121: 'thumb',
+        0x122: 'thumb2',
+        0x123: 'top',
+        0x124: 'top2',
+        0x125: 'pinkie',
+        0x126: 'base',
+        0x127: 'base2',
+        0x128: 'base3',
+        0x129: 'base4',
+        0x12a: 'base5',
+        0x12b: 'base6',
+        0x12f: 'dead',
+        0x130: 'a',
+        0x131: 'b',
+        0x132: 'c',
+        0x133: 'x',
+        0x134: 'y',
+        0x135: 'z',
+        0x136: 'tl',
+        0x137: 'tr',
+        0x138: 'tl2',
+        0x139: 'tr2',
+        0x13a: 'select',
+        0x13b: 'start',
+        0x13c: 'mode',
+        0x13d: 'thumbl',
+        0x13e: 'thumbr',
+        0x220: 'dpad_up',
+        0x221: 'dpad_down',
+        0x222: 'dpad_left',
+        0x223: 'dpad_right',
+        # XBox 360 controller uses these codes.
+        0x2c0: 'dpad_left',
+        0x2c1: 'dpad_right',
+        0x2c2: 'dpad_up',
+        0x2c3: 'dpad_down',
+    }
+
+    def __init__(self, device: str = '/dev/input/js0', *args, **kwargs):
+        """
+        :param device: Joystick device to monitor (default: ``/dev/input/js0``).
+        """
+        super().__init__(*args, **kwargs)
+        self.device = device
+        self._axis_states = {}
+        self._button_states = {}
+        self._axis_map = []
+        self._button_map = []
+
+    def _init_joystick(self, dev: IO):
+        # Get the device name.
+        buf = array.array('B', [0] * 64)
+        ioctl(dev, 0x80006a13 + (0x10000 * len(buf)), buf)  # JSIOCGNAME(len)
+        js_name = buf.tobytes().rstrip(b'\x00').decode('utf-8')
+
+        # Get number of axes and buttons.
+        buf = array.array('B', [0])
+        ioctl(dev, 0x80016a11, buf)  # JSIOCGAXES
+        num_axes = buf[0]
+
+        buf = array.array('B', [0])
+        ioctl(dev, 0x80016a12, buf)  # JSIOCGBUTTONS
+        num_buttons = buf[0]
+
+        # Get the axis map.
+        buf = array.array('B', [0] * 0x40)
+        ioctl(dev, 0x80406a32, buf)  # JSIOCGAXMAP
+
+        for axis in buf[:num_axes]:
+            axis_name = self.axis_names.get(axis, 'unknown(0x%02x)' % axis)
+            self._axis_map.append(axis_name)
+            self._axis_states[axis_name] = 0.0
+
+        # Get the button map.
+        buf = array.array('H', [0] * 200)
+        ioctl(dev, 0x80406a34, buf)  # JSIOCGBTNMAP
+
+        for btn in buf[:num_buttons]:
+            btn_name = self.button_names.get(btn, 'unknown(0x%03x)' % btn)
+            self._button_map.append(btn_name)
+            self._button_states[btn_name] = 0
+
+        self.bus.post(JoystickConnectedEvent(device=self.device, name=js_name, axes=self._axis_map,
+                                             buttons=self._button_map))
+
+    def run(self):
+        super().run()
+
+        while not self.should_stop():
+            # Open the joystick device.
+            self.logger.info(f'Opening {self.device}...')
+            try:
+                jsdev = open(self.device, 'rb')
+                self._init_joystick(jsdev)
+            except Exception as e:
+                self.logger.debug(f'Joystick device on {self.device} not available: {e}')
+                time.sleep(5)
+                continue
+
+            # Joystick event loop
+            while not self.should_stop():
+                try:
+                    evbuf = jsdev.read(8)
+                    if evbuf:
+                        _, value, evt_type, number = struct.unpack('IhBB', evbuf)
+
+                        if evt_type & 0x01:
+                            button = self._button_map[number]
+                            if button:
+                                self._button_states[button] = value
+                                evt_class = JoystickButtonPressedEvent if value else JoystickButtonReleasedEvent
+                                # noinspection PyTypeChecker
+                                self.bus.post(evt_class(button=button))
+
+                        if evt_type & 0x02:
+                            axis = self._axis_map[number]
+                            if axis:
+                                fvalue = value / 32767.0
+                                self._axis_states[axis] = fvalue
+                                # noinspection PyTypeChecker
+                                self.bus.post(JoystickAxisEvent(axis=axis, value=fvalue))
+                except OSError as e:
+                    self.logger.warning(f'Connection to {self.device} lost: {e}')
+                    self.bus.post(JoystickDisconnectedEvent(device=self.device))
+                    break
diff --git a/platypush/message/event/joystick.py b/platypush/message/event/joystick.py
index 08e37a54..472049b3 100644
--- a/platypush/message/event/joystick.py
+++ b/platypush/message/event/joystick.py
@@ -1,5 +1,5 @@
 from abc import ABC
-from typing import List
+from typing import Optional, Iterable, Union
 
 from platypush.message.event import Event
 
@@ -35,6 +35,18 @@ class JoystickConnectedEvent(_JoystickEvent):
     """
     Event triggered upon joystick connection.
     """
+    def __init__(self,
+                 *args,
+                 name: Optional[str] = None,
+                 axes: Optional[Iterable[Union[int, str]]] = None,
+                 buttons: Optional[Iterable[Union[int, str]]] = None,
+                 **kwargs):
+        """
+        :param name: Device name.
+        :param axes: List of the device axes, as indices or names.
+        :param buttons: List of the device buttons, as indices or names.
+        """
+        super().__init__(*args, name=name, axes=axes, buttons=buttons, **kwargs)
 
 
 class JoystickDisconnectedEvent(_JoystickEvent):
@@ -48,7 +60,7 @@ class JoystickStateEvent(_JoystickEvent):
     Event triggered when the state of the joystick changes.
     """
 
-    def __init__(self, *args, axes: List[int], buttons: List[bool], **kwargs):
+    def __init__(self, *args, axes: Iterable[int], buttons: Iterable[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).
@@ -61,9 +73,9 @@ class JoystickButtonPressedEvent(_JoystickEvent):
     Event triggered when a joystick button is pressed.
     """
 
-    def __init__(self, *args, button: int, **kwargs):
+    def __init__(self, *args, button: Union[int, str], **kwargs):
         """
-        :param button: Button index.
+        :param button: Button index or name.
         """
         super().__init__(*args, button=button, **kwargs)
 
@@ -73,9 +85,9 @@ class JoystickButtonReleasedEvent(_JoystickEvent):
     Event triggered when a joystick button is released.
     """
 
-    def __init__(self, *args, button: int, **kwargs):
+    def __init__(self, *args, button: Union[int, str], **kwargs):
         """
-        :param button: Button index.
+        :param button: Button index or name.
         """
         super().__init__(*args, button=button, **kwargs)
 
@@ -85,9 +97,9 @@ class JoystickAxisEvent(_JoystickEvent):
     Event triggered when an axis value of the joystick changes.
     """
 
-    def __init__(self, *args, axis: int, value: int, **kwargs):
+    def __init__(self, *args, axis: Union[int, str], value: int, **kwargs):
         """
-        :param axis: Axis index.
+        :param axis: Axis index or name.
         :param value: Axis value.
         """
         super().__init__(*args, axis=axis, value=value, **kwargs)