Merge pull request '[#311] Logic to automatically generate the documentation for the dependencies of the integrations' (#330) from 311/auto-generate-deps-docs into master

Reviewed-on: platypush/platypush#330
This commit is contained in:
Fabio Manganiello 2023-09-30 02:31:43 +02:00
commit e6f05dfe07
171 changed files with 6698 additions and 5390 deletions

View file

@ -49,7 +49,7 @@ steps:
commands: commands:
- echo "Installing required build dependencies" - echo "Installing required build dependencies"
- apk add --update --no-cache make py3-sphinx py3-pip py3-paho-mqtt - apk add --update --no-cache make py3-sphinx py3-pip py3-paho-mqtt py3-yaml
- pip install -U hid sphinx-rtd-theme sphinx-book-theme - pip install -U hid sphinx-rtd-theme sphinx-book-theme
- pip install . - pip install .
- mkdir -p /docs/current - mkdir -p /docs/current

View file

@ -0,0 +1,190 @@
import inspect
import os
import re
import sys
import textwrap as tw
from contextlib import contextmanager
from sphinx.application import Sphinx
base_path = os.path.abspath(
os.path.join(os.path.dirname(os.path.relpath(__file__)), '..', '..', '..')
)
sys.path.insert(0, base_path)
from platypush.utils import get_plugin_name_by_class # noqa
from platypush.utils.mock import mock # noqa
from platypush.utils.reflection import IntegrationMetadata, import_file # noqa
class IntegrationEnricher:
@staticmethod
def add_events(source: list[str], manifest: IntegrationMetadata, idx: int) -> int:
if not manifest.events:
return idx
source.insert(
idx,
'Triggered events\n----------------\n\n'
+ '\n'.join(
f'\t- :class:`{event.__module__}.{event.__qualname__}`'
for event in manifest.events
)
+ '\n\n',
)
return idx + 1
@staticmethod
def add_actions(source: list[str], manifest: IntegrationMetadata, idx: int) -> int:
if not (manifest.actions and manifest.cls):
return idx
source.insert(
idx,
'Actions\n-------\n\n'
+ '\n'.join(
f'\t- `{get_plugin_name_by_class(manifest.cls)}.{action} '
+ f'<#{manifest.cls.__module__}.{manifest.cls.__qualname__}.{action}>`_'
for action in sorted(manifest.actions.keys())
)
+ '\n\n',
)
return idx + 1
@staticmethod
def _shellify(title: str, cmd: str) -> str:
return f'**{title}**\n\n' + '.. code-block:: bash\n\n\t' + cmd + '\n\n'
@classmethod
def add_install_deps(
cls, source: list[str], manifest: IntegrationMetadata, idx: int
) -> int:
deps = manifest.deps
parsed_deps = {
'before': deps.before,
'pip': deps.pip,
'after': deps.after,
}
if not (any(parsed_deps.values()) or deps.by_pkg_manager):
return idx
source.insert(idx, 'Dependencies\n------------\n\n')
idx += 1
if parsed_deps['before']:
source.insert(idx, cls._shellify('Pre-install', '\n'.join(deps.before)))
idx += 1
if parsed_deps['pip']:
source.insert(idx, cls._shellify('pip', 'pip ' + ' '.join(deps.pip)))
idx += 1
for pkg_manager, sys_deps in deps.by_pkg_manager.items():
if not sys_deps:
continue
source.insert(
idx,
cls._shellify(
pkg_manager.value.default_os.value.description,
pkg_manager.value.install_doc + ' ' + ' '.join(sys_deps),
),
)
idx += 1
if parsed_deps['after']:
source.insert(idx, cls._shellify('Post-install', '\n'.join(deps.after)))
idx += 1
return idx
@classmethod
def add_description(
cls, source: list[str], manifest: IntegrationMetadata, idx: int
) -> int:
docs = (
doc
for doc in (
inspect.getdoc(manifest.cls) or '',
manifest.constructor.doc if manifest.constructor else '',
)
if doc
)
if not docs:
return idx
docstring = '\n\n'.join(docs)
source.insert(idx, f"Description\n-----------\n\n{docstring}\n\n")
return idx + 1
@classmethod
def add_conf_snippet(
cls, source: list[str], manifest: IntegrationMetadata, idx: int
) -> int:
source.insert(
idx,
tw.dedent(
f"""
Configuration
-------------
.. code-block:: yaml
{tw.indent(manifest.config_snippet, ' ')}
"""
),
)
return idx + 1
def __call__(self, _: Sphinx, doc: str, source: list[str]):
if not (source and re.match(r'^platypush/(backend|plugins)/.*', doc)):
return
src = [src.split('\n') for src in source][0]
if len(src) < 3:
return
manifest_file = os.path.join(
base_path,
*doc.split(os.sep)[:-1],
*doc.split(os.sep)[-1].split('.'),
'manifest.yaml',
)
if not os.path.isfile(manifest_file):
return
with mock_imports():
manifest = IntegrationMetadata.from_manifest(manifest_file)
idx = self.add_description(src, manifest, idx=3)
idx = self.add_conf_snippet(src, manifest, idx=idx)
idx = self.add_install_deps(src, manifest, idx=idx)
idx = self.add_events(src, manifest, idx=idx)
idx = self.add_actions(src, manifest, idx=idx)
src.insert(idx, '\n\nModule reference\n----------------\n\n')
source[0] = '\n'.join(src)
@contextmanager
def mock_imports():
conf_mod = import_file(os.path.join(base_path, 'docs', 'source', 'conf.py'))
mock_mods = getattr(conf_mod, 'autodoc_mock_imports', [])
with mock(*mock_mods):
yield
def setup(app: Sphinx):
app.connect('source-read', IntegrationEnricher())
return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}

View file

@ -20,7 +20,6 @@ Backends
platypush/backend/google.pubsub.rst platypush/backend/google.pubsub.rst
platypush/backend/gps.rst platypush/backend/gps.rst
platypush/backend/http.rst platypush/backend/http.rst
platypush/backend/inotify.rst
platypush/backend/joystick.rst platypush/backend/joystick.rst
platypush/backend/joystick.jstest.rst platypush/backend/joystick.jstest.rst
platypush/backend/joystick.linux.rst platypush/backend/joystick.linux.rst
@ -28,7 +27,6 @@ Backends
platypush/backend/log.http.rst platypush/backend/log.http.rst
platypush/backend/mail.rst platypush/backend/mail.rst
platypush/backend/midi.rst platypush/backend/midi.rst
platypush/backend/mqtt.rst
platypush/backend/music.mopidy.rst platypush/backend/music.mopidy.rst
platypush/backend/music.mpd.rst platypush/backend/music.mpd.rst
platypush/backend/music.snapcast.rst platypush/backend/music.snapcast.rst
@ -52,4 +50,3 @@ Backends
platypush/backend/weather.darksky.rst platypush/backend/weather.darksky.rst
platypush/backend/weather.openweathermap.rst platypush/backend/weather.openweathermap.rst
platypush/backend/wiimote.rst platypush/backend/wiimote.rst
platypush/backend/zwave.mqtt.rst

View file

