forked from platypush/platypush
Added HID plugin to support interaction with generic HID devices
This commit is contained in:
parent
dd3c4b10c7
commit
a77206800d
9 changed files with 413 additions and 0 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
||||||
Given the high speed of development in the first phase, changes are being
|
Given the high speed of development in the first phase, changes are being
|
||||||
reported only starting from v0.20.2.
|
reported only starting from v0.20.2.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `hid` plugin to support discoverability and data interaction with
|
||||||
|
generic HID devices - like Bluetooth/USB peripherals, joysticks, dongles and
|
||||||
|
any other type of devices that supports the HID interface.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Running the Zeroconf registration logic in another thread in `backend.http`,
|
||||||
|
so failures in the Zeroconf logic don't affect the startup of the web server.
|
||||||
|
|
||||||
## [0.24.4] - 2022-12-20
|
## [0.24.4] - 2022-12-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -30,6 +30,7 @@ Events
|
||||||
platypush/events/gotify.rst
|
platypush/events/gotify.rst
|
||||||
platypush/events/gpio.rst
|
platypush/events/gpio.rst
|
||||||
platypush/events/gps.rst
|
platypush/events/gps.rst
|
||||||
|
platypush/events/hid.rst
|
||||||
platypush/events/http.rst
|
platypush/events/http.rst
|
||||||
platypush/events/http.hook.rst
|
platypush/events/http.hook.rst
|
||||||
platypush/events/http.rss.rst
|
platypush/events/http.rss.rst
|
||||||
|
|
5
docs/source/platypush/events/hid.rst
Normal file
5
docs/source/platypush/events/hid.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``hid``
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. automodule:: platypush.message.event.hid
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/hid.rst
Normal file
5
docs/source/platypush/plugins/hid.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``hid``
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.hid
|
||||||
|
:members:
|
|
@ -57,6 +57,7 @@ Plugins
|
||||||
platypush/plugins/gpio.sensor.motion.pmw3901.rst
|
platypush/plugins/gpio.sensor.motion.pmw3901.rst
|
||||||
platypush/plugins/gpio.zeroborg.rst
|
platypush/plugins/gpio.zeroborg.rst
|
||||||
platypush/plugins/graphite.rst
|
platypush/plugins/graphite.rst
|
||||||
|
platypush/plugins/hid.rst
|
||||||
platypush/plugins/http.request.rst
|
platypush/plugins/http.request.rst
|
||||||
platypush/plugins/http.request.rss.rst
|
platypush/plugins/http.request.rss.rst
|
||||||
platypush/plugins/http.webpage.rst
|
platypush/plugins/http.webpage.rst
|
||||||
|
|
56
platypush/message/event/hid.py
Normal file
56
platypush/message/event/hid.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from platypush.message.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
class HidBaseEvent(Event):
|
||||||
|
"""
|
||||||
|
Base class for HID events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
path: str,
|
||||||
|
serial_number: str,
|
||||||
|
vendor_id: int,
|
||||||
|
product_id: int,
|
||||||
|
product_string: str,
|
||||||
|
manufacturer_string: str,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
*args,
|
||||||
|
path=path,
|
||||||
|
serial_number=serial_number,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
product_id=product_id,
|
||||||
|
product_string=product_string,
|
||||||
|
manufacturer_string=manufacturer_string,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HidDeviceConnectedEvent(HidBaseEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a device is discovered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HidDeviceDisconnectedEvent(HidBaseEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a device is disconnected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HidDeviceDataEvent(HidBaseEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a monitored device sends some data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, data: str, **kwargs):
|
||||||
|
"""
|
||||||
|
:param data: Hex-encoded representation of the received data.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, data=data, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
234
platypush/plugins/hid/__init__.py
Normal file
234
platypush/plugins/hid/__init__.py
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from multiprocessing import Process
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import hid
|
||||||
|
|
||||||
|
from platypush.context import get_bus
|
||||||
|
from platypush.message.event.hid import (
|
||||||
|
HidDeviceConnectedEvent,
|
||||||
|
HidDeviceDataEvent,
|
||||||
|
HidDeviceDisconnectedEvent,
|
||||||
|
)
|
||||||
|
from platypush.plugins import RunnablePlugin, action
|
||||||
|
from platypush.schemas.hid import HidDeviceSchema, HidMonitoredDeviceSchema
|
||||||
|
|
||||||
|
|
||||||
|
class HidPlugin(RunnablePlugin):
|
||||||
|
"""
|
||||||
|
This plugin can be used to interact directly with HID devices (including
|
||||||
|
Bluetooth, USB and several serial and wireless devices) over the raw
|
||||||
|
interface.
|
||||||
|
|
||||||
|
This is the preferred way of communicating with joypads.
|
||||||
|
|
||||||
|
Note that on Linux reading from the devices requires the user running the
|
||||||
|
Platypush service to have (at least) read access to the ``/dev/hidraw*``
|
||||||
|
devices. However, it is still possible to get connected/disconnected events
|
||||||
|
even without having to open a connection to the device. A way to make HID
|
||||||
|
raw devices accessible to e.g. a particular user group is via udev rules.
|
||||||
|
For example, create a file named ``/etc/udev/rules.d/99-hid.rules`` with
|
||||||
|
the following content::
|
||||||
|
|
||||||
|
# Make all /dev/hidraw* devices accessible in read/write to users in
|
||||||
|
# the group input
|
||||||
|
KERNEL=="hidraw*", GROUP="input", MODE="0660"
|
||||||
|
|
||||||
|
A more granular solution is to provide read and/or write access only to a
|
||||||
|
specific device that you want to access, and only to the specific user
|
||||||
|
running the Platypush service::
|
||||||
|
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", USER="user", MODE="0660"
|
||||||
|
|
||||||
|
If you don't want to reboot the device after adding the rules, then you can
|
||||||
|
reload the rules for udev service and re-trigger them::
|
||||||
|
|
||||||
|
# udevadm control --reload && udevadm trigger
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
|
||||||
|
* :class:`platypush.message.event.hid.HidDeviceConnectedEvent` when a
|
||||||
|
device is connected
|
||||||
|
* :class:`platypush.message.event.hid.HidDeviceDisconnectedEvent` when
|
||||||
|
a previously available device is disconnected
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
monitored_devices: Optional[Iterable[dict]] = None,
|
||||||
|
poll_seconds: int = 1,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
:param monitored_devices: Map of devices that should be monitored for
|
||||||
|
new data. Format (note that all the device filtering attributes are
|
||||||
|
optional):
|
||||||
|
|
||||||
|
.. schema:: hid.HidMonitoredDeviceSchema(many=True)
|
||||||
|
|
||||||
|
:param poll_seconds: How often the plugin should check for changes in
|
||||||
|
the list of devices (default: 1 second).
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._poll_seconds = poll_seconds
|
||||||
|
self._filters = HidMonitoredDeviceSchema().load(
|
||||||
|
monitored_devices or [], many=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._device_monitors: Dict[str, Process] = {}
|
||||||
|
self._available_devices: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
def main(self):
|
||||||
|
while not self.should_stop():
|
||||||
|
scanned_devices = {
|
||||||
|
dev['path']: dev for dev in self.get_devices().output # type: ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
self._handle_device_events(scanned_devices)
|
||||||
|
self._available_devices = scanned_devices
|
||||||
|
if self._poll_seconds and self._poll_seconds > 0:
|
||||||
|
sleep(self._poll_seconds)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
device_monitors = self._device_monitors.copy()
|
||||||
|
for monitored_device in device_monitors:
|
||||||
|
self._unregister_device_monitor(monitored_device)
|
||||||
|
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def get_devices(self) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Get the HID devices available on the host.
|
||||||
|
|
||||||
|
:return: .. schema:: hid.HidDeviceSchema(many=True)
|
||||||
|
"""
|
||||||
|
return list( # type: ignore
|
||||||
|
{
|
||||||
|
dev['path']: HidDeviceSchema().load(dev)
|
||||||
|
for dev in hid.enumerate() # type: ignore
|
||||||
|
}.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_monitor_rule(self, device: dict) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
:return: .. schema:: hid.HidMonitoredDeviceSchema
|
||||||
|
"""
|
||||||
|
matching_rules = [
|
||||||
|
rule
|
||||||
|
for rule in (self._filters or [])
|
||||||
|
if all(
|
||||||
|
rule[attr] == device.get(attr)
|
||||||
|
for attr in (
|
||||||
|
'path',
|
||||||
|
'serial_number',
|
||||||
|
'vendor_id',
|
||||||
|
'product_id',
|
||||||
|
'manufacturer_string',
|
||||||
|
'product_string',
|
||||||
|
)
|
||||||
|
if rule.get(attr)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if matching_rules:
|
||||||
|
return matching_rules[0]
|
||||||
|
|
||||||
|
def _device_monitor(self, dev_def: dict, rule: dict):
|
||||||
|
path = dev_def['path']
|
||||||
|
data_size = rule['data_size']
|
||||||
|
poll_seconds = rule['poll_seconds']
|
||||||
|
last_data = None
|
||||||
|
self.logger.info(f'Starting monitor for device {path}')
|
||||||
|
|
||||||
|
def wait():
|
||||||
|
if poll_seconds and poll_seconds > 0:
|
||||||
|
sleep(poll_seconds)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
device = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not device:
|
||||||
|
device = hid.Device(dev_def['vendor_id'], dev_def['product_id']) # type: ignore
|
||||||
|
data = device.read(data_size)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Read error from {path}: {e}')
|
||||||
|
device = None
|
||||||
|
wait()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(data) and data != last_data:
|
||||||
|
data_dump = ''.join(f'{x:02x}' for x in data)
|
||||||
|
get_bus().post(HidDeviceDataEvent(data=data_dump, **dev_def))
|
||||||
|
last_data = data
|
||||||
|
|
||||||
|
wait()
|
||||||
|
|
||||||
|
def _register_device_monitor(self, device: dict, rule: dict):
|
||||||
|
"""
|
||||||
|
Register a monitor for a device.
|
||||||
|
"""
|
||||||
|
path = device['path']
|
||||||
|
# Make sure that no other monitors are registered for this device
|
||||||
|
self._unregister_device_monitor(path)
|
||||||
|
monitor_proc = self._device_monitors[path] = Process(
|
||||||
|
target=self._device_monitor, args=(device, rule)
|
||||||
|
)
|
||||||
|
|
||||||
|
monitor_proc.start()
|
||||||
|
|
||||||
|
def _unregister_device_monitor(self, path: str):
|
||||||
|
"""
|
||||||
|
Unregister a monitor for a device.
|
||||||
|
"""
|
||||||
|
monitor_proc = self._device_monitors.get(path)
|
||||||
|
if monitor_proc and monitor_proc.is_alive():
|
||||||
|
self.logger.info(f'Terminating monitor for device {path}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
monitor_proc.terminate()
|
||||||
|
monitor_proc.join(2)
|
||||||
|
if monitor_proc.is_alive():
|
||||||
|
monitor_proc.kill()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Error while terminating monitor for {path}: {e}')
|
||||||
|
|
||||||
|
del self._device_monitors[path]
|
||||||
|
|
||||||
|
def _handle_new_devices(self, scanned_devices: dict):
|
||||||
|
"""
|
||||||
|
Handles connection events and monitor registering.
|
||||||
|
"""
|
||||||
|
scanned_device_paths = set(scanned_devices.keys())
|
||||||
|
available_device_paths = set(self._available_devices)
|
||||||
|
new_device_paths = scanned_device_paths.difference(available_device_paths)
|
||||||
|
|
||||||
|
for path in new_device_paths:
|
||||||
|
device = scanned_devices[path]
|
||||||
|
get_bus().post(HidDeviceConnectedEvent(**device))
|
||||||
|
|
||||||
|
monitor_rule = self._get_monitor_rule(device)
|
||||||
|
if monitor_rule:
|
||||||
|
self._register_device_monitor(device, monitor_rule)
|
||||||
|
|
||||||
|
def _handle_disconnected_devices(self, scanned_devices: dict):
|
||||||
|
"""
|
||||||
|
Handles disconnection events and monitor unregistering.
|
||||||
|
"""
|
||||||
|
scanned_device_paths = set(scanned_devices.keys())
|
||||||
|
available_device_paths = set(self._available_devices)
|
||||||
|
lost_device_paths = available_device_paths.difference(scanned_device_paths)
|
||||||
|
|
||||||
|
for path in lost_device_paths:
|
||||||
|
get_bus().post(HidDeviceDisconnectedEvent(**self._available_devices[path]))
|
||||||
|
|
||||||
|
def _handle_device_events(self, scanned_devices: dict):
|
||||||
|
"""
|
||||||
|
Handles connected/disconnected device events and register/unregister the
|
||||||
|
monitors based on the user-provided rules when required.
|
||||||
|
"""
|
||||||
|
self._handle_new_devices(scanned_devices)
|
||||||
|
self._handle_disconnected_devices(scanned_devices)
|
10
platypush/plugins/hid/manifest.yaml
Normal file
10
platypush/plugins/hid/manifest.yaml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
manifest:
|
||||||
|
events:
|
||||||
|
platypush.message.event.hid.HidDeviceConnectedEvent: when a device is connected
|
||||||
|
platypush.message.event.hid.HidDeviceDisconnectedEvent: when a previously available device is disconnected
|
||||||
|
platypush.message.event.hid.HidDeviceDataEvent: when a monitored device sends some data
|
||||||
|
install:
|
||||||
|
pip:
|
||||||
|
- hid
|
||||||
|
package: platypush.plugins.hid
|
||||||
|
type: plugin
|
88
platypush/schemas/hid.py
Normal file
88
platypush/schemas/hid.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
from marshmallow import fields, Schema, INCLUDE
|
||||||
|
|
||||||
|
|
||||||
|
class HidDeviceSchema(Schema):
|
||||||
|
class Meta:
|
||||||
|
unknown = INCLUDE
|
||||||
|
|
||||||
|
path = fields.String(
|
||||||
|
metadata={
|
||||||
|
'description': 'Path to the raw HID device',
|
||||||
|
'example': '/dev/hidraw0',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
serial_number = fields.String(
|
||||||
|
metadata={
|
||||||
|
'description': 'Serial number',
|
||||||
|
'example': '00:11:22:33:44:55',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
vendor_id = fields.Integer(
|
||||||
|
metadata={
|
||||||
|
'description': 'Vendor ID',
|
||||||
|
'example': 1234,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
product_id = fields.Integer(
|
||||||
|
metadata={
|
||||||
|
'description': 'Product ID',
|
||||||
|
'example': 4321,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
manufacturer_string = fields.String(
|
||||||
|
metadata={
|
||||||
|
'description': 'Manufacturer custom string',
|
||||||
|
'example': 'foo',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
product_string = fields.String(
|
||||||
|
metadata={
|
||||||
|
'description': 'Main name of the product',
|
||||||
|
'example': 'My Device',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HidMonitoredDeviceSchema(HidDeviceSchema):
|
||||||
|
notify_only_if_changed = fields.Boolean(
|
||||||
|
missing=True,
|
||||||
|
metadata={
|
||||||
|
'description': 'If set to true (default), only changes in the '
|
||||||
|
'values of the device will trigger events. So if you are e.g. '
|
||||||
|
'monitoring the state of a joystick, only changes in the pressed '
|
||||||
|
'buttons will trigger events.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data_size = fields.Integer(
|
||||||
|
missing=64,
|
||||||
|
metadata={
|
||||||
|
'description': 'How many bytes should be read from the device on '
|
||||||
|
'each iteration (default: 64)',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
poll_seconds = fields.Float(
|
||||||
|
missing=0.1,
|
||||||
|
metadata={
|
||||||
|
'description': 'How often we should check this device for new data '
|
||||||
|
'(default: 0.1 seconds)'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for attr in (
|
||||||
|
'path',
|
||||||
|
'serial_number',
|
||||||
|
'vendor_id',
|
||||||
|
'product_id',
|
||||||
|
'manufacturer_string',
|
||||||
|
'product_string',
|
||||||
|
):
|
||||||
|
self._declared_fields[attr].metadata['description'] += ' (optional)'
|
Loading…
Reference in a new issue