@ -15,17 +15,14 @@ import sys
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
# #
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath("./_ext")) sys.path.insert(0, os.path.abspath("./_ext"))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'Platypush' project = 'Platypush'
copyright = '2017-2021, Fabio Manganiello' copyright = '2017-2023, Fabio Manganiello'
author = 'Fabio Manganiello' author = 'Fabio Manganiello <fabio@manganiello.tech>'
# The short X.Y version # The short X.Y version
version = '' version = ''
@ -52,6 +49,7 @@ extensions = [
'sphinx.ext.githubpages', 'sphinx.ext.githubpages',
'sphinx_rtd_theme', 'sphinx_rtd_theme',
'sphinx_marshmallow', 'sphinx_marshmallow',
'add_dependencies',
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
@ -190,11 +188,6 @@ texinfo_documents = [
# Example configuration for intersphinx: refer to the Python standard library. # Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
# -- Options for todo extension ----------------------------------------------
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
autodoc_default_options = { autodoc_default_options = {
'members': True, 'members': True,
'show-inheritance': True, 'show-inheritance': True,

View file

@ -1,6 +0,0 @@
``inotify``
=============================
.. automodule:: platypush.backend.inotify
:members:

View file

@ -1,6 +0,0 @@
``mqtt``
==========================
.. automodule:: platypush.backend.mqtt
:members:

View file

@ -1,5 +0,0 @@
``zwave.mqtt``
================================
.. automodule:: platypush.backend.zwave.mqtt
:members:

View file

@ -1,6 +0,0 @@
``http.request.rss``
======================================
.. automodule:: platypush.plugins.http.request.rss
:members:

View file

@ -50,7 +50,6 @@ Plugins
platypush/plugins/graphite.rst platypush/plugins/graphite.rst
platypush/plugins/hid.rst platypush/plugins/hid.rst
platypush/plugins/http.request.rst platypush/plugins/http.request.rst
platypush/plugins/http.request.rss.rst
platypush/plugins/http.webpage.rst platypush/plugins/http.webpage.rst
platypush/plugins/ifttt.rst platypush/plugins/ifttt.rst
platypush/plugins/inputs.rst platypush/plugins/inputs.rst

View file

@ -2,23 +2,17 @@ from typing import Optional
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.adafruit import ConnectedEvent, DisconnectedEvent, \ from platypush.message.event.adafruit import (
FeedUpdateEvent ConnectedEvent,
DisconnectedEvent,
FeedUpdateEvent,
)
class AdafruitIoBackend(Backend): class AdafruitIoBackend(Backend):
""" """
Backend that listens to messages received over the Adafruit IO message queue Backend that listens to messages received over the Adafruit IO message queue
Triggers:
* :class:`platypush.message.event.adafruit.ConnectedEvent` when the
backend connects to the Adafruit queue
* :class:`platypush.message.event.adafruit.DisconnectedEvent` when the
backend disconnects from the Adafruit queue
* :class:`platypush.message.event.adafruit.FeedUpdateEvent` when an
update event is received on a monitored feed
Requires: Requires:
* The :class:`platypush.plugins.adafruit.io.AdafruitIoPlugin` plugin to * The :class:`platypush.plugins.adafruit.io.AdafruitIoPlugin` plugin to
@ -33,6 +27,7 @@ class AdafruitIoBackend(Backend):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
from Adafruit_IO import MQTTClient from Adafruit_IO import MQTTClient
self.feeds = feeds self.feeds = feeds
self._client: Optional[MQTTClient] = None self._client: Optional[MQTTClient] = None
@ -41,6 +36,7 @@ class AdafruitIoBackend(Backend):
return return
from Adafruit_IO import MQTTClient from Adafruit_IO import MQTTClient
plugin = get_plugin('adafruit.io') plugin = get_plugin('adafruit.io')
if not plugin: if not plugin:
raise RuntimeError('Adafruit IO plugin not configured') raise RuntimeError('Adafruit IO plugin not configured')
@ -80,8 +76,11 @@ class AdafruitIoBackend(Backend):
def run(self): def run(self):
super().run() super().run()
self.logger.info(('Initialized Adafruit IO backend, listening on ' + self.logger.info(
'feeds {}').format(self.feeds)) ('Initialized Adafruit IO backend, listening on ' + 'feeds {}').format(
self.feeds
)
)
while not self.should_stop(): while not self.should_stop():
try: try:
@ -94,4 +93,5 @@ class AdafruitIoBackend(Backend):
self.logger.exception(e) self.logger.exception(e)
self._client = None self._client = None
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -11,7 +11,11 @@ from dateutil.tz import gettz
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_bus, get_plugin from platypush.context import get_bus, get_plugin
from platypush.message.event.alarm import AlarmStartedEvent, AlarmDismissedEvent, AlarmSnoozedEvent from platypush.message.event.alarm import (
AlarmStartedEvent,
AlarmDismissedEvent,
AlarmSnoozedEvent,
)
from platypush.plugins.media import MediaPlugin, PlayerState from platypush.plugins.media import MediaPlugin, PlayerState
from platypush.procedure import Procedure from platypush.procedure import Procedure
@ -28,10 +32,17 @@ class Alarm:
_alarms_count = 0 _alarms_count = 0
_id_lock = threading.RLock() _id_lock = threading.RLock()
def __init__(self, when: str, actions: Optional[list] = None, name: Optional[str] = None, def __init__(
audio_file: Optional[str] = None, audio_plugin: Optional[str] = None, self,
when: str,
actions: Optional[list] = None,
name: Optional[str] = None,
audio_file: Optional[str] = None,
audio_plugin: Optional[str] = None,
audio_volume: Optional[Union[int, float]] = None, audio_volume: Optional[Union[int, float]] = None,
snooze_interval: float = 300.0, enabled: bool = True): snooze_interval: float = 300.0,
enabled: bool = True,
):
with self._id_lock: with self._id_lock:
self._alarms_count += 1 self._alarms_count += 1
self.id = self._alarms_count self.id = self._alarms_count
@ -42,20 +53,26 @@ class Alarm:
if audio_file: if audio_file:
self.audio_file = os.path.abspath(os.path.expanduser(audio_file)) self.audio_file = os.path.abspath(os.path.expanduser(audio_file))
assert os.path.isfile(self.audio_file), 'No such audio file: {}'.format(self.audio_file) assert os.path.isfile(self.audio_file), 'No such audio file: {}'.format(
self.audio_file
)
self.audio_plugin = audio_plugin self.audio_plugin = audio_plugin
self.audio_volume = audio_volume self.audio_volume = audio_volume
self.snooze_interval = snooze_interval self.snooze_interval = snooze_interval
self.state: Optional[AlarmState] = None self.state: Optional[AlarmState] = None
self.timer: Optional[threading.Timer] = None self.timer: Optional[threading.Timer] = None
self.actions = Procedure.build(name=name, _async=False, requests=actions or [], id=self.id) self.actions = Procedure.build(
name=name, _async=False, requests=actions or [], id=self.id
)
self._enabled = enabled self._enabled = enabled
self._runtime_snooze_interval = snooze_interval self._runtime_snooze_interval = snooze_interval
def get_next(self) -> float: def get_next(self) -> float:
now = datetime.datetime.now().replace(tzinfo=gettz()) # lgtm [py/call-to-non-callable] now = datetime.datetime.now().replace(
tzinfo=gettz()
) # lgtm [py/call-to-non-callable]
try: try:
cron = croniter.croniter(self.when, now) cron = croniter.croniter(self.when, now)
@ -63,10 +80,14 @@ class Alarm:
except (AttributeError, croniter.CroniterBadCronError): except (AttributeError, croniter.CroniterBadCronError):
try: try:
timestamp = datetime.datetime.fromisoformat(self.when).replace( timestamp = datetime.datetime.fromisoformat(self.when).replace(
tzinfo=gettz()) # lgtm [py/call-to-non-callable] tzinfo=gettz()
) # lgtm [py/call-to-non-callable]
except (TypeError, ValueError): except (TypeError, ValueError):
timestamp = (datetime.datetime.now().replace(tzinfo=gettz()) + # lgtm [py/call-to-non-callable] timestamp = datetime.datetime.now().replace(
datetime.timedelta(seconds=int(self.when))) tzinfo=gettz()
) + datetime.timedelta( # lgtm [py/call-to-non-callable]
seconds=int(self.when)
)
return timestamp.timestamp() if timestamp >= now else None return timestamp.timestamp() if timestamp >= now else None
@ -88,7 +109,9 @@ class Alarm:
self._runtime_snooze_interval = interval or self.snooze_interval self._runtime_snooze_interval = interval or self.snooze_interval
self.state = AlarmState.SNOOZED self.state = AlarmState.SNOOZED
self.stop_audio() self.stop_audio()
get_bus().post(AlarmSnoozedEvent(name=self.name, interval=self._runtime_snooze_interval)) get_bus().post(
AlarmSnoozedEvent(name=self.name, interval=self._runtime_snooze_interval)
)
def start(self): def start(self):
if self.timer: if self.timer:
@ -159,7 +182,9 @@ class Alarm:
break break
if not sleep_time: if not sleep_time:
sleep_time = self.get_next() - time.time() if self.get_next() else 10 sleep_time = (
self.get_next() - time.time() if self.get_next() else 10
)
time.sleep(sleep_time) time.sleep(sleep_time)
@ -179,18 +204,15 @@ class Alarm:
class AlarmBackend(Backend): class AlarmBackend(Backend):
""" """
Backend to handle user-configured alarms. Backend to handle user-configured alarms.
Triggers:
* :class:`platypush.message.event.alarm.AlarmStartedEvent` when an alarm starts.
* :class:`platypush.message.event.alarm.AlarmSnoozedEvent` when an alarm is snoozed.
* :class:`platypush.message.event.alarm.AlarmTimeoutEvent` when an alarm times out.
* :class:`platypush.message.event.alarm.AlarmDismissedEvent` when an alarm is dismissed.
""" """
def __init__(self, alarms: Optional[Union[list, Dict[str, Any]]] = None, audio_plugin: str = 'media.mplayer', def __init__(
*args, **kwargs): self,
alarms: Optional[Union[list, Dict[str, Any]]] = None,
audio_plugin: str = 'media.mplayer',
*args,
**kwargs
):
""" """
:param alarms: List or name->value dict with the configured alarms. Example: :param alarms: List or name->value dict with the configured alarms. Example:
@ -231,13 +253,29 @@ class AlarmBackend(Backend):
alarms = [{'name': name, **alarm} for name, alarm in alarms.items()] alarms = [{'name': name, **alarm} for name, alarm in alarms.items()]
self.audio_plugin = audio_plugin self.audio_plugin = audio_plugin
alarms = [Alarm(**{'audio_plugin': self.audio_plugin, **alarm}) for alarm in alarms] alarms = [
Alarm(**{'audio_plugin': self.audio_plugin, **alarm}) for alarm in alarms
]
self.alarms: Dict[str, Alarm] = {alarm.name: alarm for alarm in alarms} self.alarms: Dict[str, Alarm] = {alarm.name: alarm for alarm in alarms}
def add_alarm(self, when: str, actions: list, name: Optional[str] = None, audio_file: Optional[str] = None, def add_alarm(
audio_volume: Optional[Union[int, float]] = None, enabled: bool = True) -> Alarm: self,
alarm = Alarm(when=when, actions=actions, name=name, enabled=enabled, audio_file=audio_file, when: str,
audio_plugin=self.audio_plugin, audio_volume=audio_volume) actions: list,
name: Optional[str] = None,
audio_file: Optional[str] = None,
audio_volume: Optional[Union[int, float]] = None,
enabled: bool = True,
) -> Alarm:
alarm = Alarm(
when=when,
actions=actions,
name=name,
enabled=enabled,
audio_file=audio_file,
audio_plugin=self.audio_plugin,
audio_volume=audio_volume,
)
if alarm.name in self.alarms: if alarm.name in self.alarms:
self.logger.info('Overwriting existing alarm {}'.format(alarm.name)) self.logger.info('Overwriting existing alarm {}'.format(alarm.name))
@ -274,10 +312,15 @@ class AlarmBackend(Backend):
alarm.snooze(interval=interval) alarm.snooze(interval=interval)
def get_alarms(self) -> List[Alarm]: def get_alarms(self) -> List[Alarm]:
return sorted([alarm for alarm in self.alarms.values()], key=lambda alarm: alarm.get_next()) return sorted(
self.alarms.values(),
key=lambda alarm: alarm.get_next(),
)
def get_running_alarm(self) -> Optional[Alarm]: def get_running_alarm(self) -> Optional[Alarm]:
running_alarms = [alarm for alarm in self.alarms.values() if alarm.state == AlarmState.RUNNING] running_alarms = [
alarm for alarm in self.alarms.values() if alarm.state == AlarmState.RUNNING
]
return running_alarms[0] if running_alarms else None return running_alarms[0] if running_alarms else None
def __enter__(self): def __enter__(self):
@ -285,9 +328,11 @@ class AlarmBackend(Backend):
alarm.stop() alarm.stop()
alarm.start() alarm.start()
self.logger.info('Initialized alarm backend with {} alarms'.format(len(self.alarms))) self.logger.info(
'Initialized alarm backend with {} alarms'.format(len(self.alarms))
)
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, *_, **__):
for alarm in self.alarms.values(): for alarm in self.alarms.values():
alarm.stop() alarm.stop()
@ -295,7 +340,9 @@ class AlarmBackend(Backend):
def loop(self): def loop(self):
for name, alarm in self.alarms.copy().items(): for name, alarm in self.alarms.copy().items():
if not alarm.timer or (not alarm.timer.is_alive() and alarm.state == AlarmState.SHUTDOWN): if not alarm.timer or (
not alarm.timer.is_alive() and alarm.state == AlarmState.SHUTDOWN
):
del self.alarms[name] del self.alarms[name]
time.sleep(10) time.sleep(10)

View file

@ -31,40 +31,6 @@ class AssistantGoogleBackend(AssistantBackend):
https://developers.google.com/assistant/sdk/reference/library/python/. This backend still works on most of the https://developers.google.com/assistant/sdk/reference/library/python/. This backend still works on most of the
devices where I use it, but its correct functioning is not guaranteed as the assistant library is no longer devices where I use it, but its correct functioning is not guaranteed as the assistant library is no longer
maintained. maintained.
Triggers:
* :class:`platypush.message.event.assistant.ConversationStartEvent` \
when a new conversation starts
* :class:`platypush.message.event.assistant.SpeechRecognizedEvent` \
when a new voice command is recognized
* :class:`platypush.message.event.assistant.NoResponse` \
when a conversation returned no response
* :class:`platypush.message.event.assistant.ResponseEvent` \
when the assistant is speaking a response
* :class:`platypush.message.event.assistant.ConversationTimeoutEvent` \
when a conversation times out
* :class:`platypush.message.event.assistant.ConversationEndEvent` \
when a new conversation ends
* :class:`platypush.message.event.assistant.AlarmStartedEvent` \
when an alarm starts
* :class:`platypush.message.event.assistant.AlarmEndEvent` \
when an alarm ends
* :class:`platypush.message.event.assistant.TimerStartedEvent` \
when a timer starts
* :class:`platypush.message.event.assistant.TimerEndEvent` \
when a timer ends
* :class:`platypush.message.event.assistant.MicMutedEvent` \
when the microphone is muted.
* :class:`platypush.message.event.assistant.MicUnmutedEvent` \
when the microphone is un-muted.
Requires:
* **google-assistant-library** (``pip install google-assistant-library``)
* **google-assistant-sdk[samples]** (``pip install google-assistant-sdk[samples]``)
* **google-auth** (``pip install google-auth``)
""" """
_default_credentials_file = os.path.join( _default_credentials_file = os.path.join(
@ -164,12 +130,12 @@ class AssistantGoogleBackend(AssistantBackend):
self.bus.post(event) self.bus.post(event)
def start_conversation(self): def start_conversation(self):
"""Starts an assistant conversation""" """Starts a conversation."""
if self.assistant: if self.assistant:
self.assistant.start_conversation() self.assistant.start_conversation()
def stop_conversation(self): def stop_conversation(self):
"""Stops an assistant conversation""" """Stops an active conversation."""
if self.assistant: if self.assistant:
self.assistant.stop_conversation() self.assistant.stop_conversation()

View file

@ -15,16 +15,7 @@ class AssistantSnowboyBackend(AssistantBackend):
HotwordDetectedEvent to trigger the conversation on whichever assistant HotwordDetectedEvent to trigger the conversation on whichever assistant
plugin you're using (Google, Alexa...) plugin you're using (Google, Alexa...)
Triggers: Manual installation for snowboy and its Python bindings if the installation via package fails::
* :class:`platypush.message.event.assistant.HotwordDetectedEvent` \
whenever the hotword has been detected
Requires:
* **snowboy** (``pip install snowboy``)
Manual installation for snowboy and its Python bindings if the command above fails::
$ [sudo] apt-get install libatlas-base-dev swig $ [sudo] apt-get install libatlas-base-dev swig
$ [sudo] pip install pyaudio $ [sudo] pip install pyaudio

View file

@ -12,15 +12,12 @@ class ButtonFlicBackend(Backend):
Backend that listen for events from the Flic (https://flic.io/) bluetooth Backend that listen for events from the Flic (https://flic.io/) bluetooth
smart buttons. smart buttons.
Triggers:
* :class:`platypush.message.event.button.flic.FlicButtonEvent` when a button is pressed.
The event will also contain the press sequence
(e.g. ``["ShortPressEvent", "LongPressEvent", "ShortPressEvent"]``)
Requires: Requires:
* **fliclib** (https://github.com/50ButtonsEach/fliclib-linux-hci). For the backend to work properly you need to have the ``flicd`` daemon from the fliclib running, and you have to first pair the buttons with your device using any of the scanners provided by the library. * **fliclib** (https://github.com/50ButtonsEach/fliclib-linux-hci). For
the backend to work properly you need to have the ``flicd`` daemon
from the fliclib running, and you have to first pair the buttons with
your device using any of the scanners provided by the library.
""" """
@ -29,16 +26,23 @@ class ButtonFlicBackend(Backend):
ShortPressEvent = "ShortPressEvent" ShortPressEvent = "ShortPressEvent"
LongPressEvent = "LongPressEvent" LongPressEvent = "LongPressEvent"
def __init__(self, server='localhost', long_press_timeout=_long_press_timeout, def __init__(
btn_timeout=_btn_timeout, **kwargs): self,
server='localhost',
long_press_timeout=_long_press_timeout,
btn_timeout=_btn_timeout,
**kwargs
):
""" """
:param server: flicd server host (default: localhost) :param server: flicd server host (default: localhost)
:type server: str :type server: str
:param long_press_timeout: How long you should press a button for a press action to be considered "long press" (default: 0.3 secohds) :param long_press_timeout: How long you should press a button for a
press action to be considered "long press" (default: 0.3 secohds)
:type long_press_timeout: float :type long_press_timeout: float
:param btn_timeout: How long since the last button release before considering the user interaction completed (default: 0.5 seconds) :param btn_timeout: How long since the last button release before
considering the user interaction completed (default: 0.5 seconds)
:type btn_timeout: float :type btn_timeout: float
""" """
@ -55,15 +59,16 @@ class ButtonFlicBackend(Backend):
self._btn_addr = None self._btn_addr = None
self._down_pressed_time = None self._down_pressed_time = None
self._cur_sequence = [] self._cur_sequence = []
self.logger.info('Initialized Flic buttons backend on %s', self.server)
self.logger.info('Initialized Flic buttons backend on {}'.format(self.server))
def _got_button(self): def _got_button(self):
def _f(bd_addr): def _f(bd_addr):
cc = ButtonConnectionChannel(bd_addr) cc = ButtonConnectionChannel(bd_addr)
cc.on_button_up_or_down = \ cc.on_button_up_or_down = (
lambda channel, click_type, was_queued, time_diff: \ lambda channel, click_type, was_queued, time_diff: self._on_event()(
self._on_event()(bd_addr, channel, click_type, was_queued, time_diff) bd_addr, channel, click_type, was_queued, time_diff
)
)
self.client.add_connection_channel(cc) self.client.add_connection_channel(cc)
return _f return _f
@ -72,23 +77,27 @@ class ButtonFlicBackend(Backend):
def _f(items): def _f(items):
for bd_addr in items["bd_addr_of_verified_buttons"]: for bd_addr in items["bd_addr_of_verified_buttons"]:
self._got_button()(bd_addr) self._got_button()(bd_addr)
return _f return _f
def _on_btn_timeout(self): def _on_btn_timeout(self):
def _f(): def _f():
self.logger.info('Flic event triggered from {}: {}'.format( self.logger.info(
self._btn_addr, self._cur_sequence)) 'Flic event triggered from %s: %s', self._btn_addr, self._cur_sequence
)
self.bus.post(FlicButtonEvent( self.bus.post(
btn_addr=self._btn_addr, sequence=self._cur_sequence)) FlicButtonEvent(btn_addr=self._btn_addr, sequence=self._cur_sequence)
)
self._cur_sequence = [] self._cur_sequence = []
return _f return _f
def _on_event(self): def _on_event(self):
# noinspection PyUnusedLocal # _ = channel
def _f(bd_addr, channel, click_type, was_queued, time_diff): # __ = time_diff
def _f(bd_addr, _, click_type, was_queued, __):
if was_queued: if was_queued:
return return
@ -120,4 +129,3 @@ class ButtonFlicBackend(Backend):
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -15,10 +15,6 @@ class CameraPiBackend(Backend):
the :class:`platypush.plugins.camera.pi` plugin. Note that the Redis backend the :class:`platypush.plugins.camera.pi` plugin. Note that the Redis backend
must be configured and running to enable camera control. must be configured and running to enable camera control.
Requires:
* **picamera** (``pip install picamera``)
This backend is **DEPRECATED**. Use the plugin :class:`platypush.plugins.camera.pi.CameraPiPlugin` instead to run This backend is **DEPRECATED**. Use the plugin :class:`platypush.plugins.camera.pi.CameraPiPlugin` instead to run
Pi camera actions. If you want to start streaming the camera on application start then simply create an event hook Pi camera actions. If you want to start streaming the camera on application start then simply create an event hook
on :class:`platypush.message.event.application.ApplicationStartedEvent` that runs ``camera.pi.start_streaming``. on :class:`platypush.message.event.application.ApplicationStartedEvent` that runs ``camera.pi.start_streaming``.
@ -33,15 +29,32 @@ class CameraPiBackend(Backend):
return self.value == other return self.value == other
# noinspection PyUnresolvedReferences,PyPackageRequirements # noinspection PyUnresolvedReferences,PyPackageRequirements
def __init__(self, listen_port, bind_address='0.0.0.0', x_resolution=640, y_resolution=480, def __init__(
self,
listen_port,
bind_address='0.0.0.0',
x_resolution=640,
y_resolution=480,
redis_queue='platypush/camera/pi', redis_queue='platypush/camera/pi',
start_recording_on_startup=True, start_recording_on_startup=True,
framerate=24, hflip=False, vflip=False, framerate=24,
sharpness=0, contrast=0, brightness=50, hflip=False,
video_stabilization=False, iso=0, exposure_compensation=0, vflip=False,
exposure_mode='auto', meter_mode='average', awb_mode='auto', sharpness=0,
image_effect='none', color_effects=None, rotation=0, contrast=0,
crop=(0.0, 0.0, 1.0, 1.0), **kwargs): brightness=50,
video_stabilization=False,
iso=0,
exposure_compensation=0,
exposure_mode='auto',
meter_mode='average',
awb_mode='auto',
image_effect='none',
color_effects=None,
rotation=0,
crop=(0.0, 0.0, 1.0, 1.0),
**kwargs
):
""" """
See https://www.raspberrypi.org/documentation/usage/camera/python/README.md See https://www.raspberrypi.org/documentation/usage/camera/python/README.md
for a detailed reference about the Pi camera options. for a detailed reference about the Pi camera options.
@ -58,7 +71,9 @@ class CameraPiBackend(Backend):
self.bind_address = bind_address self.bind_address = bind_address
self.listen_port = listen_port self.listen_port = listen_port
self.server_socket = socket.socket() self.server_socket = socket.socket()
self.server_socket.bind((self.bind_address, self.listen_port)) # lgtm [py/bind-socket-all-network-interfaces] self.server_socket.bind(
(self.bind_address, self.listen_port)
) # lgtm [py/bind-socket-all-network-interfaces]
self.server_socket.listen(0) self.server_socket.listen(0)
import picamera import picamera
@ -87,10 +102,7 @@ class CameraPiBackend(Backend):
self._recording_thread = None self._recording_thread = None
def send_camera_action(self, action, **kwargs): def send_camera_action(self, action, **kwargs):
action = { action = {'action': action.value, **kwargs}
'action': action.value,
**kwargs
}
self.redis.send_message(msg=json.dumps(action), queue_name=self.redis_queue) self.redis.send_message(msg=json.dumps(action), queue_name=self.redis_queue)
@ -127,7 +139,9 @@ class CameraPiBackend(Backend):
else: else:
while not self.should_stop(): while not self.should_stop():
connection = self.server_socket.accept()[0].makefile('wb') connection = self.server_socket.accept()[0].makefile('wb')
self.logger.info('Accepted client connection on port {}'.format(self.listen_port)) self.logger.info(
'Accepted client connection on port {}'.format(self.listen_port)
)
try: try:
self.camera.start_recording(connection, format=format) self.camera.start_recording(connection, format=format)
@ -138,12 +152,16 @@ class CameraPiBackend(Backend):
try: try:
self.stop_recording() self.stop_recording()
except Exception as e: except Exception as e:
self.logger.warning('Could not stop recording: {}'.format(str(e))) self.logger.warning(
'Could not stop recording: {}'.format(str(e))
)
try: try:
connection.close() connection.close()
except Exception as e: except Exception as e:
self.logger.warning('Could not close connection: {}'.format(str(e))) self.logger.warning(
'Could not close connection: {}'.format(str(e))
)
self.send_camera_action(self.CameraAction.START_RECORDING) self.send_camera_action(self.CameraAction.START_RECORDING)
@ -152,8 +170,9 @@ class CameraPiBackend(Backend):
return return
self.logger.info('Starting camera recording') self.logger.info('Starting camera recording')
self._recording_thread = Thread(target=recording_thread, self._recording_thread = Thread(
name='PiCameraRecorder') target=recording_thread, name='PiCameraRecorder'
)
self._recording_thread.start() self._recording_thread.start()
def stop_recording(self): def stop_recording(self):

View file

@ -22,17 +22,6 @@ class ChatTelegramBackend(Backend):
""" """
Telegram bot that listens for messages and updates. Telegram bot that listens for messages and updates.
Triggers:
* :class:`platypush.message.event.chat.telegram.TextMessageEvent` when a text message is received.
* :class:`platypush.message.event.chat.telegram.PhotoMessageEvent` when a photo is received.
* :class:`platypush.message.event.chat.telegram.VideoMessageEvent` when a video is received.
* :class:`platypush.message.event.chat.telegram.LocationMessageEvent` when a location is received.
* :class:`platypush.message.event.chat.telegram.ContactMessageEvent` when a contact is received.
* :class:`platypush.message.event.chat.telegram.DocumentMessageEvent` when a document is received.
* :class:`platypush.message.event.chat.telegram.CommandMessageEvent` when a command message is received.
* :class:`platypush.message.event.chat.telegram.GroupChatCreatedEvent` when the bot is invited to a new group.
Requires: Requires:
* The :class:`platypush.plugins.chat.telegram.ChatTelegramPlugin` plugin configured * The :class:`platypush.plugins.chat.telegram.ChatTelegramPlugin` plugin configured

View file

@ -10,17 +10,6 @@ from .entities.resources import MonitoredResource, MonitoredPattern, MonitoredRe
class FileMonitorBackend(Backend): class FileMonitorBackend(Backend):
""" """
This backend monitors changes to local files and directories using the Watchdog API. This backend monitors changes to local files and directories using the Watchdog API.
Triggers:
* :class:`platypush.message.event.file.FileSystemCreateEvent` if a resource is created.
* :class:`platypush.message.event.file.FileSystemDeleteEvent` if a resource is removed.
* :class:`platypush.message.event.file.FileSystemModifyEvent` if a resource is modified.
Requires:
* **watchdog** (``pip install watchdog``)
""" """
class EventHandlerFactory: class EventHandlerFactory:
@ -29,20 +18,28 @@ class FileMonitorBackend(Backend):
""" """
@staticmethod @staticmethod
def from_resource(resource: Union[str, Dict[str, Any], MonitoredResource]) -> EventHandler: def from_resource(
resource: Union[str, Dict[str, Any], MonitoredResource]
) -> EventHandler:
if isinstance(resource, str): if isinstance(resource, str):
resource = MonitoredResource(resource) resource = MonitoredResource(resource)
elif isinstance(resource, dict): elif isinstance(resource, dict):
if 'regexes' in resource or 'ignore_regexes' in resource: if 'regexes' in resource or 'ignore_regexes' in resource:
resource = MonitoredRegex(**resource) resource = MonitoredRegex(**resource)
elif 'patterns' in resource or 'ignore_patterns' in resource or 'ignore_directories' in resource: elif (
'patterns' in resource
or 'ignore_patterns' in resource
or 'ignore_directories' in resource
):
resource = MonitoredPattern(**resource) resource = MonitoredPattern(**resource)
else: else:
resource = MonitoredResource(**resource) resource = MonitoredResource(**resource)
return EventHandler.from_resource(resource) return EventHandler.from_resource(resource)
def __init__(self, paths: Iterable[Union[str, Dict[str, Any], MonitoredResource]], **kwargs): def __init__(
self, paths: Iterable[Union[str, Dict[str, Any], MonitoredResource]], **kwargs
):
""" """
:param paths: List of paths to monitor. Paths can either be expressed in any of the following ways: :param paths: List of paths to monitor. Paths can either be expressed in any of the following ways:
@ -113,7 +110,9 @@ class FileMonitorBackend(Backend):
for path in paths: for path in paths:
handler = self.EventHandlerFactory.from_resource(path) handler = self.EventHandlerFactory.from_resource(path)
self._observer.schedule(handler, handler.resource.path, recursive=handler.resource.recursive) self._observer.schedule(
handler, handler.resource.path, recursive=handler.resource.recursive
)
def run(self): def run(self):
super().run() super().run()

View file

@ -14,10 +14,6 @@ class FoursquareBackend(Backend):
* The :class:`platypush.plugins.foursquare.FoursquarePlugin` plugin configured and enabled. * The :class:`platypush.plugins.foursquare.FoursquarePlugin` plugin configured and enabled.
Triggers:
- :class:`platypush.message.event.foursquare.FoursquareCheckinEvent` when a new check-in occurs.
""" """
_last_created_at_varname = '_foursquare_checkin_last_created_at' _last_created_at_varname = '_foursquare_checkin_last_created_at'
@ -30,8 +26,12 @@ class FoursquareBackend(Backend):
self._last_created_at = None self._last_created_at = None
def __enter__(self): def __enter__(self):
self._last_created_at = int(get_plugin('variable').get(self._last_created_at_varname). self._last_created_at = int(
output.get(self._last_created_at_varname) or 0) get_plugin('variable')
.get(self._last_created_at_varname)
.output.get(self._last_created_at_varname)
or 0
)
self.logger.info('Started Foursquare backend') self.logger.info('Started Foursquare backend')
def loop(self): def loop(self):
@ -46,7 +46,9 @@ class FoursquareBackend(Backend):
self.bus.post(FoursquareCheckinEvent(checkin=last_checkin)) self.bus.post(FoursquareCheckinEvent(checkin=last_checkin))
self._last_created_at = last_checkin_created_at self._last_created_at = last_checkin_created_at
get_plugin('variable').set(**{self._last_created_at_varname: self._last_created_at}) get_plugin('variable').set(
**{self._last_created_at_varname: self._last_created_at}
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -60,27 +60,6 @@ class GithubBackend(Backend):
- ``notifications`` - ``notifications``
- ``read:org`` if you want to access repositories on organization level. - ``read:org`` if you want to access repositories on organization level.
Triggers:
- :class:`platypush.message.event.github.GithubPushEvent` when a new push is created.
- :class:`platypush.message.event.github.GithubCommitCommentEvent` when a new commit comment is created.
- :class:`platypush.message.event.github.GithubCreateEvent` when a tag or branch is created.
- :class:`platypush.message.event.github.GithubDeleteEvent` when a tag or branch is deleted.
- :class:`platypush.message.event.github.GithubForkEvent` when a user forks a repository.
- :class:`platypush.message.event.github.GithubWikiEvent` when new activity happens on a repository wiki.
- :class:`platypush.message.event.github.GithubIssueCommentEvent` when new activity happens on an issue comment.
- :class:`platypush.message.event.github.GithubIssueEvent` when new repository issue activity happens.
- :class:`platypush.message.event.github.GithubMemberEvent` when new repository collaborators activity happens.
- :class:`platypush.message.event.github.GithubPublicEvent` when a repository goes public.
- :class:`platypush.message.event.github.GithubPullRequestEvent` when new pull request related activity happens.
- :class:`platypush.message.event.github.GithubPullRequestReviewCommentEvent` when activity happens on a pull
request commit.
- :class:`platypush.message.event.github.GithubReleaseEvent` when a new release happens.
- :class:`platypush.message.event.github.GithubSponsorshipEvent` when new sponsorship related activity happens.
- :class:`platypush.message.event.github.GithubWatchEvent` when someone stars/starts watching a repository.
- :class:`platypush.message.event.github.GithubEvent` for any event that doesn't fall in the above categories
(``event_type`` will be set accordingly).
""" """
_base_url = 'https://api.github.com' _base_url = 'https://api.github.com'

View file

@ -13,24 +13,24 @@ class GoogleFitBackend(Backend):
measurements, new fitness activities etc.) on the specified data streams and measurements, new fitness activities etc.) on the specified data streams and
fire an event upon new data. fire an event upon new data.
Triggers:
* :class:`platypush.message.event.google.fit.GoogleFitEvent` when a new
data point is received on one of the registered streams.
Requires: Requires:
* The **google.fit** plugin * The **google.fit** plugin
(:class:`platypush.plugins.google.fit.GoogleFitPlugin`) enabled. (:class:`platypush.plugins.google.fit.GoogleFitPlugin`) enabled.
* The **db** plugin (:class:`platypush.plugins.db`) configured
""" """
_default_poll_seconds = 60 _default_poll_seconds = 60
_default_user_id = 'me' _default_user_id = 'me'
_last_timestamp_varname = '_GOOGLE_FIT_LAST_TIMESTAMP_' _last_timestamp_varname = '_GOOGLE_FIT_LAST_TIMESTAMP_'
def __init__(self, data_sources, user_id=_default_user_id, def __init__(
poll_seconds=_default_poll_seconds, *args, **kwargs): self,
data_sources,
user_id=_default_user_id,
poll_seconds=_default_poll_seconds,
*args,
**kwargs
):
""" """
:param data_sources: Google Fit data source IDs to monitor. You can :param data_sources: Google Fit data source IDs to monitor. You can
get a list of the available data sources through the get a list of the available data sources through the
@ -53,23 +53,31 @@ class GoogleFitBackend(Backend):
def run(self): def run(self):
super().run() super().run()
self.logger.info('Started Google Fit backend on data sources {}'.format( self.logger.info(
self.data_sources)) 'Started Google Fit backend on data sources {}'.format(self.data_sources)
)
while not self.should_stop(): while not self.should_stop():
try: try:
for data_source in self.data_sources: for data_source in self.data_sources:
varname = self._last_timestamp_varname + data_source varname = self._last_timestamp_varname + data_source
last_timestamp = float(get_plugin('variable'). last_timestamp = float(
get(varname).output.get(varname) or 0) get_plugin('variable').get(varname).output.get(varname) or 0
)
new_last_timestamp = last_timestamp new_last_timestamp = last_timestamp
self.logger.info('Processing new entries from data source {}, last timestamp: {}'. self.logger.info(
format(data_source, 'Processing new entries from data source {}, last timestamp: {}'.format(
str(datetime.datetime.fromtimestamp(last_timestamp)))) data_source,
str(datetime.datetime.fromtimestamp(last_timestamp)),
)
)
data_points = get_plugin('google.fit').get_data( data_points = (
user_id=self.user_id, data_source_id=data_source).output get_plugin('google.fit')
.get_data(user_id=self.user_id, data_source_id=data_source)
.output
)
new_data_points = 0 new_data_points = 0
for dp in data_points: for dp in data_points:
@ -78,25 +86,34 @@ class GoogleFitBackend(Backend):
del dp['dataSourceId'] del dp['dataSourceId']
if dp_time > last_timestamp: if dp_time > last_timestamp:
self.bus.post(GoogleFitEvent( self.bus.post(
user_id=self.user_id, data_source_id=data_source, GoogleFitEvent(
user_id=self.user_id,
data_source_id=data_source,
data_type=dp.pop('dataTypeName'), data_type=dp.pop('dataTypeName'),
start_time=dp_time, start_time=dp_time,
end_time=dp.pop('endTime'), end_time=dp.pop('endTime'),
modified_time=dp.pop('modifiedTime'), modified_time=dp.pop('modifiedTime'),
values=dp.pop('values'), values=dp.pop('values'),
**{camel_case_to_snake_case(k): v **{
for k, v in dp.items()} camel_case_to_snake_case(k): v
)) for k, v in dp.items()
}
)
)
new_data_points += 1 new_data_points += 1
new_last_timestamp = max(dp_time, new_last_timestamp) new_last_timestamp = max(dp_time, new_last_timestamp)
last_timestamp = new_last_timestamp last_timestamp = new_last_timestamp
self.logger.info('Got {} new entries from data source {}, last timestamp: {}'. self.logger.info(
format(new_data_points, data_source, 'Got {} new entries from data source {}, last timestamp: {}'.format(
str(datetime.datetime.fromtimestamp(last_timestamp)))) new_data_points,
data_source,
str(datetime.datetime.fromtimestamp(last_timestamp)),
)
)
get_plugin('variable').set(**{varname: last_timestamp}) get_plugin('variable').set(**{varname: last_timestamp})
except Exception as e: except Exception as e:

View file

@ -12,16 +12,6 @@ class GooglePubsubBackend(Backend):
Subscribe to a list of topics on a Google Pub/Sub instance. See Subscribe to a list of topics on a Google Pub/Sub instance. See
:class:`platypush.plugins.google.pubsub.GooglePubsubPlugin` for a reference on how to generate your :class:`platypush.plugins.google.pubsub.GooglePubsubPlugin` for a reference on how to generate your
project and credentials file. project and credentials file.
Triggers:
* :class:`platypush.message.event.google.pubsub.GooglePubsubMessageEvent` when a new message is received on
a subscribed topic.
Requires:
* **google-cloud-pubsub** (``pip install google-cloud-pubsub``)
""" """
def __init__( def __init__(

View file

@ -9,17 +9,6 @@ class GpsBackend(Backend):
""" """
This backend can interact with a GPS device and listen for events. This backend can interact with a GPS device and listen for events.
Triggers:
* :class:`platypush.message.event.gps.GPSVersionEvent` when a GPS device advertises its version data
* :class:`platypush.message.event.gps.GPSDeviceEvent` when a GPS device is connected or updated
* :class:`platypush.message.event.gps.GPSUpdateEvent` when a GPS device has new data
Requires:
* **gps** (``pip install gps``)
* **gpsd** daemon running (``apt-get install gpsd`` or ``pacman -S gpsd`` depending on your distro)
Once installed gpsd you need to run it and associate it to your device. Example if your GPS device communicates Once installed gpsd you need to run it and associate it to your device. Example if your GPS device communicates
over USB and is available on /dev/ttyUSB0:: over USB and is available on /dev/ttyUSB0::
@ -52,41 +41,68 @@ class GpsBackend(Backend):
with self._session_lock: with self._session_lock:
if not self._session: if not self._session:
self._session = gps.gps(host=self.gpsd_server, port=self.gpsd_port, reconnect=True) self._session = gps.gps(
host=self.gpsd_server, port=self.gpsd_port, reconnect=True
)
self._session.stream(gps.WATCH_ENABLE | gps.WATCH_NEWSTYLE) self._session.stream(gps.WATCH_ENABLE | gps.WATCH_NEWSTYLE)
return self._session return self._session
def _gps_report_to_event(self, report): def _gps_report_to_event(self, report):
if report.get('class').lower() == 'version': if report.get('class').lower() == 'version':
return GPSVersionEvent(release=report.get('release'), return GPSVersionEvent(
release=report.get('release'),
rev=report.get('rev'), rev=report.get('rev'),
proto_major=report.get('proto_major'), proto_major=report.get('proto_major'),
proto_minor=report.get('proto_minor')) proto_minor=report.get('proto_minor'),
)
if report.get('class').lower() == 'devices': if report.get('class').lower() == 'devices':
for device in report.get('devices', []): for device in report.get('devices', []):
if device.get('path') not in self._devices or device != self._devices.get('path'): if device.get(
'path'
) not in self._devices or device != self._devices.get('path'):
# noinspection DuplicatedCode # noinspection DuplicatedCode
self._devices[device.get('path')] = device self._devices[device.get('path')] = device
return GPSDeviceEvent(path=device.get('path'), activated=device.get('activated'), return GPSDeviceEvent(
native=device.get('native'), bps=device.get('bps'), path=device.get('path'),
parity=device.get('parity'), stopbits=device.get('stopbits'), activated=device.get('activated'),
cycle=device.get('cycle'), driver=device.get('driver')) native=device.get('native'),
bps=device.get('bps'),
parity=device.get('parity'),
stopbits=device.get('stopbits'),
cycle=device.get('cycle'),
driver=device.get('driver'),
)
if report.get('class').lower() == 'device': if report.get('class').lower() == 'device':
# noinspection DuplicatedCode # noinspection DuplicatedCode
self._devices[report.get('path')] = report self._devices[report.get('path')] = report
return GPSDeviceEvent(path=report.get('path'), activated=report.get('activated'), return GPSDeviceEvent(
native=report.get('native'), bps=report.get('bps'), path=report.get('path'),
parity=report.get('parity'), stopbits=report.get('stopbits'), activated=report.get('activated'),
cycle=report.get('cycle'), driver=report.get('driver')) native=report.get('native'),
bps=report.get('bps'),
parity=report.get('parity'),
stopbits=report.get('stopbits'),
cycle=report.get('cycle'),
driver=report.get('driver'),
)
if report.get('class').lower() == 'tpv': if report.get('class').lower() == 'tpv':
return GPSUpdateEvent(device=report.get('device'), latitude=report.get('lat'), longitude=report.get('lon'), return GPSUpdateEvent(
altitude=report.get('alt'), mode=report.get('mode'), epv=report.get('epv'), device=report.get('device'),
eph=report.get('eph'), sep=report.get('sep')) latitude=report.get('lat'),
longitude=report.get('lon'),
altitude=report.get('alt'),
mode=report.get('mode'),
epv=report.get('epv'),
eph=report.get('eph'),
sep=report.get('sep'),
)
def run(self): def run(self):
super().run() super().run()
self.logger.info('Initialized GPS backend on {}:{}'.format(self.gpsd_server, self.gpsd_port)) self.logger.info(
'Initialized GPS backend on {}:{}'.format(self.gpsd_server, self.gpsd_port)
)
last_event = None last_event = None
while not self.should_stop(): while not self.should_stop():
@ -94,15 +110,31 @@ class GpsBackend(Backend):
session = self._get_session() session = self._get_session()
report = session.next() report = session.next()
event = self._gps_report_to_event(report) event = self._gps_report_to_event(report)
if event and (last_event is None or if event and (
abs((last_event.args.get('latitude') or 0) - (event.args.get('latitude') or 0)) >= self._lat_lng_tolerance or last_event is None
abs((last_event.args.get('longitude') or 0) - (event.args.get('longitude') or 0)) >= self._lat_lng_tolerance or or abs(
abs((last_event.args.get('altitude') or 0) - (event.args.get('altitude') or 0)) >= self._alt_tolerance): (last_event.args.get('latitude') or 0)
- (event.args.get('latitude') or 0)
)
>= self._lat_lng_tolerance
or abs(
(last_event.args.get('longitude') or 0)
- (event.args.get('longitude') or 0)
)
>= self._lat_lng_tolerance
or abs(
(last_event.args.get('altitude') or 0)
- (event.args.get('altitude') or 0)
)
>= self._alt_tolerance
):
self.bus.post(event) self.bus.post(event)
last_event = event last_event = event
except Exception as e: except Exception as e:
if isinstance(e, StopIteration): if isinstance(e, StopIteration):
self.logger.warning('GPS service connection lost, check that gpsd is running') self.logger.warning(
'GPS service connection lost, check that gpsd is running'
)
else: else:
self.logger.exception(e) self.logger.exception(e)

View file

@ -40,15 +40,6 @@ class RssUpdates(HttpRequest):
poll_seconds: 86400 # Poll once a day poll_seconds: 86400 # Poll once a day
digest_format: html # Generate an HTML feed with the new items digest_format: html # Generate an HTML feed with the new items
Triggers:
- :class:`platypush.message.event.http.rss.NewFeedEvent` when new items are parsed from a feed or a new digest
is available.
Requires:
* **feedparser** (``pip install feedparser``)
""" """
user_agent = ( user_agent = (

View file

@ -1,101 +0,0 @@
import os
from platypush.backend import Backend
from platypush.message.event.inotify import InotifyCreateEvent, InotifyDeleteEvent, \
InotifyOpenEvent, InotifyModifyEvent, InotifyCloseEvent, InotifyAccessEvent, InotifyMovedEvent
class InotifyBackend(Backend):
"""
**NOTE**: This backend is *deprecated* in favour of :class:`platypush.backend.file.monitor.FileMonitorBackend`.
(Linux only) This backend will listen for events on the filesystem (whether
a file/directory on a watch list is opened, modified, created, deleted,
closed or had its permissions changed) and will trigger a relevant event.
Triggers:
* :class:`platypush.message.event.inotify.InotifyCreateEvent` if a resource is created
* :class:`platypush.message.event.inotify.InotifyAccessEvent` if a resource is accessed
* :class:`platypush.message.event.inotify.InotifyOpenEvent` if a resource is opened
* :class:`platypush.message.event.inotify.InotifyModifyEvent` if a resource is modified
* :class:`platypush.message.event.inotify.InotifyPermissionsChangeEvent` if the permissions of a resource are changed
* :class:`platypush.message.event.inotify.InotifyCloseEvent` if a resource is closed
* :class:`platypush.message.event.inotify.InotifyDeleteEvent` if a resource is removed
Requires:
* **inotify** (``pip install inotify``)
"""
inotify_watch = None
def __init__(self, watch_paths=None, **kwargs):
"""
:param watch_paths: Filesystem resources to watch for events
:type watch_paths: str
"""
super().__init__(**kwargs)
self.watch_paths = set(map(
lambda path: os.path.abspath(os.path.expanduser(path)),
watch_paths if watch_paths else []))
def _cleanup(self):
if not self.inotify_watch:
return
for path in self.watch_paths:
self.inotify_watch.remove_watch(path)
self.inotify_watch = None
def run(self):
import inotify.adapters
super().run()
self.inotify_watch = inotify.adapters.Inotify()
for path in self.watch_paths:
self.inotify_watch.add_watch(path)
moved_file = None
self.logger.info('Initialized inotify file monitoring backend, monitored resources: {}'
.format(self.watch_paths))
try:
for inotify_event in self.inotify_watch.event_gen():
if inotify_event is not None:
(header, inotify_types, watch_path, filename) = inotify_event
event = None
resource_type = inotify_types[1] if len(inotify_types) > 1 else None
if moved_file:
new = filename if 'IN_MOVED_TO' in inotify_types else None
event = InotifyMovedEvent(path=watch_path, old=moved_file, new=new)
moved_file = None
if 'IN_OPEN' in inotify_types:
event = InotifyOpenEvent(path=watch_path, resource=filename, resource_type=resource_type)
elif 'IN_ACCESS' in inotify_types:
event = InotifyAccessEvent(path=watch_path, resource=filename, resource_type=resource_type)
elif 'IN_CREATE' in inotify_types:
event = InotifyCreateEvent(path=watch_path, resource=filename, resource_type=resource_type)
elif 'IN_MOVED_FROM' in inotify_types:
moved_file = filename
elif 'IN_MOVED_TO' in inotify_types and not moved_file:
event = InotifyMovedEvent(path=watch_path, old=None, new=filename)
elif 'IN_DELETE' in inotify_types:
event = InotifyDeleteEvent(path=watch_path, resource=filename, resource_type=resource_type)
elif 'IN_MODIFY' in inotify_types:
event = InotifyModifyEvent(path=watch_path, resource=filename, resource_type=resource_type)
elif 'IN_CLOSE_WRITE' in inotify_types or 'IN_CLOSE_NOWRITE' in inotify_types:
event = InotifyCloseEvent(path=watch_path, resource=filename, resource_type=resource_type)
if event:
self.bus.post(event)
finally:
self._cleanup()
# vim:sw=4:ts=4:et:

View file

@ -1,21 +0,0 @@
manifest:
events:
platypush.message.event.inotify.InotifyAccessEvent: if a resource is accessed
platypush.message.event.inotify.InotifyCloseEvent: if a resource is closed
platypush.message.event.inotify.InotifyCreateEvent: if a resource is created
platypush.message.event.inotify.InotifyDeleteEvent: if a resource is removed
platypush.message.event.inotify.InotifyModifyEvent: if a resource is modified
platypush.message.event.inotify.InotifyOpenEvent: if a resource is opened
platypush.message.event.inotify.InotifyPermissionsChangeEvent: if the permissions
of a resource are changed
install:
apk:
- py3-inotify
apt:
- python3-inotify
dnf:
- python-inotify
pip:
- inotify
package: platypush.backend.inotify
type: backend

View file

@ -8,14 +8,6 @@ class JoystickBackend(Backend):
""" """
This backend will listen for events from a joystick device and post a This backend will listen for events from a joystick device and post a
JoystickEvent whenever a new event is captured. JoystickEvent whenever a new event is captured.
Triggers:
* :class:`platypush.message.event.joystick.JoystickEvent` when a new joystick event is received
Requires:
* **inputs** (``pip install inputs``)
""" """
def __init__(self, device, *args, **kwargs): def __init__(self, device, *args, **kwargs):
@ -32,7 +24,9 @@ class JoystickBackend(Backend):
import inputs import inputs
super().run() super().run()
self.logger.info('Initialized joystick backend on device {}'.format(self.device)) self.logger.info(
'Initialized joystick backend on device {}'.format(self.device)
)
while not self.should_stop(): while not self.should_stop():
try: try:

View file

@ -6,8 +6,14 @@ import time
from typing import Optional, List from typing import Optional, List
from platypush.backend import Backend from platypush.backend import Backend
from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, JoystickStateEvent, \ from platypush.message.event.joystick import (
JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent JoystickConnectedEvent,
JoystickDisconnectedEvent,
JoystickStateEvent,
JoystickButtonPressedEvent,
JoystickButtonReleasedEvent,
JoystickAxisEvent,
)
class JoystickState: class JoystickState:
@ -38,9 +44,7 @@ class JoystickState:
}, },
} }
return { return {k: v for k, v in diff.items() if v}
k: v for k, v in diff.items() if v
}
class JoystickJstestBackend(Backend): class JoystickJstestBackend(Backend):
@ -49,35 +53,17 @@ class JoystickJstestBackend(Backend):
:class:`platypush.backend.joystick.JoystickBackend` backend (this may especially happen with some Bluetooth :class:`platypush.backend.joystick.JoystickBackend` backend (this may especially happen with some Bluetooth
joysticks that don't support the ``ioctl`` requests used by ``inputs``). 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. 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. **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` Consider it as a last resort if your joystick works with neither :class:`platypush.backend.joystick.JoystickBackend`
nor :class:`platypush.backend.joystick.JoystickLinuxBackend`. nor :class:`platypush.backend.joystick.JoystickLinuxBackend`.
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 To test if your joystick is compatible, connect it to your device, check for its path (usually under
``/dev/input/js*``) and run:: ``/dev/input/js*``) and run::
$ jstest /dev/input/js[n] $ 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_axes_regex = re.compile(r'Axes:\s+(((\d+):\s*([\-\d]+)\s*)+)')
@ -85,10 +71,12 @@ class JoystickJstestBackend(Backend):
js_axis_regex = re.compile(r'^\s*(\d+):\s*([\-\d]+)\s*(.*)') js_axis_regex = re.compile(r'^\s*(\d+):\s*([\-\d]+)\s*(.*)')
js_button_regex = re.compile(r'^\s*(\d+):\s*(on|off)\s*(.*)') js_button_regex = re.compile(r'^\s*(\d+):\s*(on|off)\s*(.*)')
def __init__(self, def __init__(
self,
device: str = '/dev/input/js0', device: str = '/dev/input/js0',
jstest_path: str = '/usr/bin/jstest', jstest_path: str = '/usr/bin/jstest',
**kwargs): **kwargs,
):
""" """
:param device: Path to the joystick device (default: ``/dev/input/js0``). :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 :param jstest_path: Path to the ``jstest`` executable that comes with the ``joystick`` system package
@ -140,7 +128,11 @@ class JoystickJstestBackend(Backend):
if line.endswith('Axes: '): if line.endswith('Axes: '):
break break
while os.path.exists(self.device) and not self.should_stop() and len(axes) < len(self._state.axes): while (
os.path.exists(self.device)
and not self.should_stop()
and len(axes) < len(self._state.axes)
):
ch = ' ' ch = ' '
while ch == ' ': while ch == ' ':
ch = self._process.stdout.read(1).decode() ch = self._process.stdout.read(1).decode()
@ -174,7 +166,11 @@ class JoystickJstestBackend(Backend):
if line.endswith('Buttons: '): if line.endswith('Buttons: '):
break break
while os.path.exists(self.device) and not self.should_stop() and len(buttons) < len(self._state.buttons): while (
os.path.exists(self.device)
and not self.should_stop()
and len(buttons) < len(self._state.buttons)
):
ch = ' ' ch = ' '
while ch == ' ': while ch == ' ':
ch = self._process.stdout.read(1).decode() ch = self._process.stdout.read(1).decode()
@ -195,10 +191,12 @@ class JoystickJstestBackend(Backend):
return JoystickState(axes=axes, buttons=buttons) return JoystickState(axes=axes, buttons=buttons)
def _initialize(self): def _initialize(self):
while self._process.poll() is None and \ while (
os.path.exists(self.device) and \ self._process.poll() is None
not self.should_stop() and \ and os.path.exists(self.device)
not self._state: and not self.should_stop()
and not self._state
):
line = b'' line = b''
ch = None ch = None
@ -243,7 +241,9 @@ class JoystickJstestBackend(Backend):
self.bus.post(JoystickStateEvent(device=self.device, **state.__dict__)) self.bus.post(JoystickStateEvent(device=self.device, **state.__dict__))
for button, pressed in diff.get('buttons', {}).items(): for button, pressed in diff.get('buttons', {}).items():
evt_class = JoystickButtonPressedEvent if pressed else JoystickButtonReleasedEvent evt_class = (
JoystickButtonPressedEvent if pressed else JoystickButtonReleasedEvent
)
self.bus.post(evt_class(device=self.device, button=button)) self.bus.post(evt_class(device=self.device, button=button))
for axis, value in diff.get('axes', {}).items(): for axis, value in diff.get('axes', {}).items():
@ -259,8 +259,8 @@ class JoystickJstestBackend(Backend):
self._wait_ready() self._wait_ready()
with subprocess.Popen( with subprocess.Popen(
[self.jstest_path, '--normal', self.device], [self.jstest_path, '--normal', self.device], stdout=subprocess.PIPE
stdout=subprocess.PIPE) as self._process: ) as self._process:
self.logger.info('Device opened') self.logger.info('Device opened')
self._initialize() self._initialize()
@ -268,7 +268,9 @@ class JoystickJstestBackend(Backend):
break break
for state in self._read_states(): for state in self._read_states():
if self._process.poll() is not None or not os.path.exists(self.device): if self._process.poll() is not None or not os.path.exists(
self.device
):
self.logger.warning(f'Connection to {self.device} lost') self.logger.warning(f'Connection to {self.device} lost')
self.bus.post(JoystickDisconnectedEvent(self.device)) self.bus.post(JoystickDisconnectedEvent(self.device))
break break

View file

@ -1,17 +1,11 @@
manifest: manifest:
events: events:
platypush.message.event.joystick.JoystickAxisEvent: when an axis value of the - platypush.message.event.joystick.JoystickAxisEvent
joystick changes. - platypush.message.event.joystick.JoystickButtonPressedEvent
platypush.message.event.joystick.JoystickButtonPressedEvent: when a joystick button - platypush.message.event.joystick.JoystickButtonReleasedEvent
is pressed. - platypush.message.event.joystick.JoystickConnectedEvent
platypush.message.event.joystick.JoystickButtonReleasedEvent: when a joystick - platypush.message.event.joystick.JoystickDisconnectedEvent
button is released. - platypush.message.event.joystick.JoystickStateEvent
platypush.message.event.joystick.JoystickConnectedEvent: when the joystick is
connected.
platypush.message.event.joystick.JoystickDisconnectedEvent: when the joystick
is disconnected.
platypush.message.event.joystick.JoystickStateEvent: when the state of the joystick
(i.e. some of itsaxes or buttons values) changes.
install: install:
apk: apk:
- linuxconsoletools - linuxconsoletools

View file

@ -5,8 +5,13 @@ from fcntl import ioctl
from typing import IO from typing import IO
from platypush.backend import Backend from platypush.backend import Backend
from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, \ from platypush.message.event.joystick import (
JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent JoystickConnectedEvent,
JoystickDisconnectedEvent,
JoystickButtonPressedEvent,
JoystickButtonReleasedEvent,
JoystickAxisEvent,
)
class JoystickLinuxBackend(Backend): class JoystickLinuxBackend(Backend):
@ -16,15 +21,6 @@ class JoystickLinuxBackend(Backend):
It is loosely based on https://gist.github.com/rdb/8864666, which itself uses the 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 `Linux kernel joystick API <https://www.kernel.org/doc/Documentation/input/joystick-api.txt>`_ to interact with
the devices. 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 # These constants were borrowed from linux/input.h
@ -39,7 +35,7 @@ class JoystickLinuxBackend(Backend):
0x07: 'rudder', 0x07: 'rudder',
0x08: 'wheel', 0x08: 'wheel',
0x09: 'gas', 0x09: 'gas',
0x0a: 'brake', 0x0A: 'brake',
0x10: 'hat0x', 0x10: 'hat0x',
0x11: 'hat0y', 0x11: 'hat0y',
0x12: 'hat1x', 0x12: 'hat1x',
@ -50,9 +46,9 @@ class JoystickLinuxBackend(Backend):
0x17: 'hat3y', 0x17: 'hat3y',
0x18: 'pressure', 0x18: 'pressure',
0x19: 'distance', 0x19: 'distance',
0x1a: 'tilt_x', 0x1A: 'tilt_x',
0x1b: 'tilt_y', 0x1B: 'tilt_y',
0x1c: 'tool_width', 0x1C: 'tool_width',
0x20: 'volume', 0x20: 'volume',
0x28: 'misc', 0x28: 'misc',
} }
@ -68,9 +64,9 @@ class JoystickLinuxBackend(Backend):
0x127: 'base2', 0x127: 'base2',
0x128: 'base3', 0x128: 'base3',
0x129: 'base4', 0x129: 'base4',
0x12a: 'base5', 0x12A: 'base5',
0x12b: 'base6', 0x12B: 'base6',
0x12f: 'dead', 0x12F: 'dead',
0x130: 'a', 0x130: 'a',
0x131: 'b', 0x131: 'b',
0x132: 'c', 0x132: 'c',
@ -81,20 +77,20 @@ class JoystickLinuxBackend(Backend):
0x137: 'tr', 0x137: 'tr',
0x138: 'tl2', 0x138: 'tl2',
0x139: 'tr2', 0x139: 'tr2',
0x13a: 'select', 0x13A: 'select',
0x13b: 'start', 0x13B: 'start',
0x13c: 'mode', 0x13C: 'mode',
0x13d: 'thumbl', 0x13D: 'thumbl',
0x13e: 'thumbr', 0x13E: 'thumbr',
0x220: 'dpad_up', 0x220: 'dpad_up',
0x221: 'dpad_down', 0x221: 'dpad_down',
0x222: 'dpad_left', 0x222: 'dpad_left',
0x223: 'dpad_right', 0x223: 'dpad_right',
# XBox 360 controller uses these codes. # XBox 360 controller uses these codes.
0x2c0: 'dpad_left', 0x2C0: 'dpad_left',
0x2c1: 'dpad_right', 0x2C1: 'dpad_right',
0x2c2: 'dpad_up', 0x2C2: 'dpad_up',
0x2c3: 'dpad_down', 0x2C3: 'dpad_down',
} }
def __init__(self, device: str = '/dev/input/js0', *args, **kwargs): def __init__(self, device: str = '/dev/input/js0', *args, **kwargs):
@ -111,21 +107,21 @@ class JoystickLinuxBackend(Backend):
def _init_joystick(self, dev: IO): def _init_joystick(self, dev: IO):
# Get the device name. # Get the device name.
buf = array.array('B', [0] * 64) buf = array.array('B', [0] * 64)
ioctl(dev, 0x80006a13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len) ioctl(dev, 0x80006A13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len)
js_name = buf.tobytes().rstrip(b'\x00').decode('utf-8') js_name = buf.tobytes().rstrip(b'\x00').decode('utf-8')
# Get number of axes and buttons. # Get number of axes and buttons.
buf = array.array('B', [0]) buf = array.array('B', [0])
ioctl(dev, 0x80016a11, buf) # JSIOCGAXES ioctl(dev, 0x80016A11, buf) # JSIOCGAXES
num_axes = buf[0] num_axes = buf[0]
buf = array.array('B', [0]) buf = array.array('B', [0])
ioctl(dev, 0x80016a12, buf) # JSIOCGBUTTONS ioctl(dev, 0x80016A12, buf) # JSIOCGBUTTONS
num_buttons = buf[0] num_buttons = buf[0]
# Get the axis map. # Get the axis map.
buf = array.array('B', [0] * 0x40) buf = array.array('B', [0] * 0x40)
ioctl(dev, 0x80406a32, buf) # JSIOCGAXMAP ioctl(dev, 0x80406A32, buf) # JSIOCGAXMAP
for axis in buf[:num_axes]: for axis in buf[:num_axes]:
axis_name = self.axis_names.get(axis, 'unknown(0x%02x)' % axis) axis_name = self.axis_names.get(axis, 'unknown(0x%02x)' % axis)
@ -134,15 +130,21 @@ class JoystickLinuxBackend(Backend):
# Get the button map. # Get the button map.
buf = array.array('H', [0] * 200) buf = array.array('H', [0] * 200)
ioctl(dev, 0x80406a34, buf) # JSIOCGBTNMAP ioctl(dev, 0x80406A34, buf) # JSIOCGBTNMAP
for btn in buf[:num_buttons]: for btn in buf[:num_buttons]:
btn_name = self.button_names.get(btn, 'unknown(0x%03x)' % btn) btn_name = self.button_names.get(btn, 'unknown(0x%03x)' % btn)
self._button_map.append(btn_name) self._button_map.append(btn_name)
self._button_states[btn_name] = 0 self._button_states[btn_name] = 0
self.bus.post(JoystickConnectedEvent(device=self.device, name=js_name, axes=self._axis_map, self.bus.post(
buttons=self._button_map)) JoystickConnectedEvent(
device=self.device,
name=js_name,
axes=self._axis_map,
buttons=self._button_map,
)
)
def run(self): def run(self):
super().run() super().run()
@ -151,13 +153,16 @@ class JoystickLinuxBackend(Backend):
while not self.should_stop(): while not self.should_stop():
# Open the joystick device. # Open the joystick device.
try: try:
jsdev = open(self.device, 'rb') jsdev = open(self.device, 'rb') # noqa
self._init_joystick(jsdev) self._init_joystick(jsdev)
except Exception as e: except Exception as e:
self.logger.debug(f'Joystick device on {self.device} not available: {e}') self.logger.debug(
'Joystick device on %s not available: %s', self.device, e
)
time.sleep(5) time.sleep(5)
continue continue
try:
# Joystick event loop # Joystick event loop
while not self.should_stop(): while not self.should_stop():
try: try:
@ -172,9 +177,15 @@ class JoystickLinuxBackend(Backend):
button = self._button_map[number] button = self._button_map[number]
if button: if button:
self._button_states[button] = value self._button_states[button] = value
evt_class = JoystickButtonPressedEvent if value else JoystickButtonReleasedEvent evt_class = (
JoystickButtonPressedEvent
if value
else JoystickButtonReleasedEvent
)
# noinspection PyTypeChecker # noinspection PyTypeChecker
self.bus.post(evt_class(device=self.device, button=button)) self.bus.post(
evt_class(device=self.device, button=button)
)
if evt_type & 0x02: if evt_type & 0x02:
axis = self._axis_map[number] axis = self._axis_map[number]
@ -182,8 +193,14 @@ class JoystickLinuxBackend(Backend):
fvalue = value / 32767.0 fvalue = value / 32767.0
self._axis_states[axis] = fvalue self._axis_states[axis] = fvalue
# noinspection PyTypeChecker # noinspection PyTypeChecker
self.bus.post(JoystickAxisEvent(device=self.device, axis=axis, value=fvalue)) self.bus.post(
JoystickAxisEvent(
device=self.device, axis=axis, value=fvalue
)
)
except OSError as e: except OSError as e:
self.logger.warning(f'Connection to {self.device} lost: {e}') self.logger.warning(f'Connection to {self.device} lost: {e}')
self.bus.post(JoystickDisconnectedEvent(device=self.device)) self.bus.post(JoystickDisconnectedEvent(device=self.device))
break break
finally:
jsdev.close()

View file

@ -11,10 +11,6 @@ class KafkaBackend(Backend):
""" """
Backend to interact with an Apache Kafka (https://kafka.apache.org/) Backend to interact with an Apache Kafka (https://kafka.apache.org/)
streaming platform, send and receive messages. streaming platform, send and receive messages.
Requires:
* **kafka** (``pip install kafka-python``)
""" """
_conn_retry_secs = 5 _conn_retry_secs = 5
@ -24,7 +20,9 @@ class KafkaBackend(Backend):
:param server: Kafka server name or address + port (default: ``localhost:9092``) :param server: Kafka server name or address + port (default: ``localhost:9092``)
:type server: str :type server: str
:param topic: (Prefix) topic to listen to (default: platypush). The Platypush device_id (by default the hostname) will be appended to the topic (the real topic name will e.g. be "platypush.my_rpi") :param topic: (Prefix) topic to listen to (default: platypush). The
Platypush device_id (by default the hostname) will be appended to
the topic (the real topic name will e.g. be "platypush.my_rpi")
:type topic: str :type topic: str
""" """
@ -40,7 +38,8 @@ class KafkaBackend(Backend):
logging.getLogger('kafka').setLevel(logging.ERROR) logging.getLogger('kafka').setLevel(logging.ERROR)
def _on_record(self, record): def _on_record(self, record):
if record.topic != self.topic: return if record.topic != self.topic:
return
msg = record.value.decode('utf-8') msg = record.value.decode('utf-8')
is_platypush_message = False is_platypush_message = False
@ -60,12 +59,12 @@ class KafkaBackend(Backend):
def _topic_by_device_id(self, device_id): def _topic_by_device_id(self, device_id):
return '{}.{}'.format(self.topic_prefix, device_id) return '{}.{}'.format(self.topic_prefix, device_id)
def send_message(self, msg, **kwargs): def send_message(self, msg, **_):
target = msg.target target = msg.target
kafka_plugin = get_plugin('kafka') kafka_plugin = get_plugin('kafka')
kafka_plugin.send_message(msg=msg, kafka_plugin.send_message(
topic=self._topic_by_device_id(target), msg=msg, topic=self._topic_by_device_id(target), server=self.server
server=self.server) )
def on_stop(self): def on_stop(self):
super().on_stop() super().on_stop()
@ -82,21 +81,29 @@ class KafkaBackend(Backend):
def run(self): def run(self):
from kafka import KafkaConsumer from kafka import KafkaConsumer
super().run() super().run()
self.consumer = KafkaConsumer(self.topic, bootstrap_servers=self.server) self.consumer = KafkaConsumer(self.topic, bootstrap_servers=self.server)
self.logger.info('Initialized kafka backend - server: {}, topic: {}' self.logger.info(
.format(self.server, self.topic)) 'Initialized kafka backend - server: {}, topic: {}'.format(
self.server, self.topic
)
)
try: try:
for msg in self.consumer: for msg in self.consumer:
self._on_record(msg) self._on_record(msg)
if self.should_stop(): break if self.should_stop():
break
except Exception as e: except Exception as e:
self.logger.warning('Kafka connection error, reconnecting in {} seconds'. self.logger.warning(
format(self._conn_retry_secs)) 'Kafka connection error, reconnecting in {} seconds'.format(
self._conn_retry_secs
)
)
self.logger.exception(e) self.logger.exception(e)
time.sleep(self._conn_retry_secs) time.sleep(self._conn_retry_secs)
# vim:sw=4:ts=4:et:
# vim:sw=4:ts=4:et:

View file

@ -7,7 +7,11 @@ from logging import getLogger
from threading import RLock from threading import RLock
from typing import List, Optional, Iterable from typing import List, Optional, Iterable
from platypush.backend.file.monitor import FileMonitorBackend, EventHandler, MonitoredResource from platypush.backend.file.monitor import (
FileMonitorBackend,
EventHandler,
MonitoredResource,
)
from platypush.context import get_bus from platypush.context import get_bus
from platypush.message.event.log.http import HttpLogEvent from platypush.message.event.log.http import HttpLogEvent
@ -15,8 +19,10 @@ logger = getLogger(__name__)
class LogEventHandler(EventHandler): class LogEventHandler(EventHandler):
http_line_regex = re.compile(r'^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[([^]]+)]\s+"([^"]+)"\s+([\d]+)\s+' http_line_regex = re.compile(
r'([\d]+)\s*("([^"\s]+)")?\s*("([^"]+)")?$') r'^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[([^]]+)]\s+"([^"]+)"\s+([\d]+)\s+'
r'([\d]+)\s*("([^"\s]+)")?\s*("([^"]+)")?$'
)
@dataclass @dataclass
class FileResource: class FileResource:
@ -25,16 +31,17 @@ class LogEventHandler(EventHandler):
lock: RLock = RLock() lock: RLock = RLock()
last_timestamp: Optional[datetime.datetime] = None last_timestamp: Optional[datetime.datetime] = None
def __init__(self, *args, monitored_files: Optional[Iterable[str]] = None, **kwargs): def __init__(
self, *args, monitored_files: Optional[Iterable[str]] = None, **kwargs
):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._monitored_files = {} self._monitored_files = {}
self.monitor_files(monitored_files or []) self.monitor_files(monitored_files or [])
def monitor_files(self, files: Iterable[str]): def monitor_files(self, files: Iterable[str]):
self._monitored_files.update({ self._monitored_files.update(
f: self.FileResource(path=f, pos=self._get_size(f)) {f: self.FileResource(path=f, pos=self._get_size(f)) for f in files}
for f in files )
})
@staticmethod @staticmethod
def _get_size(file: str) -> int: def _get_size(file: str) -> int:
@ -68,12 +75,17 @@ class LogEventHandler(EventHandler):
try: try:
file_size = os.path.getsize(event.src_path) file_size = os.path.getsize(event.src_path)
except OSError as e: except OSError as e:
logger.warning('Could not get the size of {}: {}'.format(event.src_path, str(e))) logger.warning(
'Could not get the size of {}: {}'.format(event.src_path, str(e))
)
return return
if file_info.pos > file_size: if file_info.pos > file_size:
logger.warning('The size of {} been unexpectedly decreased from {} to {} bytes'.format( logger.warning(
event.src_path, file_info.pos, file_size)) 'The size of {} been unexpectedly decreased from {} to {} bytes'.format(
event.src_path, file_info.pos, file_size
)
)
file_info.pos = 0 file_info.pos = 0
try: try:
@ -81,13 +93,18 @@ class LogEventHandler(EventHandler):
f.seek(file_info.pos) f.seek(file_info.pos)
for line in f.readlines(): for line in f.readlines():
evt = self._build_event(file=event.src_path, line=line) evt = self._build_event(file=event.src_path, line=line)
if evt and (not file_info.last_timestamp or evt.args['time'] >= file_info.last_timestamp): if evt and (
not file_info.last_timestamp
or evt.args['time'] >= file_info.last_timestamp
):
get_bus().post(evt) get_bus().post(evt)
file_info.last_timestamp = evt.args['time'] file_info.last_timestamp = evt.args['time']
file_info.pos = f.tell() file_info.pos = f.tell()
except OSError as e: except OSError as e:
logger.warning('Error while reading from {}: {}'.format(self.resource.path, str(e))) logger.warning(
'Error while reading from {}: {}'.format(self.resource.path, str(e))
)
@classmethod @classmethod
def _build_event(cls, file: str, line: str) -> Optional[HttpLogEvent]: def _build_event(cls, file: str, line: str) -> Optional[HttpLogEvent]:
@ -139,15 +156,6 @@ class LogHttpBackend(FileMonitorBackend):
""" """
This backend can be used to monitor one or more HTTP log files (tested on Apache and Nginx) and trigger events This backend can be used to monitor one or more HTTP log files (tested on Apache and Nginx) and trigger events
whenever a new log line is added. whenever a new log line is added.
Triggers:
* :class:`platypush.message.event.log.http.HttpLogEvent` when a new log line is created.
Requires:
* **watchdog** (``pip install watchdog``)
""" """
class EventHandlerFactory: class EventHandlerFactory:

View file

@ -60,14 +60,6 @@ class MailBackend(Backend):
It requires at least one plugin that extends :class:`platypush.plugins.mail.MailInPlugin` (e.g. ``mail.imap``) to It requires at least one plugin that extends :class:`platypush.plugins.mail.MailInPlugin` (e.g. ``mail.imap``) to
be installed. be installed.
Triggers:
- :class:`platypush.message.event.mail.MailReceivedEvent` when a new message is received.
- :class:`platypush.message.event.mail.MailSeenEvent` when a message is marked as seen.
- :class:`platypush.message.event.mail.MailFlaggedEvent` when a message is marked as flagged/starred.
- :class:`platypush.message.event.mail.MailUnflaggedEvent` when a message is marked as unflagged/unstarred.
""" """
def __init__( def __init__(

View file

@ -10,18 +10,16 @@ class MidiBackend(Backend):
""" """
This backend will listen for events from a MIDI device and post a This backend will listen for events from a MIDI device and post a
MidiMessageEvent whenever a new MIDI event happens. MidiMessageEvent whenever a new MIDI event happens.
Triggers:
* :class:`platypush.message.event.midi.MidiMessageEvent` when a new MIDI event is received
Requires:
* **rtmidi** (``pip install rtmidi``)
""" """
def __init__(self, device_name=None, port_number=None, def __init__(
midi_throttle_time=None, *args, **kwargs): self,
device_name=None,
port_number=None,
midi_throttle_time=None,
*args,
**kwargs
):
""" """
:param device_name: Name of the MIDI device. *N.B.* either :param device_name: Name of the MIDI device. *N.B.* either
`device_name` or `port_number` must be set. `device_name` or `port_number` must be set.
@ -40,12 +38,16 @@ class MidiBackend(Backend):
""" """
import rtmidi import rtmidi
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if (device_name and port_number is not None) or \ if (device_name and port_number is not None) or (
(not device_name and port_number is None): not device_name and port_number is None
raise RuntimeError('Either device_name or port_number (not both) ' + ):
'must be set in the MIDI backend configuration') raise RuntimeError(
'Either device_name or port_number (not both) '
+ 'must be set in the MIDI backend configuration'
)
self.midi_throttle_time = midi_throttle_time self.midi_throttle_time = midi_throttle_time
self.midi = rtmidi.MidiIn() self.midi = rtmidi.MidiIn()
@ -75,9 +77,12 @@ class MidiBackend(Backend):
def _on_midi_message(self): def _on_midi_message(self):
def flush_midi_message(message): def flush_midi_message(message):
def _f(): def _f():
self.logger.info('Flushing throttled MIDI message {} to the bus'.format(message)) self.logger.info(
'Flushing throttled MIDI message {} to the bus'.format(message)
)
delay = time.time() - self.last_trigger_event_time delay = time.time() - self.last_trigger_event_time
self.bus.post(MidiMessageEvent(message=message, delay=delay)) self.bus.post(MidiMessageEvent(message=message, delay=delay))
return _f return _f
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@ -96,7 +101,8 @@ class MidiBackend(Backend):
self.midi_flush_timeout = Timer( self.midi_flush_timeout = Timer(
self.midi_throttle_time - event_delta, self.midi_throttle_time - event_delta,
flush_midi_message(message)) flush_midi_message(message),
)
self.midi_flush_timeout.start() self.midi_flush_timeout.start()
return return
@ -110,8 +116,11 @@ class MidiBackend(Backend):
super().run() super().run()
self.midi.open_port(self.port_number) self.midi.open_port(self.port_number)
self.logger.info('Initialized MIDI backend, listening for events on device {}'. self.logger.info(
format(self.device_name)) 'Initialized MIDI backend, listening for events on device {}'.format(
self.device_name
)
)
while not self.should_stop(): while not self.should_stop():
try: try:

View file

@ -1,475 +0,0 @@
import hashlib
import json
import os
import threading
from typing import Any, Dict, Optional, List, Callable
import paho.mqtt.client as mqtt
from platypush.backend import Backend
from platypush.config import Config
from platypush.context import get_plugin
from platypush.message import Message
from platypush.message.event.mqtt import MQTTMessageEvent
from platypush.message.request import Request
from platypush.plugins.mqtt import MqttPlugin as MQTTPlugin
class MqttClient(mqtt.Client, threading.Thread):
"""
Wrapper class for an MQTT client executed in a separate thread.
"""
def __init__(
self,
*args,
host: str,
port: int,
topics: Optional[List[str]] = None,
on_message: Optional[Callable] = None,
username: Optional[str] = None,
password: Optional[str] = None,
client_id: Optional[str] = None,
tls_cafile: Optional[str] = None,
tls_certfile: Optional[str] = None,
tls_keyfile: Optional[str] = None,
tls_version=None,
tls_ciphers=None,
tls_insecure: bool = False,
keepalive: Optional[int] = 60,
**kwargs,
):
mqtt.Client.__init__(self, *args, client_id=client_id, **kwargs)
threading.Thread.__init__(self)
self.name = f'MQTTClient:{client_id}'
self.host = host
self.port = port
self.topics = set(topics or [])
self.keepalive = keepalive
self.on_connect = self.connect_hndl()
if on_message:
self.on_message = on_message
if username and password:
self.username_pw_set(username, password)
if tls_cafile:
self.tls_set(
ca_certs=tls_cafile,
certfile=tls_certfile,
keyfile=tls_keyfile,
tls_version=tls_version,
ciphers=tls_ciphers,
)
self.tls_insecure_set(tls_insecure)
self._running = False
self._stop_scheduled = False
def subscribe(self, *topics, **kwargs):
"""
Client subscription handler.
"""
if not topics:
topics = self.topics
self.topics.update(topics)
for topic in topics:
super().subscribe(topic, **kwargs)
def unsubscribe(self, *topics, **kwargs):
"""
Client unsubscribe handler.
"""
if not topics:
topics = self.topics
for topic in topics:
super().unsubscribe(topic, **kwargs)
self.topics.remove(topic)
def connect_hndl(self):
def handler(*_, **__):
self.subscribe()
return handler
def run(self):
super().run()
self.connect(host=self.host, port=self.port, keepalive=self.keepalive)
self._running = True
self.loop_forever()
def stop(self):
if not self.is_alive():
return
self._stop_scheduled = True
self.disconnect()
self._running = False
class MqttBackend(Backend):
"""
Backend that reads messages from a configured MQTT topic (default:
``platypush_bus_mq/<device_id>``) and posts them to the application bus.
Triggers:
* :class:`platypush.message.event.mqtt.MQTTMessageEvent` when a new
message is received on one of the custom listeners
Requires:
* **paho-mqtt** (``pip install paho-mqtt``)
"""
_default_mqtt_port = 1883
def __init__(
self,
*args,
host: Optional[str] = None,
port: int = _default_mqtt_port,
topic: str = 'platypush_bus_mq',
subscribe_default_topic: bool = True,
tls_cafile: Optional[str] = None,
tls_certfile: Optional[str] = None,
tls_keyfile: Optional[str] = None,
tls_version: Optional[str] = None,
tls_ciphers: Optional[str] = None,
tls_insecure: bool = False,
username: Optional[str] = None,
password: Optional[str] = None,
client_id: Optional[str] = None,
listeners=None,
**kwargs,
):
"""
:param host: MQTT broker host. If no host configuration is specified then
the backend will use the host configuration specified on the ``mqtt``
plugin if it's available.
:param port: MQTT broker port (default: 1883)
:param topic: Topic to read messages from (default: ``platypush_bus_mq/<device_id>``)
:param subscribe_default_topic: Whether the backend should subscribe the default topic (default:
``platypush_bus_mq/<device_id>``) and execute the messages received there as action requests
(default: True).
:param tls_cafile: If TLS/SSL is enabled on the MQTT server and the certificate requires a certificate authority
to authenticate it, `ssl_cafile` will point to the provided ca.crt file (default: None)
:param tls_certfile: If TLS/SSL is enabled on the MQTT server and a client certificate it required, specify it
here (default: None)
:param tls_keyfile: If TLS/SSL is enabled on the MQTT server and a client certificate key it required,
specify it here (default: None) :type tls_keyfile: str
:param tls_version: If TLS/SSL is enabled on the MQTT server and it requires a certain TLS version, specify it
here (default: None). Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``, ``tlsv1.2``.
:param tls_ciphers: If TLS/SSL is enabled on the MQTT server and an explicit list of supported ciphers is
required, specify it here (default: None)
:param tls_insecure: Set to True to ignore TLS insecure warnings (default: False).
:param username: Specify it if the MQTT server requires authentication (default: None)
:param password: Specify it if the MQTT server requires authentication (default: None)
:param client_id: ID used to identify the client on the MQTT server (default: None).
If None is specified then ``Config.get('device_id')`` will be used.
:param listeners: If specified then the MQTT backend will also listen for
messages on the additional configured message queues. This parameter
is a list of maps where each item supports the same arguments passed
to the main backend configuration (host, port, topic, password etc.).
Note that the message queue configured on the main configuration
will expect valid Platypush messages that then can execute, while
message queues registered to the listeners will accept any message. Example::
listeners:
- host: localhost
topics:
- topic1
- topic2
- topic3
- host: sensors
topics:
- topic4
- topic5
"""
super().__init__(*args, **kwargs)
if host:
self.host = host
self.port = port
self.tls_cafile = self._expandpath(tls_cafile) if tls_cafile else None
self.tls_certfile = self._expandpath(tls_certfile) if tls_certfile else None
self.tls_keyfile = self._expandpath(tls_keyfile) if tls_keyfile else None
self.tls_version = MQTTPlugin.get_tls_version(tls_version)
self.tls_ciphers = tls_ciphers
self.tls_insecure = tls_insecure
self.username = username
self.password = password
self.client_id: str = client_id or Config.get('device_id')
else:
client = get_plugin('mqtt')
assert (
client.host
), 'No host specified on backend.mqtt nor mqtt configuration'
self.host = client.host
self.port = client.port
self.tls_cafile = client.tls_cafile
self.tls_certfile = client.tls_certfile
self.tls_keyfile = client.tls_keyfile
self.tls_version = client.tls_version
self.tls_ciphers = client.tls_ciphers
self.tls_insecure = client.tls_insecure
self.username = client.username
self.password = client.password
self.client_id = client_id or client.client_id
self.topic = f'{topic}/{self.device_id}'
self.subscribe_default_topic = subscribe_default_topic
self._listeners: Dict[str, MqttClient] = {} # client_id -> MqttClient map
self.listeners_conf = listeners or []
def send_message(self, msg, *_, topic: Optional[str] = None, **kwargs):
try:
client = get_plugin('mqtt')
client.send_message(
topic=topic or self.topic,
msg=msg,
host=self.host,
port=self.port,
username=self.username,
password=self.password,
tls_cafile=self.tls_cafile,
tls_certfile=self.tls_certfile,
tls_keyfile=self.tls_keyfile,
tls_version=self.tls_version,
tls_insecure=self.tls_insecure,
tls_ciphers=self.tls_ciphers,
**kwargs,
)
except Exception as e:
self.logger.exception(e)
@staticmethod
def _expandpath(path: str) -> str:
return os.path.abspath(os.path.expanduser(path)) if path else path
def add_listeners(self, *listeners):
for i, listener in enumerate(listeners):
host = listener.get('host', self.host)
port = listener.get('port', self.port)
username = listener.get('username', self.username)
password = listener.get('password', self.password)
tls_cafile = self._expandpath(listener.get('tls_cafile', self.tls_cafile))
tls_certfile = self._expandpath(
listener.get('tls_certfile', self.tls_certfile)
)
tls_keyfile = self._expandpath(
listener.get('tls_keyfile', self.tls_keyfile)
)
tls_version = MQTTPlugin.get_tls_version(
listener.get('tls_version', self.tls_version)
)
tls_ciphers = listener.get('tls_ciphers', self.tls_ciphers)
tls_insecure = listener.get('tls_insecure', self.tls_insecure)
topics = listener.get('topics')
if not topics:
self.logger.warning(
'No list of topics specified for listener n.%d', i + 1
)
continue
client = self._get_client(
host=host,
port=port,
topics=topics,
username=username,
password=password,
client_id=self.client_id,
tls_cafile=tls_cafile,
tls_certfile=tls_certfile,
tls_keyfile=tls_keyfile,
tls_version=tls_version,
tls_ciphers=tls_ciphers,
tls_insecure=tls_insecure,
)
if not client.is_alive():
client.start()
def _get_client_id(
self,
host: str,
port: int,
topics: Optional[List[str]] = None,
client_id: Optional[str] = None,
on_message: Optional[Callable[[MqttClient, Any, mqtt.MQTTMessage], Any]] = None,
) -> str:
client_id = client_id or self.client_id
client_hash = hashlib.sha1(
'|'.join(
[
host,
str(port),
json.dumps(sorted(topics or [])),
str(id(on_message)),
]
).encode()
).hexdigest()
return f'{client_id}-{client_hash}'
def _get_client(
self,
host: str,
port: int,
topics: Optional[List[str]] = None,
username: Optional[str] = None,
password: Optional[str] = None,
client_id: Optional[str] = None,
tls_cafile: Optional[str] = None,
tls_certfile: Optional[str] = None,
tls_keyfile: Optional[str] = None,
tls_version=None,
tls_ciphers=None,
tls_insecure: bool = False,
on_message: Optional[Callable] = None,
) -> MqttClient:
on_message = on_message or self.on_mqtt_message()
client_id = self._get_client_id(
host=host,
port=port,
topics=topics,
client_id=client_id,
on_message=on_message,
)
client = self._listeners.get(client_id)
if not (client and client.is_alive()):
client = self._listeners[client_id] = MqttClient(
host=host,
port=port,
topics=topics,
username=username,
password=password,
client_id=client_id,
tls_cafile=tls_cafile,
tls_certfile=tls_certfile,
tls_keyfile=tls_keyfile,
tls_version=tls_version,
tls_ciphers=tls_ciphers,
tls_insecure=tls_insecure,
on_message=on_message,
)
if topics:
client.subscribe(*topics)
return client
def on_mqtt_message(self):
def handler(client: MqttClient, _, msg: mqtt.MQTTMessage):
data = msg.payload
try:
data = data.decode('utf-8')
data = json.loads(data)
except Exception as e:
self.logger.debug(str(e))
self.bus.post(
MQTTMessageEvent(
host=client.host, port=client.port, topic=msg.topic, msg=data
)
)
return handler
def on_exec_message(self):
def handler(_, __, msg: mqtt.MQTTMessage):
def response_thread(msg):
response = self.get_message_response(msg)
if not response:
return
response_topic = f'{self.topic}/responses/{msg.id}'
self.logger.info(
'Processing response on the MQTT topic %s: %s',
response_topic,
response,
)
self.send_message(response, topic=response_topic)
msg = msg.payload.decode('utf-8')
try:
msg = json.loads(msg)
msg = Message.build(msg)
except Exception as e:
self.logger.debug(str(e))
if not msg:
return
self.logger.info('Received message on the MQTT backend: %s', msg)
try:
self.on_message(msg)
except Exception as e:
self.logger.exception(e)
return
if isinstance(msg, Request):
threading.Thread(
target=response_thread,
name='MQTTProcessorResponseThread',
args=(msg,),
).start()
return handler
def run(self):
super().run()
if self.host and self.subscribe_default_topic:
topics = [self.topic]
client = self._get_client(
host=self.host,
port=self.port,
topics=topics,
username=self.username,
password=self.password,
client_id=self.client_id,
tls_cafile=self.tls_cafile,
tls_certfile=self.tls_certfile,
tls_keyfile=self.tls_keyfile,
tls_version=self.tls_version,
tls_ciphers=self.tls_ciphers,
tls_insecure=self.tls_insecure,
on_message=self.on_exec_message(),
)
client.start()
self.logger.info(
'Initialized MQTT backend on host %s:%d, topic=%s',
self.host,
self.port,
self.topic,
)
self.add_listeners(*self.listeners_conf)
def on_stop(self):
self.logger.info('Received STOP event on the MQTT backend')
for listener in self._listeners.values():
try:
listener.stop()
except Exception as e:
self.logger.warning('Could not stop MQTT listener: %s', e)
self.logger.info('MQTT backend terminated')
# vim:sw=4:ts=4:et:

View file

@ -1,17 +0,0 @@
manifest:
events:
platypush.message.event.mqtt.MQTTMessageEvent: when a newmessage is received on
one of the custom listeners
install:
apk:
- py3-paho-mqtt
dnf:
- python-paho-mqtt
pacman:
- python-paho-mqtt
apt:
- python3-paho-mqtt
pip:
- paho-mqtt
package: platypush.backend.mqtt
type: backend

View file

@ -5,11 +5,20 @@ import threading
import websocket import websocket
from platypush.backend import Backend from platypush.backend import Backend
from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, \ from platypush.message.event.music import (
MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, \ MusicPlayEvent,
PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, \ MusicPauseEvent,
PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent, \ MusicStopEvent,
MuteChangeEvent, SeekChangeEvent NewPlayingTrackEvent,
PlaylistChangeEvent,
VolumeChangeEvent,
PlaybackConsumeModeChangeEvent,
PlaybackSingleModeChangeEvent,
PlaybackRepeatModeChangeEvent,
PlaybackRandomModeChangeEvent,
MuteChangeEvent,
SeekChangeEvent,
)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@ -22,20 +31,10 @@ class MusicMopidyBackend(Backend):
solution if you're not running Mopidy or your instance has the websocket solution if you're not running Mopidy or your instance has the websocket
interface or web port disabled. interface or web port disabled.
Triggers:
* :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play
* :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause
* :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop
* :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played
* :class:`platypush.message.event.music.PlaylistChangeEvent` if the main playlist has changed
* :class:`platypush.message.event.music.VolumeChangeEvent` if the main volume has changed
* :class:`platypush.message.event.music.MuteChangeEvent` if the mute status has changed
* :class:`platypush.message.event.music.SeekChangeEvent` if a track seek event occurs
Requires: Requires:
* Mopidy installed and the HTTP service enabled * A Mopidy instance running with the HTTP service enabled.
""" """
def __init__(self, host='localhost', port=6680, **kwargs): def __init__(self, host='localhost', port=6680, **kwargs):
@ -77,8 +76,11 @@ class MusicMopidyBackend(Backend):
conv_track['album'] = conv_track['album']['name'] conv_track['album'] = conv_track['album']['name']
if 'length' in conv_track: if 'length' in conv_track:
conv_track['time'] = conv_track['length']/1000 \ conv_track['time'] = (
if conv_track['length'] else conv_track['length'] conv_track['length'] / 1000
if conv_track['length']
else conv_track['length']
)
del conv_track['length'] del conv_track['length']
if pos is not None: if pos is not None:
@ -90,7 +92,6 @@ class MusicMopidyBackend(Backend):
return conv_track return conv_track
def _communicate(self, msg): def _communicate(self, msg):
if isinstance(msg, str): if isinstance(msg, str):
msg = json.loads(msg) msg = json.loads(msg)
@ -107,14 +108,10 @@ class MusicMopidyBackend(Backend):
def _get_tracklist_status(self): def _get_tracklist_status(self):
return { return {
'repeat': self._communicate({ 'repeat': self._communicate({'method': 'core.tracklist.get_repeat'}),
'method': 'core.tracklist.get_repeat'}), 'random': self._communicate({'method': 'core.tracklist.get_random'}),
'random': self._communicate({ 'single': self._communicate({'method': 'core.tracklist.get_single'}),
'method': 'core.tracklist.get_random'}), 'consume': self._communicate({'method': 'core.tracklist.get_consume'}),
'single': self._communicate({
'method': 'core.tracklist.get_single'}),
'consume': self._communicate({
'method': 'core.tracklist.get_consume'}),
} }
def _on_msg(self): def _on_msg(self):
@ -133,19 +130,25 @@ class MusicMopidyBackend(Backend):
track = self._parse_track(track) track = self._parse_track(track)
if not track: if not track:
return return
self.bus.post(MusicPauseEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
MusicPauseEvent(status=status, track=track, plugin_name='music.mpd')
)
elif event == 'track_playback_resumed': elif event == 'track_playback_resumed':
status['state'] = 'play' status['state'] = 'play'
track = self._parse_track(track) track = self._parse_track(track)
if not track: if not track:
return return
self.bus.post(MusicPlayEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
MusicPlayEvent(status=status, track=track, plugin_name='music.mpd')
)
elif event == 'track_playback_ended' or ( elif event == 'track_playback_ended' or (
event == 'playback_state_changed' event == 'playback_state_changed' and msg.get('new_state') == 'stopped'
and msg.get('new_state') == 'stopped'): ):
status['state'] = 'stop' status['state'] = 'stop'
track = self._parse_track(track) track = self._parse_track(track)
self.bus.post(MusicStopEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
MusicStopEvent(status=status, track=track, plugin_name='music.mpd')
)
elif event == 'track_playback_started': elif event == 'track_playback_started':
track = self._parse_track(track) track = self._parse_track(track)
if not track: if not track:
@ -154,9 +157,13 @@ class MusicMopidyBackend(Backend):
status['state'] = 'play' status['state'] = 'play'
status['position'] = 0.0 status['position'] = 0.0
status['time'] = track.get('time') status['time'] = track.get('time')
self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
NewPlayingTrackEvent(
status=status, track=track, plugin_name='music.mpd'
)
)
elif event == 'stream_title_changed': elif event == 'stream_title_changed':
m = re.match('^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', '')) m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', ''))
if not m: if not m:
return return
@ -164,35 +171,78 @@ class MusicMopidyBackend(Backend):
track['title'] = m.group(2) track['title'] = m.group(2)
status['state'] = 'play' status['state'] = 'play'
status['position'] = 0.0 status['position'] = 0.0
self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
NewPlayingTrackEvent(
status=status, track=track, plugin_name='music.mpd'
)
)
elif event == 'volume_changed': elif event == 'volume_changed':
status['volume'] = msg.get('volume') status['volume'] = msg.get('volume')
self.bus.post(VolumeChangeEvent(volume=status['volume'], status=status, track=track, self.bus.post(
plugin_name='music.mpd')) VolumeChangeEvent(
volume=status['volume'],
status=status,
track=track,
plugin_name='music.mpd',
)
)
elif event == 'mute_changed': elif event == 'mute_changed':
status['mute'] = msg.get('mute') status['mute'] = msg.get('mute')
self.bus.post(MuteChangeEvent(mute=status['mute'], status=status, track=track, self.bus.post(
plugin_name='music.mpd')) MuteChangeEvent(
mute=status['mute'],
status=status,
track=track,
plugin_name='music.mpd',
)
)
elif event == 'seeked': elif event == 'seeked':
status['position'] = msg.get('time_position') / 1000 status['position'] = msg.get('time_position') / 1000
self.bus.post(SeekChangeEvent(position=status['position'], status=status, track=track, self.bus.post(
plugin_name='music.mpd')) SeekChangeEvent(
position=status['position'],
status=status,
track=track,
plugin_name='music.mpd',
)
)
elif event == 'tracklist_changed': elif event == 'tracklist_changed':
tracklist = [self._parse_track(t, pos=i) tracklist = [
for i, t in enumerate(self._communicate({ self._parse_track(t, pos=i)
'method': 'core.tracklist.get_tl_tracks'}))] for i, t in enumerate(
self._communicate({'method': 'core.tracklist.get_tl_tracks'})
)
]
self.bus.post(PlaylistChangeEvent(changes=tracklist, plugin_name='music.mpd')) self.bus.post(
PlaylistChangeEvent(changes=tracklist, plugin_name='music.mpd')
)
elif event == 'options_changed': elif event == 'options_changed':
new_status = self._get_tracklist_status() new_status = self._get_tracklist_status()
if new_status['random'] != self._latest_status.get('random'): if new_status['random'] != self._latest_status.get('random'):
self.bus.post(PlaybackRandomModeChangeEvent(state=new_status['random'], plugin_name='music.mpd')) self.bus.post(
PlaybackRandomModeChangeEvent(
state=new_status['random'], plugin_name='music.mpd'
)
)
if new_status['repeat'] != self._latest_status['repeat']: if new_status['repeat'] != self._latest_status['repeat']:
self.bus.post(PlaybackRepeatModeChangeEvent(state=new_status['repeat'], plugin_name='music.mpd')) self.bus.post(
PlaybackRepeatModeChangeEvent(
state=new_status['repeat'], plugin_name='music.mpd'
)
)
if new_status['single'] != self._latest_status['single']: if new_status['single'] != self._latest_status['single']:
self.bus.post(PlaybackSingleModeChangeEvent(state=new_status['single'], plugin_name='music.mpd')) self.bus.post(
PlaybackSingleModeChangeEvent(
state=new_status['single'], plugin_name='music.mpd'
)
)
if new_status['consume'] != self._latest_status['consume']: if new_status['consume'] != self._latest_status['consume']:
self.bus.post(PlaybackConsumeModeChangeEvent(state=new_status['consume'], plugin_name='music.mpd')) self.bus.post(
PlaybackConsumeModeChangeEvent(
state=new_status['consume'], plugin_name='music.mpd'
)
)
self._latest_status = new_status self._latest_status = new_status
@ -204,7 +254,7 @@ class MusicMopidyBackend(Backend):
try: try:
self._connect() self._connect()
except Exception as e: except Exception as e:
self.logger.warning('Error on websocket reconnection: '.format(str(e))) self.logger.warning('Error on websocket reconnection: %s', e)
self._connected_event.wait(timeout=10) self._connected_event.wait(timeout=10)
@ -244,17 +294,23 @@ class MusicMopidyBackend(Backend):
def _connect(self): def _connect(self):
if not self._ws: if not self._ws:
self._ws = websocket.WebSocketApp(self.url, self._ws = websocket.WebSocketApp(
self.url,
on_open=self._on_open(), on_open=self._on_open(),
on_message=self._on_msg(), on_message=self._on_msg(),
on_error=self._on_error(), on_error=self._on_error(),
on_close=self._on_close()) on_close=self._on_close(),
)
self._ws.run_forever() self._ws.run_forever()
def run(self): def run(self):
super().run() super().run()
self.logger.info('Started tracking Mopidy events backend on {}:{}'.format(self.host, self.port)) self.logger.info(
'Started tracking Mopidy events backend on {}:{}'.format(
self.host, self.port
)
)
self._connect() self._connect()
def on_stop(self): def on_stop(self):

View file

@ -2,28 +2,28 @@ import time
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, \ from platypush.message.event.music import (
MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, \ MusicPlayEvent,
PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, \ MusicPauseEvent,
PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent MusicStopEvent,
NewPlayingTrackEvent,
PlaylistChangeEvent,
VolumeChangeEvent,
PlaybackConsumeModeChangeEvent,
PlaybackSingleModeChangeEvent,
PlaybackRepeatModeChangeEvent,
PlaybackRandomModeChangeEvent,
)
class MusicMpdBackend(Backend): class MusicMpdBackend(Backend):
""" """
This backend listens for events on a MPD/Mopidy music server. This backend listens for events on a MPD/Mopidy music server.
Triggers:
* :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play
* :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause
* :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop
* :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played
* :class:`platypush.message.event.music.PlaylistChangeEvent` if the main playlist has changed
* :class:`platypush.message.event.music.VolumeChangeEvent` if the main volume has changed
Requires: Requires:
* **python-mpd2** (``pip install python-mpd2``)
* The :mod:`platypush.plugins.music.mpd` plugin to be configured * :class:`platypush.plugins.music.mpd.MusicMpdPlugin` configured
""" """
def __init__(self, server='localhost', port=6600, poll_seconds=3, **kwargs): def __init__(self, server='localhost', port=6600, poll_seconds=3, **kwargs):
@ -81,11 +81,23 @@ class MusicMpdBackend(Backend):
if state != last_state: if state != last_state:
if state == 'stop': if state == 'stop':
self.bus.post(MusicStopEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
MusicStopEvent(
status=status, track=track, plugin_name='music.mpd'
)
)
elif state == 'pause': elif state == 'pause':
self.bus.post(MusicPauseEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
MusicPauseEvent(
status=status, track=track, plugin_name='music.mpd'
)
)
elif state == 'play': elif state == 'play':
self.bus.post(MusicPlayEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
MusicPlayEvent(
status=status, track=track, plugin_name='music.mpd'
)
)
if playlist != last_playlist: if playlist != last_playlist:
if last_playlist: if last_playlist:
@ -97,31 +109,66 @@ class MusicMpdBackend(Backend):
last_playlist = playlist last_playlist = playlist
if state == 'play' and track != last_track: if state == 'play' and track != last_track:
self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd')) self.bus.post(
NewPlayingTrackEvent(
status=status, track=track, plugin_name='music.mpd'
)
)
if last_status.get('volume', None) != status['volume']: if last_status.get('volume') != status['volume']:
self.bus.post(VolumeChangeEvent(volume=int(status['volume']), status=status, track=track, self.bus.post(
plugin_name='music.mpd')) VolumeChangeEvent(
volume=int(status['volume']),
status=status,
track=track,
plugin_name='music.mpd',
)
)
if last_status.get('random', None) != status['random']: if last_status.get('random') != status['random']:
self.bus.post(PlaybackRandomModeChangeEvent(state=bool(int(status['random'])), status=status, self.bus.post(
track=track, plugin_name='music.mpd')) PlaybackRandomModeChangeEvent(
state=bool(int(status['random'])),
status=status,
track=track,
plugin_name='music.mpd',
)
)
if last_status.get('repeat', None) != status['repeat']: if last_status.get('repeat') != status['repeat']:
self.bus.post(PlaybackRepeatModeChangeEvent(state=bool(int(status['repeat'])), status=status, self.bus.post(
track=track, plugin_name='music.mpd')) PlaybackRepeatModeChangeEvent(
state=bool(int(status['repeat'])),
status=status,
track=track,
plugin_name='music.mpd',
)
)
if last_status.get('consume', None) != status['consume']: if last_status.get('consume') != status['consume']:
self.bus.post(PlaybackConsumeModeChangeEvent(state=bool(int(status['consume'])), status=status, self.bus.post(
track=track, plugin_name='music.mpd')) PlaybackConsumeModeChangeEvent(
state=bool(int(status['consume'])),
status=status,
track=track,
plugin_name='music.mpd',
)
)
if last_status.get('single', None) != status['single']: if last_status.get('single') != status['single']:
self.bus.post(PlaybackSingleModeChangeEvent(state=bool(int(status['single'])), status=status, self.bus.post(
track=track, plugin_name='music.mpd')) PlaybackSingleModeChangeEvent(
state=bool(int(status['single'])),
status=status,
track=track,
plugin_name='music.mpd',
)
)
last_status = status last_status = status
last_state = state last_state = state
last_track = track last_track = track
time.sleep(self.poll_seconds) time.sleep(self.poll_seconds)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -21,19 +21,7 @@ from platypush.message.event.music.snapcast import (
class MusicSnapcastBackend(Backend): class MusicSnapcastBackend(Backend):
""" """
Backend that listens for notification and status changes on one or more Backend that listens for notification and status changes on one or more
[Snapcast](https://github.com/badaix/snapcast) servers. `Snapcast <https://github.com/badaix/snapcast>`_ servers.
Triggers:
* :class:`platypush.message.event.music.snapcast.ClientConnectedEvent`
* :class:`platypush.message.event.music.snapcast.ClientDisconnectedEvent`
* :class:`platypush.message.event.music.snapcast.ClientVolumeChangeEvent`
* :class:`platypush.message.event.music.snapcast.ClientLatencyChangeEvent`
* :class:`platypush.message.event.music.snapcast.ClientNameChangeEvent`
* :class:`platypush.message.event.music.snapcast.GroupMuteChangeEvent`
* :class:`platypush.message.event.music.snapcast.GroupStreamChangeEvent`
* :class:`platypush.message.event.music.snapcast.StreamUpdateEvent`
* :class:`platypush.message.event.music.snapcast.ServerUpdateEvent`
""" """
_DEFAULT_SNAPCAST_PORT = 1705 _DEFAULT_SNAPCAST_PORT = 1705

View file

@ -7,8 +7,14 @@ from typing import Optional, Dict, Any
from platypush.backend import Backend from platypush.backend import Backend
from platypush.common.spotify import SpotifyMixin from platypush.common.spotify import SpotifyMixin
from platypush.config import Config from platypush.config import Config
from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, MusicStopEvent, \ from platypush.message.event.music import (
NewPlayingTrackEvent, SeekChangeEvent, VolumeChangeEvent MusicPlayEvent,
MusicPauseEvent,
MusicStopEvent,
NewPlayingTrackEvent,
SeekChangeEvent,
VolumeChangeEvent,
)
from platypush.utils import get_redis from platypush.utils import get_redis
from .event import status_queue from .event import status_queue
@ -21,21 +27,14 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
stream Spotify through the Platypush host. After the backend has started, you should see a new entry in the stream Spotify through the Platypush host. After the backend has started, you should see a new entry in the
Spotify Connect devices list in your app. Spotify Connect devices list in your app.
Triggers:
* :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play
* :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause
* :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop
* :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played
* :class:`platypush.message.event.music.VolumeChangeEvent` if the volume changes
Requires: Requires:
* **librespot**. Consult the `README <https://github.com/librespot-org/librespot>`_ for instructions. * **librespot**. Consult the `README <https://github.com/librespot-org/librespot>`_ for instructions.
""" """
def __init__(self, def __init__(
self,
librespot_path: str = 'librespot', librespot_path: str = 'librespot',
device_name: Optional[str] = None, device_name: Optional[str] = None,
device_type: str = 'speaker', device_type: str = 'speaker',
@ -63,11 +62,12 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
enable_volume_normalization: bool = False, enable_volume_normalization: bool = False,
normalization_method: str = 'dynamic', normalization_method: str = 'dynamic',
normalization_pre_gain: Optional[float] = None, normalization_pre_gain: Optional[float] = None,
normalization_threshold: float = -1., normalization_threshold: float = -1.0,
normalization_attack: int = 5, normalization_attack: int = 5,
normalization_release: int = 100, normalization_release: int = 100,
normalization_knee: float = 1., normalization_knee: float = 1.0,
**kwargs): **kwargs,
):
""" """
:param librespot_path: Librespot path/executable name (default: ``librespot``). :param librespot_path: Librespot path/executable name (default: ``librespot``).
:param device_name: Device name (default: same as configured Platypush ``device_id`` or hostname). :param device_name: Device name (default: same as configured Platypush ``device_id`` or hostname).
@ -121,17 +121,36 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret) SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret)
self.device_name = device_name or Config.get('device_id') self.device_name = device_name or Config.get('device_id')
self._librespot_args = [ self._librespot_args = [
librespot_path, '--name', self.device_name, '--backend', audio_backend, librespot_path,
'--device-type', device_type, '--mixer', mixer, '--alsa-mixer-control', mixer_name, '--name',
'--initial-volume', str(volume), '--volume-ctrl', volume_ctrl, '--bitrate', str(bitrate), self.device_name,
'--emit-sink-events', '--onevent', 'python -m platypush.backend.music.spotify.event', '--backend',
audio_backend,
'--device-type',
device_type,
'--mixer',
mixer,
'--alsa-mixer-control',
mixer_name,
'--initial-volume',
str(volume),
'--volume-ctrl',
volume_ctrl,
'--bitrate',
str(bitrate),
'--emit-sink-events',
'--onevent',
'python -m platypush.backend.music.spotify.event',
] ]
if audio_device: if audio_device:
self._librespot_args += ['--alsa-mixer-device', audio_device] self._librespot_args += ['--alsa-mixer-device', audio_device]
else: else:
self._librespot_args += [ self._librespot_args += [
'--alsa-mixer-device', mixer_card, '--alsa-mixer-index', str(mixer_index) '--alsa-mixer-device',
mixer_card,
'--alsa-mixer-index',
str(mixer_index),
] ]
if autoplay: if autoplay:
self._librespot_args += ['--autoplay'] self._librespot_args += ['--autoplay']
@ -148,17 +167,30 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
if cache_dir: if cache_dir:
self._librespot_args += ['--cache', os.path.expanduser(cache_dir)] self._librespot_args += ['--cache', os.path.expanduser(cache_dir)]
if system_cache_dir: if system_cache_dir:
self._librespot_args += ['--system-cache', os.path.expanduser(system_cache_dir)] self._librespot_args += [
'--system-cache',
os.path.expanduser(system_cache_dir),
]
if enable_volume_normalization: if enable_volume_normalization:
self._librespot_args += [ self._librespot_args += [
'--enable-volume-normalisation', '--normalisation-method', normalization_method, '--enable-volume-normalisation',
'--normalisation-threshold', str(normalization_threshold), '--normalisation-attack', '--normalisation-method',
str(normalization_attack), '--normalisation-release', str(normalization_release), normalization_method,
'--normalisation-knee', str(normalization_knee), '--normalisation-threshold',
str(normalization_threshold),
'--normalisation-attack',
str(normalization_attack),
'--normalisation-release',
str(normalization_release),
'--normalisation-knee',
str(normalization_knee),
] ]
if normalization_pre_gain: if normalization_pre_gain:
self._librespot_args += ['--normalisation-pregain', str(normalization_pre_gain)] self._librespot_args += [
'--normalisation-pregain',
str(normalization_pre_gain),
]
self._librespot_dump_args = self._librespot_args.copy() self._librespot_dump_args = self._librespot_args.copy()
if username and password: if username and password:
@ -227,11 +259,21 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
def _process_status_msg(self, status): def _process_status_msg(self, status):
event_type = status.get('PLAYER_EVENT') event_type = status.get('PLAYER_EVENT')
volume = int(status['VOLUME'])/655.35 if status.get('VOLUME') is not None else None volume = (
int(status['VOLUME']) / 655.35 if status.get('VOLUME') is not None else None
)
track_id = status.get('TRACK_ID') track_id = status.get('TRACK_ID')
old_track_id = status.get('OLD_TRACK_ID', self.track['id']) old_track_id = status.get('OLD_TRACK_ID', self.track['id'])
duration = int(status['DURATION_MS'])/1000. if status.get('DURATION_MS') is not None else None duration = (
elapsed = int(status['POSITION_MS'])/1000. if status.get('POSITION_MS') is not None else None int(status['DURATION_MS']) / 1000.0
if status.get('DURATION_MS') is not None
else None
)
elapsed = (
int(status['POSITION_MS']) / 1000.0
if status.get('POSITION_MS') is not None
else None
)
if volume is not None: if volume is not None:
self.status['volume'] = volume self.status['volume'] = volume
@ -275,7 +317,7 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
self._librespot_proc.terminate() self._librespot_proc.terminate()
try: try:
self._librespot_proc.wait(timeout=5.) self._librespot_proc.wait(timeout=5.0)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self.logger.warning('Librespot has not yet terminated: killing it') self.logger.warning('Librespot has not yet terminated: killing it')
self._librespot_proc.kill() self._librespot_proc.kill()

View file

@ -11,10 +11,8 @@ class NextcloudBackend(Backend):
""" """
This backend triggers events when new activities occur on a NextCloud instance. This backend triggers events when new activities occur on a NextCloud instance.
Triggers: The field ``activity_type`` in the triggered :class:`platypush.message.event.nextcloud.NextCloudActivityEvent`
events identifies the activity type (e.g. ``file_created``, ``file_deleted``,
- :class:`platypush.message.event.nextcloud.NextCloudActivityEvent` when new activity occurs on the instance.
The field ``activity_type`` identifies the activity type (e.g. ``file_created``, ``file_deleted``,
``file_changed``). Example in the case of the creation of new files: ``file_changed``). Example in the case of the creation of new files:
.. code-block:: json .. code-block:: json
@ -24,7 +22,7 @@ class NextcloudBackend(Backend):
"app": "files", "app": "files",
"activity_type": "file_created", "activity_type": "file_created",
"user": "your-user", "user": "your-user",
"subject": "You created InstantUpload/Camera/IMG_0100.jpg, InstantUpload/Camera/IMG_0101.jpg and InstantUpload/Camera/IMG_0102.jpg", "subject": "You created InstantUpload/Camera/IMG_0100.jpg",
"subject_rich": [ "subject_rich": [
"You created {file3}, {file2} and {file1}", "You created {file3}, {file2} and {file1}",
{ {
@ -73,9 +71,16 @@ class NextcloudBackend(Backend):
_LAST_ACTIVITY_VARNAME = '_NEXTCLOUD_LAST_ACTIVITY_ID' _LAST_ACTIVITY_VARNAME = '_NEXTCLOUD_LAST_ACTIVITY_ID'
def __init__(self, url: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, def __init__(
object_type: Optional[str] = None, object_id: Optional[int] = None, self,
poll_seconds: Optional[float] = 60., **kwargs): url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
object_type: Optional[str] = None,
object_id: Optional[int] = None,
poll_seconds: Optional[float] = 60.0,
**kwargs
):
""" """
:param url: NextCloud instance URL (default: same as the :class:`platypush.plugins.nextcloud.NextCloudPlugin`). :param url: NextCloud instance URL (default: same as the :class:`platypush.plugins.nextcloud.NextCloudPlugin`).
:param username: NextCloud username (default: same as the :class:`platypush.plugins.nextcloud.NextCloudPlugin`). :param username: NextCloud username (default: same as the :class:`platypush.plugins.nextcloud.NextCloudPlugin`).
@ -106,14 +111,17 @@ class NextcloudBackend(Backend):
self.username = username if username else self.username self.username = username if username else self.username
self.password = password if password else self.password self.password = password if password else self.password
assert self.url and self.username and self.password, \ assert (
'No configuration provided neither for the NextCloud plugin nor the backend' self.url and self.username and self.password
), 'No configuration provided neither for the NextCloud plugin nor the backend'
@property @property
def last_seen_id(self) -> Optional[int]: def last_seen_id(self) -> Optional[int]:
if self._last_seen_id is None: if self._last_seen_id is None:
variables: VariablePlugin = get_plugin('variable') variables: VariablePlugin = get_plugin('variable')
last_seen_id = variables.get(self._LAST_ACTIVITY_VARNAME).output.get(self._LAST_ACTIVITY_VARNAME) last_seen_id = variables.get(self._LAST_ACTIVITY_VARNAME).output.get(
self._LAST_ACTIVITY_VARNAME
)
self._last_seen_id = last_seen_id self._last_seen_id = last_seen_id
return self._last_seen_id return self._last_seen_id
@ -133,8 +141,14 @@ class NextcloudBackend(Backend):
new_last_seen_id = int(last_seen_id) new_last_seen_id = int(last_seen_id)
plugin: NextcloudPlugin = get_plugin('nextcloud') plugin: NextcloudPlugin = get_plugin('nextcloud')
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
activities = plugin.get_activities(sort='desc', url=self.url, username=self.username, password=self.password, activities = plugin.get_activities(
object_type=self.object_type, object_id=self.object_id).output sort='desc',
url=self.url,
username=self.username,
password=self.password,
object_type=self.object_type,
object_id=self.object_id,
).output
events = [] events = []
for activity in activities: for activity in activities:

View file

@ -14,18 +14,6 @@ class NfcBackend(Backend):
""" """
Backend to detect NFC card events from a compatible reader. Backend to detect NFC card events from a compatible reader.
Triggers:
* :class:`platypush.message.event.nfc.NFCDeviceConnectedEvent` when an NFC reader/writer is connected
* :class:`platypush.message.event.nfc.NFCDeviceDisconnectedEvent` when an NFC reader/writer is disconnected
* :class:`platypush.message.event.nfc.NFCTagDetectedEvent` when an NFC tag is detected
* :class:`platypush.message.event.nfc.NFCTagRemovedEvent` when an NFC tag is removed
Requires:
* **nfcpy** >= 1.0 (``pip install 'nfcpy>=1.0'``)
* **ndef** (``pip install ndeflib``)
Run the following to check if your device is compatible with nfcpy and the right permissions are set:: Run the following to check if your device is compatible with nfcpy and the right permissions are set::
python -m nfc python -m nfc

View file

@ -13,11 +13,6 @@ class NoderedBackend(Backend):
used in your flows. This block will accept JSON requests as input in the format used in your flows. This block will accept JSON requests as input in the format
``{"type":"request", "action":"plugin.name.action_name", "args": {...}}`` and return the output ``{"type":"request", "action":"plugin.name.action_name", "args": {...}}`` and return the output
of the action as block output, or raise an exception if the action failed. of the action as block output, or raise an exception if the action failed.
Requires:
* **pynodered** (``pip install pynodered``)
""" """
def __init__(self, port: int = 5051, *args, **kwargs): def __init__(self, port: int = 5051, *args, **kwargs):
@ -27,7 +22,8 @@ class NoderedBackend(Backend):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.port = port self.port = port
self._runner_path = os.path.join( self._runner_path = os.path.join(
os.path.dirname(inspect.getfile(self.__class__)), 'runner.py') os.path.dirname(inspect.getfile(self.__class__)), 'runner.py'
)
self._server = None self._server = None
def on_stop(self): def on_stop(self):
@ -40,8 +36,16 @@ class NoderedBackend(Backend):
super().run() super().run()
self.register_service(port=self.port, name='node') self.register_service(port=self.port, name='node')
self._server = subprocess.Popen([sys.executable, '-m', 'pynodered.server', self._server = subprocess.Popen(
'--port', str(self.port), self._runner_path]) [
sys.executable,
'-m',
'pynodered.server',
'--port',
str(self.port),
self._runner_path,
]
)
self.logger.info('Started Node-RED backend on port {}'.format(self.port)) self.logger.info('Started Node-RED backend on port {}'.format(self.port))
self._server.wait() self._server.wait()

View file

@ -11,12 +11,6 @@ from platypush.utils.workers import Worker, Workers
class PingBackend(Backend): class PingBackend(Backend):
""" """
This backend allows you to ping multiple remote hosts at regular intervals. This backend allows you to ping multiple remote hosts at regular intervals.
Triggers:
- :class:`platypush.message.event.ping.HostDownEvent` if a host stops responding ping requests
- :class:`platypush.message.event.ping.HostUpEvent` if a host starts responding ping requests
""" """
class Pinger(Worker): class Pinger(Worker):
@ -30,7 +24,15 @@ class PingBackend(Backend):
response = pinger.ping(host, timeout=self.timeout, count=self.count).output response = pinger.ping(host, timeout=self.timeout, count=self.count).output
return host, response['success'] is True return host, response['success'] is True
def __init__(self, hosts: List[str], timeout: float = 5.0, interval: float = 60.0, count: int = 1, *args, **kwargs): def __init__(
self,
hosts: List[str],
timeout: float = 5.0,
interval: float = 60.0,
count: int = 1,
*args,
**kwargs
):
""" """
:param hosts: List of IP addresses or host names to monitor. :param hosts: List of IP addresses or host names to monitor.
:param timeout: Ping timeout. :param timeout: Ping timeout.
@ -47,7 +49,9 @@ class PingBackend(Backend):
def run(self): def run(self):
super().run() super().run()
self.logger.info('Starting ping backend with {} hosts to monitor'.format(len(self.hosts))) self.logger.info(
'Starting ping backend with {} hosts to monitor'.format(len(self.hosts))
)
while not self.should_stop(): while not self.should_stop():
workers = Workers(10, self.Pinger, timeout=self.timeout, count=self.count) workers = Workers(10, self.Pinger, timeout=self.timeout, count=self.count)

View file

@ -14,19 +14,16 @@ class PushbulletBackend(Backend):
Pushbullet app and/or through Tasker), synchronize clipboards, send pictures Pushbullet app and/or through Tasker), synchronize clipboards, send pictures
and files to other devices etc. You can also wrap Platypush messages as JSON and files to other devices etc. You can also wrap Platypush messages as JSON
into a push body to execute them. into a push body to execute them.
Triggers:
* :class:`platypush.message.event.pushbullet.PushbulletEvent` if a new push is received
Requires:
* **pushbullet.py** (``pip install git+https://github.com/pushbullet.py/pushbullet.py``)
""" """
def __init__(self, token: str, device: str = 'Platypush', proxy_host: Optional[str] = None, def __init__(
proxy_port: Optional[int] = None, **kwargs): self,
token: str,
device: str = 'Platypush',
proxy_host: Optional[str] = None,
proxy_port: Optional[int] = None,
**kwargs,
):
""" """
:param token: Your Pushbullet API token, see https://docs.pushbullet.com/#authentication :param token: Your Pushbullet API token, see https://docs.pushbullet.com/#authentication
:param device: Name of the virtual device for Platypush (default: Platypush) :param device: Name of the virtual device for Platypush (default: Platypush)
@ -47,12 +44,15 @@ class PushbulletBackend(Backend):
def _initialize(self): def _initialize(self):
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
from pushbullet import Pushbullet from pushbullet import Pushbullet
self.pb = Pushbullet(self.token) self.pb = Pushbullet(self.token)
try: try:
self.device = self.pb.get_device(self.device_name) self.device = self.pb.get_device(self.device_name)
except Exception as e: except Exception as e:
self.logger.info(f'Device {self.device_name} does not exist: {e}. Creating it') self.logger.info(
f'Device {self.device_name} does not exist: {e}. Creating it'
)
self.device = self.pb.new_device(self.device_name) self.device = self.pb.new_device(self.device_name)
self.pb_device_id = self.get_device_id() self.pb_device_id = self.get_device_id()
@ -98,8 +98,10 @@ class PushbulletBackend(Backend):
body = json.loads(body) body = json.loads(body)
self.on_message(body) self.on_message(body)
except Exception as e: except Exception as e:
self.logger.debug('Unexpected message received on the ' + self.logger.debug(
f'Pushbullet backend: {e}. Message: {body}') 'Unexpected message received on the '
+ f'Pushbullet backend: {e}. Message: {body}'
)
except Exception as e: except Exception as e:
self.logger.exception(e) self.logger.exception(e)
return return
@ -111,8 +113,12 @@ class PushbulletBackend(Backend):
try: try:
return self.pb.get_device(self.device_name).device_iden return self.pb.get_device(self.device_name).device_iden
except Exception: except Exception:
device = self.pb.new_device(self.device_name, model='Platypush virtual device', device = self.pb.new_device(
manufacturer='platypush', icon='system') self.device_name,
model='Platypush virtual device',
manufacturer='platypush',
icon='system',
)
self.logger.info(f'Created Pushbullet device {self.device_name}') self.logger.info(f'Created Pushbullet device {self.device_name}')
return device.device_iden return device.device_iden
@ -158,14 +164,18 @@ class PushbulletBackend(Backend):
def run_listener(self): def run_listener(self):
from .listener import Listener from .listener import Listener
self.logger.info(f'Initializing Pushbullet backend - device_id: {self.device_name}') self.logger.info(
self.listener = Listener(account=self.pb, f'Initializing Pushbullet backend - device_id: {self.device_name}'
)
self.listener = Listener(
account=self.pb,
on_push=self.on_push(), on_push=self.on_push(),
on_open=self.on_open(), on_open=self.on_open(),
on_close=self.on_close(), on_close=self.on_close(),
on_error=self.on_error(), on_error=self.on_error(),
http_proxy_host=self.proxy_host, http_proxy_host=self.proxy_host,
http_proxy_port=self.proxy_port) http_proxy_port=self.proxy_port,
)
self.listener.run_forever() self.listener.run_forever()

View file

@ -9,23 +9,18 @@ class ScardBackend(Backend):
Extend this backend to implement more advanced communication with custom Extend this backend to implement more advanced communication with custom
smart cards. smart cards.
Triggers:
* :class:`platypush.message.event.scard.SmartCardDetectedEvent` when a smart card is detected
* :class:`platypush.message.event.scard.SmartCardRemovedEvent` when a smart card is removed
Requires:
* **pyscard** (``pip install pyscard``)
""" """
def __init__(self, atr=None, *args, **kwargs): def __init__(self, atr=None, *args, **kwargs):
""" """
:param atr: If set, the backend will trigger events only for card(s) with the specified ATR(s). It can be either an ATR string (space-separated hex octects) or a list of ATR strings. Default: none (any card will be detected) :param atr: If set, the backend will trigger events only for card(s)
with the specified ATR(s). It can be either an ATR string
(space-separated hex octects) or a list of ATR strings. Default:
none (any card will be detected).
""" """
from smartcard.CardType import AnyCardType, ATRCardType from smartcard.CardType import AnyCardType, ATRCardType
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.ATRs = [] self.ATRs = []
@ -35,9 +30,10 @@ class ScardBackend(Backend):
elif isinstance(atr, list): elif isinstance(atr, list):
self.ATRs = atr self.ATRs = atr
else: else:
raise RuntimeError("Unsupported ATR: \"{}\" - type: {}, " + raise RuntimeError(
"supported types: string, list".format( f"Unsupported ATR: \"{atr}\" - type: {type(atr)}, "
atr, type(atr))) + "supported types: string, list"
)
self.cardtype = ATRCardType(*[self._to_bytes(atr) for atr in self.ATRs]) self.cardtype = ATRCardType(*[self._to_bytes(atr) for atr in self.ATRs])
else: else:
@ -56,8 +52,9 @@ class ScardBackend(Backend):
super().run() super().run()
self.logger.info('Initialized smart card reader backend - ATR filter: {}'. self.logger.info(
format(self.ATRs)) 'Initialized smart card reader backend - ATR filter: {}'.format(self.ATRs)
)
prev_atr = None prev_atr = None
reader = None reader = None
@ -72,17 +69,19 @@ class ScardBackend(Backend):
atr = toHexString(cardservice.connection.getATR()) atr = toHexString(cardservice.connection.getATR())
if atr != prev_atr: if atr != prev_atr:
self.logger.info('Smart card detected on reader {}, ATR: {}'. self.logger.info(
format(reader, atr)) 'Smart card detected on reader {}, ATR: {}'.format(reader, atr)
)
self.bus.post(SmartCardDetectedEvent(atr=atr, reader=reader)) self.bus.post(SmartCardDetectedEvent(atr=atr, reader=reader))
prev_atr = atr prev_atr = atr
except Exception as e: except Exception as e:
if isinstance(e, NoCardException) or isinstance(e, CardConnectionException): if isinstance(e, (NoCardException, CardConnectionException)):
self.bus.post(SmartCardRemovedEvent(atr=prev_atr, reader=reader)) self.bus.post(SmartCardRemovedEvent(atr=prev_atr, reader=reader))
else: else:
self.logger.exception(e) self.logger.exception(e)
prev_atr = None prev_atr = None
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -14,11 +14,6 @@ class SensorIrZeroborgBackend(Backend):
remote by running the scan utility:: remote by running the scan utility::
python -m platypush.backend.sensor.ir.zeroborg.scan python -m platypush.backend.sensor.ir.zeroborg.scan
Triggers:
* :class:`platypush.message.event.sensor.ir.IrKeyDownEvent` when a key is pressed
* :class:`platypush.message.event.sensor.ir.IrKeyUpEvent` when a key is released
""" """
last_message = None last_message = None
@ -40,20 +35,29 @@ class SensorIrZeroborgBackend(Backend):
if self.zb.HasNewIrMessage(): if self.zb.HasNewIrMessage():
message = self.zb.GetIrMessage() message = self.zb.GetIrMessage()
if message != self.last_message: if message != self.last_message:
self.logger.info('Received key down event on the IR sensor: {}'.format(message)) self.logger.info(
'Received key down event on the IR sensor: {}'.format(
message
)
)
self.bus.post(IrKeyDownEvent(message=message)) self.bus.post(IrKeyDownEvent(message=message))
self.last_message = message self.last_message = message
self.last_message_timestamp = time.time() self.last_message_timestamp = time.time()
except OSError as e: except OSError as e:
self.logger.warning('Failed reading IR sensor status: {}: {}'.format(type(e), str(e))) self.logger.warning(
'Failed reading IR sensor status: {}: {}'.format(type(e), str(e))
)
if self.last_message_timestamp and \ if (
time.time() - self.last_message_timestamp > self.no_message_timeout: self.last_message_timestamp
and time.time() - self.last_message_timestamp > self.no_message_timeout
):
self.logger.info('Received key up event on the IR sensor') self.logger.info('Received key up event on the IR sensor')
self.bus.post(IrKeyUpEvent(message=self.last_message)) self.bus.post(IrKeyUpEvent(message=self.last_message))
self.last_message = None self.last_message = None
self.last_message_timestamp = None self.last_message_timestamp = None
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -7,8 +7,13 @@ import Leap
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_backend from platypush.context import get_backend
from platypush.message.event.sensor.leap import LeapFrameEvent, \ from platypush.message.event.sensor.leap import (
LeapFrameStartEvent, LeapFrameStopEvent, LeapConnectEvent, LeapDisconnectEvent LeapFrameEvent,
LeapFrameStartEvent,
LeapFrameStopEvent,
LeapConnectEvent,
LeapDisconnectEvent,
)
class SensorLeapBackend(Backend): class SensorLeapBackend(Backend):
@ -26,28 +31,25 @@ class SensorLeapBackend(Backend):
Requires: Requires:
* The Redis backend enabled * The Leap Motion SDK compiled with Python 3 support, see my port at
* The Leap Motion SDK compiled with Python 3 support, see my port at https://github.com:BlackLight/leap-sdk-python3.git https://github.com:BlackLight/leap-sdk-python3.git
* The `leapd` daemon to be running and your Leap Motion connected * The ``leapd`` daemon to be running and your Leap Motion connected
Triggers:
* :class:`platypush.message.event.sensor.leap.LeapFrameEvent` when a new frame is received
* :class:`platypush.message.event.sensor.leap.LeapFrameStartEvent` when a new sequence of frame starts
* :class:`platypush.message.event.sensor.leap.LeapFrameStopEvent` when a sequence of frame stops
* :class:`platypush.message.event.sensor.leap.LeapConnectEvent` when a Leap Motion device is connected
* :class:`platypush.message.event.sensor.leap.LeapDisconnectEvent` when a Leap Motion device disconnects
""" """
_listener_proc = None _listener_proc = None
def __init__(self, def __init__(
self,
position_ranges=None, position_ranges=None,
position_tolerance=0.0, # Position variation tolerance in % position_tolerance=0.0, # Position variation tolerance in %
frames_throttle_secs=None, frames_throttle_secs=None,
*args, **kwargs): *args,
**kwargs
):
""" """
:param position_ranges: It specifies how wide the hand space (x, y and z axes) should be in millimiters. :param position_ranges: It specifies how wide the hand space (x, y and
z axes) should be in millimiters.
Default:: Default::
@ -59,7 +61,8 @@ class SensorLeapBackend(Backend):
:type position_ranges: list[list[float]] :type position_ranges: list[list[float]]
:param position_tolerance: % of change between a frame and the next to really consider the next frame as a new one (default: 0) :param position_tolerance: % of change between a frame and the next to
really consider the next frame as a new one (default: 0).
:type position_tolerance: float :type position_tolerance: float
:param frames_throttle_secs: If set, the frame events will be throttled :param frames_throttle_secs: If set, the frame events will be throttled
@ -87,16 +90,20 @@ class SensorLeapBackend(Backend):
super().run() super().run()
def _listener_process(): def _listener_process():
listener = LeapListener(position_ranges=self.position_ranges, listener = LeapListener(
position_ranges=self.position_ranges,
position_tolerance=self.position_tolerance, position_tolerance=self.position_tolerance,
frames_throttle_secs=self.frames_throttle_secs, frames_throttle_secs=self.frames_throttle_secs,
logger=self.logger) logger=self.logger,
)
controller = Leap.Controller() controller = Leap.Controller()
if not controller: if not controller:
raise RuntimeError('No Leap Motion controller found - is your ' + raise RuntimeError(
'device connected and is leapd running?') 'No Leap Motion controller found - is your '
+ 'device connected and is leapd running?'
)
controller.add_listener(listener) controller.add_listener(listener)
self.logger.info('Leap Motion backend initialized') self.logger.info('Leap Motion backend initialized')
@ -120,12 +127,14 @@ class LeapFuture(Timer):
def _callback_wrapper(self): def _callback_wrapper(self):
def _callback(): def _callback():
self.listener._send_event(self.event) self.listener._send_event(self.event)
return _callback return _callback
class LeapListener(Leap.Listener): class LeapListener(Leap.Listener):
def __init__(self, position_ranges, position_tolerance, logger, def __init__(
frames_throttle_secs=None): self, position_ranges, position_tolerance, logger, frames_throttle_secs=None
):
super().__init__() super().__init__()
self.prev_frame = None self.prev_frame = None
@ -138,8 +147,11 @@ class LeapListener(Leap.Listener):
def _send_event(self, event): def _send_event(self, event):
backend = get_backend('redis') backend = get_backend('redis')
if not backend: if not backend:
self.logger.warning('Redis backend not configured, I cannot propagate the following event: {}'. self.logger.warning(
format(event)) 'Redis backend not configured, I cannot propagate the following event: {}'.format(
event
)
)
return return
backend.send_message(event) backend.send_message(event)
@ -147,8 +159,9 @@ class LeapListener(Leap.Listener):
def send_event(self, event): def send_event(self, event):
if self.frames_throttle_secs: if self.frames_throttle_secs:
if not self.running_future or not self.running_future.is_alive(): if not self.running_future or not self.running_future.is_alive():
self.running_future = LeapFuture(seconds=self.frames_throttle_secs, self.running_future = LeapFuture(
listener=self, event=event) seconds=self.frames_throttle_secs, listener=self, event=event
)
self.running_future.start() self.running_future.start()
else: else:
self._send_event(event) self._send_event(event)
@ -193,23 +206,38 @@ class LeapListener(Leap.Listener):
'id': hand.id, 'id': hand.id,
'is_left': hand.is_left, 'is_left': hand.is_left,
'is_right': hand.is_right, 'is_right': hand.is_right,
'palm_normal': [hand.palm_normal[0], hand.palm_normal[1], hand.palm_normal[2]], 'palm_normal': [
hand.palm_normal[0],
hand.palm_normal[1],
hand.palm_normal[2],
],
'palm_position': self._normalize_position(hand.palm_position), 'palm_position': self._normalize_position(hand.palm_position),
'palm_velocity': [hand.palm_velocity[0], hand.palm_velocity[1], hand.palm_velocity[2]], 'palm_velocity': [
hand.palm_velocity[0],
hand.palm_velocity[1],
hand.palm_velocity[2],
],
'palm_width': hand.palm_width, 'palm_width': hand.palm_width,
'sphere_center': [hand.sphere_center[0], hand.sphere_center[1], hand.sphere_center[2]], 'sphere_center': [
hand.sphere_center[0],
hand.sphere_center[1],
hand.sphere_center[2],
],
'sphere_radius': hand.sphere_radius, 'sphere_radius': hand.sphere_radius,
'stabilized_palm_position': self._normalize_position(hand.stabilized_palm_position), 'stabilized_palm_position': self._normalize_position(
hand.stabilized_palm_position
),
'time_visible': hand.time_visible, 'time_visible': hand.time_visible,
'wrist_position': self._normalize_position(hand.wrist_position), 'wrist_position': self._normalize_position(hand.wrist_position),
} }
for i, hand in enumerate(frame.hands) for i, hand in enumerate(frame.hands)
if hand.is_valid and ( if hand.is_valid
len(frame.hands) != len(self.prev_frame.hands) or and (
self._position_changed( len(frame.hands) != len(self.prev_frame.hands)
or self._position_changed(
old_position=self.prev_frame.hands[i].stabilized_palm_position, old_position=self.prev_frame.hands[i].stabilized_palm_position,
new_position=hand.stabilized_palm_position) new_position=hand.stabilized_palm_position,
)
if self.prev_frame if self.prev_frame
else True else True
) )
@ -220,9 +248,19 @@ class LeapListener(Leap.Listener):
# having x_range = z_range = [-100, 100], y_range = [0, 100] # having x_range = z_range = [-100, 100], y_range = [0, 100]
return [ return [
self._scale_scalar(value=position[0], range=self.position_ranges[0], new_range=[-100.0, 100.0]), self._scale_scalar(
self._scale_scalar(value=position[1], range=self.position_ranges[1], new_range=[0.0, 100.0]), value=position[0],
self._scale_scalar(value=position[2], range=self.position_ranges[2], new_range=[-100.0, 100.0]), range=self.position_ranges[0],
new_range=[-100.0, 100.0],
),
self._scale_scalar(
value=position[1], range=self.position_ranges[1], new_range=[0.0, 100.0]
),
self._scale_scalar(
value=position[2],
range=self.position_ranges[2],
new_range=[-100.0, 100.0],
),
] ]
@staticmethod @staticmethod
@ -232,13 +270,16 @@ class LeapListener(Leap.Listener):
if value > range[1]: if value > range[1]:
value = range[1] value = range[1]
return ((new_range[1]-new_range[0])/(range[1]-range[0]))*(value-range[0]) + new_range[0] return ((new_range[1] - new_range[0]) / (range[1] - range[0])) * (
value - range[0]
) + new_range[0]
def _position_changed(self, old_position, new_position): def _position_changed(self, old_position, new_position):
return ( return (
abs(old_position[0]-new_position[0]) > self.position_tolerance or abs(old_position[0] - new_position[0]) > self.position_tolerance
abs(old_position[1]-new_position[1]) > self.position_tolerance or or abs(old_position[1] - new_position[1]) > self.position_tolerance
abs(old_position[2]-new_position[2]) > self.position_tolerance) or abs(old_position[2] - new_position[2]) > self.position_tolerance
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -10,7 +10,18 @@ from platypush.message import Message
class TcpBackend(Backend): class TcpBackend(Backend):
""" """
Backend that reads messages from a configured TCP port Backend that reads messages from a configured TCP port.
You can use this backend to send messages to Platypush from any TCP client, for example:
.. code-block:: bash
$ echo '{"type": "request", "action": "shell.exec", "args": {"cmd": "ls /"}}' | nc localhost 1234
.. warning:: Be **VERY** careful when exposing this backend to the Internet. Unlike the HTTP backend, this backend
doesn't implement any authentication mechanisms, so anyone who can connect to the TCP port will be able to
execute commands on your Platypush instance.
""" """
# Maximum length of a request to be processed # Maximum length of a request to be processed

View file

@ -3,31 +3,21 @@ import time
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.todoist import NewItemEvent, RemovedItemEvent, ModifiedItemEvent, CheckedItemEvent, \ from platypush.message.event.todoist import (
ItemContentChangeEvent, TodoistSyncRequiredEvent NewItemEvent,
RemovedItemEvent,
ModifiedItemEvent,
CheckedItemEvent,
ItemContentChangeEvent,
TodoistSyncRequiredEvent,
)
from platypush.plugins.todoist import TodoistPlugin from platypush.plugins.todoist import TodoistPlugin
class TodoistBackend(Backend): class TodoistBackend(Backend):
""" """
This backend listens for events on a remote Todoist account. This backend listens for events on a Todoist account.
Requires:
* **todoist-python** (``pip install todoist-python``)
Triggers:
* :class:`platypush.message.event.todoist.NewItemEvent` when a new item is created.
* :class:`platypush.message.event.todoist.RemovedItemEvent` when an item is removed.
* :class:`platypush.message.event.todoist.CheckedItemEvent` when an item is checked.
* :class:`platypush.message.event.todoist.ItemContentChangeEvent` when the content of an item is changed.
* :class:`platypush.message.event.todoist.ModifiedItemEvent` when an item is changed and the change
doesn't fall into the categories above.
* :class:`platypush.message.event.todoist.TodoistSyncRequiredEvent` when an update has occurred that doesn't
fall into the categories above and a sync is required to get up-to-date.
""" """
def __init__(self, api_token: str = None, **kwargs): def __init__(self, api_token: str = None, **kwargs):
@ -35,7 +25,9 @@ class TodoistBackend(Backend):
self._plugin: TodoistPlugin = get_plugin('todoist') self._plugin: TodoistPlugin = get_plugin('todoist')
if not api_token: if not api_token:
assert self._plugin and self._plugin.api_token, 'No api_token specified either on Todoist backend or plugin' assert (
self._plugin and self._plugin.api_token
), 'No api_token specified either on Todoist backend or plugin'
self.api_token = self._plugin.api_token self.api_token = self._plugin.api_token
else: else:
self.api_token = api_token self.api_token = api_token
@ -97,16 +89,15 @@ class TodoistBackend(Backend):
import websocket import websocket
if not self._ws: if not self._ws:
self._ws = websocket.WebSocketApp(self.url, self._ws = websocket.WebSocketApp(
self.url,
on_message=self._on_msg(), on_message=self._on_msg(),
on_error=self._on_error(), on_error=self._on_error(),
on_close=self._on_close()) on_close=self._on_close(),
)
def _refresh_items(self): def _refresh_items(self):
new_items = { new_items = {i['id']: i for i in self._plugin.get_items().output}
i['id']: i
for i in self._plugin.get_items().output
}
if self._todoist_initialized: if self._todoist_initialized:
for id, item in new_items.items(): for id, item in new_items.items():

View file

@ -34,13 +34,6 @@ class TrelloBackend(Backend):
* The :class:`platypush.plugins.trello.TrelloPlugin` configured. * The :class:`platypush.plugins.trello.TrelloPlugin` configured.
Triggers:
* :class:`platypush.message.event.trello.NewCardEvent` when a card is created.
* :class:`platypush.message.event.trello.MoveCardEvent` when a card is moved.
* :class:`platypush.message.event.trello.ArchivedCardEvent` when a card is archived/closed.
* :class:`platypush.message.event.trello.UnarchivedCardEvent` when a card is un-archived/opened.
""" """
_websocket_url_base = 'wss://trello.com/1/Session/socket?token={token}' _websocket_url_base = 'wss://trello.com/1/Session/socket?token={token}'

View file

@ -2,7 +2,10 @@ import time
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.weather import NewWeatherConditionEvent, NewPrecipitationForecastEvent from platypush.message.event.weather import (
NewWeatherConditionEvent,
NewPrecipitationForecastEvent,
)
from platypush.plugins.weather.buienradar import WeatherBuienradarPlugin from platypush.plugins.weather.buienradar import WeatherBuienradarPlugin
@ -10,10 +13,6 @@ class WeatherBuienradarBackend(Backend):
""" """
Buienradar weather forecast backend. Listens for new weather or precipitation updates. Buienradar weather forecast backend. Listens for new weather or precipitation updates.
Triggers:
* :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update
Requires: Requires:
* The :mod:`platypush.plugins.weather.buienradar` plugin configured * The :mod:`platypush.plugins.weather.buienradar` plugin configured
@ -37,16 +36,24 @@ class WeatherBuienradarBackend(Backend):
del weather['measured'] del weather['measured']
if precip != self.last_precip: if precip != self.last_precip:
self.bus.post(NewPrecipitationForecastEvent(plugin_name='weather.buienradar', self.bus.post(
NewPrecipitationForecastEvent(
plugin_name='weather.buienradar',
average=precip.get('average'), average=precip.get('average'),
total=precip.get('total'), total=precip.get('total'),
time_frame=precip.get('time_frame'))) time_frame=precip.get('time_frame'),
)
)
if weather != self.last_weather: if weather != self.last_weather:
self.bus.post(NewWeatherConditionEvent(**{ self.bus.post(
NewWeatherConditionEvent(
**{
**weather, **weather,
'plugin_name': 'weather.buienradar', 'plugin_name': 'weather.buienradar',
})) }
)
)
self.last_weather = weather self.last_weather = weather
self.last_precip = precip self.last_precip = precip

View file

@ -5,10 +5,6 @@ class WeatherDarkskyBackend(WeatherBackend):
""" """
Weather forecast backend that leverages the DarkSky API. Weather forecast backend that leverages the DarkSky API.
Triggers:
* :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update
Requires: Requires:
* The :class:`platypush.plugins.weather.darksky.WeatherDarkskyPlugin` plugin configured * The :class:`platypush.plugins.weather.darksky.WeatherDarkskyPlugin` plugin configured
@ -19,7 +15,9 @@ class WeatherDarkskyBackend(WeatherBackend):
""" """
:param poll_seconds: How often the backend should check for updates (default: every 5 minutes). :param poll_seconds: How often the backend should check for updates (default: every 5 minutes).
""" """
super().__init__(plugin_name='weather.darksky', poll_seconds=poll_seconds, **kwargs) super().__init__(
plugin_name='weather.darksky', poll_seconds=poll_seconds, **kwargs
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -5,10 +5,6 @@ class WeatherOpenweathermapBackend(WeatherBackend):
""" """
Weather forecast backend that leverages the OpenWeatherMap API. Weather forecast backend that leverages the OpenWeatherMap API.
Triggers:
* :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update
Requires: Requires:
* The :class:`platypush.plugins.weather.openweathermap.WeatherOpenWeatherMapPlugin` plugin configured * The :class:`platypush.plugins.weather.openweathermap.WeatherOpenWeatherMapPlugin` plugin configured
@ -19,7 +15,9 @@ class WeatherOpenweathermapBackend(WeatherBackend):
""" """
:param poll_seconds: How often the backend should check for updates (default: every minute). :param poll_seconds: How often the backend should check for updates (default: every minute).
""" """
super().__init__(plugin_name='weather.openweathermap', poll_seconds=poll_seconds, **kwargs) super().__init__(
plugin_name='weather.openweathermap', poll_seconds=poll_seconds, **kwargs
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -13,14 +13,10 @@ class WiimoteBackend(Backend):
""" """
Backend to communicate with a Nintendo WiiMote controller Backend to communicate with a Nintendo WiiMote controller
Triggers:
* :class:`platypush.message.event.wiimote.WiimoteEvent` \
when the state of the Wiimote (battery, buttons, acceleration etc.) changes
Requires: Requires:
* **python3-wiimote** (follow instructions at https://github.com/azzra/python3-wiimote) * **python3-wiimote** (follow instructions at https://github.com/azzra/python3-wiimote)
""" """
_wiimote = None _wiimote = None

View file

@ -1,7 +1,6 @@
manifest: manifest:
events: events:
platypush.message.event.wiimote.WiimoteEvent: when the state of the Wiimote (battery, - platypush.message.event.wiimote.WiimoteEvent
buttons, acceleration etc.) changes
install: install:
apt: apt:
- libcwiid1 - libcwiid1

View file

@ -1,34 +0,0 @@
import warnings
from platypush.backend import Backend
class ZwaveMqttBackend(Backend):
"""
Listen for events on a zwave2mqtt service.
**WARNING**: This backend is **DEPRECATED** and it will be removed in a
future version.
It has been merged with
:class:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin`.
Now you can simply configure the `zwave.mqtt` plugin in order to enable
the Zwave integration - no need to enable both the plugin and the backend.
"""
def run(self):
super().run()
warnings.warn(
'''
The zwave.mqtt backend has been merged into the zwave.mqtt plugin.
It is now deprecated and it will be removed in a future version.
Please remove any references to it from your configuration.
''',
DeprecationWarning,
)
self.wait_stop()
# vim:sw=4:ts=4:et:

View file

@ -1,28 +0,0 @@
manifest:
events:
platypush.message.event.zwave.ZwaveNodeAddedEvent: when a node is added to the
network.
platypush.message.event.zwave.ZwaveNodeAsleepEvent: when a node goes into sleep
mode.
platypush.message.event.zwave.ZwaveNodeAwakeEvent: when a node goes back into
awake mode.
platypush.message.event.zwave.ZwaveNodeEvent: when a node attribute changes.
platypush.message.event.zwave.ZwaveNodeReadyEvent: when a node is ready.
platypush.message.event.zwave.ZwaveNodeRemovedEvent: when a node is removed from
the network.
platypush.message.event.zwave.ZwaveNodeRenamedEvent: when a node is renamed.
platypush.message.event.zwave.ZwaveValueChangedEvent: when the value of a node
on the networkchanges.
install:
apk:
- py3-paho-mqtt
dnf:
- python-paho-mqtt
pacman:
- python-paho-mqtt
apt:
- python3-paho-mqtt
pip:
- paho-mqtt
package: platypush.backend.zwave.mqtt
type: backend

View file

@ -18,11 +18,6 @@ class AdafruitIoPlugin(Plugin):
You can send values to feeds on your Adafruit IO account and read the You can send values to feeds on your Adafruit IO account and read the
values of those feeds as well through any device. values of those feeds as well through any device.
Requires:
* **adafruit-io** (``pip install adafruit-io``)
* Redis server running and Redis backend configured if you want to enable throttling
Some example usages:: Some example usages::
# Send the temperature value for a connected sensor to the "temperature" feed # Send the temperature value for a connected sensor to the "temperature" feed
@ -63,6 +58,7 @@ class AdafruitIoPlugin(Plugin):
""" """
from Adafruit_IO import Client from Adafruit_IO import Client
global data_throttler_lock global data_throttler_lock
super().__init__(**kwargs) super().__init__(**kwargs)
@ -109,15 +105,19 @@ class AdafruitIoPlugin(Plugin):
while True: while True:
try: try:
new_data = ast.literal_eval( new_data = ast.literal_eval(
redis.blpop(self._DATA_THROTTLER_QUEUE)[1].decode('utf-8')) redis.blpop(self._DATA_THROTTLER_QUEUE)[1].decode('utf-8')
)
for (key, value) in new_data.items(): for (key, value) in new_data.items():
data.setdefault(key, []).append(value) data.setdefault(key, []).append(value)
except QueueTimeoutError: except QueueTimeoutError:
pass pass
if data and (last_processed_batch_timestamp is None or if data and (
time.time() - last_processed_batch_timestamp >= self.throttle_seconds): last_processed_batch_timestamp is None
or time.time() - last_processed_batch_timestamp
>= self.throttle_seconds
):
last_processed_batch_timestamp = time.time() last_processed_batch_timestamp = time.time()
self.logger.info('Processing feeds batch for Adafruit IO') self.logger.info('Processing feeds batch for Adafruit IO')
@ -128,8 +128,10 @@ class AdafruitIoPlugin(Plugin):
try: try:
self.send(feed, value, enqueue=False) self.send(feed, value, enqueue=False)
except ThrottlingError: except ThrottlingError:
self.logger.warning('Adafruit IO throttling threshold hit, taking a nap ' + self.logger.warning(
'before retrying') 'Adafruit IO throttling threshold hit, taking a nap '
+ 'before retrying'
)
time.sleep(self.throttle_seconds) time.sleep(self.throttle_seconds)
data = {} data = {}
@ -184,11 +186,15 @@ class AdafruitIoPlugin(Plugin):
:type value: Numeric or string :type value: Numeric or string
""" """
self.aio.send_data(feed=feed, value=value, metadata={ self.aio.send_data(
feed=feed,
value=value,
metadata={
'lat': lat, 'lat': lat,
'lon': lon, 'lon': lon,
'ele': ele, 'ele': ele,
}) },
)
@classmethod @classmethod
def _cast_value(cls, value): def _cast_value(cls, value):
@ -205,9 +211,12 @@ class AdafruitIoPlugin(Plugin):
return [ return [
{ {
attr: self._cast_value(getattr(i, attr)) attr: self._cast_value(getattr(i, attr))
if attr == 'value' else getattr(i, attr) if attr == 'value'
for attr in DATA_FIELDS if getattr(i, attr) is not None else getattr(i, attr)
} for i in data for attr in DATA_FIELDS
if getattr(i, attr) is not None
}
for i in data
] ]
@action @action

View file

@ -58,17 +58,6 @@ class ArduinoPlugin(SensorPlugin):
Download and flash the Download and flash the
`Standard Firmata <https://github.com/firmata/arduino/blob/master/examples/StandardFirmata/StandardFirmata.ino>`_ `Standard Firmata <https://github.com/firmata/arduino/blob/master/examples/StandardFirmata/StandardFirmata.ino>`_
firmware to the Arduino in order to use this plugin. firmware to the Arduino in order to use this plugin.
Requires:
* **pyfirmata2** (``pip install pyfirmata2``)
Triggers:
* :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent`
* :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent`
* :class:`platypush.message.event.sensor.SensorDataChangeEvent`
""" """
def __init__( def __init__(

View file

@ -25,18 +25,6 @@ class AssistantEchoPlugin(AssistantPlugin):
4. Log in to your Amazon account 4. Log in to your Amazon account
5. The required credentials will be stored to ~/.avs.json 5. The required credentials will be stored to ~/.avs.json
Triggers:
* :class:`platypush.message.event.assistant.ConversationStartEvent`
when a new conversation starts
* :class:`platypush.message.event.assistant.SpeechRecognizedEvent`
when a new voice command is recognized
* :class:`platypush.message.event.assistant.ConversationEndEvent`
when a new conversation ends
Requires:
* **avs** (``pip install avs``)
""" """
def __init__( def __init__(

View file

@ -20,22 +20,6 @@ from platypush.plugins.assistant import AssistantPlugin
class AssistantGooglePushtotalkPlugin(AssistantPlugin): class AssistantGooglePushtotalkPlugin(AssistantPlugin):
""" """
Plugin for the Google Assistant push-to-talk API. Plugin for the Google Assistant push-to-talk API.
Triggers:
* :class:`platypush.message.event.assistant.ConversationStartEvent`
when a new conversation starts
* :class:`platypush.message.event.assistant.SpeechRecognizedEvent`
when a new voice command is recognized
* :class:`platypush.message.event.assistant.ConversationEndEvent`
when a new conversation ends
Requires:
* **tenacity** (``pip install tenacity``)
* **google-assistant-sdk** (``pip install google-assistant-sdk[samples]``)
* **google-auth** (``pip install google-auth``)
""" """
api_endpoint = 'embeddedassistant.googleapis.com' api_endpoint = 'embeddedassistant.googleapis.com'

View file

@ -1,3 +1,650 @@
from ._plugin import BluetoothPlugin import base64
import os
import re
from queue import Empty, Queue
import threading
import time
from typing import (
Any,
Collection,
Dict,
Final,
List,
Optional,
Union,
Type,
)
from platypush.common import StoppableThread
from platypush.context import get_bus, get_plugin
from platypush.entities import (
EnumSwitchEntityManager,
get_entities_engine,
)
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.message.event.bluetooth import (
BluetoothScanPausedEvent,
BluetoothScanResumedEvent,
)
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.db import DbPlugin
from ._ble import BLEManager
from ._cache import EntityCache
from ._legacy import LegacyManager
from ._types import DevicesBlacklist, RawServiceClass
from ._manager import BaseBluetoothManager
# pylint: disable=too-many-ancestors
class BluetoothPlugin(RunnablePlugin, EnumSwitchEntityManager):
"""
Plugin to interact with Bluetooth devices.
This plugin uses `Bleak <https://github.com/hbldh/bleak>`_ to interact
with the Bluetooth stack and `Theengs <https://github.com/theengs/decoder>`_
to map the services exposed by the devices into native entities.
The full list of devices natively supported can be found
`here <https://decoder.theengs.io/devices/devices_by_brand.html>`_.
It also supports legacy Bluetooth services, as well as the transfer of
files.
Note that the support for Bluetooth low-energy devices requires a Bluetooth
adapter compatible with the Bluetooth 5.0 specification or higher.
"""
_default_connect_timeout: Final[int] = 20
""" Default connection timeout (in seconds) """
_default_scan_duration: Final[float] = 10.0
""" Default duration of a discovery session (in seconds) """
def __init__(
self,
interface: Optional[str] = None,
connect_timeout: float = _default_connect_timeout,
service_uuids: Optional[Collection[RawServiceClass]] = None,
scan_paused_on_start: bool = False,
poll_interval: float = _default_scan_duration,
exclude_known_noisy_beacons: bool = True,
ignored_device_addresses: Optional[Collection[str]] = None,
ignored_device_names: Optional[Collection[str]] = None,
ignored_device_manufacturers: Optional[Collection[str]] = None,
**kwargs,
):
"""
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
on Linux). Default: first available interface.
:param connect_timeout: Timeout in seconds for the connection to a
Bluetooth device. Default: 20 seconds.
:param service_uuids: List of service UUIDs to discover.
Default: all.
:param scan_paused_on_start: If ``True``, the plugin will not the
scanning thread until :meth:`.scan_resume` is called (default:
``False``).
:param exclude_known_noisy_beacons: Exclude BLE beacons from devices
known for being very noisy. It mainly includes tracking services on
Google, Apple, Microsoft and Samsung devices. These devices are
also known for refreshing their MAC address very frequently, which
may result in a large (and constantly increasing) list of devices.
Disable this flag if you need to track BLE beacons from these
devices, but beware that you may need periodically clean up your
list of scanned devices.
:param ignored_device_addresses: List of device addresses to ignore.
:param ignored_device_names: List of device names to ignore.
:param ignored_device_manufacturers: List of device manufacturers to
ignore.
"""
kwargs['poll_interval'] = poll_interval
super().__init__(**kwargs)
self._interface: Optional[str] = interface
""" Default Bluetooth interface to use """
self._connect_timeout: float = connect_timeout
""" Connection timeout in seconds """
self._service_uuids: Collection[RawServiceClass] = service_uuids or []
""" UUIDs to discover """
self._scan_lock = threading.RLock()
""" Lock to synchronize scanning access to the Bluetooth device """
self._scan_enabled = threading.Event()
""" Event used to enable/disable scanning """
self._device_queue: Queue[BluetoothDevice] = Queue()
"""
Queue used by the Bluetooth managers to published the discovered
Bluetooth devices.
"""
self._device_cache = EntityCache()
"""
Cache of the devices discovered by the plugin.
"""
self._excluded_known_noisy_beacons = exclude_known_noisy_beacons
""" Exclude known noisy BLE beacons. """
self._blacklist = DevicesBlacklist(
addresses=set(ignored_device_addresses or []),
names=set(ignored_device_names or []),
manufacturers=set(ignored_device_manufacturers or []),
)
""" Blacklist rules for the devices to ignore. """
self._managers: Dict[Type[BaseBluetoothManager], BaseBluetoothManager] = {}
"""
Bluetooth managers threads, one for BLE devices and one for non-BLE
devices.
"""
self._scan_controller_timer: Optional[threading.Timer] = None
""" Timer used to temporarily pause the discovery process """
if not scan_paused_on_start:
self._scan_enabled.set()
def _refresh_cache(self) -> None:
# Wait for the entities engine to start
get_entities_engine().wait_start()
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
existing_devices = [d.copy() for d in session.query(BluetoothDevice).all()]
for dev in existing_devices:
self._device_cache.add(dev)
def _init_bluetooth_managers(self):
"""
Initializes the Bluetooth managers threads.
"""
manager_args = {
'interface': self._interface,
'poll_interval': self.poll_interval,
'connect_timeout': self._connect_timeout,
'stop_event': self._should_stop,
'scan_lock': self._scan_lock,
'scan_enabled': self._scan_enabled,
'device_queue': self._device_queue,
'service_uuids': list(map(BluetoothService.to_uuid, self._service_uuids)),
'device_cache': self._device_cache,
'exclude_known_noisy_beacons': self._excluded_known_noisy_beacons,
'blacklist': self._blacklist,
}
self._managers = {
BLEManager: BLEManager(**manager_args),
LegacyManager: LegacyManager(**manager_args),
}
def _scan_state_set(self, state: bool, duration: Optional[float] = None):
"""
Set the state of the scanning process.
:param state: ``True`` to enable the scanning process, ``False`` to
disable it.
:param duration: The duration of the pause (in seconds) or ``None``.
"""
def timer_callback():
if state:
self.scan_pause()
else:
self.scan_resume()
self._scan_controller_timer = None
with self._scan_lock:
if not state and self._scan_enabled.is_set():
get_bus().post(BluetoothScanPausedEvent(duration=duration))
elif state and not self._scan_enabled.is_set():
get_bus().post(BluetoothScanResumedEvent(duration=duration))
if state:
self._scan_enabled.set()
else:
self._scan_enabled.clear()
if duration and not self._scan_controller_timer:
self._scan_controller_timer = threading.Timer(duration, timer_callback)
self._scan_controller_timer.start()
def _cancel_scan_controller_timer(self):
"""
Cancels a scan controller timer if scheduled.
"""
if self._scan_controller_timer:
self._scan_controller_timer.cancel()
def _manager_by_device(
self,
device: BluetoothDevice,
port: Optional[int] = None,
service_uuid: Optional[Union[str, RawServiceClass]] = None,
) -> BaseBluetoothManager:
"""
:param device: A discovered Bluetooth device.
:param port: The port to connect to.
:param service_uuid: The UUID of the service to connect to.
:return: The manager associated with the device (BLE or legacy).
"""
# No port nor service UUID -> use the BLE manager for direct connection
if not (port or service_uuid):
return self._managers[BLEManager]
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
matching_services = (
[srv for srv in device.services if srv.port == port]
if port
else [srv for srv in device.services if srv.uuid == uuid]
)
if not matching_services:
# It could be a GATT characteristic, so try BLE
return self._managers[BLEManager]
srv = matching_services[0]
return (
self._managers[BLEManager] if srv.is_ble else self._managers[LegacyManager]
)
def _get_device(self, device: str, _fail_if_not_cached=False) -> BluetoothDevice:
"""
Get a device by its address or name, and scan for it if it's not
cached.
"""
# If device is a compound entity ID in the format
# ``<mac_address>:<service>``, then split the MAC address part
m = re.match(r'^(([0-9a-f]{2}:){6}):.*', device, re.IGNORECASE)
if m:
device = m.group(1).rstrip(':')
dev = self._device_cache.get(device)
if dev:
return dev
assert not _fail_if_not_cached, f'Device {device} not found'
self.logger.info('Scanning for unknown device %s', device)
self.scan()
return self._get_device(device, _fail_if_not_cached=True)
@action
def connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[Union[RawServiceClass, str]] = None,
interface: Optional[str] = None,
timeout: Optional[float] = None,
):
"""
Pair and connect to a device by address or name.
:param device: The device address or name.
:param port: The port to connect to. Either ``port`` or
``service_uuid`` is required for non-BLE devices.
:param service_uuid: The UUID of the service to connect to. Either
``port`` or ``service_uuid`` is required for non-BLE devices.
:param interface: The Bluetooth interface to use (it overrides the
default ``interface``).
:param timeout: The connection timeout in seconds (it overrides the
default ``connect_timeout``).
"""
dev = self._get_device(device)
manager = self._manager_by_device(dev, port=port, service_uuid=service_uuid)
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
manager.connect(
dev.address,
port=port,
service_uuid=uuid,
interface=interface,
timeout=timeout,
)
@action
def disconnect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
):
"""
Close an active connection to a device.
Note that this method can only close connections that have been
initiated by the application. It can't close connections owned by
other applications or agents.
:param device: The device address or name.
:param port: If connected to a non-BLE device, the optional port to
disconnect.
:param service_uuid: The optional UUID of the service to disconnect
from, for non-BLE devices.
"""
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
err = None
success = False
for manager in self._managers.values():
try:
manager.disconnect(dev.address, port=port, service_uuid=uuid)
success = True
except Exception as e:
err = e
assert success, f'Could not disconnect from {device}: {err}'
@action
def scan_pause(self, duration: Optional[float] = None):
"""
Pause the scanning thread.
:param duration: For how long the scanning thread should be paused
(default: null = indefinitely).
"""
self._scan_state_set(False, duration)
@action
def scan_resume(self, duration: Optional[float] = None):
"""
Resume the scanning thread, if inactive.
:param duration: For how long the scanning thread should be running
(default: null = indefinitely).
"""
self._scan_state_set(True, duration)
@action
def scan(
self,
duration: Optional[float] = None,
devices: Optional[Collection[str]] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
) -> List[BluetoothDevice]:
"""
Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter)
:param devices: List of device addresses or names to scan for.
:param service_uuids: List of service UUIDs to discover. Default: all.
"""
scanned_device_addresses = set()
duration = duration or self.poll_interval or self._default_scan_duration
uuids = {BluetoothService.to_uuid(uuid) for uuid in (service_uuids or [])}
for manager in self._managers.values():
scanned_device_addresses.update(
[
device.address
for device in manager.scan(duration=duration // len(self._managers))
if (not uuids or any(srv.uuid in uuids for srv in device.services))
and (
not devices
or device.address in devices
or device.name in devices
)
]
)
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
return [
d.copy()
for d in session.query(BluetoothDevice).all()
if d.address in scanned_device_addresses
]
@action
def read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> str:
"""
Read a message from a device.
:param device: Name or address of the device to read from.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None).
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
:return: The base64-encoded response received from the device.
"""
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid)
manager = self._manager_by_device(dev, service_uuid=uuid)
data = manager.read(
dev.address, uuid, interface=interface, connect_timeout=connect_timeout
)
return base64.b64encode(data).decode()
@action
def write(
self,
device: str,
data: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
"""
Writes data to a device
:param device: Name or address of the device to read from.
:param data: Data to be written, as a base64-encoded string.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None)
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
binary_data = base64.b64decode(data.encode())
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid)
manager = self._manager_by_device(dev, service_uuid=uuid)
manager.write(
dev.address,
binary_data,
service_uuid=uuid,
interface=interface,
connect_timeout=connect_timeout,
)
@action
def set(self, entity: str, value: Any, **_):
"""
Set the value of an entity.
This is currently only supported for SwitchBot devices, where the value
can be one among ``on``, ``off`` and ``press``.
:param entity: The entity to set the value for. It can be the full
entity ID in the format ``<mac-address>::<service>``, or just
the MAC address if the plugin supports it.
:param value: The value to set the entity to.
"""
device = self._get_device(entity)
matching_plugin = next(
iter(
plugin
for manager in self._managers.values()
for plugin in manager.plugins
if plugin.supports_device(device)
),
None,
)
assert (
matching_plugin is not None
), f'Action `set` not supported on device {entity}'
method = getattr(matching_plugin, 'set', None)
assert method, f'The plugin {matching_plugin} does not support `set`'
return method(device, value)
@action
def send_file(
self,
file: str,
device: str,
data: Optional[Union[str, bytes, bytearray]] = None,
binary: bool = False,
):
"""
Send a file to a device that exposes an OBEX Object Push service.
:param file: Path of the file to be sent. If ``data`` is specified
then ``file`` should include the proposed file on the
receiving host.
:param data: Alternatively to a file on disk you can send raw (string
or binary) content.
:param device: Device address or name.
:param binary: Set to true if data is a base64-encoded binary string.
"""
from ._file import FileSender
if not data:
file = os.path.abspath(os.path.expanduser(file))
with open(file, 'rb') as f:
binary_data = f.read()
elif binary:
binary_data = base64.b64decode(
data.encode() if isinstance(data, str) else data
)
elif isinstance(data, str):
binary_data = data.encode()
else:
binary_data = data
sender = FileSender(self._managers[LegacyManager]) # type: ignore
sender.send_file(file, device, binary_data)
@action
def status(
self,
*_,
duration: Optional[float] = None,
devices: Optional[Collection[str]] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
**__,
) -> List[BluetoothDevice]:
"""
Retrieve the status of all the devices, or the matching
devices/services.
If scanning is currently disabled, it will enable it and perform a
scan.
The differences between this method and :meth:`.scan` are:
1. :meth:`.status` will return the status of all the devices known
to the application, while :meth:`.scan` will return the status
only of the devices discovered in the provided time window.
2. :meth:`.status` will not initiate a new scan if scanning is
already enabled (it will only return the status of the known
devices), while :meth:`.scan` will initiate a new scan.
:param duration: Scan duration in seconds, if scanning is disabled
(default: same as the plugin's `poll_interval` configuration
parameter)
:param devices: List of device addresses or names to filter for.
Default: all.
:param service_uuids: List of service UUIDs to filter for. Default:
all.
"""
if not self._scan_enabled.is_set():
self.scan(
duration=duration,
devices=devices,
service_uuids=service_uuids,
)
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
known_devices = [
d.copy()
for d in session.query(BluetoothDevice).all()
if (not devices or d.address in devices or d.name in devices)
and (
not service_uuids
or any(str(srv.uuid) in service_uuids for srv in d.services)
)
]
# Send entity update events to keep any asynchronous clients in sync
get_entities_engine().notify(*known_devices)
return known_devices
def transform_entities(
self, entities: Collection[BluetoothDevice]
) -> Collection[BluetoothDevice]:
return super().transform_entities(entities)
def main(self):
self._refresh_cache()
self._init_bluetooth_managers()
for manager in self._managers.values():
manager.start()
try:
while not self.should_stop():
try:
device = self._device_queue.get(timeout=1)
except Empty:
continue
device = self._device_cache.add(device)
self.publish_entities([device], callback=self._device_cache.add)
finally:
self.stop()
def stop(self):
"""
Upon stop request, it stops any pending scans and closes all active
connections.
"""
super().stop()
self._cancel_scan_controller_timer()
self._stop_threads(self._managers.values())
def _stop_threads(self, threads: Collection[StoppableThread], timeout: float = 5):
"""
Set the stop events on active threads and wait for them to stop.
"""
# Set the stop events and call `.stop`
for thread in threads:
if thread and thread.is_alive():
self.logger.info('Waiting for %s to stop', thread.name)
try:
thread.stop()
except Exception as e:
self.logger.exception('Error while stopping %s: %s', thread.name, e)
# Wait for the manager threads to stop
wait_start = time.time()
for thread in threads:
if (
thread
and thread.ident != threading.current_thread().ident
and thread.is_alive()
):
thread.join(timeout=max(0, int(timeout - (time.time() - wait_start))))
if thread and thread.is_alive():
self.logger.warning(
'Timeout while waiting for %s to stop', thread.name
)
__all__ = ["BluetoothPlugin"] __all__ = ["BluetoothPlugin"]
# vim:sw=4:ts=4:et:

View file

@ -1,674 +0,0 @@
import base64
import os
import re
from queue import Empty, Queue
import threading
import time
from typing import (
Any,
Collection,
Dict,
Final,
List,
Optional,
Union,
Type,
)
from platypush.common import StoppableThread
from platypush.context import get_bus, get_plugin
from platypush.entities import (
EnumSwitchEntityManager,
get_entities_engine,
)
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.message.event.bluetooth import (
BluetoothScanPausedEvent,
BluetoothScanResumedEvent,
)
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.db import DbPlugin
from ._ble import BLEManager
from ._cache import EntityCache
from ._legacy import LegacyManager
from ._types import DevicesBlacklist, RawServiceClass
from ._manager import BaseBluetoothManager
# pylint: disable=too-many-ancestors
class BluetoothPlugin(RunnablePlugin, EnumSwitchEntityManager):
"""
Plugin to interact with Bluetooth devices.
This plugin uses `_Bleak_ <https://github.com/hbldh/bleak>`_ to interact
with the Bluetooth stack and `_Theengs_ <https://github.com/theengs/decoder>`_
to map the services exposed by the devices into native entities.
The full list of devices natively supported can be found
`here <https://decoder.theengs.io/devices/devices_by_brand.html>`_.
It also supports legacy Bluetooth services, as well as the transfer of
files.
Note that the support for Bluetooth low-energy devices requires a Bluetooth
adapter compatible with the Bluetooth 5.0 specification or higher.
Requires:
* **bleak** (``pip install bleak``)
* **bluetooth-numbers** (``pip install bluetooth-numbers``)
* **TheengsDecoder** (``pip install TheengsDecoder``)
* **pydbus** (``pip install pydbus``)
* **pybluez** (``pip install git+https://github.com/pybluez/pybluez``)
Triggers:
* :class:`platypush.message.event.bluetooth.BluetoothConnectionFailedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent`
* :class:`platypush.message.event.bluetooth.BluetoothFileReceivedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothFileSentEvent`
* :class:`platypush.message.event.bluetooth.BluetoothFileTransferStartedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent`
* :class:`platypush.message.event.entities.EntityUpdateEvent`
"""
_default_connect_timeout: Final[int] = 20
""" Default connection timeout (in seconds) """
_default_scan_duration: Final[float] = 10.0
""" Default duration of a discovery session (in seconds) """
def __init__(
self,
interface: Optional[str] = None,
connect_timeout: float = _default_connect_timeout,
service_uuids: Optional[Collection[RawServiceClass]] = None,
scan_paused_on_start: bool = False,
poll_interval: float = _default_scan_duration,
exclude_known_noisy_beacons: bool = True,
ignored_device_addresses: Optional[Collection[str]] = None,
ignored_device_names: Optional[Collection[str]] = None,
ignored_device_manufacturers: Optional[Collection[str]] = None,
**kwargs,
):
"""
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
on Linux). Default: first available interface.
:param connect_timeout: Timeout in seconds for the connection to a
Bluetooth device. Default: 20 seconds.
:param service_uuids: List of service UUIDs to discover.
Default: all.
:param scan_paused_on_start: If ``True``, the plugin will not the
scanning thread until :meth:`.scan_resume` is called (default:
``False``).
:param exclude_known_noisy_beacons: Exclude BLE beacons from devices
known for being very noisy. It mainly includes tracking services on
Google, Apple, Microsoft and Samsung devices. These devices are
also known for refreshing their MAC address very frequently, which
may result in a large (and constantly increasing) list of devices.
Disable this flag if you need to track BLE beacons from these
devices, but beware that you may need periodically clean up your
list of scanned devices.
:param ignored_device_addresses: List of device addresses to ignore.
:param ignored_device_names: List of device names to ignore.
:param ignored_device_manufacturers: List of device manufacturers to
ignore.
"""
kwargs['poll_interval'] = poll_interval
super().__init__(**kwargs)
self._interface: Optional[str] = interface
""" Default Bluetooth interface to use """
self._connect_timeout: float = connect_timeout
""" Connection timeout in seconds """
self._service_uuids: Collection[RawServiceClass] = service_uuids or []
""" UUIDs to discover """
self._scan_lock = threading.RLock()
""" Lock to synchronize scanning access to the Bluetooth device """
self._scan_enabled = threading.Event()
""" Event used to enable/disable scanning """
self._device_queue: Queue[BluetoothDevice] = Queue()
"""
Queue used by the Bluetooth managers to published the discovered
Bluetooth devices.
"""
self._device_cache = EntityCache()
"""
Cache of the devices discovered by the plugin.
"""
self._excluded_known_noisy_beacons = exclude_known_noisy_beacons
""" Exclude known noisy BLE beacons. """
self._blacklist = DevicesBlacklist(
addresses=set(ignored_device_addresses or []),
names=set(ignored_device_names or []),
manufacturers=set(ignored_device_manufacturers or []),
)
""" Blacklist rules for the devices to ignore. """
self._managers: Dict[Type[BaseBluetoothManager], BaseBluetoothManager] = {}
"""
Bluetooth managers threads, one for BLE devices and one for non-BLE
devices.
"""
self._scan_controller_timer: Optional[threading.Timer] = None
""" Timer used to temporarily pause the discovery process """
if not scan_paused_on_start:
self._scan_enabled.set()
def _refresh_cache(self) -> None:
# Wait for the entities engine to start
get_entities_engine().wait_start()
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
existing_devices = [d.copy() for d in session.query(BluetoothDevice).all()]
for dev in existing_devices:
self._device_cache.add(dev)
def _init_bluetooth_managers(self):
"""
Initializes the Bluetooth managers threads.
"""
manager_args = {
'interface': self._interface,
'poll_interval': self.poll_interval,
'connect_timeout': self._connect_timeout,
'stop_event': self._should_stop,
'scan_lock': self._scan_lock,
'scan_enabled': self._scan_enabled,
'device_queue': self._device_queue,
'service_uuids': list(map(BluetoothService.to_uuid, self._service_uuids)),
'device_cache': self._device_cache,
'exclude_known_noisy_beacons': self._excluded_known_noisy_beacons,
'blacklist': self._blacklist,
}
self._managers = {
BLEManager: BLEManager(**manager_args),
LegacyManager: LegacyManager(**manager_args),
}
def _scan_state_set(self, state: bool, duration: Optional[float] = None):
"""
Set the state of the scanning process.
:param state: ``True`` to enable the scanning process, ``False`` to
disable it.
:param duration: The duration of the pause (in seconds) or ``None``.
"""
def timer_callback():
if state:
self.scan_pause()
else:
self.scan_resume()
self._scan_controller_timer = None
with self._scan_lock:
if not state and self._scan_enabled.is_set():
get_bus().post(BluetoothScanPausedEvent(duration=duration))
elif state and not self._scan_enabled.is_set():
get_bus().post(BluetoothScanResumedEvent(duration=duration))
if state:
self._scan_enabled.set()
else:
self._scan_enabled.clear()
if duration and not self._scan_controller_timer:
self._scan_controller_timer = threading.Timer(duration, timer_callback)
self._scan_controller_timer.start()
def _cancel_scan_controller_timer(self):
"""
Cancels a scan controller timer if scheduled.
"""
if self._scan_controller_timer:
self._scan_controller_timer.cancel()
def _manager_by_device(
self,
device: BluetoothDevice,
port: Optional[int] = None,
service_uuid: Optional[Union[str, RawServiceClass]] = None,
) -> BaseBluetoothManager:
"""
:param device: A discovered Bluetooth device.
:param port: The port to connect to.
:param service_uuid: The UUID of the service to connect to.
:return: The manager associated with the device (BLE or legacy).
"""
# No port nor service UUID -> use the BLE manager for direct connection
if not (port or service_uuid):
return self._managers[BLEManager]
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
matching_services = (
[srv for srv in device.services if srv.port == port]
if port
else [srv for srv in device.services if srv.uuid == uuid]
)
if not matching_services:
# It could be a GATT characteristic, so try BLE
return self._managers[BLEManager]
srv = matching_services[0]
return (
self._managers[BLEManager] if srv.is_ble else self._managers[LegacyManager]
)
def _get_device(self, device: str, _fail_if_not_cached=False) -> BluetoothDevice:
"""
Get a device by its address or name, and scan for it if it's not
cached.
"""
# If device is a compound entity ID in the format
# ``<mac_address>:<service>``, then split the MAC address part
m = re.match(r'^(([0-9a-f]{2}:){6}):.*', device, re.IGNORECASE)
if m:
device = m.group(1).rstrip(':')
dev = self._device_cache.get(device)
if dev:
return dev
assert not _fail_if_not_cached, f'Device {device} not found'
self.logger.info('Scanning for unknown device %s', device)
self.scan()
return self._get_device(device, _fail_if_not_cached=True)
@action
def connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[Union[RawServiceClass, str]] = None,
interface: Optional[str] = None,
timeout: Optional[float] = None,
):
"""
Pair and connect to a device by address or name.
:param device: The device address or name.
:param port: The port to connect to. Either ``port`` or
``service_uuid`` is required for non-BLE devices.
:param service_uuid: The UUID of the service to connect to. Either
``port`` or ``service_uuid`` is required for non-BLE devices.
:param interface: The Bluetooth interface to use (it overrides the
default ``interface``).
:param timeout: The connection timeout in seconds (it overrides the
default ``connect_timeout``).
"""
dev = self._get_device(device)
manager = self._manager_by_device(dev, port=port, service_uuid=service_uuid)
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
manager.connect(
dev.address,
port=port,
service_uuid=uuid,
interface=interface,
timeout=timeout,
)
@action
def disconnect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
):
"""
Close an active connection to a device.
Note that this method can only close connections that have been
initiated by the application. It can't close connections owned by
other applications or agents.
:param device: The device address or name.
:param port: If connected to a non-BLE device, the optional port to
disconnect.
:param service_uuid: The optional UUID of the service to disconnect
from, for non-BLE devices.
"""
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
err = None
success = False
for manager in self._managers.values():
try:
manager.disconnect(dev.address, port=port, service_uuid=uuid)
success = True
except Exception as e:
err = e
assert success, f'Could not disconnect from {device}: {err}'
@action
def scan_pause(self, duration: Optional[float] = None):
"""
Pause the scanning thread.
:param duration: For how long the scanning thread should be paused
(default: null = indefinitely).
"""
self._scan_state_set(False, duration)
@action
def scan_resume(self, duration: Optional[float] = None):
"""
Resume the scanning thread, if inactive.
:param duration: For how long the scanning thread should be running
(default: null = indefinitely).
"""
self._scan_state_set(True, duration)
@action
def scan(
self,
duration: Optional[float] = None,
devices: Optional[Collection[str]] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
) -> List[BluetoothDevice]:
"""
Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter)
:param devices: List of device addresses or names to scan for.
:param service_uuids: List of service UUIDs to discover. Default: all.
"""
scanned_device_addresses = set()
duration = duration or self.poll_interval or self._default_scan_duration
uuids = {BluetoothService.to_uuid(uuid) for uuid in (service_uuids or [])}
for manager in self._managers.values():
scanned_device_addresses.update(
[
device.address
for device in manager.scan(duration=duration // len(self._managers))
if (not uuids or any(srv.uuid in uuids for srv in device.services))
and (
not devices
or device.address in devices
or device.name in devices
)
]
)
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
return [
d.copy()
for d in session.query(BluetoothDevice).all()
if d.address in scanned_device_addresses
]
@action
def read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> str:
"""
Read a message from a device.
:param device: Name or address of the device to read from.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None).
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
:return: The base64-encoded response received from the device.
"""
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid)
manager = self._manager_by_device(dev, service_uuid=uuid)
data = manager.read(
dev.address, uuid, interface=interface, connect_timeout=connect_timeout
)
return base64.b64encode(data).decode()
@action
def write(
self,
device: str,
data: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
"""
Writes data to a device
:param device: Name or address of the device to read from.
:param data: Data to be written, as a base64-encoded string.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None)
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
binary_data = base64.b64decode(data.encode())
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid)
manager = self._manager_by_device(dev, service_uuid=uuid)
manager.write(
dev.address,
binary_data,
service_uuid=uuid,
interface=interface,
connect_timeout=connect_timeout,
)
@action
def set(self, entity: str, value: Any, **_):
"""
Set the value of an entity.
This is currently only supported for Switchbot devices, where the value
can be one among ``on``, ``off`` and ``press``.
:param entity: The entity to set the value for. It can be the full
entity ID in the format ``<mac-address>::<service>``, or just
the MAC address if the plugin supports it.
:param value: The value to set the entity to.
"""
device = self._get_device(entity)
matching_plugin = next(
iter(
plugin
for manager in self._managers.values()
for plugin in manager.plugins
if plugin.supports_device(device)
),
None,
)
assert (
matching_plugin is not None
), f'Action `set` not supported on device {entity}'
method = getattr(matching_plugin, 'set', None)
assert method, f'The plugin {matching_plugin} does not support `set`'
return method(device, value)
@action
def send_file(
self,
file: str,
device: str,
data: Optional[Union[str, bytes, bytearray]] = None,
binary: bool = False,
):
"""
Send a file to a device that exposes an OBEX Object Push service.
:param file: Path of the file to be sent. If ``data`` is specified
then ``file`` should include the proposed file on the
receiving host.
:param data: Alternatively to a file on disk you can send raw (string
or binary) content.
:param device: Device address or name.
:param binary: Set to true if data is a base64-encoded binary string.
"""
from ._file import FileSender
if not data:
file = os.path.abspath(os.path.expanduser(file))
with open(file, 'rb') as f:
binary_data = f.read()
elif binary:
binary_data = base64.b64decode(
data.encode() if isinstance(data, str) else data
)
elif isinstance(data, str):
binary_data = data.encode()
else:
binary_data = data
sender = FileSender(self._managers[LegacyManager]) # type: ignore
sender.send_file(file, device, binary_data)
@action
def status(
self,
*_,
duration: Optional[float] = None,
devices: Optional[Collection[str]] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
**__,
) -> List[BluetoothDevice]:
"""
Retrieve the status of all the devices, or the matching
devices/services.
If scanning is currently disabled, it will enable it and perform a
scan.
The differences between this method and :meth:`.scan` are:
1. :meth:`.status` will return the status of all the devices known
to the application, while :meth:`.scan` will return the status
only of the devices discovered in the provided time window.
2. :meth:`.status` will not initiate a new scan if scanning is
already enabled (it will only returned the status of the known
devices), while :meth:`.scan` will initiate a new scan.
:param duration: Scan duration in seconds, if scanning is disabled
(default: same as the plugin's `poll_interval` configuration
parameter)
:param devices: List of device addresses or names to filter for.
Default: all.
:param service_uuids: List of service UUIDs to filter for. Default:
all.
"""
if not self._scan_enabled.is_set():
self.scan(
duration=duration,
devices=devices,
service_uuids=service_uuids,
)
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
known_devices = [
d.copy()
for d in session.query(BluetoothDevice).all()
if (not devices or d.address in devices or d.name in devices)
and (
not service_uuids
or any(str(srv.uuid) in service_uuids for srv in d.services)
)
]
# Send entity update events to keep any asynchronous clients in sync
get_entities_engine().notify(*known_devices)
return known_devices
def transform_entities(
self, entities: Collection[BluetoothDevice]
) -> Collection[BluetoothDevice]:
return super().transform_entities(entities)
def main(self):
self._refresh_cache()
self._init_bluetooth_managers()
for manager in self._managers.values():
manager.start()
try:
while not self.should_stop():
try:
device = self._device_queue.get(timeout=1)
except Empty:
continue
device = self._device_cache.add(device)
self.publish_entities([device], callback=self._device_cache.add)
finally:
self.stop()
def stop(self):
"""
Upon stop request, it stops any pending scans and closes all active
connections.
"""
super().stop()
self._cancel_scan_controller_timer()
self._stop_threads(self._managers.values())
def _stop_threads(self, threads: Collection[StoppableThread], timeout: float = 5):
"""
Set the stop events on active threads and wait for them to stop.
"""
# Set the stop events and call `.stop`
for thread in threads:
if thread and thread.is_alive():
self.logger.info('Waiting for %s to stop', thread.name)
try:
thread.stop()
except Exception as e:
self.logger.exception('Error while stopping %s: %s', thread.name, e)
# Wait for the manager threads to stop
wait_start = time.time()
for thread in threads:
if (
thread
and thread.ident != threading.current_thread().ident
and thread.is_alive()
):
thread.join(timeout=max(0, timeout - (time.time() - wait_start)))
if thread and thread.is_alive():
self.logger.warning(
'Timeout while waiting for %s to stop', thread.name
)
__all__ = ["BluetoothPlugin"]
# vim:sw=4:ts=4:et:

View file

@ -9,11 +9,6 @@ from platypush.plugins.calendar import CalendarInterface
class CalendarIcalPlugin(Plugin, CalendarInterface): class CalendarIcalPlugin(Plugin, CalendarInterface):
""" """
iCal calendars plugin. Interact with remote calendars in iCal format. iCal calendars plugin. Interact with remote calendars in iCal format.
Requires:
* **icalendar** (``pip install icalendar``)
""" """
def __init__(self, url, *args, **kwargs): def __init__(self, url, *args, **kwargs):

View file

@ -60,25 +60,6 @@ class CameraPlugin(Plugin, ABC):
Both the endpoints support the same parameters of the constructor of this class (e.g. ``device``, ``warmup_frames``, Both the endpoints support the same parameters of the constructor of this class (e.g. ``device``, ``warmup_frames``,
``duration`` etc.) as ``GET`` parameters. ``duration`` etc.) as ``GET`` parameters.
Requires:
* **Pillow** (``pip install Pillow``) [optional] default handler for image transformations.
* **wxPython** (``pip install wxPython``) [optional] default handler for camera previews (``ffplay`` will be
used as a fallback if ``wxPython`` is not installed).
* **ffmpeg** (see installation instructions for your OS) for rendering/streaming videos.
Triggers:
* :class:`platypush.message.event.camera.CameraRecordingStartedEvent`
when a new video recording/photo burst starts
* :class:`platypush.message.event.camera.CameraRecordingStoppedEvent`
when a video recording/photo burst ends
* :class:`platypush.message.event.camera.CameraVideoRenderedEvent`
when a sequence of captured is successfully rendered into a video
* :class:`platypush.message.event.camera.CameraPictureTakenEvent`
when a snapshot is captured and stored to an image file
""" """
_camera_class = Camera _camera_class = Camera

View file

@ -7,16 +7,15 @@ from platypush.plugins.camera.model.writer.cv import CvFileWriter
class CameraCvPlugin(CameraPlugin): class CameraCvPlugin(CameraPlugin):
""" """
Plugin to control generic cameras over OpenCV. Plugin to control generic cameras over OpenCV.
Requires:
* **opencv** (``pip install opencv-python``)
* **Pillow** (``pip install Pillow``)
""" """
def __init__(self, color_transform: Optional[str] = 'COLOR_BGR2RGB', video_type: str = 'XVID', def __init__(
video_writer: str = 'ffmpeg', **kwargs): self,
color_transform: Optional[str] = 'COLOR_BGR2RGB',
video_type: str = 'XVID',
video_writer: str = 'ffmpeg',
**kwargs
):
""" """
:param device: Device ID (0 for the first camera, 1 for the second etc.) or path (e.g. ``/dev/video0``). :param device: Device ID (0 for the first camera, 1 for the second etc.) or path (e.g. ``/dev/video0``).
:param video_type: Default video type to use when exporting captured frames to camera (default: 0, infers the :param video_type: Default video type to use when exporting captured frames to camera (default: 0, infers the
@ -38,7 +37,9 @@ class CameraCvPlugin(CameraPlugin):
:param kwargs: Extra arguments to be passed up to :class:`platypush.plugins.camera.CameraPlugin`. :param kwargs: Extra arguments to be passed up to :class:`platypush.plugins.camera.CameraPlugin`.
""" """
super().__init__(color_transform=color_transform, video_type=video_type, **kwargs) super().__init__(
color_transform=color_transform, video_type=video_type, **kwargs
)
if video_writer == 'cv': if video_writer == 'cv':
self._video_writer_class = CvFileWriter self._video_writer_class = CvFileWriter
@ -60,12 +61,15 @@ class CameraCvPlugin(CameraPlugin):
def capture_frame(self, camera: Camera, *args, **kwargs): def capture_frame(self, camera: Camera, *args, **kwargs):
import cv2 import cv2
from PIL import Image from PIL import Image
ret, frame = camera.object.read() ret, frame = camera.object.read()
assert ret, 'Cannot retrieve frame from {}'.format(camera.info.device) assert ret, 'Cannot retrieve frame from {}'.format(camera.info.device)
color_transform = camera.info.color_transform color_transform = camera.info.color_transform
if isinstance(color_transform, str): if isinstance(color_transform, str):
color_transform = getattr(cv2, color_transform or self.camera_info.color_transform) color_transform = getattr(
cv2, color_transform or self.camera_info.color_transform
)
if color_transform: if color_transform:
frame = cv2.cvtColor(frame, color_transform) frame = cv2.cvtColor(frame, color_transform)

View file

@ -12,18 +12,18 @@ from platypush.plugins.camera.ffmpeg.model import FFmpegCamera, FFmpegCameraInfo
class CameraFfmpegPlugin(CameraPlugin): class CameraFfmpegPlugin(CameraPlugin):
""" """
Plugin to interact with a camera over FFmpeg. Plugin to interact with a camera over FFmpeg.
Requires:
* **ffmpeg** package installed on the system.
""" """
_camera_class = FFmpegCamera _camera_class = FFmpegCamera
_camera_info_class = FFmpegCameraInfo _camera_info_class = FFmpegCameraInfo
def __init__(self, device: Optional[str] = '/dev/video0', input_format: str = 'v4l2', ffmpeg_args: Tuple[str] = (), def __init__(
**opts): self,
device: Optional[str] = '/dev/video0',
input_format: str = 'v4l2',
ffmpeg_args: Tuple[str] = (),
**opts
):
""" """
:param device: Path to the camera device (default: ``/dev/video0``). :param device: Path to the camera device (default: ``/dev/video0``).
:param input_format: FFmpeg input format for the the camera device (default: ``v4l2``). :param input_format: FFmpeg input format for the the camera device (default: ``v4l2``).
@ -35,10 +35,25 @@ class CameraFfmpegPlugin(CameraPlugin):
def prepare_device(self, camera: FFmpegCamera) -> subprocess.Popen: def prepare_device(self, camera: FFmpegCamera) -> subprocess.Popen:
warmup_seconds = self._get_warmup_seconds(camera) warmup_seconds = self._get_warmup_seconds(camera)
ffmpeg = [camera.info.ffmpeg_bin, '-y', '-f', camera.info.input_format, '-i', camera.info.device, '-s', ffmpeg = [
'{}x{}'.format(*camera.info.resolution), '-ss', str(warmup_seconds), camera.info.ffmpeg_bin,
'-y',
'-f',
camera.info.input_format,
'-i',
camera.info.device,
'-s',
'{}x{}'.format(*camera.info.resolution),
'-ss',
str(warmup_seconds),
*(('-r', str(camera.info.fps)) if camera.info.fps else ()), *(('-r', str(camera.info.fps)) if camera.info.fps else ()),
'-pix_fmt', 'rgb24', '-f', 'rawvideo', *camera.info.ffmpeg_args, '-'] '-pix_fmt',
'rgb24',
'-f',
'rawvideo',
*camera.info.ffmpeg_args,
'-',
]
self.logger.info('Running FFmpeg: {}'.format(' '.join(ffmpeg))) self.logger.info('Running FFmpeg: {}'.format(' '.join(ffmpeg)))
proc = subprocess.Popen(ffmpeg, stdout=subprocess.PIPE) proc = subprocess.Popen(ffmpeg, stdout=subprocess.PIPE)
@ -46,7 +61,9 @@ class CameraFfmpegPlugin(CameraPlugin):
proc.send_signal(signal.SIGSTOP) proc.send_signal(signal.SIGSTOP)
return proc return proc
def start_camera(self, camera: FFmpegCamera, preview: bool = False, *args, **kwargs): def start_camera(
self, camera: FFmpegCamera, preview: bool = False, *args, **kwargs
):
super().start_camera(*args, camera=camera, preview=preview, **kwargs) super().start_camera(*args, camera=camera, preview=preview, **kwargs)
if camera.object: if camera.object:
camera.object.send_signal(signal.SIGCONT) camera.object.send_signal(signal.SIGCONT)
@ -65,7 +82,9 @@ class CameraFfmpegPlugin(CameraPlugin):
except Exception as e: except Exception as e:
self.logger.warning('Error on FFmpeg capture wait: {}'.format(str(e))) self.logger.warning('Error on FFmpeg capture wait: {}'.format(str(e)))
def capture_frame(self, camera: FFmpegCamera, *args, **kwargs) -> Optional[ImageType]: def capture_frame(
self, camera: FFmpegCamera, *args, **kwargs
) -> Optional[ImageType]:
raw_size = camera.info.resolution[0] * camera.info.resolution[1] * 3 raw_size = camera.info.resolution[0] * camera.info.resolution[1] * 3
data = camera.object.stdout.read(raw_size) data = camera.object.stdout.read(raw_size)
if len(data) < raw_size: if len(data) < raw_size:

View file

@ -11,20 +11,6 @@ from platypush.common.gstreamer import Pipeline
class CameraGstreamerPlugin(CameraPlugin): class CameraGstreamerPlugin(CameraPlugin):
""" """
Plugin to interact with a camera over GStreamer. Plugin to interact with a camera over GStreamer.
Requires:
* **gst-python**
* **pygobject**
On Debian and derived systems:
* ``[sudo] apt-get install python3-gi python3-gst-1.0``
On Arch and derived systems:
* ``[sudo] pacman -S gst-python``
""" """
_camera_class = GStreamerCamera _camera_class = GStreamerCamera

View file

@ -25,15 +25,15 @@ class CameraIrMlx90640Plugin(CameraPlugin):
$ make bcm2835 $ make bcm2835
$ make examples/rawrgb I2C_MODE=LINUX $ make examples/rawrgb I2C_MODE=LINUX
Requires:
* **mlx90640-library** installation (see instructions above)
* **PIL** image library (``pip install Pillow``)
""" """
def __init__(self, rawrgb_path: Optional[str] = None, resolution: Tuple[int, int] = (32, 24), def __init__(
warmup_frames: Optional[int] = 5, **kwargs): self,
rawrgb_path: Optional[str] = None,
resolution: Tuple[int, int] = (32, 24),
warmup_frames: Optional[int] = 5,
**kwargs
):
""" """
:param rawrgb_path: Specify it if the rawrgb executable compiled from :param rawrgb_path: Specify it if the rawrgb executable compiled from
https://github.com/pimoroni/mlx90640-library is in another folder than https://github.com/pimoroni/mlx90640-library is in another folder than
@ -42,14 +42,22 @@ class CameraIrMlx90640Plugin(CameraPlugin):
:param warmup_frames: Number of frames to be skipped on sensor initialization/warmup (default: 2). :param warmup_frames: Number of frames to be skipped on sensor initialization/warmup (default: 2).
:param kwargs: Extra parameters to be passed to :class:`platypush.plugins.camera.CameraPlugin`. :param kwargs: Extra parameters to be passed to :class:`platypush.plugins.camera.CameraPlugin`.
""" """
super().__init__(device='mlx90640', resolution=resolution, warmup_frames=warmup_frames, **kwargs) super().__init__(
device='mlx90640',
resolution=resolution,
warmup_frames=warmup_frames,
**kwargs
)
if not rawrgb_path: if not rawrgb_path:
rawrgb_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib', 'examples', 'rawrgb') rawrgb_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), 'lib', 'examples', 'rawrgb'
)
rawrgb_path = os.path.abspath(os.path.expanduser(rawrgb_path)) rawrgb_path = os.path.abspath(os.path.expanduser(rawrgb_path))
assert os.path.isfile(rawrgb_path),\ assert os.path.isfile(
'rawrgb executable not found. Please follow the documentation of this plugin to build it' rawrgb_path
), 'rawrgb executable not found. Please follow the documentation of this plugin to build it'
self.rawrgb_path = rawrgb_path self.rawrgb_path = rawrgb_path
self._capture_proc = None self._capture_proc = None
@ -59,8 +67,11 @@ class CameraIrMlx90640Plugin(CameraPlugin):
def prepare_device(self, device: Camera): def prepare_device(self, device: Camera):
if not self._is_capture_running(): if not self._is_capture_running():
self._capture_proc = subprocess.Popen([self.rawrgb_path, '{}'.format(device.info.fps)], self._capture_proc = subprocess.Popen(
stdin=subprocess.PIPE, stdout=subprocess.PIPE) [self.rawrgb_path, '{}'.format(device.info.fps)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
return self._capture_proc return self._capture_proc
@ -77,11 +88,14 @@ class CameraIrMlx90640Plugin(CameraPlugin):
from PIL import Image from PIL import Image
camera = self.prepare_device(device) camera = self.prepare_device(device)
frame = camera.stdout.read(device.info.resolution[0] * device.info.resolution[1] * 3) frame = camera.stdout.read(
device.info.resolution[0] * device.info.resolution[1] * 3
)
return Image.frombytes('RGB', device.info.resolution, frame) return Image.frombytes('RGB', device.info.resolution, frame)
def to_grayscale(self, image): def to_grayscale(self, image):
from PIL import Image from PIL import Image
new_image = Image.new('L', image.size) new_image = Image.new('L', image.size)
for i in range(0, image.size[0]): for i in range(0, image.size[0]):

View file

@ -12,30 +12,45 @@ class CameraPiPlugin(CameraPlugin):
""" """
Plugin to control a Pi camera. Plugin to control a Pi camera.
Requires: .. warning::
This plugin is **DEPRECATED**, as it relies on the old ``picamera`` module.
* **picamera** (``pip install picamera``) On recent systems, it should be possible to access the Pi Camera through
* **numpy** (``pip install numpy``) the ffmpeg or gstreamer integrations.
* **Pillow** (``pip install Pillow``)
""" """
_camera_class = PiCamera _camera_class = PiCamera
_camera_info_class = PiCameraInfo _camera_info_class = PiCameraInfo
def __init__(self, device: int = 0, fps: float = 30., warmup_seconds: float = 2., sharpness: int = 0, def __init__(
contrast: int = 0, brightness: int = 50, video_stabilization: bool = False, iso: int = 0, self,
exposure_compensation: int = 0, exposure_mode: str = 'auto', meter_mode: str = 'average', device: int = 0,
awb_mode: str = 'auto', image_effect: str = 'none', led_pin: Optional[int] = None, fps: float = 30.0,
warmup_seconds: float = 2.0,
sharpness: int = 0,
contrast: int = 0,
brightness: int = 50,
video_stabilization: bool = False,
iso: int = 0,
exposure_compensation: int = 0,
exposure_mode: str = 'auto',
meter_mode: str = 'average',
awb_mode: str = 'auto',
image_effect: str = 'none',
led_pin: Optional[int] = None,
color_effects: Optional[Union[str, List[str]]] = None, color_effects: Optional[Union[str, List[str]]] = None,
zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0), **camera): zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0),
**camera
):
""" """
See https://www.raspberrypi.org/documentation/usage/camera/python/README.md See https://www.raspberrypi.org/documentation/usage/camera/python/README.md
for a detailed reference about the Pi camera options. for a detailed reference about the Pi camera options.
:param camera: Options for the base camera plugin (see :class:`platypush.plugins.camera.CameraPlugin`). :param camera: Options for the base camera plugin (see :class:`platypush.plugins.camera.CameraPlugin`).
""" """
super().__init__(device=device, fps=fps, warmup_seconds=warmup_seconds, **camera) super().__init__(
device=device, fps=fps, warmup_seconds=warmup_seconds, **camera
)
self.camera_info.sharpness = sharpness self.camera_info.sharpness = sharpness
self.camera_info.contrast = contrast self.camera_info.contrast = contrast
@ -56,8 +71,12 @@ class CameraPiPlugin(CameraPlugin):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
import picamera import picamera
camera = picamera.PiCamera(camera_num=device.info.device, resolution=device.info.resolution, camera = picamera.PiCamera(
framerate=device.info.fps, led_pin=device.info.led_pin) camera_num=device.info.device,
resolution=device.info.resolution,
framerate=device.info.fps,
led_pin=device.info.led_pin,
)
camera.hflip = device.info.horizontal_flip camera.hflip = device.info.horizontal_flip
camera.vflip = device.info.vertical_flip camera.vflip = device.info.vertical_flip
@ -97,9 +116,11 @@ class CameraPiPlugin(CameraPlugin):
import numpy as np import numpy as np
from PIL import Image from PIL import Image
shape = (camera.info.resolution[1] + (camera.info.resolution[1] % 16), shape = (
camera.info.resolution[1] + (camera.info.resolution[1] % 16),
camera.info.resolution[0] + (camera.info.resolution[0] % 32), camera.info.resolution[0] + (camera.info.resolution[0] % 32),
3) 3,
)
frame = np.empty(shape, dtype=np.uint8) frame = np.empty(shape, dtype=np.uint8)
camera.object.capture(frame, 'rgb') camera.object.capture(frame, 'rgb')
@ -121,7 +142,9 @@ class CameraPiPlugin(CameraPlugin):
self.logger.warning(str(e)) self.logger.warning(str(e))
@action @action
def capture_preview(self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera) -> dict: def capture_preview(
self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera
) -> dict:
camera = self.open_device(**camera) camera = self.open_device(**camera)
self.start_preview(camera) self.start_preview(camera)
@ -132,11 +155,15 @@ class CameraPiPlugin(CameraPlugin):
return self.status() return self.status()
def streaming_thread(self, camera: PiCamera, stream_format: str, duration: Optional[float] = None): def streaming_thread(
self, camera: PiCamera, stream_format: str, duration: Optional[float] = None
):
server_socket = self._prepare_server_socket(camera) server_socket = self._prepare_server_socket(camera)
sock = None sock = None
streaming_started_time = time.time() streaming_started_time = time.time()
self.logger.info('Starting streaming on port {}'.format(camera.info.listen_port)) self.logger.info(
'Starting streaming on port {}'.format(camera.info.listen_port)
)
try: try:
while camera.stream_event.is_set(): while camera.stream_event.is_set():
@ -161,7 +188,9 @@ class CameraPiPlugin(CameraPlugin):
try: try:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error while closing client socket: {}'.format(str(e))) self.logger.warning(
'Error while closing client socket: {}'.format(str(e))
)
self.close_device(camera) self.close_device(camera)
finally: finally:
@ -169,7 +198,9 @@ class CameraPiPlugin(CameraPlugin):
self.logger.info('Stopped camera stream') self.logger.info('Stopped camera stream')
@action @action
def start_streaming(self, duration: Optional[float] = None, stream_format: str = 'h264', **camera) -> dict: def start_streaming(
self, duration: Optional[float] = None, stream_format: str = 'h264', **camera
) -> dict:
camera = self.open_device(stream_format=stream_format, **camera) camera = self.open_device(stream_format=stream_format, **camera)
return self._start_streaming(camera, duration, stream_format) return self._start_streaming(camera, duration, stream_format)

View file

@ -18,32 +18,6 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
This plugin allows you to easily create IRC bots with custom logic that reacts to IRC events This plugin allows you to easily create IRC bots with custom logic that reacts to IRC events
and interact with IRC sessions. and interact with IRC sessions.
Triggers:
* :class:`platypush.message.event.irc.IRCChannelJoinEvent` when a user joins a channel.
* :class:`platypush.message.event.irc.IRCChannelKickEvent` when a user is kicked from a channel.
* :class:`platypush.message.event.irc.IRCModeEvent` when a user/channel mode change event occurs.
* :class:`platypush.message.event.irc.IRCPartEvent` when a user parts a channel.
* :class:`platypush.message.event.irc.IRCQuitEvent` when a user quits.
* :class:`platypush.message.event.irc.IRCNickChangeEvent` when a user nick changes.
* :class:`platypush.message.event.irc.IRCConnectEvent` when the bot connects to a server.
* :class:`platypush.message.event.irc.IRCDisconnectEvent` when the bot disconnects from a server.
* :class:`platypush.message.event.irc.IRCPrivateMessageEvent` when a private message is received.
* :class:`platypush.message.event.irc.IRCPublicMessageEvent` when a public message is received.
* :class:`platypush.message.event.irc.IRCDCCRequestEvent` when a DCC connection request is received.
* :class:`platypush.message.event.irc.IRCDCCMessageEvent` when a DCC message is received.
* :class:`platypush.message.event.irc.IRCCTCPMessageEvent` when a CTCP message is received.
* :class:`platypush.message.event.irc.IRCDCCFileRequestEvent` when a DCC file request is received.
* :class:`platypush.message.event.irc.IRCDCCFileRecvCompletedEvent` when a DCC file download is completed.
* :class:`platypush.message.event.irc.IRCDCCFileRecvCancelledEvent` when a DCC file download is cancelled.
* :class:`platypush.message.event.irc.IRCDCCFileSendCompletedEvent` when a DCC file upload is completed.
* :class:`platypush.message.event.irc.IRCDCCFileSendCancelledEvent` when a DCC file upload is cancelled.
Requires:
* **irc** (``pip install irc``)
""" """
def __init__(self, servers: Sequence[dict], **kwargs): def __init__(self, servers: Sequence[dict], **kwargs):

View file

@ -4,21 +4,28 @@ import os
from threading import RLock from threading import RLock
from typing import Optional, Union from typing import Optional, Union
# noinspection PyPackageRequirements
from telegram.ext import Updater from telegram.ext import Updater
# noinspection PyPackageRequirements
from telegram.message import Message as TelegramMessage from telegram.message import Message as TelegramMessage
# noinspection PyPackageRequirements
from telegram.user import User as TelegramUser from telegram.user import User as TelegramUser
from platypush.message.response.chat.telegram import TelegramMessageResponse, TelegramFileResponse, \ from platypush.message.response.chat.telegram import (
TelegramChatResponse, TelegramUserResponse, TelegramUsersResponse TelegramMessageResponse,
TelegramFileResponse,
TelegramChatResponse,
TelegramUserResponse,
TelegramUsersResponse,
)
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.chat import ChatPlugin from platypush.plugins.chat import ChatPlugin
class Resource: class Resource:
def __init__(self, file_id: Optional[int] = None, url: Optional[str] = None, path: Optional[str] = None): def __init__(
self,
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
):
assert file_id or url or path, 'You need to specify either file_id, url or path' assert file_id or url or path, 'You need to specify either file_id, url or path'
self.file_id = file_id self.file_id = file_id
self.url = url self.url = url
@ -27,12 +34,14 @@ class Resource:
def __enter__(self): def __enter__(self):
if self.path: if self.path:
self._file = open(os.path.abspath(os.path.expanduser(self.path)), 'rb') self._file = open( # noqa
os.path.abspath(os.path.expanduser(self.path)), 'rb'
)
return self._file return self._file
return self.file_id or self.url return self.file_id or self.url
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, *_, **__):
if self._file: if self._file:
self._file.close() self._file.close()
@ -47,10 +56,6 @@ class ChatTelegramPlugin(ChatPlugin):
3. Copy the provided API token in the configuration of this plugin. 3. Copy the provided API token in the configuration of this plugin.
4. Open a conversation with your newly created bot. 4. Open a conversation with your newly created bot.
Requires:
* **python-telegram-bot** (``pip install python-telegram-bot``)
""" """
def __init__(self, api_token: str, **kwargs): def __init__(self, api_token: str, **kwargs):
@ -117,7 +122,7 @@ class ChatTelegramPlugin(ChatPlugin):
contact_user_id=msg.contact.user_id if msg.contact else None, contact_user_id=msg.contact.user_id if msg.contact else None,
contact_vcard=msg.contact.vcard if msg.contact else None, contact_vcard=msg.contact.vcard if msg.contact else None,
link=msg.link, link=msg.link,
media_group_id=msg.media_group_id media_group_id=msg.media_group_id,
) )
@staticmethod @staticmethod
@ -129,13 +134,19 @@ class ChatTelegramPlugin(ChatPlugin):
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
language_code=user.language_code, language_code=user.language_code,
link=user.link link=user.link,
) )
@action @action
def send_message(self, chat_id: Union[str, int], text: str, parse_mode: Optional[str] = None, def send_message(
disable_web_page_preview: bool = False, disable_notification: bool = False, self,
reply_to_message_id: Optional[int] = None) -> TelegramMessageResponse: chat_id: Union[str, int],
text: str,
parse_mode: Optional[str] = None,
disable_web_page_preview: bool = False,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
) -> TelegramMessageResponse:
""" """
Send a message to a chat. Send a message to a chat.
@ -152,17 +163,21 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
msg = telegram.bot.send_message(chat_id=chat_id, msg = telegram.bot.send_message(
chat_id=chat_id,
text=text, text=text,
parse_mode=parse_mode, parse_mode=parse_mode,
disable_web_page_preview=disable_web_page_preview, disable_web_page_preview=disable_web_page_preview,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id) reply_to_message_id=reply_to_message_id,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_photo(self, chat_id: Union[str, int], def send_photo(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
@ -170,7 +185,8 @@ class ChatTelegramPlugin(ChatPlugin):
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send a picture to a chat. Send a picture to a chat.
@ -198,17 +214,22 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_photo(chat_id=chat_id, msg = telegram.bot.send_photo(
chat_id=chat_id,
photo=resource, photo=resource,
caption=caption, caption=caption,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout, parse_mode=parse_mode) timeout=timeout,
parse_mode=parse_mode,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_audio(self, chat_id: Union[str, int], def send_audio(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
@ -219,7 +240,8 @@ class ChatTelegramPlugin(ChatPlugin):
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send audio to a chat. Send audio to a chat.
@ -250,7 +272,8 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_audio(chat_id=chat_id, msg = telegram.bot.send_audio(
chat_id=chat_id,
audio=resource, audio=resource,
caption=caption, caption=caption,
disable_notification=disable_notification, disable_notification=disable_notification,
@ -259,12 +282,15 @@ class ChatTelegramPlugin(ChatPlugin):
duration=duration, duration=duration,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout, timeout=timeout,
parse_mode=parse_mode) parse_mode=parse_mode,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_document(self, chat_id: Union[str, int], def send_document(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
@ -273,7 +299,8 @@ class ChatTelegramPlugin(ChatPlugin):
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send a document to a chat. Send a document to a chat.
@ -302,19 +329,23 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_document(chat_id=chat_id, msg = telegram.bot.send_document(
chat_id=chat_id,
document=resource, document=resource,
filename=filename, filename=filename,
caption=caption, caption=caption,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout, timeout=timeout,
parse_mode=parse_mode) parse_mode=parse_mode,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_video(self, chat_id: Union[str, int], def send_video(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
@ -325,7 +356,8 @@ class ChatTelegramPlugin(ChatPlugin):
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send a video to a chat. Send a video to a chat.
@ -356,7 +388,8 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_video(chat_id=chat_id, msg = telegram.bot.send_video(
chat_id=chat_id,
video=resource, video=resource,
duration=duration, duration=duration,
caption=caption, caption=caption,
@ -365,12 +398,15 @@ class ChatTelegramPlugin(ChatPlugin):
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout, timeout=timeout,
parse_mode=parse_mode) parse_mode=parse_mode,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_animation(self, chat_id: Union[str, int], def send_animation(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
@ -381,7 +417,8 @@ class ChatTelegramPlugin(ChatPlugin):
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send an animation (GIF or H.264/MPEG-4 AVC video without sound) to a chat. Send an animation (GIF or H.264/MPEG-4 AVC video without sound) to a chat.
@ -412,7 +449,8 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_animation(chat_id=chat_id, msg = telegram.bot.send_animation(
chat_id=chat_id,
animation=resource, animation=resource,
duration=duration, duration=duration,
caption=caption, caption=caption,
@ -421,12 +459,15 @@ class ChatTelegramPlugin(ChatPlugin):
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout, timeout=timeout,
parse_mode=parse_mode) parse_mode=parse_mode,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_voice(self, chat_id: Union[str, int], def send_voice(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
@ -435,7 +476,8 @@ class ChatTelegramPlugin(ChatPlugin):
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send audio to a chat as a voice file. For this to work, your audio must be in an .ogg file encoded with OPUS Send audio to a chat as a voice file. For this to work, your audio must be in an .ogg file encoded with OPUS
(other formats may be sent as Audio or Document). (other formats may be sent as Audio or Document).
@ -465,25 +507,31 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_voice(chat_id=chat_id, msg = telegram.bot.send_voice(
chat_id=chat_id,
voice=resource, voice=resource,
caption=caption, caption=caption,
disable_notification=disable_notification, disable_notification=disable_notification,
duration=duration, duration=duration,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout, parse_mode=parse_mode) timeout=timeout,
parse_mode=parse_mode,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_video_note(self, chat_id: Union[str, int], def send_video_note(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None, file_id: Optional[int] = None,
url: Optional[str] = None, url: Optional[str] = None,
path: Optional[str] = None, path: Optional[str] = None,
duration: Optional[int] = None, duration: Optional[int] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send a video note to a chat. As of v.4.0, Telegram clients support rounded square mp4 videos of up to Send a video note to a chat. As of v.4.0, Telegram clients support rounded square mp4 videos of up to
1 minute long. 1 minute long.
@ -511,22 +559,27 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
with Resource(file_id=file_id, url=url, path=path) as resource: with Resource(file_id=file_id, url=url, path=path) as resource:
msg = telegram.bot.send_video_note(chat_id=chat_id, msg = telegram.bot.send_video_note(
chat_id=chat_id,
video=resource, video=resource,
duration=duration, duration=duration,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout) timeout=timeout,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_location(self, chat_id: Union[str, int], def send_location(
self,
chat_id: Union[str, int],
latitude: float, latitude: float,
longitude: float, longitude: float,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send a location to a chat. Send a location to a chat.
@ -543,17 +596,21 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
msg = telegram.bot.send_location(chat_id=chat_id, msg = telegram.bot.send_location(
chat_id=chat_id,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout) timeout=timeout,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_venue(self, chat_id: Union[str, int], def send_venue(
self,
chat_id: Union[str, int],
latitude: float, latitude: float,
longitude: float, longitude: float,
title: str, title: str,
@ -562,7 +619,8 @@ class ChatTelegramPlugin(ChatPlugin):
foursquare_type: Optional[str] = None, foursquare_type: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send the address of a venue to a chat. Send the address of a venue to a chat.
@ -583,7 +641,8 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
msg = telegram.bot.send_venue(chat_id=chat_id, msg = telegram.bot.send_venue(
chat_id=chat_id,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
title=title, title=title,
@ -592,19 +651,23 @@ class ChatTelegramPlugin(ChatPlugin):
foursquare_type=foursquare_type, foursquare_type=foursquare_type,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout) timeout=timeout,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@action @action
def send_contact(self, chat_id: Union[str, int], def send_contact(
self,
chat_id: Union[str, int],
phone_number: str, phone_number: str,
first_name: str, first_name: str,
last_name: Optional[str] = None, last_name: Optional[str] = None,
vcard: Optional[str] = None, vcard: Optional[str] = None,
disable_notification: bool = False, disable_notification: bool = False,
reply_to_message_id: Optional[int] = None, reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse: timeout: int = 20,
) -> TelegramMessageResponse:
""" """
Send a contact to a chat. Send a contact to a chat.
@ -623,14 +686,16 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
msg = telegram.bot.send_contact(chat_id=chat_id, msg = telegram.bot.send_contact(
chat_id=chat_id,
phone_number=phone_number, phone_number=phone_number,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
vcard=vcard, vcard=vcard,
disable_notification=disable_notification, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
timeout=timeout) timeout=timeout,
)
return self.parse_msg(msg) return self.parse_msg(msg)
@ -645,10 +710,14 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
file = telegram.bot.get_file(file_id, timeout=timeout) file = telegram.bot.get_file(file_id, timeout=timeout)
return TelegramFileResponse(file_id=file.file_id, file_path=file.file_path, file_size=file.file_size) return TelegramFileResponse(
file_id=file.file_id, file_path=file.file_path, file_size=file.file_size
)
@action @action
def get_chat(self, chat_id: Union[int, str], timeout: int = 20) -> TelegramChatResponse: def get_chat(
self, chat_id: Union[int, str], timeout: int = 20
) -> TelegramChatResponse:
""" """
Get the info about a Telegram chat. Get the info about a Telegram chat.
@ -658,7 +727,8 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
chat = telegram.bot.get_chat(chat_id, timeout=timeout) chat = telegram.bot.get_chat(chat_id, timeout=timeout)
return TelegramChatResponse(chat_id=chat.id, return TelegramChatResponse(
chat_id=chat.id,
link=chat.link, link=chat.link,
username=chat.username, username=chat.username,
invite_link=chat.invite_link, invite_link=chat.invite_link,
@ -666,10 +736,13 @@ class ChatTelegramPlugin(ChatPlugin):
description=chat.description, description=chat.description,
type=chat.type, type=chat.type,
first_name=chat.first_name, first_name=chat.first_name,
last_name=chat.last_name) last_name=chat.last_name,
)
@action @action
def get_chat_user(self, chat_id: Union[int, str], user_id: int, timeout: int = 20) -> TelegramUserResponse: def get_chat_user(
self, chat_id: Union[int, str], user_id: int, timeout: int = 20
) -> TelegramUserResponse:
""" """
Get the info about a user connected to a chat. Get the info about a user connected to a chat.
@ -680,16 +753,20 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
user = telegram.bot.get_chat_member(chat_id, user_id, timeout=timeout) user = telegram.bot.get_chat_member(chat_id, user_id, timeout=timeout)
return TelegramUserResponse(user_id=user.user.id, return TelegramUserResponse(
user_id=user.user.id,
link=user.user.link, link=user.user.link,
username=user.user.username, username=user.user.username,
first_name=user.user.first_name, first_name=user.user.first_name,
last_name=user.user.last_name, last_name=user.user.last_name,
is_bot=user.user.is_bot, is_bot=user.user.is_bot,
language_code=user.user.language_code) language_code=user.user.language_code,
)
@action @action
def get_chat_administrators(self, chat_id: Union[int, str], timeout: int = 20) -> TelegramUsersResponse: def get_chat_administrators(
self, chat_id: Union[int, str], timeout: int = 20
) -> TelegramUsersResponse:
""" """
Get the list of the administrators of a chat. Get the list of the administrators of a chat.
@ -699,7 +776,8 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
admins = telegram.bot.get_chat_administrators(chat_id, timeout=timeout) admins = telegram.bot.get_chat_administrators(chat_id, timeout=timeout)
return TelegramUsersResponse([ return TelegramUsersResponse(
[
TelegramUserResponse( TelegramUserResponse(
user_id=user.user.id, user_id=user.user.id,
link=user.user.link, link=user.user.link,
@ -708,11 +786,15 @@ class ChatTelegramPlugin(ChatPlugin):
last_name=user.user.last_name, last_name=user.user.last_name,
is_bot=user.user.is_bot, is_bot=user.user.is_bot,
language_code=user.user.language_code, language_code=user.user.language_code,
) for user in admins )
]) for user in admins
]
)
@action @action
def get_chat_members_count(self, chat_id: Union[int, str], timeout: int = 20) -> int: def get_chat_members_count(
self, chat_id: Union[int, str], timeout: int = 20
) -> int:
""" """
Get the number of users in a chat. Get the number of users in a chat.
@ -723,10 +805,13 @@ class ChatTelegramPlugin(ChatPlugin):
return telegram.bot.get_chat_members_count(chat_id, timeout=timeout) return telegram.bot.get_chat_members_count(chat_id, timeout=timeout)
@action @action
def kick_chat_member(self, chat_id: Union[str, int], def kick_chat_member(
self,
chat_id: Union[str, int],
user_id: int, user_id: int,
until_date: Optional[datetime.datetime] = None, until_date: Optional[datetime.datetime] = None,
timeout: int = 20): timeout: int = 20,
):
""" """
Kick a user from a chat. Kick a user from a chat.
@ -742,15 +827,13 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.kick_chat_member( telegram.bot.kick_chat_member(
chat_id=chat_id, chat_id=chat_id, user_id=user_id, until_date=until_date, timeout=timeout
user_id=user_id, )
until_date=until_date,
timeout=timeout)
@action @action
def unban_chat_member(self, chat_id: Union[str, int], def unban_chat_member(
user_id: int, self, chat_id: Union[str, int], user_id: int, timeout: int = 20
timeout: int = 20): ):
""" """
Lift the ban from a chat member. Lift the ban from a chat member.
@ -765,12 +848,13 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.unban_chat_member( telegram.bot.unban_chat_member(
chat_id=chat_id, chat_id=chat_id, user_id=user_id, timeout=timeout
user_id=user_id, )
timeout=timeout)
@action @action
def promote_chat_member(self, chat_id: Union[str, int], def promote_chat_member(
self,
chat_id: Union[str, int],
user_id: int, user_id: int,
can_change_info: Optional[bool] = None, can_change_info: Optional[bool] = None,
can_post_messages: Optional[bool] = None, can_post_messages: Optional[bool] = None,
@ -780,7 +864,8 @@ class ChatTelegramPlugin(ChatPlugin):
can_restrict_members: Optional[bool] = None, can_restrict_members: Optional[bool] = None,
can_promote_members: Optional[bool] = None, can_promote_members: Optional[bool] = None,
can_pin_messages: Optional[bool] = None, can_pin_messages: Optional[bool] = None,
timeout: int = 20): timeout: int = 20,
):
""" """
Promote or demote a member. Promote or demote a member.
@ -813,12 +898,11 @@ class ChatTelegramPlugin(ChatPlugin):
can_restrict_members=can_restrict_members, can_restrict_members=can_restrict_members,
can_promote_members=can_promote_members, can_promote_members=can_promote_members,
can_pin_messages=can_pin_messages, can_pin_messages=can_pin_messages,
timeout=timeout) timeout=timeout,
)
@action @action
def set_chat_title(self, chat_id: Union[str, int], def set_chat_title(self, chat_id: Union[str, int], title: str, timeout: int = 20):
title: str,
timeout: int = 20):
""" """
Set the title of a channel/group. Set the title of a channel/group.
@ -832,15 +916,12 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.set_chat_title( telegram.bot.set_chat_title(chat_id=chat_id, description=title, timeout=timeout)
chat_id=chat_id,
description=title,
timeout=timeout)
@action @action
def set_chat_description(self, chat_id: Union[str, int], def set_chat_description(
description: str, self, chat_id: Union[str, int], description: str, timeout: int = 20
timeout: int = 20): ):
""" """
Set the description of a channel/group. Set the description of a channel/group.
@ -855,14 +936,11 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.set_chat_description( telegram.bot.set_chat_description(
chat_id=chat_id, chat_id=chat_id, description=description, timeout=timeout
description=description, )
timeout=timeout)
@action @action
def set_chat_photo(self, chat_id: Union[str, int], def set_chat_photo(self, chat_id: Union[str, int], path: str, timeout: int = 20):
path: str,
timeout: int = 20):
""" """
Set the photo of a channel/group. Set the photo of a channel/group.
@ -879,13 +957,11 @@ class ChatTelegramPlugin(ChatPlugin):
with Resource(path=path) as resource: with Resource(path=path) as resource:
telegram.bot.set_chat_photo( telegram.bot.set_chat_photo(
chat_id=chat_id, chat_id=chat_id, photo=resource, timeout=timeout
photo=resource, )
timeout=timeout)
@action @action
def delete_chat_photo(self, chat_id: Union[str, int], def delete_chat_photo(self, chat_id: Union[str, int], timeout: int = 20):
timeout: int = 20):
""" """
Delete the photo of a channel/group. Delete the photo of a channel/group.
@ -898,15 +974,16 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.delete_chat_photo( telegram.bot.delete_chat_photo(chat_id=chat_id, timeout=timeout)
chat_id=chat_id,
timeout=timeout)
@action @action
def pin_chat_message(self, chat_id: Union[str, int], def pin_chat_message(
self,
chat_id: Union[str, int],
message_id: int, message_id: int,
disable_notification: Optional[bool] = None, disable_notification: Optional[bool] = None,
timeout: int = 20): timeout: int = 20,
):
""" """
Pin a message in a chat. Pin a message in a chat.
@ -925,11 +1002,11 @@ class ChatTelegramPlugin(ChatPlugin):
chat_id=chat_id, chat_id=chat_id,
message_id=message_id, message_id=message_id,
disable_notification=disable_notification, disable_notification=disable_notification,
timeout=timeout) timeout=timeout,
)
@action @action
def unpin_chat_message(self, chat_id: Union[str, int], def unpin_chat_message(self, chat_id: Union[str, int], timeout: int = 20):
timeout: int = 20):
""" """
Unpin the message of a chat. Unpin the message of a chat.
@ -942,13 +1019,10 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.unpin_chat_message( telegram.bot.unpin_chat_message(chat_id=chat_id, timeout=timeout)
chat_id=chat_id,
timeout=timeout)
@action @action
def leave_chat(self, chat_id: Union[str, int], def leave_chat(self, chat_id: Union[str, int], timeout: int = 20):
timeout: int = 20):
""" """
Leave a chat. Leave a chat.
@ -961,9 +1035,7 @@ class ChatTelegramPlugin(ChatPlugin):
""" """
telegram = self.get_telegram() telegram = self.get_telegram()
telegram.bot.leave_chat( telegram.bot.leave_chat(chat_id=chat_id, timeout=timeout)
chat_id=chat_id,
timeout=timeout)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -10,15 +10,6 @@ class ClipboardPlugin(RunnablePlugin):
""" """
Plugin to programmatically copy strings to your system clipboard, Plugin to programmatically copy strings to your system clipboard,
monitor and get the current clipboard content. monitor and get the current clipboard content.
Requires:
- **pyclip** (``pip install pyclip``)
Triggers:
- :class:`platypush.message.event.clipboard.ClipboardEvent` on clipboard update.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View file

@ -27,7 +27,7 @@ class BusType(enum.Enum):
SESSION = 'session' SESSION = 'session'
class DBusService(): class DBusService:
""" """
<node> <node>
<interface name="org.platypush.Bus"> <interface name="org.platypush.Bus">
@ -94,21 +94,14 @@ class DbusPlugin(RunnablePlugin):
* It can be used to execute methods exponsed by D-Bus objects through the * It can be used to execute methods exponsed by D-Bus objects through the
:meth:`.execute` method. :meth:`.execute` method.
Requires:
* **pydbus** (``pip install pydbus``)
* **defusedxml** (``pip install defusedxml``)
Triggers:
* :class:`platypush.message.event.dbus.DbusSignalEvent` when a signal is received.
""" """
def __init__( def __init__(
self, signals: Optional[Iterable[dict]] = None, self,
signals: Optional[Iterable[dict]] = None,
service_name: Optional[str] = _default_service_name, service_name: Optional[str] = _default_service_name,
service_path: Optional[str] = _default_service_path, **kwargs service_path: Optional[str] = _default_service_path,
**kwargs,
): ):
""" """
:param signals: Specify this if you want to subscribe to specific DBus :param signals: Specify this if you want to subscribe to specific DBus
@ -138,8 +131,7 @@ class DbusPlugin(RunnablePlugin):
self._loop = None self._loop = None
self._signals = DbusSignalSchema().load(signals or [], many=True) self._signals = DbusSignalSchema().load(signals or [], many=True)
self._signal_handlers = [ self._signal_handlers = [
self._get_signal_handler(**signal) self._get_signal_handler(**signal) for signal in self._signals
for signal in self._signals
] ]
self.service_name = service_name self.service_name = service_name
@ -150,8 +142,12 @@ class DbusPlugin(RunnablePlugin):
def handler(sender, path, interface, signal, params): def handler(sender, path, interface, signal, params):
get_bus().post( get_bus().post(
DbusSignalEvent( DbusSignalEvent(
bus=bus, signal=signal, path=path, bus=bus,
interface=interface, sender=sender, params=params signal=signal,
path=path,
interface=interface,
sender=sender,
params=params,
) )
) )
@ -201,7 +197,9 @@ class DbusPlugin(RunnablePlugin):
def _get_bus_names(bus: Bus) -> Set[str]: def _get_bus_names(bus: Bus) -> Set[str]:
return {str(name) for name in bus.dbus.ListNames() if not name.startswith(':')} return {str(name) for name in bus.dbus.ListNames() if not name.startswith(':')}
def path_names(self, bus: Bus, service: str, object_path='/', paths=None, service_dict=None): def path_names(
self, bus: Bus, service: str, object_path='/', paths=None, service_dict=None
):
if paths is None: if paths is None:
paths = {} paths = {}
if service_dict is None: if service_dict is None:
@ -212,10 +210,14 @@ class DbusPlugin(RunnablePlugin):
obj = bus.get(service, object_path) obj = bus.get(service, object_path)
interface = obj['org.freedesktop.DBus.Introspectable'] interface = obj['org.freedesktop.DBus.Introspectable']
except GLib.GError as e: except GLib.GError as e:
self.logger.warning(f'Could not inspect D-Bus object {service}, path={object_path}: {e}') self.logger.warning(
f'Could not inspect D-Bus object {service}, path={object_path}: {e}'
)
return {} return {}
except KeyError as e: except KeyError as e:
self.logger.warning(f'Could not get interfaces on the D-Bus object {service}, path={object_path}: {e}') self.logger.warning(
f'Could not get interfaces on the D-Bus object {service}, path={object_path}: {e}'
)
return {} return {}
xml_string = interface.Introspect() xml_string = interface.Introspect()
@ -226,7 +228,9 @@ class DbusPlugin(RunnablePlugin):
if object_path == '/': if object_path == '/':
object_path = '' object_path = ''
new_path = '/'.join((object_path, child.attrib['name'])) new_path = '/'.join((object_path, child.attrib['name']))
self.path_names(bus, service, new_path, paths, service_dict=service_dict) self.path_names(
bus, service, new_path, paths, service_dict=service_dict
)
else: else:
if not object_path: if not object_path:
object_path = '/' object_path = '/'
@ -253,8 +257,9 @@ class DbusPlugin(RunnablePlugin):
return service_dict return service_dict
@action @action
def query(self, service: Optional[str] = None, bus=tuple(t.value for t in BusType)) \ def query(
-> Dict[str, dict]: self, service: Optional[str] = None, bus=tuple(t.value for t in BusType)
) -> Dict[str, dict]:
""" """
Query DBus for a specific service or for the full list of services. Query DBus for a specific service or for the full list of services.
@ -433,7 +438,7 @@ class DbusPlugin(RunnablePlugin):
method_name: str, method_name: str,
bus: str = BusType.SESSION.value, bus: str = BusType.SESSION.value,
path: str = '/', path: str = '/',
args: Optional[list] = None args: Optional[list] = None,
): ):
""" """
Execute a method exposed on DBus. Execute a method exposed on DBus.

View file

@ -7,10 +7,6 @@ from platypush.plugins import Plugin, action
class DropboxPlugin(Plugin): class DropboxPlugin(Plugin):
""" """
Plugin to manage a Dropbox account and its files and folders. Plugin to manage a Dropbox account and its files and folders.
Requires:
* **dropbox** (``pip install dropbox``)
""" """
def __init__(self, access_token, **kwargs): def __init__(self, access_token, **kwargs):
@ -101,15 +97,26 @@ class DropboxPlugin(Plugin):
for item in files: for item in files:
entry = { entry = {
attr: getattr(item, attr) attr: getattr(item, attr)
for attr in ['id', 'name', 'path_display', 'path_lower', for attr in [
'parent_shared_folder_id', 'property_groups'] 'id',
'name',
'path_display',
'path_lower',
'parent_shared_folder_id',
'property_groups',
]
} }
if item.sharing_info: if item.sharing_info:
entry['sharing_info'] = { entry['sharing_info'] = {
attr: getattr(item.sharing_info, attr) attr: getattr(item.sharing_info, attr)
for attr in ['no_access', 'parent_shared_folder_id', 'read_only', for attr in [
'shared_folder_id', 'traverse_only'] 'no_access',
'parent_shared_folder_id',
'read_only',
'shared_folder_id',
'traverse_only',
]
} }
else: else:
entry['sharing_info'] = {} entry['sharing_info'] = {}
@ -118,7 +125,13 @@ class DropboxPlugin(Plugin):
entry['client_modified'] = item.client_modified.isoformat() entry['client_modified'] = item.client_modified.isoformat()
entry['server_modified'] = item.server_modified.isoformat() entry['server_modified'] = item.server_modified.isoformat()
for attr in ['content_hash', 'has_explicit_shared_members', 'is_downloadable', 'rev', 'size']: for attr in [
'content_hash',
'has_explicit_shared_members',
'is_downloadable',
'rev',
'size',
]:
if hasattr(item, attr): if hasattr(item, attr):
entry[attr] = getattr(item, attr) entry[attr] = getattr(item, attr)
@ -127,8 +140,14 @@ class DropboxPlugin(Plugin):
return entries return entries
@action @action
def copy(self, from_path: str, to_path: str, allow_shared_folder=True, autorename=False, def copy(
allow_ownership_transfer=False): self,
from_path: str,
to_path: str,
allow_shared_folder=True,
autorename=False,
allow_ownership_transfer=False,
):
""" """
Copy a file or folder to a different location in the user's Dropbox. If the source path is a folder all Copy a file or folder to a different location in the user's Dropbox. If the source path is a folder all
its contents will be copied. its contents will be copied.
@ -148,12 +167,23 @@ class DropboxPlugin(Plugin):
""" """
dbx = self._get_instance() dbx = self._get_instance()
dbx.files_copy_v2(from_path, to_path, allow_shared_folder=allow_shared_folder, dbx.files_copy_v2(
autorename=autorename, allow_ownership_transfer=allow_ownership_transfer) from_path,
to_path,
allow_shared_folder=allow_shared_folder,
autorename=autorename,
allow_ownership_transfer=allow_ownership_transfer,
)
@action @action
def move(self, from_path: str, to_path: str, allow_shared_folder=True, autorename=False, def move(
allow_ownership_transfer=False): self,
from_path: str,
to_path: str,
allow_shared_folder=True,
autorename=False,
allow_ownership_transfer=False,
):
""" """
Move a file or folder to a different location in the user's Dropbox. If the source path is a folder all its Move a file or folder to a different location in the user's Dropbox. If the source path is a folder all its
contents will be moved. contents will be moved.
@ -173,8 +203,13 @@ class DropboxPlugin(Plugin):
""" """
dbx = self._get_instance() dbx = self._get_instance()
dbx.files_move_v2(from_path, to_path, allow_shared_folder=allow_shared_folder, dbx.files_move_v2(
autorename=autorename, allow_ownership_transfer=allow_ownership_transfer) from_path,
to_path,
allow_shared_folder=allow_shared_folder,
autorename=autorename,
allow_ownership_transfer=allow_ownership_transfer,
)
@action @action
def delete(self, path: str): def delete(self, path: str):
@ -251,7 +286,9 @@ class DropboxPlugin(Plugin):
if download_path: if download_path:
if os.path.isdir(download_path): if os.path.isdir(download_path):
download_path = os.path.join(download_path, result.metadata.name + '.zip') download_path = os.path.join(
download_path, result.metadata.name + '.zip'
)
with open(download_path, 'wb') as f: with open(download_path, 'wb') as f:
f.write(response.content) f.write(response.content)
@ -350,8 +387,13 @@ class DropboxPlugin(Plugin):
from dropbox.files import SearchMode from dropbox.files import SearchMode
dbx = self._get_instance() dbx = self._get_instance()
response = dbx.files_search(query=query, path=path, start=start, max_results=max_results, response = dbx.files_search(
mode=SearchMode.filename_and_content if content else SearchMode.filename) query=query,
path=path,
start=start,
max_results=max_results,
mode=SearchMode.filename_and_content if content else SearchMode.filename,
)
results = [self._parse_metadata(match.metadata) for match in response.matches] results = [self._parse_metadata(match.metadata) for match in response.matches]
@ -397,8 +439,12 @@ class DropboxPlugin(Plugin):
else: else:
raise SyntaxError('Please specify either a file or text to be uploaded') raise SyntaxError('Please specify either a file or text to be uploaded')
metadata = dbx.files_upload(content, path, autorename=autorename, metadata = dbx.files_upload(
mode=WriteMode.overwrite if overwrite else WriteMode.add) content,
path,
autorename=autorename,
mode=WriteMode.overwrite if overwrite else WriteMode.add,
)
return self._parse_metadata(metadata) return self._parse_metadata(metadata)

View file

@ -9,15 +9,11 @@ from platypush.plugins import Plugin, action
class FfmpegPlugin(Plugin): class FfmpegPlugin(Plugin):
""" """
Generic FFmpeg plugin to interact with media files and devices. Generic FFmpeg plugin to interact with media files and devices.
Requires:
* **ffmpeg-python** (``pip install ffmpeg-python``)
* The **ffmpeg** package installed on the system.
""" """
def __init__(self, ffmpeg_cmd: str = 'ffmpeg', ffprobe_cmd: str = 'ffprobe', **kwargs): def __init__(
self, ffmpeg_cmd: str = 'ffmpeg', ffprobe_cmd: str = 'ffprobe', **kwargs
):
super().__init__(**kwargs) super().__init__(**kwargs)
self.ffmpeg_cmd = ffmpeg_cmd self.ffmpeg_cmd = ffmpeg_cmd
self.ffprobe_cmd = ffprobe_cmd self.ffprobe_cmd = ffprobe_cmd
@ -102,14 +98,19 @@ class FfmpegPlugin(Plugin):
""" """
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import ffmpeg import ffmpeg
filename = os.path.abspath(os.path.expanduser(filename)) filename = os.path.abspath(os.path.expanduser(filename))
info = ffmpeg.probe(filename, cmd=self.ffprobe_cmd, **kwargs) info = ffmpeg.probe(filename, cmd=self.ffprobe_cmd, **kwargs)
return info return info
@staticmethod @staticmethod
def _poll_thread(proc: subprocess.Popen, packet_size: int, on_packet: Callable[[bytes], None], def _poll_thread(
proc: subprocess.Popen,
packet_size: int,
on_packet: Callable[[bytes], None],
on_open: Optional[Callable[[], None]] = None, on_open: Optional[Callable[[], None]] = None,
on_close: Optional[Callable[[], None]] = None): on_close: Optional[Callable[[], None]] = None,
):
try: try:
if on_open: if on_open:
on_open() on_open()
@ -122,25 +123,49 @@ class FfmpegPlugin(Plugin):
on_close() on_close()
@action @action
def start(self, pipeline: List[dict], pipe_stdin: bool = False, pipe_stdout: bool = False, def start(
pipe_stderr: bool = False, quiet: bool = False, overwrite_output: bool = False, self,
on_packet: Callable[[bytes], None] = None, packet_size: int = 4096): pipeline: List[dict],
pipe_stdin: bool = False,
pipe_stdout: bool = False,
pipe_stderr: bool = False,
quiet: bool = False,
overwrite_output: bool = False,
on_packet: Callable[[bytes], None] = None,
packet_size: int = 4096,
):
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import ffmpeg import ffmpeg
stream = ffmpeg stream = ffmpeg
for step in pipeline: for step in pipeline:
args = step.pop('args') if 'args' in step else [] args = step.pop('args') if 'args' in step else []
stream = getattr(stream, step.pop('method'))(*args, **step) stream = getattr(stream, step.pop('method'))(*args, **step)
self.logger.info('Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args())) self.logger.info(
proc = stream.run_async(cmd=self.ffmpeg_cmd, pipe_stdin=pipe_stdin, pipe_stdout=pipe_stdout, 'Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args())
pipe_stderr=pipe_stderr, quiet=quiet, overwrite_output=overwrite_output) )
proc = stream.run_async(
cmd=self.ffmpeg_cmd,
pipe_stdin=pipe_stdin,
pipe_stdout=pipe_stdout,
pipe_stderr=pipe_stderr,
quiet=quiet,
overwrite_output=overwrite_output,
)
if on_packet: if on_packet:
with self._thread_lock: with self._thread_lock:
self._threads[self._next_thread_id] = threading.Thread(target=self._poll_thread, kwargs=dict( self._threads[self._next_thread_id] = threading.Thread(
proc=proc, on_packet=on_packet, packet_size=packet_size)) target=self._poll_thread,
kwargs={
'proc': proc,
'on_packet': on_packet,
'packet_size': packet_size,
},
)
self._threads[self._next_thread_id].start() self._threads[self._next_thread_id].start()
self._next_thread_id += 1 self._next_thread_id += 1

View file

@ -22,11 +22,6 @@ class GooglePlugin(Plugin):
python -m platypush.plugins.google.credentials \ python -m platypush.plugins.google.credentials \
'https://www.googleapis.com/auth/gmail.compose' ~/client_secret.json 'https://www.googleapis.com/auth/gmail.compose' ~/client_secret.json
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
def __init__(self, scopes=None, **kwargs): def __init__(self, scopes=None, **kwargs):

View file

@ -7,13 +7,7 @@ from platypush.plugins.calendar import CalendarInterface
class GoogleCalendarPlugin(GooglePlugin, CalendarInterface): class GoogleCalendarPlugin(GooglePlugin, CalendarInterface):
""" """
Google calendar plugin. Google Calendar plugin.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
scopes = ['https://www.googleapis.com/auth/calendar.readonly'] scopes = ['https://www.googleapis.com/auth/calendar.readonly']

View file

@ -10,17 +10,13 @@ from platypush.message.response.google.drive import GoogleDriveFile
class GoogleDrivePlugin(GooglePlugin): class GoogleDrivePlugin(GooglePlugin):
""" """
Google Drive plugin. Google Drive plugin.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
scopes = ['https://www.googleapis.com/auth/drive', scopes = [
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.appfolder', 'https://www.googleapis.com/auth/drive.appfolder',
'https://www.googleapis.com/auth/drive.photos.readonly'] 'https://www.googleapis.com/auth/drive.photos.readonly',
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(scopes=self.scopes, *args, **kwargs) super().__init__(scopes=self.scopes, *args, **kwargs)
@ -30,13 +26,15 @@ class GoogleDrivePlugin(GooglePlugin):
# noinspection PyShadowingBuiltins # noinspection PyShadowingBuiltins
@action @action
def files(self, def files(
self,
filter: Optional[str] = None, filter: Optional[str] = None,
folder_id: Optional[str] = None, folder_id: Optional[str] = None,
limit: Optional[int] = 100, limit: Optional[int] = 100,
drive_id: Optional[str] = None, drive_id: Optional[str] = None,
spaces: Optional[Union[str, List[str]]] = None, spaces: Optional[Union[str, List[str]]] = None,
order_by: Optional[Union[str, List[str]]] = None) -> Union[GoogleDriveFile, List[GoogleDriveFile]]: order_by: Optional[Union[str, List[str]]] = None,
) -> Union[GoogleDriveFile, List[GoogleDriveFile]]:
""" """
Get the list of files. Get the list of files.
@ -90,7 +88,9 @@ class GoogleDrivePlugin(GooglePlugin):
filter += "'{}' in parents".format(folder_id) filter += "'{}' in parents".format(folder_id)
while True: while True:
results = service.files().list( results = (
service.files()
.list(
q=filter, q=filter,
driveId=drive_id, driveId=drive_id,
pageSize=limit, pageSize=limit,
@ -98,17 +98,22 @@ class GoogleDrivePlugin(GooglePlugin):
fields="nextPageToken, files(id, name, kind, mimeType)", fields="nextPageToken, files(id, name, kind, mimeType)",
pageToken=page_token, pageToken=page_token,
spaces=spaces, spaces=spaces,
).execute() )
.execute()
)
page_token = results.get('nextPageToken') page_token = results.get('nextPageToken')
files.extend([ files.extend(
[
GoogleDriveFile( GoogleDriveFile(
id=f.get('id'), id=f.get('id'),
name=f.get('name'), name=f.get('name'),
type=f.get('kind').split('#')[1], type=f.get('kind').split('#')[1],
mime_type=f.get('mimeType'), mime_type=f.get('mimeType'),
) for f in results.get('files', []) )
]) for f in results.get('files', [])
]
)
if not page_token or (limit and len(files) >= limit): if not page_token or (limit and len(files) >= limit):
break break
@ -131,14 +136,16 @@ class GoogleDrivePlugin(GooglePlugin):
) )
@action @action
def upload(self, def upload(
self,
path: str, path: str,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
parents: Optional[List[str]] = None, parents: Optional[List[str]] = None,
starred: bool = False, starred: bool = False,
target_mime_type: Optional[str] = None) -> GoogleDriveFile: target_mime_type: Optional[str] = None,
) -> GoogleDriveFile:
""" """
Upload a file to Google Drive. Upload a file to Google Drive.
@ -171,11 +178,11 @@ class GoogleDrivePlugin(GooglePlugin):
media = MediaFileUpload(path, mimetype=mime_type) media = MediaFileUpload(path, mimetype=mime_type)
service = self.get_service() service = self.get_service()
file = service.files().create( file = (
body=metadata, service.files()
media_body=media, .create(body=metadata, media_body=media, fields='*')
fields='*' .execute()
).execute() )
return GoogleDriveFile( return GoogleDriveFile(
type=file.get('kind').split('#')[1], type=file.get('kind').split('#')[1],
@ -216,12 +223,14 @@ class GoogleDrivePlugin(GooglePlugin):
return path return path
@action @action
def create(self, def create(
self,
name: str, name: str,
description: Optional[str] = None, description: Optional[str] = None,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
parents: Optional[List[str]] = None, parents: Optional[List[str]] = None,
starred: bool = False) -> GoogleDriveFile: starred: bool = False,
) -> GoogleDriveFile:
""" """
Create a file. Create a file.
@ -242,10 +251,7 @@ class GoogleDrivePlugin(GooglePlugin):
metadata['mimeType'] = mime_type metadata['mimeType'] = mime_type
service = self.get_service() service = self.get_service()
file = service.files().create( file = service.files().create(body=metadata, fields='*').execute()
body=metadata,
fields='*'
).execute()
return GoogleDriveFile( return GoogleDriveFile(
type=file.get('kind').split('#')[1], type=file.get('kind').split('#')[1],
@ -255,7 +261,8 @@ class GoogleDrivePlugin(GooglePlugin):
) )
@action @action
def update(self, def update(
self,
file_id: str, file_id: str,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
@ -263,7 +270,8 @@ class GoogleDrivePlugin(GooglePlugin):
remove_parents: Optional[List[str]] = None, remove_parents: Optional[List[str]] = None,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
starred: bool = None, starred: bool = None,
trashed: bool = None) -> GoogleDriveFile: trashed: bool = None,
) -> GoogleDriveFile:
""" """
Update the metadata or the content of a file. Update the metadata or the content of a file.
@ -293,11 +301,9 @@ class GoogleDrivePlugin(GooglePlugin):
metadata['trashed'] = trashed metadata['trashed'] = trashed
service = self.get_service() service = self.get_service()
file = service.files().update( file = (
fileId=file_id, service.files().update(fileId=file_id, body=metadata, fields='*').execute()
body=metadata, )
fields='*'
).execute()
return GoogleDriveFile( return GoogleDriveFile(
type=file.get('kind').split('#')[1], type=file.get('kind').split('#')[1],

View file

@ -5,20 +5,16 @@ from platypush.plugins.google import GooglePlugin
class GoogleFitPlugin(GooglePlugin): class GoogleFitPlugin(GooglePlugin):
""" """
Google Fit plugin. Google Fit plugin.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
scopes = ['https://www.googleapis.com/auth/fitness.activity.read', scopes = [
'https://www.googleapis.com/auth/fitness.activity.read',
'https://www.googleapis.com/auth/fitness.body.read', 'https://www.googleapis.com/auth/fitness.body.read',
'https://www.googleapis.com/auth/fitness.body_temperature.read', 'https://www.googleapis.com/auth/fitness.body_temperature.read',
'https://www.googleapis.com/auth/fitness.heart_rate.read', 'https://www.googleapis.com/auth/fitness.heart_rate.read',
'https://www.googleapis.com/auth/fitness.sleep.read', 'https://www.googleapis.com/auth/fitness.sleep.read',
'https://www.googleapis.com/auth/fitness.location.read'] 'https://www.googleapis.com/auth/fitness.location.read',
]
def __init__(self, user_id='me', *args, **kwargs): def __init__(self, user_id='me', *args, **kwargs):
""" """
@ -30,7 +26,6 @@ class GoogleFitPlugin(GooglePlugin):
super().__init__(scopes=self.scopes, *args, **kwargs) super().__init__(scopes=self.scopes, *args, **kwargs)
self.user_id = user_id self.user_id = user_id
@action @action
def get_data_sources(self, user_id=None): def get_data_sources(self, user_id=None):
""" """
@ -38,8 +33,9 @@ class GoogleFitPlugin(GooglePlugin):
""" """
service = self.get_service(service='fitness', version='v1') service = self.get_service(service='fitness', version='v1')
sources = service.users().dataSources(). \ sources = (
list(userId=user_id or self.user_id).execute() service.users().dataSources().list(userId=user_id or self.user_id).execute()
)
return sources['dataSource'] return sources['dataSource']
@ -64,11 +60,19 @@ class GoogleFitPlugin(GooglePlugin):
kwargs['limit'] = limit kwargs['limit'] = limit
data_points = [] data_points = []
for data_point in service.users().dataSources().dataPointChanges(). \ for data_point in (
list(**kwargs).execute().get('insertedDataPoint', []): service.users()
.dataSources()
.dataPointChanges()
.list(**kwargs)
.execute()
.get('insertedDataPoint', [])
):
data_point['startTime'] = float(data_point.pop('startTimeNanos')) / 1e9 data_point['startTime'] = float(data_point.pop('startTimeNanos')) / 1e9
data_point['endTime'] = float(data_point.pop('endTimeNanos')) / 1e9 data_point['endTime'] = float(data_point.pop('endTimeNanos')) / 1e9
data_point['modifiedTime'] = float(data_point.pop('modifiedTimeMillis'))/1e6 data_point['modifiedTime'] = (
float(data_point.pop('modifiedTimeMillis')) / 1e6
)
values = [] values = []
for value in data_point.pop('value'): for value in data_point.pop('value'):
@ -81,9 +85,11 @@ class GoogleFitPlugin(GooglePlugin):
elif value.get('mapVal'): elif value.get('mapVal'):
value = { value = {
v['key']: v['value'].get( v['key']: v['value'].get(
'intVal', v['value'].get( 'intVal',
'fpVal', v['value'].get('stringVal'))) v['value'].get('fpVal', v['value'].get('stringVal')),
for v in value['mapVal'] } )
for v in value['mapVal']
}
values.append(value) values.append(value)

View file

@ -17,12 +17,6 @@ from platypush.plugins.google import GooglePlugin
class GoogleMailPlugin(GooglePlugin): class GoogleMailPlugin(GooglePlugin):
""" """
GMail plugin. It allows you to programmatically compose and (TODO) get emails GMail plugin. It allows you to programmatically compose and (TODO) get emails
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
scopes = ['https://www.googleapis.com/auth/gmail.modify'] scopes = ['https://www.googleapis.com/auth/gmail.modify']

View file

@ -14,12 +14,6 @@ datetime_types = Union[str, int, float, datetime]
class GoogleMapsPlugin(GooglePlugin): class GoogleMapsPlugin(GooglePlugin):
""" """
Plugins that provides utilities to interact with Google Maps API services. Plugins that provides utilities to interact with Google Maps API services.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
scopes = [] scopes = []

View file

@ -19,19 +19,13 @@ class GooglePubsubPlugin(Plugin):
3. Download the JSON service credentials file. By default platypush will look for the credentials file under 3. Download the JSON service credentials file. By default platypush will look for the credentials file under
~/.credentials/platypush/google/pubsub.json. ~/.credentials/platypush/google/pubsub.json.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
* **google-cloud-pubsub** (``pip install google-cloud-pubsub``)
""" """
publisher_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher' publisher_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher'
subscriber_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber' subscriber_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber'
default_credentials_file = os.path.join(os.path.expanduser('~'), default_credentials_file = os.path.join(
'.credentials', 'platypush', 'google', 'pubsub.json') os.path.expanduser('~'), '.credentials', 'platypush', 'google', 'pubsub.json'
)
def __init__(self, credentials_file: str = default_credentials_file, **kwargs): def __init__(self, credentials_file: str = default_credentials_file, **kwargs):
""" """
@ -43,13 +37,15 @@ class GooglePubsubPlugin(Plugin):
self.project_id = self.get_project_id() self.project_id = self.get_project_id()
def get_project_id(self): def get_project_id(self):
credentials = json.load(open(self.credentials_file)) with open(self.credentials_file) as f:
return credentials.get('project_id') return json.load(f).get('project_id')
def get_credentials(self, audience: str): def get_credentials(self, audience: str):
# noinspection PyPackageRequirements
from google.auth import jwt from google.auth import jwt
return jwt.Credentials.from_service_account_file(self.credentials_file, audience=audience)
return jwt.Credentials.from_service_account_file(
self.credentials_file, audience=audience
)
@action @action
def send_message(self, topic: str, msg, **kwargs): def send_message(self, topic: str, msg, **kwargs):
@ -63,9 +59,7 @@ class GooglePubsubPlugin(Plugin):
:param msg: Message to be sent. It can be a list, a dict, or a Message object :param msg: Message to be sent. It can be a list, a dict, or a Message object
:param kwargs: Extra arguments to be passed to .publish() :param kwargs: Extra arguments to be passed to .publish()
""" """
# noinspection PyPackageRequirements
from google.cloud import pubsub_v1 from google.cloud import pubsub_v1
# noinspection PyPackageRequirements
from google.api_core.exceptions import AlreadyExists from google.api_core.exceptions import AlreadyExists
credentials = self.get_credentials(self.publisher_audience) credentials = self.get_credentials(self.publisher_audience)
@ -79,9 +73,9 @@ class GooglePubsubPlugin(Plugin):
except AlreadyExists: except AlreadyExists:
pass pass
if isinstance(msg, int) or isinstance(msg, float): if isinstance(msg, (int, float)):
msg = str(msg) msg = str(msg)
if isinstance(msg, dict) or isinstance(msg, list): if isinstance(msg, (dict, list)):
msg = json.dumps(msg) msg = json.dumps(msg)
if isinstance(msg, str): if isinstance(msg, str):
msg = msg.encode() msg = msg.encode()

View file

@ -24,19 +24,19 @@ class GoogleTranslatePlugin(Plugin):
4. Create a new private JSON key for the service account and download it. By default platypush will look for the 4. Create a new private JSON key for the service account and download it. By default platypush will look for the
credentials file under ``~/.credentials/platypush/google/translate.json``. credentials file under ``~/.credentials/platypush/google/translate.json``.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
* **google-cloud-translate** (``pip install google-cloud-translate``)
""" """
_maximum_text_length = 2000 _maximum_text_length = 2000
default_credentials_file = os.path.join(os.path.expanduser('~'), '.credentials', 'platypush', 'google', default_credentials_file = os.path.join(
'translate.json') os.path.expanduser('~'), '.credentials', 'platypush', 'google', 'translate.json'
)
def __init__(self, target_language: str = 'en', credentials_file: Optional[str] = None, **kwargs): def __init__(
self,
target_language: str = 'en',
credentials_file: Optional[str] = None,
**kwargs
):
""" """
:param target_language: Default target language (default: 'en'). :param target_language: Default target language (default: 'en').
:param credentials_file: Google service account JSON credentials file. If none is specified then the plugin will :param credentials_file: Google service account JSON credentials file. If none is specified then the plugin will
@ -50,7 +50,9 @@ class GoogleTranslatePlugin(Plugin):
self.credentials_file = None self.credentials_file = None
if credentials_file: if credentials_file:
self.credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) self.credentials_file = os.path.abspath(
os.path.expanduser(credentials_file)
)
elif os.path.isfile(self.default_credentials_file): elif os.path.isfile(self.default_credentials_file):
self.credentials_file = self.default_credentials_file self.credentials_file = self.default_credentials_file
@ -86,8 +88,13 @@ class GoogleTranslatePlugin(Plugin):
# noinspection PyShadowingBuiltins # noinspection PyShadowingBuiltins
@action @action
def translate(self, text: str, target_language: Optional[str] = None, source_language: Optional[str] = None, def translate(
format: Optional[str] = None) -> TranslateResponse: self,
text: str,
target_language: Optional[str] = None,
source_language: Optional[str] = None,
format: Optional[str] = None,
) -> TranslateResponse:
""" """
Translate a piece of text or HTML. Translate a piece of text or HTML.

View file

@ -5,12 +5,6 @@ from platypush.plugins.google import GooglePlugin
class GoogleYoutubePlugin(GooglePlugin): class GoogleYoutubePlugin(GooglePlugin):
""" """
YouTube plugin. YouTube plugin.
Requires:
* **google-api-python-client** (``pip install google-api-python-client``)
* **oauth2client** (``pip install oauth2client``)
""" """
scopes = ['https://www.googleapis.com/auth/youtube.readonly'] scopes = ['https://www.googleapis.com/auth/youtube.readonly']

View file

@ -17,11 +17,6 @@ class GotifyPlugin(RunnablePlugin):
`Gotify <https://gotify.net>`_ allows you process messages and notifications asynchronously `Gotify <https://gotify.net>`_ allows you process messages and notifications asynchronously
over your own devices without relying on 3rd-party cloud services. over your own devices without relying on 3rd-party cloud services.
Triggers:
* :class:`platypush.message.event.gotify.GotifyMessageEvent` when a new message is received.
""" """
def __init__(self, server_url: str, app_token: str, client_token: str, **kwargs): def __init__(self, server_url: str, app_token: str, client_token: str, **kwargs):
@ -47,11 +42,13 @@ class GotifyPlugin(RunnablePlugin):
rs = getattr(requests, method)( rs = getattr(requests, method)(
f'{self.server_url}/{endpoint}', f'{self.server_url}/{endpoint}',
headers={ headers={
'X-Gotify-Key': self.app_token if method == 'post' else self.client_token, 'X-Gotify-Key': self.app_token
if method == 'post'
else self.client_token,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
**kwargs.pop('headers', {}), **kwargs.pop('headers', {}),
}, },
**kwargs **kwargs,
) )
rs.raise_for_status() rs.raise_for_status()
@ -65,7 +62,9 @@ class GotifyPlugin(RunnablePlugin):
stop_events = [] stop_events = []
while not any(stop_events): while not any(stop_events):
stop_events = self._should_stop.wait(timeout=1), self._disconnected_event.wait(timeout=1) stop_events = self._should_stop.wait(
timeout=1
), self._disconnected_event.wait(timeout=1)
def stop(self): def stop(self):
if self._ws_app: if self._ws_app:
@ -78,7 +77,9 @@ class GotifyPlugin(RunnablePlugin):
self._ws_listener.join(5) self._ws_listener.join(5)
if self._ws_listener and self._ws_listener.is_alive(): if self._ws_listener and self._ws_listener.is_alive():
self.logger.warning('Terminating the websocket process failed, killing the process') self.logger.warning(
'Terminating the websocket process failed, killing the process'
)
self._ws_listener.kill() self._ws_listener.kill()
if self._ws_listener: if self._ws_listener:
@ -92,13 +93,18 @@ class GotifyPlugin(RunnablePlugin):
if self.should_stop() or self._connected_event.is_set(): if self.should_stop() or self._connected_event.is_set():
return return
ws_url = '/'.join([self.server_url.split('/')[0].replace('http', 'ws'), *self.server_url.split('/')[1:]]) ws_url = '/'.join(
[
self.server_url.split('/')[0].replace('http', 'ws'),
*self.server_url.split('/')[1:],
]
)
self._ws_app = websocket.WebSocketApp( self._ws_app = websocket.WebSocketApp(
f'{ws_url}/stream?token={self.client_token}', f'{ws_url}/stream?token={self.client_token}',
on_open=self._on_open(), on_open=self._on_open(),
on_message=self._on_msg(), on_message=self._on_msg(),
on_error=self._on_error(), on_error=self._on_error(),
on_close=self._on_close() on_close=self._on_close(),
) )
def server(): def server():
@ -144,7 +150,13 @@ class GotifyPlugin(RunnablePlugin):
return hndl return hndl
@action @action
def send_message(self, message: str, title: Optional[str] = None, priority: int = 0, extras: Optional[dict] = None): def send_message(
self,
message: str,
title: Optional[str] = None,
priority: int = 0,
extras: Optional[dict] = None,
):
""" """
Send a message to the server. Send a message to the server.
@ -155,12 +167,16 @@ class GotifyPlugin(RunnablePlugin):
:return: .. schema:: gotify.GotifyMessageSchema :return: .. schema:: gotify.GotifyMessageSchema
""" """
return GotifyMessageSchema().dump( return GotifyMessageSchema().dump(
self._execute('post', 'message', json={ self._execute(
'post',
'message',
json={
'message': message, 'message': message,
'title': title, 'title': title,
'priority': priority, 'priority': priority,
'extras': extras or {}, 'extras': extras or {},
}) },
)
) )
@action @action
@ -174,11 +190,14 @@ class GotifyPlugin(RunnablePlugin):
""" """
return GotifyMessageSchema().dump( return GotifyMessageSchema().dump(
self._execute( self._execute(
'get', 'message', params={ 'get',
'message',
params={
'limit': limit, 'limit': limit,
**({'since': since} if since else {}), **({'since': since} if since else {}),
} },
).get('messages', []), many=True ).get('messages', []),
many=True,
) )
@action @action

View file

@ -10,16 +10,6 @@ class GpioPlugin(RunnablePlugin):
""" """
This plugin can be used to interact with custom electronic devices This plugin can be used to interact with custom electronic devices
connected to a Raspberry Pi (or compatible device) over GPIO pins. connected to a Raspberry Pi (or compatible device) over GPIO pins.
Requires:
* **RPi.GPIO** (``pip install RPi.GPIO``)
Triggers:
* :class:`platypush.message.event.gpio.GPIOEvent` when the value of a
monitored PIN changes.
""" """
def __init__( def __init__(

View file

@ -22,12 +22,6 @@ class GpioZeroborgPlugin(Plugin):
ZeroBorg plugin. It allows you to control a ZeroBorg ZeroBorg plugin. It allows you to control a ZeroBorg
(https://www.piborg.org/motor-control-1135/zeroborg) motor controller and (https://www.piborg.org/motor-control-1135/zeroborg) motor controller and
infrared sensor circuitry for Raspberry Pi infrared sensor circuitry for Raspberry Pi
Triggers:
* :class:`platypush.message.event.zeroborg.ZeroborgDriveEvent` when motors direction changes
* :class:`platypush.message.event.zeroborg.ZeroborgStopEvent` upon motors stop
""" """
def __init__(self, directions: Dict[str, List[float]] = None, **kwargs): def __init__(self, directions: Dict[str, List[float]] = None, **kwargs):
@ -72,6 +66,7 @@ class GpioZeroborgPlugin(Plugin):
directions = {} directions = {}
import platypush.plugins.gpio.zeroborg.lib as ZeroBorg import platypush.plugins.gpio.zeroborg.lib as ZeroBorg
super().__init__(**kwargs) super().__init__(**kwargs)
self.directions = directions self.directions = directions
@ -109,9 +104,15 @@ class GpioZeroborgPlugin(Plugin):
if self._direction in self.directions: if self._direction in self.directions:
self._motors = self.directions[self._direction] self._motors = self.directions[self._direction]
else: else:
self.logger.warning('Invalid direction {}: stopping motors'.format(self._direction)) self.logger.warning(
'Invalid direction {}: stopping motors'.format(
self._direction
)
)
except Exception as e: except Exception as e:
self.logger.error('Error on _get_direction_from_sensors: {}'.format(str(e))) self.logger.error(
'Error on _get_direction_from_sensors: {}'.format(str(e))
)
break break
for i, power in enumerate(self._motors): for i, power in enumerate(self._motors):
@ -129,7 +130,11 @@ class GpioZeroborgPlugin(Plugin):
drive_thread.start() drive_thread.start()
self._drive_thread = drive_thread self._drive_thread = drive_thread
get_bus().post(ZeroborgDriveEvent(direction=self._direction, motors=self.directions[self._direction])) get_bus().post(
ZeroborgDriveEvent(
direction=self._direction, motors=self.directions[self._direction]
)
)
return {'status': 'running', 'direction': direction} return {'status': 'running', 'direction': direction}
@action @action
@ -163,7 +168,9 @@ class GpioZeroborgPlugin(Plugin):
return { return {
'status': 'running' if self._direction else 'stopped', 'status': 'running' if self._direction else 'stopped',
'direction': self._direction, 'direction': self._direction,
'motors': [getattr(self.zb, 'GetMotor{}'.format(i+1))() for i in range(4)], 'motors': [
getattr(self.zb, 'GetMotor{}'.format(i + 1))() for i in range(4)
],
} }

View file

@ -46,15 +46,6 @@ class HidPlugin(RunnablePlugin):
# udevadm control --reload && udevadm trigger # 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
* :class:`platypush.message.event.hid.HidDeviceDataEvent` when a
monitored device sends some data
""" """
def __init__( def __init__(

View file

@ -1,23 +0,0 @@
from platypush.plugins import action
from platypush.plugins.http.request import HttpRequestPlugin
class HttpRequestRssPlugin(HttpRequestPlugin):
"""
Plugin to programmatically retrieve and parse an RSS feed URL.
Requires:
* **feedparser** (``pip install feedparser``)
"""
@action
def get(self, url, **_):
import feedparser
response = super().get(url, output='text').output
feed = feedparser.parse(response)
return feed.entries
# vim:sw=4:ts=4:et:

View file

@ -1,15 +0,0 @@
manifest:
events: {}
install:
apk:
- py3-feedparser
apt:
- python3-feedparser
dnf:
- python-feedparser
pacman:
- python-feedparser
pip:
- feedparser
package: platypush.plugins.http.request.rss
type: plugin

View file

@ -71,8 +71,6 @@ class HttpWebpagePlugin(Plugin):
Requires: Requires:
* **weasyprint** (``pip install weasyprint``), optional, for HTML->PDF conversion
* **node** and **npm** installed on your system (to use the mercury-parser interface)
* The mercury-parser library installed (``npm install -g @postlight/mercury-parser``) * The mercury-parser library installed (``npm install -g @postlight/mercury-parser``)
""" """

View file

@ -7,23 +7,20 @@ class InputsPlugin(Plugin):
""" """
This plugin emulates user input on a keyboard/mouse. It requires the a graphical server (X server or Mac/Win This plugin emulates user input on a keyboard/mouse. It requires the a graphical server (X server or Mac/Win
interface) to be running - it won't work in console mode. interface) to be running - it won't work in console mode.
Requires:
* **pyuserinput** (``pip install pyuserinput``)
""" """
@staticmethod @staticmethod
def _get_keyboard(): def _get_keyboard():
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
from pykeyboard import PyKeyboard from pykeyboard import PyKeyboard
return PyKeyboard() return PyKeyboard()
@staticmethod @staticmethod
def _get_mouse(): def _get_mouse():
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
from pymouse import PyMouse from pymouse import PyMouse
return PyMouse() return PyMouse()
@classmethod @classmethod

View file

@ -8,14 +8,6 @@ from platypush.plugins import Plugin, action
class KafkaPlugin(Plugin): class KafkaPlugin(Plugin):
""" """
Plugin to send messages to an Apache Kafka instance (https://kafka.apache.org/) Plugin to send messages to an Apache Kafka instance (https://kafka.apache.org/)
Triggers:
* :class:`platypush.message.event.kafka.KafkaMessageEvent` when a new message is received on the consumer topic.
Requires:
* **kafka** (``pip install kafka-python``)
""" """
def __init__(self, server=None, port=9092, **kwargs): def __init__(self, server=None, port=9092, **kwargs):
@ -30,8 +22,9 @@ class KafkaPlugin(Plugin):
super().__init__(**kwargs) super().__init__(**kwargs)
self.server = '{server}:{port}'.format(server=server, port=port) \ self.server = (
if server else None '{server}:{port}'.format(server=server, port=port) if server else None
)
self.producer = None self.producer = None
@ -60,13 +53,15 @@ class KafkaPlugin(Plugin):
kafka_backend = get_backend('kafka') kafka_backend = get_backend('kafka')
server = kafka_backend.server server = kafka_backend.server
except Exception as e: except Exception as e:
raise RuntimeError(f'No Kafka server nor default server specified: {str(e)}') raise RuntimeError(
f'No Kafka server nor default server specified: {str(e)}'
)
else: else:
server = self.server server = self.server
if isinstance(msg, dict) or isinstance(msg, list): if isinstance(msg, (dict, list)):
msg = json.dumps(msg) msg = json.dumps(msg)
msg = str(msg).encode('utf-8') msg = str(msg).encode()
producer = KafkaProducer(bootstrap_servers=server) producer = KafkaProducer(bootstrap_servers=server)
producer.send(topic, msg) producer.send(topic, msg)

View file

@ -8,10 +8,6 @@ class LastfmPlugin(Plugin):
""" """
Plugin to interact with your Last.FM (https://last.fm) account, update your Plugin to interact with your Last.FM (https://last.fm) account, update your
current track and your scrobbles. current track and your scrobbles.
Requires:
* **pylast** (``pip install pylast``)
""" """
def __init__(self, api_key, api_secret, username, password): def __init__(self, api_key, api_secret, username, password):

View file

@ -7,13 +7,8 @@ from platypush.plugins import Plugin, action
class LcdPlugin(Plugin, ABC): class LcdPlugin(Plugin, ABC):
""" """
Abstract class for plugins to communicate with LCD displays. Abstract class for plugins to communicate with LCD displays.
Requires:
* **RPLCD** (``pip install RPLCD``)
* **RPi.GPIO** (``pip install RPi.GPIO``)
""" """
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.lcd = None self.lcd = None
@ -21,9 +16,12 @@ class LcdPlugin(Plugin, ABC):
@staticmethod @staticmethod
def _get_pin_mode(pin_mode: str) -> int: def _get_pin_mode(pin_mode: str) -> int:
import RPi.GPIO import RPi.GPIO
pin_modes = ['BOARD', 'BCM'] pin_modes = ['BOARD', 'BCM']
pin_mode = pin_mode.upper() pin_mode = pin_mode.upper()
assert pin_mode in pin_modes, 'Invalid pin_mode: {}. Supported modes: {}'.format(pin_mode, pin_modes) assert (
pin_mode in pin_modes
), 'Invalid pin_mode: {}. Supported modes: {}'.format(pin_mode, pin_modes)
return getattr(RPi.GPIO, pin_mode).value return getattr(RPi.GPIO, pin_mode).value
@abstractmethod @abstractmethod
@ -105,7 +103,8 @@ class LcdPlugin(Plugin, ABC):
modes = ['left', 'right'] modes = ['left', 'right']
mode = mode.lower() mode = mode.lower()
assert mode in modes, 'Unsupported text mode: {}. Supported modes: {}'.format( assert mode in modes, 'Unsupported text mode: {}. Supported modes: {}'.format(
mode, modes) mode, modes
)
self._init_lcd() self._init_lcd()
self.lcd.text_align_mode = mode self.lcd.text_align_mode = mode

View file

@ -6,23 +6,26 @@ from platypush.plugins.lcd import LcdPlugin
class LcdGpioPlugin(LcdPlugin): class LcdGpioPlugin(LcdPlugin):
""" """
Plugin to write to an LCD display connected via GPIO. Plugin to write to an LCD display connected via GPIO.
Requires:
* **RPLCD** (``pip install RPLCD``)
* **RPi.GPIO** (``pip install RPi.GPIO``)
""" """
def __init__(self, pin_rs: int, pin_e: int, pins_data: List[int], def __init__(
pin_rw: Optional[int] = None, pin_mode: str = 'BOARD', self,
pin_rs: int,
pin_e: int,
pins_data: List[int],
pin_rw: Optional[int] = None,
pin_mode: str = 'BOARD',
pin_backlight: Optional[int] = None, pin_backlight: Optional[int] = None,
cols: int = 16, rows: int = 2, cols: int = 16,
rows: int = 2,
backlight_enabled: bool = True, backlight_enabled: bool = True,
backlight_mode: str = 'active_low', backlight_mode: str = 'active_low',
dotsize: int = 8, charmap: str = 'A02', dotsize: int = 8,
charmap: str = 'A02',
auto_linebreaks: bool = True, auto_linebreaks: bool = True,
compat_mode: bool = False, **kwargs): compat_mode: bool = False,
**kwargs
):
""" """
:param pin_rs: Pin for register select (RS). :param pin_rs: Pin for register select (RS).
:param pin_e: Pin to start data read or write (E). :param pin_e: Pin to start data read or write (E).
@ -70,15 +73,23 @@ class LcdGpioPlugin(LcdPlugin):
def _get_lcd(self): def _get_lcd(self):
from RPLCD.gpio import CharLCD from RPLCD.gpio import CharLCD
return CharLCD(cols=self.cols, rows=self.rows, pin_rs=self.pin_rs,
pin_e=self.pin_e, pins_data=self.pins_data, return CharLCD(
numbering_mode=self.pin_mode, pin_rw=self.pin_rw, cols=self.cols,
rows=self.rows,
pin_rs=self.pin_rs,
pin_e=self.pin_e,
pins_data=self.pins_data,
numbering_mode=self.pin_mode,
pin_rw=self.pin_rw,
pin_backlight=self.pin_backlight, pin_backlight=self.pin_backlight,
backlight_enabled=self.backlight_enabled, backlight_enabled=self.backlight_enabled,
backlight_mode=self.backlight_mode, backlight_mode=self.backlight_mode,
dotsize=self.dotsize, charmap=self.charmap, dotsize=self.dotsize,
charmap=self.charmap,
auto_linebreaks=self.auto_linebreaks, auto_linebreaks=self.auto_linebreaks,
compat_mode=self.compat_mode) compat_mode=self.compat_mode,
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -8,47 +8,63 @@ class LcdI2cPlugin(LcdPlugin):
Plugin to write to an LCD display connected via I2C. Plugin to write to an LCD display connected via I2C.
Adafruit I2C/SPI LCD Backback is supported. Adafruit I2C/SPI LCD Backback is supported.
Warning: You might need a level shifter (that supports i2c) Warning: You might need a level shifter (that supports i2c) between the
between the SCL/SDA connections on the MCP chip / backpack and the Raspberry Pi. SCL/SDA connections on the MCP chip / backpack and the Raspberry Pi.
Or you might damage the Pi and possibly any other 3.3V i2c devices
connected on the i2c bus. Or cause reliability issues. The SCL/SDA are rated 0.7*VDD Otherwise, you might damage the Pi and possibly any other 3.3V i2c devices
on the MCP23008, so it needs 3.5V on the SCL/SDA when 5V is applied to drive the LCD. connected on the i2c bus. Or cause reliability issues.
The MCP23008 and MCP23017 needs to be connected exactly the same way as the backpack.
The SCL/SDA are rated 0.7*VDD on the MCP23008, so it needs 3.5V on the
SCL/SDA when 5V is applied to drive the LCD.
The MCP23008 and MCP23017 needs to be connected exactly the same way as the
backpack.
For complete schematics see the adafruit page at: For complete schematics see the adafruit page at:
https://learn.adafruit.com/i2c-spi-lcd-backpack/ https://learn.adafruit.com/i2c-spi-lcd-backpack/
4-bit operation. I2C only supported.
4-bit operations. I2C only supported.
Pin mapping:: Pin mapping::
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0
BL | D7 | D6 | D5 | D4 | E | RS | - BL | D7 | D6 | D5 | D4 | E | RS | -
Requires:
* **RPLCD** (``pip install RPLCD``)
* **RPi.GPIO** (``pip install RPi.GPIO``)
""" """
def __init__(self, i2c_expander: str, address: int, def __init__(
self,
i2c_expander: str,
address: int,
expander_params: Optional[dict] = None, expander_params: Optional[dict] = None,
port: int = 1, cols: int = 16, rows: int = 2, port: int = 1,
cols: int = 16,
rows: int = 2,
backlight_enabled: bool = True, backlight_enabled: bool = True,
dotsize: int = 8, charmap: str = 'A02', dotsize: int = 8,
auto_linebreaks: bool = True, **kwargs): charmap: str = 'A02',
auto_linebreaks: bool = True,
**kwargs
):
""" """
:param i2c_expander: Set your I²C chip type. Supported: "PCF8574", "MCP23008", "MCP23017". :param i2c_expander: Set your I²C chip type. Supported: "PCF8574",
"MCP23008", "MCP23017".
:param address: The I2C address of your LCD. :param address: The I2C address of your LCD.
:param expander_params: Parameters for expanders, in a dictionary. Only needed for MCP23017 :param expander_params: Parameters for expanders, in a dictionary. Only
gpio_bank - This must be either ``A`` or ``B``. If you have a HAT, A is usually marked 1 and B is 2. needed for MCP23017 gpio_bank - This must be either ``A`` or ``B``.
Example: ``expander_params={'gpio_bank': 'A'}`` If you have a HAT, A is usually marked 1 and B is 2. Example:
``expander_params={'gpio_bank': 'A'}``
:param port: The I2C port number. Default: ``1``. :param port: The I2C port number. Default: ``1``.
:param cols: Number of columns per row (usually 16 or 20). Default: ``16``. :param cols: Number of columns per row (usually 16 or 20). Default: ``16``.
:param rows: Number of display rows (usually 1, 2 or 4). Default: ``2``. :param rows: Number of display rows (usually 1, 2 or 4). Default: ``2``.
:param backlight_enabled: Whether the backlight is enabled initially. Default: ``True``. Has no effect if pin_backlight is ``None`` :param backlight_enabled: Whether the backlight is enabled initially.
:param dotsize: Some 1 line displays allow a font height of 10px. Allowed: ``8`` or ``10``. Default: ``8``. Default: ``True``. Has no effect if pin_backlight is ``None``
:param charmap: The character map used. Depends on your LCD. This must be either ``A00`` or ``A02`` or ``ST0B``. Default: ``A02``. :param dotsize: Some 1 line displays allow a font height of 10px.
:param auto_linebreaks: Whether or not to automatically insert line breaks. Default: ``True``. Allowed: ``8`` or ``10``. Default: ``8``.
:param charmap: The character map used. Depends on your LCD. This must
be either ``A00`` or ``A02`` or ``ST0B``. Default: ``A02``.
:param auto_linebreaks: Whether or not to automatically insert line
breaks. Default: ``True``.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
@ -65,12 +81,18 @@ class LcdI2cPlugin(LcdPlugin):
def _get_lcd(self): def _get_lcd(self):
from RPLCD.i2c import CharLCD from RPLCD.i2c import CharLCD
return CharLCD(cols=self.cols, rows=self.rows,
return CharLCD(
cols=self.cols,
rows=self.rows,
i2c_expander=self.i2c_expander, i2c_expander=self.i2c_expander,
address=self.address, port=self.port, address=self.address,
port=self.port,
backlight_enabled=self.backlight_enabled, backlight_enabled=self.backlight_enabled,
dotsize=self.dotsize, charmap=self.charmap, dotsize=self.dotsize,
auto_linebreaks=self.auto_linebreaks) charmap=self.charmap,
auto_linebreaks=self.auto_linebreaks,
)
class LcdI2CPlugin(LcdI2cPlugin): class LcdI2CPlugin(LcdI2cPlugin):

View file

@ -34,18 +34,6 @@ from platypush.plugins import RunnablePlugin, action
class LightHuePlugin(RunnablePlugin, LightEntityManager): class LightHuePlugin(RunnablePlugin, LightEntityManager):
""" """
Philips Hue lights plugin. Philips Hue lights plugin.
Requires:
* **phue** (``pip install phue``)
Triggers:
- :class:`platypush.message.event.light.LightAnimationStartedEvent` when an animation is started.
- :class:`platypush.message.event.light.LightAnimationStoppedEvent` when an animation is stopped.
- :class:`platypush.message.event.light.LightStatusChangeEvent` when the status of a lightbulb
changes.
""" """
MAX_BRI = 255 MAX_BRI = 255
@ -88,7 +76,7 @@ class LightHuePlugin(RunnablePlugin, LightEntityManager):
""" """
:param bridge: Bridge address or hostname :param bridge: Bridge address or hostname
:param lights: Default lights to be controlled (default: all) :param lights: Default lights to be controlled (default: all)
:param groups Default groups to be controlled (default: all) :param groups: Default groups to be controlled (default: all)
:param poll_interval: How often the plugin should check the bridge for light :param poll_interval: How often the plugin should check the bridge for light
updates (default: 20 seconds). updates (default: 20 seconds).
:param config_file: Path to the phue configuration file containing the :param config_file: Path to the phue configuration file containing the

Some files were not shown because too many files have changed in this diff Show more