forked from platypush/platypush
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:
commit
e6f05dfe07
171 changed files with 6698 additions and 5390 deletions
|
@ -49,7 +49,7 @@ steps:
|
|||
|
||||
commands:
|
||||
- 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 .
|
||||
- mkdir -p /docs/current
|
||||
|
|
190
docs/source/_ext/add_dependencies.py
Normal file
190
docs/source/_ext/add_dependencies.py
Normal 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,
|
||||
}
|
|
@ -20,7 +20,6 @@ Backends
|
|||
platypush/backend/google.pubsub.rst
|
||||
platypush/backend/gps.rst
|
||||
platypush/backend/http.rst
|
||||
platypush/backend/inotify.rst
|
||||
platypush/backend/joystick.rst
|
||||
platypush/backend/joystick.jstest.rst
|
||||
platypush/backend/joystick.linux.rst
|
||||
|
@ -28,7 +27,6 @@ Backends
|
|||
platypush/backend/log.http.rst
|
||||
platypush/backend/mail.rst
|
||||
platypush/backend/midi.rst
|
||||
platypush/backend/mqtt.rst
|
||||
platypush/backend/music.mopidy.rst
|
||||
platypush/backend/music.mpd.rst
|
||||
platypush/backend/music.snapcast.rst
|
||||
|
@ -52,4 +50,3 @@ Backends
|
|||
platypush/backend/weather.darksky.rst
|
||||
platypush/backend/weather.openweathermap.rst
|
||||
platypush/backend/wiimote.rst
|
||||
platypush/backend/zwave.mqtt.rst
|
||||
|
|
|
@ -15,17 +15,14 @@ import sys
|
|||
# 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.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
sys.path.insert(0, os.path.abspath("./_ext"))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'Platypush'
|
||||
copyright = '2017-2021, Fabio Manganiello'
|
||||
author = 'Fabio Manganiello'
|
||||
copyright = '2017-2023, Fabio Manganiello'
|
||||
author = 'Fabio Manganiello <fabio@manganiello.tech>'
|
||||
|
||||
# The short X.Y version
|
||||
version = ''
|
||||
|
@ -52,6 +49,7 @@ extensions = [
|
|||
'sphinx.ext.githubpages',
|
||||
'sphinx_rtd_theme',
|
||||
'sphinx_marshmallow',
|
||||
'add_dependencies',
|
||||
]
|
||||
|
||||
# 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.
|
||||
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 = {
|
||||
'members': True,
|
||||
'show-inheritance': True,
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
``inotify``
|
||||
=============================
|
||||
|
||||
.. automodule:: platypush.backend.inotify
|
||||
:members:
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
``mqtt``
|
||||
==========================
|
||||
|
||||
.. automodule:: platypush.backend.mqtt
|
||||
:members:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
``zwave.mqtt``
|
||||
================================
|
||||
|
||||
.. automodule:: platypush.backend.zwave.mqtt
|
||||
:members:
|
|
@ -1,6 +0,0 @@
|
|||
``http.request.rss``
|
||||
======================================
|
||||
|
||||
.. automodule:: platypush.plugins.http.request.rss
|
||||
:members:
|
||||
|
|
@ -50,7 +50,6 @@ Plugins
|
|||
platypush/plugins/graphite.rst
|
||||
platypush/plugins/hid.rst
|
||||
platypush/plugins/http.request.rst
|
||||
platypush/plugins/http.request.rss.rst
|
||||
platypush/plugins/http.webpage.rst
|
||||
platypush/plugins/ifttt.rst
|
||||
platypush/plugins/inputs.rst
|
||||
|
|
|
@ -2,23 +2,17 @@ from typing import Optional
|
|||
|
||||
from platypush.backend import Backend
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.adafruit import ConnectedEvent, DisconnectedEvent, \
|
||||
FeedUpdateEvent
|
||||
from platypush.message.event.adafruit import (
|
||||
ConnectedEvent,
|
||||
DisconnectedEvent,
|
||||
FeedUpdateEvent,
|
||||
)
|
||||
|
||||
|
||||
class AdafruitIoBackend(Backend):
|
||||
"""
|
||||
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:
|
||||
|
||||
* The :class:`platypush.plugins.adafruit.io.AdafruitIoPlugin` plugin to
|
||||
|
@ -33,6 +27,7 @@ class AdafruitIoBackend(Backend):
|
|||
|
||||
super().__init__(*args, **kwargs)
|
||||
from Adafruit_IO import MQTTClient
|
||||
|
||||
self.feeds = feeds
|
||||
self._client: Optional[MQTTClient] = None
|
||||
|
||||
|
@ -41,6 +36,7 @@ class AdafruitIoBackend(Backend):
|
|||
return
|
||||
|
||||
from Adafruit_IO import MQTTClient
|
||||
|
||||
plugin = get_plugin('adafruit.io')
|
||||
if not plugin:
|
||||
raise RuntimeError('Adafruit IO plugin not configured')
|
||||
|
@ -80,8 +76,11 @@ class AdafruitIoBackend(Backend):
|
|||
def run(self):
|
||||
super().run()
|
||||
|
||||
self.logger.info(('Initialized Adafruit IO backend, listening on ' +
|
||||
'feeds {}').format(self.feeds))
|
||||
self.logger.info(
|
||||
('Initialized Adafruit IO backend, listening on ' + 'feeds {}').format(
|
||||
self.feeds
|
||||
)
|
||||
)
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
|
@ -94,4 +93,5 @@ class AdafruitIoBackend(Backend):
|
|||
self.logger.exception(e)
|
||||
self._client = None
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -11,7 +11,11 @@ from dateutil.tz import gettz
|
|||
|
||||
from platypush.backend import Backend
|
||||
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.procedure import Procedure
|
||||
|
||||
|
@ -28,10 +32,17 @@ class Alarm:
|
|||
_alarms_count = 0
|
||||
_id_lock = threading.RLock()
|
||||
|
||||
def __init__(self, when: str, actions: Optional[list] = None, name: Optional[str] = None,
|
||||
audio_file: Optional[str] = None, audio_plugin: Optional[str] = None,
|
||||
def __init__(
|
||||
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,
|
||||
snooze_interval: float = 300.0, enabled: bool = True):
|
||||
snooze_interval: float = 300.0,
|
||||
enabled: bool = True,
|
||||
):
|
||||
with self._id_lock:
|
||||
self._alarms_count += 1
|
||||
self.id = self._alarms_count
|
||||
|
@ -42,20 +53,26 @@ class Alarm:
|
|||
|
||||
if 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_volume = audio_volume
|
||||
self.snooze_interval = snooze_interval
|
||||
self.state: Optional[AlarmState] = 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._runtime_snooze_interval = snooze_interval
|
||||
|
||||
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:
|
||||
cron = croniter.croniter(self.when, now)
|
||||
|
@ -63,10 +80,14 @@ class Alarm:
|
|||
except (AttributeError, croniter.CroniterBadCronError):
|
||||
try:
|
||||
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):
|
||||
timestamp = (datetime.datetime.now().replace(tzinfo=gettz()) + # lgtm [py/call-to-non-callable]
|
||||
datetime.timedelta(seconds=int(self.when)))
|
||||
timestamp = datetime.datetime.now().replace(
|
||||
tzinfo=gettz()
|
||||
) + datetime.timedelta( # lgtm [py/call-to-non-callable]
|
||||
seconds=int(self.when)
|
||||
)
|
||||
|
||||
return timestamp.timestamp() if timestamp >= now else None
|
||||
|
||||
|
@ -88,7 +109,9 @@ class Alarm:
|
|||
self._runtime_snooze_interval = interval or self.snooze_interval
|
||||
self.state = AlarmState.SNOOZED
|
||||
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):
|
||||
if self.timer:
|
||||
|
@ -159,7 +182,9 @@ class Alarm:
|
|||
break
|
||||
|
||||
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)
|
||||
|
||||
|
@ -179,18 +204,15 @@ class Alarm:
|
|||
class AlarmBackend(Backend):
|
||||
"""
|
||||
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',
|
||||
*args, **kwargs):
|
||||
def __init__(
|
||||
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:
|
||||
|
||||
|
@ -231,13 +253,29 @@ class AlarmBackend(Backend):
|
|||
alarms = [{'name': name, **alarm} for name, alarm in alarms.items()]
|
||||
|
||||
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}
|
||||
|
||||
def add_alarm(self, when: str, 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)
|
||||
def add_alarm(
|
||||
self,
|
||||
when: str,
|
||||
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:
|
||||
self.logger.info('Overwriting existing alarm {}'.format(alarm.name))
|
||||
|
@ -274,10 +312,15 @@ class AlarmBackend(Backend):
|
|||
alarm.snooze(interval=interval)
|
||||
|
||||
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]:
|
||||
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
|
||||
|
||||
def __enter__(self):
|
||||
|
@ -285,9 +328,11 @@ class AlarmBackend(Backend):
|
|||
alarm.stop()
|
||||
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():
|
||||
alarm.stop()
|
||||
|
||||
|
@ -295,7 +340,9 @@ class AlarmBackend(Backend):
|
|||
|
||||
def loop(self):
|
||||
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]
|
||||
|
||||
time.sleep(10)
|
||||
|
|
|
@ -31,40 +31,6 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
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
|
||||
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(
|
||||
|
@ -164,12 +130,12 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
self.bus.post(event)
|
||||
|
||||
def start_conversation(self):
|
||||
"""Starts an assistant conversation"""
|
||||
"""Starts a conversation."""
|
||||
if self.assistant:
|
||||
self.assistant.start_conversation()
|
||||
|
||||
def stop_conversation(self):
|
||||
"""Stops an assistant conversation"""
|
||||
"""Stops an active conversation."""
|
||||
if self.assistant:
|
||||
self.assistant.stop_conversation()
|
||||
|
||||
|
|
|
@ -15,16 +15,7 @@ class AssistantSnowboyBackend(AssistantBackend):
|
|||
HotwordDetectedEvent to trigger the conversation on whichever assistant
|
||||
plugin you're using (Google, Alexa...)
|
||||
|
||||
Triggers:
|
||||
|
||||
* :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::
|
||||
Manual installation for snowboy and its Python bindings if the installation via package fails::
|
||||
|
||||
$ [sudo] apt-get install libatlas-base-dev swig
|
||||
$ [sudo] pip install pyaudio
|
||||
|
|
|
@ -12,15 +12,12 @@ class ButtonFlicBackend(Backend):
|
|||
Backend that listen for events from the Flic (https://flic.io/) bluetooth
|
||||
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:
|
||||
|
||||
* **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"
|
||||
LongPressEvent = "LongPressEvent"
|
||||
|
||||
def __init__(self, server='localhost', long_press_timeout=_long_press_timeout,
|
||||
btn_timeout=_btn_timeout, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
server='localhost',
|
||||
long_press_timeout=_long_press_timeout,
|
||||
btn_timeout=_btn_timeout,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param server: flicd server host (default: localhost)
|
||||
: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
|
||||
|
||||
: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
|
||||
"""
|
||||
|
||||
|
@ -55,15 +59,16 @@ class ButtonFlicBackend(Backend):
|
|||
self._btn_addr = None
|
||||
self._down_pressed_time = None
|
||||
self._cur_sequence = []
|
||||
|
||||
self.logger.info('Initialized Flic buttons backend on {}'.format(self.server))
|
||||
self.logger.info('Initialized Flic buttons backend on %s', self.server)
|
||||
|
||||
def _got_button(self):
|
||||
def _f(bd_addr):
|
||||
cc = ButtonConnectionChannel(bd_addr)
|
||||
cc.on_button_up_or_down = \
|
||||
lambda channel, click_type, was_queued, time_diff: \
|
||||
self._on_event()(bd_addr, channel, click_type, was_queued, time_diff)
|
||||
cc.on_button_up_or_down = (
|
||||
lambda channel, click_type, was_queued, time_diff: self._on_event()(
|
||||
bd_addr, channel, click_type, was_queued, time_diff
|
||||
)
|
||||
)
|
||||
self.client.add_connection_channel(cc)
|
||||
|
||||
return _f
|
||||
|
@ -72,23 +77,27 @@ class ButtonFlicBackend(Backend):
|
|||
def _f(items):
|
||||
for bd_addr in items["bd_addr_of_verified_buttons"]:
|
||||
self._got_button()(bd_addr)
|
||||
|
||||
return _f
|
||||
|
||||
def _on_btn_timeout(self):
|
||||
def _f():
|
||||
self.logger.info('Flic event triggered from {}: {}'.format(
|
||||
self._btn_addr, self._cur_sequence))
|
||||
self.logger.info(
|
||||
'Flic event triggered from %s: %s', self._btn_addr, self._cur_sequence
|
||||
)
|
||||
|
||||
self.bus.post(FlicButtonEvent(
|
||||
btn_addr=self._btn_addr, sequence=self._cur_sequence))
|
||||
self.bus.post(
|
||||
FlicButtonEvent(btn_addr=self._btn_addr, sequence=self._cur_sequence)
|
||||
)
|
||||
|
||||
self._cur_sequence = []
|
||||
|
||||
return _f
|
||||
|
||||
def _on_event(self):
|
||||
# noinspection PyUnusedLocal
|
||||
def _f(bd_addr, channel, click_type, was_queued, time_diff):
|
||||
# _ = channel
|
||||
# __ = time_diff
|
||||
def _f(bd_addr, _, click_type, was_queued, __):
|
||||
if was_queued:
|
||||
return
|
||||
|
||||
|
@ -120,4 +129,3 @@ class ButtonFlicBackend(Backend):
|
|||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
||||
|
|
|
@ -15,10 +15,6 @@ class CameraPiBackend(Backend):
|
|||
the :class:`platypush.plugins.camera.pi` plugin. Note that the Redis backend
|
||||
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
|
||||
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``.
|
||||
|
@ -33,15 +29,32 @@ class CameraPiBackend(Backend):
|
|||
return self.value == other
|
||||
|
||||
# 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',
|
||||
start_recording_on_startup=True,
|
||||
framerate=24, hflip=False, vflip=False,
|
||||
sharpness=0, contrast=0, 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):
|
||||
framerate=24,
|
||||
hflip=False,
|
||||
vflip=False,
|
||||
sharpness=0,
|
||||
contrast=0,
|
||||
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
|
||||
for a detailed reference about the Pi camera options.
|
||||
|
@ -58,7 +71,9 @@ class CameraPiBackend(Backend):
|
|||
self.bind_address = bind_address
|
||||
self.listen_port = listen_port
|
||||
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)
|
||||
|
||||
import picamera
|
||||
|
@ -87,10 +102,7 @@ class CameraPiBackend(Backend):
|
|||
self._recording_thread = None
|
||||
|
||||
def send_camera_action(self, action, **kwargs):
|
||||
action = {
|
||||
'action': action.value,
|
||||
**kwargs
|
||||
}
|
||||
action = {'action': action.value, **kwargs}
|
||||
|
||||
self.redis.send_message(msg=json.dumps(action), queue_name=self.redis_queue)
|
||||
|
||||
|
@ -127,7 +139,9 @@ class CameraPiBackend(Backend):
|
|||
else:
|
||||
while not self.should_stop():
|
||||
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:
|
||||
self.camera.start_recording(connection, format=format)
|
||||
|
@ -138,12 +152,16 @@ class CameraPiBackend(Backend):
|
|||
try:
|
||||
self.stop_recording()
|
||||
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:
|
||||
connection.close()
|
||||
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)
|
||||
|
||||
|
@ -152,12 +170,13 @@ class CameraPiBackend(Backend):
|
|||
return
|
||||
|
||||
self.logger.info('Starting camera recording')
|
||||
self._recording_thread = Thread(target=recording_thread,
|
||||
name='PiCameraRecorder')
|
||||
self._recording_thread = Thread(
|
||||
target=recording_thread, name='PiCameraRecorder'
|
||||
)
|
||||
self._recording_thread.start()
|
||||
|
||||
def stop_recording(self):
|
||||
""" Stops recording """
|
||||
"""Stops recording"""
|
||||
|
||||
self.logger.info('Stopping camera recording')
|
||||
|
||||
|
|
|
@ -22,17 +22,6 @@ class ChatTelegramBackend(Backend):
|
|||
"""
|
||||
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:
|
||||
|
||||
* The :class:`platypush.plugins.chat.telegram.ChatTelegramPlugin` plugin configured
|
||||
|
|
|
@ -10,17 +10,6 @@ from .entities.resources import MonitoredResource, MonitoredPattern, MonitoredRe
|
|||
class FileMonitorBackend(Backend):
|
||||
"""
|
||||
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:
|
||||
|
@ -29,20 +18,28 @@ class FileMonitorBackend(Backend):
|
|||
"""
|
||||
|
||||
@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):
|
||||
resource = MonitoredResource(resource)
|
||||
elif isinstance(resource, dict):
|
||||
if 'regexes' in resource or 'ignore_regexes' in 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)
|
||||
else:
|
||||
resource = MonitoredResource(**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:
|
||||
|
||||
|
@ -113,7 +110,9 @@ class FileMonitorBackend(Backend):
|
|||
|
||||
for path in paths:
|
||||
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):
|
||||
super().run()
|
||||
|
|
|
@ -14,10 +14,6 @@ class FoursquareBackend(Backend):
|
|||
|
||||
* 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'
|
||||
|
@ -30,8 +26,12 @@ class FoursquareBackend(Backend):
|
|||
self._last_created_at = None
|
||||
|
||||
def __enter__(self):
|
||||
self._last_created_at = int(get_plugin('variable').get(self._last_created_at_varname).
|
||||
output.get(self._last_created_at_varname) or 0)
|
||||
self._last_created_at = int(
|
||||
get_plugin('variable')
|
||||
.get(self._last_created_at_varname)
|
||||
.output.get(self._last_created_at_varname)
|
||||
or 0
|
||||
)
|
||||
self.logger.info('Started Foursquare backend')
|
||||
|
||||
def loop(self):
|
||||
|
@ -46,7 +46,9 @@ class FoursquareBackend(Backend):
|
|||
|
||||
self.bus.post(FoursquareCheckinEvent(checkin=last_checkin))
|
||||
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:
|
||||
|
|
|
@ -60,27 +60,6 @@ class GithubBackend(Backend):
|
|||
- ``notifications``
|
||||
- ``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'
|
||||
|
|
|
@ -13,24 +13,24 @@ class GoogleFitBackend(Backend):
|
|||
measurements, new fitness activities etc.) on the specified data streams and
|
||||
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:
|
||||
|
||||
* The **google.fit** plugin
|
||||
(:class:`platypush.plugins.google.fit.GoogleFitPlugin`) enabled.
|
||||
* The **db** plugin (:class:`platypush.plugins.db`) configured
|
||||
"""
|
||||
|
||||
_default_poll_seconds = 60
|
||||
_default_user_id = 'me'
|
||||
_last_timestamp_varname = '_GOOGLE_FIT_LAST_TIMESTAMP_'
|
||||
|
||||
def __init__(self, data_sources, user_id=_default_user_id,
|
||||
poll_seconds=_default_poll_seconds, *args, **kwargs):
|
||||
def __init__(
|
||||
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
|
||||
get a list of the available data sources through the
|
||||
|
@ -53,23 +53,31 @@ class GoogleFitBackend(Backend):
|
|||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.logger.info('Started Google Fit backend on data sources {}'.format(
|
||||
self.data_sources))
|
||||
self.logger.info(
|
||||
'Started Google Fit backend on data sources {}'.format(self.data_sources)
|
||||
)
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
for data_source in self.data_sources:
|
||||
varname = self._last_timestamp_varname + data_source
|
||||
last_timestamp = float(get_plugin('variable').
|
||||
get(varname).output.get(varname) or 0)
|
||||
last_timestamp = float(
|
||||
get_plugin('variable').get(varname).output.get(varname) or 0
|
||||
)
|
||||
|
||||
new_last_timestamp = last_timestamp
|
||||
self.logger.info('Processing new entries from data source {}, last timestamp: {}'.
|
||||
format(data_source,
|
||||
str(datetime.datetime.fromtimestamp(last_timestamp))))
|
||||
self.logger.info(
|
||||
'Processing new entries from data source {}, last timestamp: {}'.format(
|
||||
data_source,
|
||||
str(datetime.datetime.fromtimestamp(last_timestamp)),
|
||||
)
|
||||
)
|
||||
|
||||
data_points = get_plugin('google.fit').get_data(
|
||||
user_id=self.user_id, data_source_id=data_source).output
|
||||
data_points = (
|
||||
get_plugin('google.fit')
|
||||
.get_data(user_id=self.user_id, data_source_id=data_source)
|
||||
.output
|
||||
)
|
||||
new_data_points = 0
|
||||
|
||||
for dp in data_points:
|
||||
|
@ -78,25 +86,34 @@ class GoogleFitBackend(Backend):
|
|||
del dp['dataSourceId']
|
||||
|
||||
if dp_time > last_timestamp:
|
||||
self.bus.post(GoogleFitEvent(
|
||||
user_id=self.user_id, data_source_id=data_source,
|
||||
self.bus.post(
|
||||
GoogleFitEvent(
|
||||
user_id=self.user_id,
|
||||
data_source_id=data_source,
|
||||
data_type=dp.pop('dataTypeName'),
|
||||
start_time=dp_time,
|
||||
end_time=dp.pop('endTime'),
|
||||
modified_time=dp.pop('modifiedTime'),
|
||||
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_last_timestamp = max(dp_time, new_last_timestamp)
|
||||
|
||||
last_timestamp = new_last_timestamp
|
||||
self.logger.info('Got {} new entries from data source {}, last timestamp: {}'.
|
||||
format(new_data_points, data_source,
|
||||
str(datetime.datetime.fromtimestamp(last_timestamp))))
|
||||
self.logger.info(
|
||||
'Got {} new entries from data source {}, last timestamp: {}'.format(
|
||||
new_data_points,
|
||||
data_source,
|
||||
str(datetime.datetime.fromtimestamp(last_timestamp)),
|
||||
)
|
||||
)
|
||||
|
||||
get_plugin('variable').set(**{varname: last_timestamp})
|
||||
except Exception as e:
|
||||
|
|
|
@ -12,16 +12,6 @@ class GooglePubsubBackend(Backend):
|
|||
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
|
||||
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__(
|
||||
|
|
|
@ -9,17 +9,6 @@ class GpsBackend(Backend):
|
|||
"""
|
||||
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
|
||||
over USB and is available on /dev/ttyUSB0::
|
||||
|
||||
|
@ -52,41 +41,68 @@ class GpsBackend(Backend):
|
|||
|
||||
with self._session_lock:
|
||||
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)
|
||||
|
||||
return self._session
|
||||
|
||||
def _gps_report_to_event(self, report):
|
||||
if report.get('class').lower() == 'version':
|
||||
return GPSVersionEvent(release=report.get('release'),
|
||||
return GPSVersionEvent(
|
||||
release=report.get('release'),
|
||||
rev=report.get('rev'),
|
||||
proto_major=report.get('proto_major'),
|
||||
proto_minor=report.get('proto_minor'))
|
||||
proto_minor=report.get('proto_minor'),
|
||||
)
|
||||
if report.get('class').lower() == '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
|
||||
self._devices[device.get('path')] = device
|
||||
return GPSDeviceEvent(path=device.get('path'), activated=device.get('activated'),
|
||||
native=device.get('native'), bps=device.get('bps'),
|
||||
parity=device.get('parity'), stopbits=device.get('stopbits'),
|
||||
cycle=device.get('cycle'), driver=device.get('driver'))
|
||||
return GPSDeviceEvent(
|
||||
path=device.get('path'),
|
||||
activated=device.get('activated'),
|
||||
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':
|
||||
# noinspection DuplicatedCode
|
||||
self._devices[report.get('path')] = report
|
||||
return GPSDeviceEvent(path=report.get('path'), activated=report.get('activated'),
|
||||
native=report.get('native'), bps=report.get('bps'),
|
||||
parity=report.get('parity'), stopbits=report.get('stopbits'),
|
||||
cycle=report.get('cycle'), driver=report.get('driver'))
|
||||
return GPSDeviceEvent(
|
||||
path=report.get('path'),
|
||||
activated=report.get('activated'),
|
||||
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':
|
||||
return GPSUpdateEvent(device=report.get('device'), 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'))
|
||||
return GPSUpdateEvent(
|
||||
device=report.get('device'),
|
||||
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):
|
||||
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
|
||||
|
||||
while not self.should_stop():
|
||||
|
@ -94,15 +110,31 @@ class GpsBackend(Backend):
|
|||
session = self._get_session()
|
||||
report = session.next()
|
||||
event = self._gps_report_to_event(report)
|
||||
if event and (last_event is None or
|
||||
abs((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):
|
||||
if event and (
|
||||
last_event is None
|
||||
or abs(
|
||||
(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)
|
||||
last_event = event
|
||||
except Exception as e:
|
||||
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:
|
||||
self.logger.exception(e)
|
||||
|
||||
|
|
|
@ -40,15 +40,6 @@ class RssUpdates(HttpRequest):
|
|||
poll_seconds: 86400 # Poll once a day
|
||||
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 = (
|
||||
|
|
|
@ -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:
|
|
@ -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
|
|
@ -8,14 +8,6 @@ class JoystickBackend(Backend):
|
|||
"""
|
||||
This backend will listen for events from a joystick device and post a
|
||||
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):
|
||||
|
@ -32,7 +24,9 @@ class JoystickBackend(Backend):
|
|||
import inputs
|
||||
|
||||
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():
|
||||
try:
|
||||
|
|
|
@ -6,8 +6,14 @@ import time
|
|||
from typing import Optional, List
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, JoystickStateEvent, \
|
||||
JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent
|
||||
from platypush.message.event.joystick import (
|
||||
JoystickConnectedEvent,
|
||||
JoystickDisconnectedEvent,
|
||||
JoystickStateEvent,
|
||||
JoystickButtonPressedEvent,
|
||||
JoystickButtonReleasedEvent,
|
||||
JoystickAxisEvent,
|
||||
)
|
||||
|
||||
|
||||
class JoystickState:
|
||||
|
@ -38,9 +44,7 @@ class JoystickState:
|
|||
},
|
||||
}
|
||||
|
||||
return {
|
||||
k: v for k, v in diff.items() if v
|
||||
}
|
||||
return {k: v for k, v in diff.items() if v}
|
||||
|
||||
|
||||
class JoystickJstestBackend(Backend):
|
||||
|
@ -49,35 +53,17 @@ class JoystickJstestBackend(Backend):
|
|||
:class:`platypush.backend.joystick.JoystickBackend` backend (this may especially happen with some Bluetooth
|
||||
joysticks that don't support the ``ioctl`` requests used by ``inputs``).
|
||||
|
||||
This backend only works on Linux and it requires the ``joystick`` package to be installed.
|
||||
This backend only works on Linux, and it requires the ``joystick`` package to be installed.
|
||||
|
||||
**NOTE**: This backend can be quite slow, since it has to run another program (``jstest``) and parse its output.
|
||||
Consider it as a last resort if your joystick works with neither :class:`platypush.backend.joystick.JoystickBackend`
|
||||
nor :class:`platypush.backend.joystick.JoystickLinuxBackend`.
|
||||
|
||||
Instructions on Debian-based distros::
|
||||
|
||||
# apt-get install joystick
|
||||
|
||||
Instructions on Arch-based distros::
|
||||
|
||||
# pacman -S joyutils
|
||||
|
||||
To test if your joystick is compatible, connect it to your device, check for its path (usually under
|
||||
``/dev/input/js*``) and run::
|
||||
|
||||
$ jstest /dev/input/js[n]
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.joystick.JoystickConnectedEvent` when the joystick is connected.
|
||||
* :class:`platypush.message.event.joystick.JoystickDisconnectedEvent` when the joystick is disconnected.
|
||||
* :class:`platypush.message.event.joystick.JoystickStateEvent` when the state of the joystick (i.e. some of its
|
||||
axes or buttons values) changes.
|
||||
* :class:`platypush.message.event.joystick.JoystickButtonPressedEvent` when a joystick button is pressed.
|
||||
* :class:`platypush.message.event.joystick.JoystickButtonReleasedEvent` when a joystick button is released.
|
||||
* :class:`platypush.message.event.joystick.JoystickAxisEvent` when an axis value of the joystick changes.
|
||||
|
||||
"""
|
||||
|
||||
js_axes_regex = re.compile(r'Axes:\s+(((\d+):\s*([\-\d]+)\s*)+)')
|
||||
|
@ -85,10 +71,12 @@ class JoystickJstestBackend(Backend):
|
|||
js_axis_regex = re.compile(r'^\s*(\d+):\s*([\-\d]+)\s*(.*)')
|
||||
js_button_regex = re.compile(r'^\s*(\d+):\s*(on|off)\s*(.*)')
|
||||
|
||||
def __init__(self,
|
||||
def __init__(
|
||||
self,
|
||||
device: str = '/dev/input/js0',
|
||||
jstest_path: str = '/usr/bin/jstest',
|
||||
**kwargs):
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param device: Path to the joystick device (default: ``/dev/input/js0``).
|
||||
:param jstest_path: Path to the ``jstest`` executable that comes with the ``joystick`` system package
|
||||
|
@ -140,7 +128,11 @@ class JoystickJstestBackend(Backend):
|
|||
if line.endswith('Axes: '):
|
||||
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 = ' '
|
||||
while ch == ' ':
|
||||
ch = self._process.stdout.read(1).decode()
|
||||
|
@ -174,7 +166,11 @@ class JoystickJstestBackend(Backend):
|
|||
if line.endswith('Buttons: '):
|
||||
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 = ' '
|
||||
while ch == ' ':
|
||||
ch = self._process.stdout.read(1).decode()
|
||||
|
@ -195,10 +191,12 @@ class JoystickJstestBackend(Backend):
|
|||
return JoystickState(axes=axes, buttons=buttons)
|
||||
|
||||
def _initialize(self):
|
||||
while self._process.poll() is None and \
|
||||
os.path.exists(self.device) and \
|
||||
not self.should_stop() and \
|
||||
not self._state:
|
||||
while (
|
||||
self._process.poll() is None
|
||||
and os.path.exists(self.device)
|
||||
and not self.should_stop()
|
||||
and not self._state
|
||||
):
|
||||
line = b''
|
||||
ch = None
|
||||
|
||||
|
@ -243,7 +241,9 @@ class JoystickJstestBackend(Backend):
|
|||
self.bus.post(JoystickStateEvent(device=self.device, **state.__dict__))
|
||||
|
||||
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))
|
||||
|
||||
for axis, value in diff.get('axes', {}).items():
|
||||
|
@ -259,8 +259,8 @@ class JoystickJstestBackend(Backend):
|
|||
self._wait_ready()
|
||||
|
||||
with subprocess.Popen(
|
||||
[self.jstest_path, '--normal', self.device],
|
||||
stdout=subprocess.PIPE) as self._process:
|
||||
[self.jstest_path, '--normal', self.device], stdout=subprocess.PIPE
|
||||
) as self._process:
|
||||
self.logger.info('Device opened')
|
||||
self._initialize()
|
||||
|
||||
|
@ -268,7 +268,9 @@ class JoystickJstestBackend(Backend):
|
|||
break
|
||||
|
||||
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.bus.post(JoystickDisconnectedEvent(self.device))
|
||||
break
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
manifest:
|
||||
events:
|
||||
platypush.message.event.joystick.JoystickAxisEvent: when an axis value of the
|
||||
joystick changes.
|
||||
platypush.message.event.joystick.JoystickButtonPressedEvent: when a joystick button
|
||||
is pressed.
|
||||
platypush.message.event.joystick.JoystickButtonReleasedEvent: when a joystick
|
||||
button is released.
|
||||
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.
|
||||
- platypush.message.event.joystick.JoystickAxisEvent
|
||||
- platypush.message.event.joystick.JoystickButtonPressedEvent
|
||||
- platypush.message.event.joystick.JoystickButtonReleasedEvent
|
||||
- platypush.message.event.joystick.JoystickConnectedEvent
|
||||
- platypush.message.event.joystick.JoystickDisconnectedEvent
|
||||
- platypush.message.event.joystick.JoystickStateEvent
|
||||
install:
|
||||
apk:
|
||||
- linuxconsoletools
|
||||
|
|
|
@ -5,8 +5,13 @@ from fcntl import ioctl
|
|||
from typing import IO
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, \
|
||||
JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent
|
||||
from platypush.message.event.joystick import (
|
||||
JoystickConnectedEvent,
|
||||
JoystickDisconnectedEvent,
|
||||
JoystickButtonPressedEvent,
|
||||
JoystickButtonReleasedEvent,
|
||||
JoystickAxisEvent,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
`Linux kernel joystick API <https://www.kernel.org/doc/Documentation/input/joystick-api.txt>`_ to interact with
|
||||
the devices.
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.joystick.JoystickConnectedEvent` when the joystick is connected.
|
||||
* :class:`platypush.message.event.joystick.JoystickDisconnectedEvent` when the joystick is disconnected.
|
||||
* :class:`platypush.message.event.joystick.JoystickButtonPressedEvent` when a joystick button is pressed.
|
||||
* :class:`platypush.message.event.joystick.JoystickButtonReleasedEvent` when a joystick button is released.
|
||||
* :class:`platypush.message.event.joystick.JoystickAxisEvent` when an axis value of the joystick changes.
|
||||
|
||||
"""
|
||||
|
||||
# These constants were borrowed from linux/input.h
|
||||
|
@ -39,7 +35,7 @@ class JoystickLinuxBackend(Backend):
|
|||
0x07: 'rudder',
|
||||
0x08: 'wheel',
|
||||
0x09: 'gas',
|
||||
0x0a: 'brake',
|
||||
0x0A: 'brake',
|
||||
0x10: 'hat0x',
|
||||
0x11: 'hat0y',
|
||||
0x12: 'hat1x',
|
||||
|
@ -50,9 +46,9 @@ class JoystickLinuxBackend(Backend):
|
|||
0x17: 'hat3y',
|
||||
0x18: 'pressure',
|
||||
0x19: 'distance',
|
||||
0x1a: 'tilt_x',
|
||||
0x1b: 'tilt_y',
|
||||
0x1c: 'tool_width',
|
||||
0x1A: 'tilt_x',
|
||||
0x1B: 'tilt_y',
|
||||
0x1C: 'tool_width',
|
||||
0x20: 'volume',
|
||||
0x28: 'misc',
|
||||
}
|
||||
|
@ -68,9 +64,9 @@ class JoystickLinuxBackend(Backend):
|
|||
0x127: 'base2',
|
||||
0x128: 'base3',
|
||||
0x129: 'base4',
|
||||
0x12a: 'base5',
|
||||
0x12b: 'base6',
|
||||
0x12f: 'dead',
|
||||
0x12A: 'base5',
|
||||
0x12B: 'base6',
|
||||
0x12F: 'dead',
|
||||
0x130: 'a',
|
||||
0x131: 'b',
|
||||
0x132: 'c',
|
||||
|
@ -81,20 +77,20 @@ class JoystickLinuxBackend(Backend):
|
|||
0x137: 'tr',
|
||||
0x138: 'tl2',
|
||||
0x139: 'tr2',
|
||||
0x13a: 'select',
|
||||
0x13b: 'start',
|
||||
0x13c: 'mode',
|
||||
0x13d: 'thumbl',
|
||||
0x13e: 'thumbr',
|
||||
0x13A: 'select',
|
||||
0x13B: 'start',
|
||||
0x13C: 'mode',
|
||||
0x13D: 'thumbl',
|
||||
0x13E: 'thumbr',
|
||||
0x220: 'dpad_up',
|
||||
0x221: 'dpad_down',
|
||||
0x222: 'dpad_left',
|
||||
0x223: 'dpad_right',
|
||||
# XBox 360 controller uses these codes.
|
||||
0x2c0: 'dpad_left',
|
||||
0x2c1: 'dpad_right',
|
||||
0x2c2: 'dpad_up',
|
||||
0x2c3: 'dpad_down',
|
||||
0x2C0: 'dpad_left',
|
||||
0x2C1: 'dpad_right',
|
||||
0x2C2: 'dpad_up',
|
||||
0x2C3: 'dpad_down',
|
||||
}
|
||||
|
||||
def __init__(self, device: str = '/dev/input/js0', *args, **kwargs):
|
||||
|
@ -111,21 +107,21 @@ class JoystickLinuxBackend(Backend):
|
|||
def _init_joystick(self, dev: IO):
|
||||
# Get the device name.
|
||||
buf = array.array('B', [0] * 64)
|
||||
ioctl(dev, 0x80006a13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len)
|
||||
ioctl(dev, 0x80006A13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len)
|
||||
js_name = buf.tobytes().rstrip(b'\x00').decode('utf-8')
|
||||
|
||||
# Get number of axes and buttons.
|
||||
buf = array.array('B', [0])
|
||||
ioctl(dev, 0x80016a11, buf) # JSIOCGAXES
|
||||
ioctl(dev, 0x80016A11, buf) # JSIOCGAXES
|
||||
num_axes = buf[0]
|
||||
|
||||
buf = array.array('B', [0])
|
||||
ioctl(dev, 0x80016a12, buf) # JSIOCGBUTTONS
|
||||
ioctl(dev, 0x80016A12, buf) # JSIOCGBUTTONS
|
||||
num_buttons = buf[0]
|
||||
|
||||
# Get the axis map.
|
||||
buf = array.array('B', [0] * 0x40)
|
||||
ioctl(dev, 0x80406a32, buf) # JSIOCGAXMAP
|
||||
ioctl(dev, 0x80406A32, buf) # JSIOCGAXMAP
|
||||
|
||||
for axis in buf[:num_axes]:
|
||||
axis_name = self.axis_names.get(axis, 'unknown(0x%02x)' % axis)
|
||||
|
@ -134,15 +130,21 @@ class JoystickLinuxBackend(Backend):
|
|||
|
||||
# Get the button map.
|
||||
buf = array.array('H', [0] * 200)
|
||||
ioctl(dev, 0x80406a34, buf) # JSIOCGBTNMAP
|
||||
ioctl(dev, 0x80406A34, buf) # JSIOCGBTNMAP
|
||||
|
||||
for btn in buf[:num_buttons]:
|
||||
btn_name = self.button_names.get(btn, 'unknown(0x%03x)' % btn)
|
||||
self._button_map.append(btn_name)
|
||||
self._button_states[btn_name] = 0
|
||||
|
||||
self.bus.post(JoystickConnectedEvent(device=self.device, name=js_name, axes=self._axis_map,
|
||||
buttons=self._button_map))
|
||||
self.bus.post(
|
||||
JoystickConnectedEvent(
|
||||
device=self.device,
|
||||
name=js_name,
|
||||
axes=self._axis_map,
|
||||
buttons=self._button_map,
|
||||
)
|
||||
)
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
|
@ -151,13 +153,16 @@ class JoystickLinuxBackend(Backend):
|
|||
while not self.should_stop():
|
||||
# Open the joystick device.
|
||||
try:
|
||||
jsdev = open(self.device, 'rb')
|
||||
jsdev = open(self.device, 'rb') # noqa
|
||||
self._init_joystick(jsdev)
|
||||
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)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Joystick event loop
|
||||
while not self.should_stop():
|
||||
try:
|
||||
|
@ -172,9 +177,15 @@ class JoystickLinuxBackend(Backend):
|
|||
button = self._button_map[number]
|
||||
if button:
|
||||
self._button_states[button] = value
|
||||
evt_class = JoystickButtonPressedEvent if value else JoystickButtonReleasedEvent
|
||||
evt_class = (
|
||||
JoystickButtonPressedEvent
|
||||
if value
|
||||
else JoystickButtonReleasedEvent
|
||||
)
|
||||
# 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:
|
||||
axis = self._axis_map[number]
|
||||
|
@ -182,8 +193,14 @@ class JoystickLinuxBackend(Backend):
|
|||
fvalue = value / 32767.0
|
||||
self._axis_states[axis] = fvalue
|
||||
# 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:
|
||||
self.logger.warning(f'Connection to {self.device} lost: {e}')
|
||||
self.bus.post(JoystickDisconnectedEvent(device=self.device))
|
||||
break
|
||||
finally:
|
||||
jsdev.close()
|
||||
|
|
|
@ -11,10 +11,6 @@ class KafkaBackend(Backend):
|
|||
"""
|
||||
Backend to interact with an Apache Kafka (https://kafka.apache.org/)
|
||||
streaming platform, send and receive messages.
|
||||
|
||||
Requires:
|
||||
|
||||
* **kafka** (``pip install kafka-python``)
|
||||
"""
|
||||
|
||||
_conn_retry_secs = 5
|
||||
|
@ -24,7 +20,9 @@ class KafkaBackend(Backend):
|
|||
:param server: Kafka server name or address + port (default: ``localhost:9092``)
|
||||
: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
|
||||
"""
|
||||
|
||||
|
@ -40,7 +38,8 @@ class KafkaBackend(Backend):
|
|||
logging.getLogger('kafka').setLevel(logging.ERROR)
|
||||
|
||||
def _on_record(self, record):
|
||||
if record.topic != self.topic: return
|
||||
if record.topic != self.topic:
|
||||
return
|
||||
msg = record.value.decode('utf-8')
|
||||
is_platypush_message = False
|
||||
|
||||
|
@ -60,12 +59,12 @@ class KafkaBackend(Backend):
|
|||
def _topic_by_device_id(self, device_id):
|
||||
return '{}.{}'.format(self.topic_prefix, device_id)
|
||||
|
||||
def send_message(self, msg, **kwargs):
|
||||
def send_message(self, msg, **_):
|
||||
target = msg.target
|
||||
kafka_plugin = get_plugin('kafka')
|
||||
kafka_plugin.send_message(msg=msg,
|
||||
topic=self._topic_by_device_id(target),
|
||||
server=self.server)
|
||||
kafka_plugin.send_message(
|
||||
msg=msg, topic=self._topic_by_device_id(target), server=self.server
|
||||
)
|
||||
|
||||
def on_stop(self):
|
||||
super().on_stop()
|
||||
|
@ -82,21 +81,29 @@ class KafkaBackend(Backend):
|
|||
|
||||
def run(self):
|
||||
from kafka import KafkaConsumer
|
||||
|
||||
super().run()
|
||||
|
||||
self.consumer = KafkaConsumer(self.topic, bootstrap_servers=self.server)
|
||||
self.logger.info('Initialized kafka backend - server: {}, topic: {}'
|
||||
.format(self.server, self.topic))
|
||||
self.logger.info(
|
||||
'Initialized kafka backend - server: {}, topic: {}'.format(
|
||||
self.server, self.topic
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
for msg in self.consumer:
|
||||
self._on_record(msg)
|
||||
if self.should_stop(): break
|
||||
if self.should_stop():
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.warning('Kafka connection error, reconnecting in {} seconds'.
|
||||
format(self._conn_retry_secs))
|
||||
self.logger.warning(
|
||||
'Kafka connection error, reconnecting in {} seconds'.format(
|
||||
self._conn_retry_secs
|
||||
)
|
||||
)
|
||||
self.logger.exception(e)
|
||||
time.sleep(self._conn_retry_secs)
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -7,7 +7,11 @@ from logging import getLogger
|
|||
from threading import RLock
|
||||
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.message.event.log.http import HttpLogEvent
|
||||
|
||||
|
@ -15,8 +19,10 @@ logger = getLogger(__name__)
|
|||
|
||||
|
||||
class LogEventHandler(EventHandler):
|
||||
http_line_regex = re.compile(r'^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[([^]]+)]\s+"([^"]+)"\s+([\d]+)\s+'
|
||||
r'([\d]+)\s*("([^"\s]+)")?\s*("([^"]+)")?$')
|
||||
http_line_regex = re.compile(
|
||||
r'^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[([^]]+)]\s+"([^"]+)"\s+([\d]+)\s+'
|
||||
r'([\d]+)\s*("([^"\s]+)")?\s*("([^"]+)")?$'
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class FileResource:
|
||||
|
@ -25,16 +31,17 @@ class LogEventHandler(EventHandler):
|
|||
lock: RLock = RLock()
|
||||
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)
|
||||
self._monitored_files = {}
|
||||
self.monitor_files(monitored_files or [])
|
||||
|
||||
def monitor_files(self, files: Iterable[str]):
|
||||
self._monitored_files.update({
|
||||
f: self.FileResource(path=f, pos=self._get_size(f))
|
||||
for f in files
|
||||
})
|
||||
self._monitored_files.update(
|
||||
{f: self.FileResource(path=f, pos=self._get_size(f)) for f in files}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_size(file: str) -> int:
|
||||
|
@ -68,12 +75,17 @@ class LogEventHandler(EventHandler):
|
|||
try:
|
||||
file_size = os.path.getsize(event.src_path)
|
||||
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
|
||||
|
||||
if file_info.pos > file_size:
|
||||
logger.warning('The size of {} been unexpectedly decreased from {} to {} bytes'.format(
|
||||
event.src_path, file_info.pos, file_size))
|
||||
logger.warning(
|
||||
'The size of {} been unexpectedly decreased from {} to {} bytes'.format(
|
||||
event.src_path, file_info.pos, file_size
|
||||
)
|
||||
)
|
||||
file_info.pos = 0
|
||||
|
||||
try:
|
||||
|
@ -81,13 +93,18 @@ class LogEventHandler(EventHandler):
|
|||
f.seek(file_info.pos)
|
||||
for line in f.readlines():
|
||||
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)
|
||||
file_info.last_timestamp = evt.args['time']
|
||||
|
||||
file_info.pos = f.tell()
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
|
|
@ -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
|
||||
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__(
|
||||
|
|
|
@ -10,18 +10,16 @@ class MidiBackend(Backend):
|
|||
"""
|
||||
This backend will listen for events from a MIDI device and post a
|
||||
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,
|
||||
midi_throttle_time=None, *args, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
device_name=None,
|
||||
port_number=None,
|
||||
midi_throttle_time=None,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param device_name: Name of the MIDI device. *N.B.* either
|
||||
`device_name` or `port_number` must be set.
|
||||
|
@ -40,12 +38,16 @@ class MidiBackend(Backend):
|
|||
"""
|
||||
|
||||
import rtmidi
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if (device_name and port_number is not None) or \
|
||||
(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')
|
||||
if (device_name and port_number is not None) or (
|
||||
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'
|
||||
)
|
||||
|
||||
self.midi_throttle_time = midi_throttle_time
|
||||
self.midi = rtmidi.MidiIn()
|
||||
|
@ -75,9 +77,12 @@ class MidiBackend(Backend):
|
|||
def _on_midi_message(self):
|
||||
def flush_midi_message(message):
|
||||
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
|
||||
self.bus.post(MidiMessageEvent(message=message, delay=delay))
|
||||
|
||||
return _f
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
|
@ -95,8 +100,9 @@ class MidiBackend(Backend):
|
|||
self.midi_flush_timeout.cancel()
|
||||
|
||||
self.midi_flush_timeout = Timer(
|
||||
self.midi_throttle_time-event_delta,
|
||||
flush_midi_message(message))
|
||||
self.midi_throttle_time - event_delta,
|
||||
flush_midi_message(message),
|
||||
)
|
||||
|
||||
self.midi_flush_timeout.start()
|
||||
return
|
||||
|
@ -110,8 +116,11 @@ class MidiBackend(Backend):
|
|||
super().run()
|
||||
|
||||
self.midi.open_port(self.port_number)
|
||||
self.logger.info('Initialized MIDI backend, listening for events on device {}'.
|
||||
format(self.device_name))
|
||||
self.logger.info(
|
||||
'Initialized MIDI backend, listening for events on device {}'.format(
|
||||
self.device_name
|
||||
)
|
||||
)
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
|
|
|
@ -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:
|
|
@ -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
|
|
@ -5,11 +5,20 @@ import threading
|
|||
import websocket
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, \
|
||||
MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, \
|
||||
PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, \
|
||||
PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent, \
|
||||
MuteChangeEvent, SeekChangeEvent
|
||||
from platypush.message.event.music import (
|
||||
MusicPlayEvent,
|
||||
MusicPauseEvent,
|
||||
MusicStopEvent,
|
||||
NewPlayingTrackEvent,
|
||||
PlaylistChangeEvent,
|
||||
VolumeChangeEvent,
|
||||
PlaybackConsumeModeChangeEvent,
|
||||
PlaybackSingleModeChangeEvent,
|
||||
PlaybackRepeatModeChangeEvent,
|
||||
PlaybackRandomModeChangeEvent,
|
||||
MuteChangeEvent,
|
||||
SeekChangeEvent,
|
||||
)
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
|
@ -22,20 +31,10 @@ class MusicMopidyBackend(Backend):
|
|||
solution if you're not running Mopidy or your instance has the websocket
|
||||
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:
|
||||
|
||||
* Mopidy installed and the HTTP service enabled
|
||||
* A Mopidy instance running with the HTTP service enabled.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, host='localhost', port=6680, **kwargs):
|
||||
|
@ -77,8 +76,11 @@ class MusicMopidyBackend(Backend):
|
|||
conv_track['album'] = conv_track['album']['name']
|
||||
|
||||
if 'length' in conv_track:
|
||||
conv_track['time'] = conv_track['length']/1000 \
|
||||
if conv_track['length'] else conv_track['length']
|
||||
conv_track['time'] = (
|
||||
conv_track['length'] / 1000
|
||||
if conv_track['length']
|
||||
else conv_track['length']
|
||||
)
|
||||
del conv_track['length']
|
||||
|
||||
if pos is not None:
|
||||
|
@ -90,7 +92,6 @@ class MusicMopidyBackend(Backend):
|
|||
return conv_track
|
||||
|
||||
def _communicate(self, msg):
|
||||
|
||||
if isinstance(msg, str):
|
||||
msg = json.loads(msg)
|
||||
|
||||
|
@ -107,14 +108,10 @@ class MusicMopidyBackend(Backend):
|
|||
|
||||
def _get_tracklist_status(self):
|
||||
return {
|
||||
'repeat': self._communicate({
|
||||
'method': 'core.tracklist.get_repeat'}),
|
||||
'random': self._communicate({
|
||||
'method': 'core.tracklist.get_random'}),
|
||||
'single': self._communicate({
|
||||
'method': 'core.tracklist.get_single'}),
|
||||
'consume': self._communicate({
|
||||
'method': 'core.tracklist.get_consume'}),
|
||||
'repeat': self._communicate({'method': 'core.tracklist.get_repeat'}),
|
||||
'random': self._communicate({'method': 'core.tracklist.get_random'}),
|
||||
'single': self._communicate({'method': 'core.tracklist.get_single'}),
|
||||
'consume': self._communicate({'method': 'core.tracklist.get_consume'}),
|
||||
}
|
||||
|
||||
def _on_msg(self):
|
||||
|
@ -133,19 +130,25 @@ class MusicMopidyBackend(Backend):
|
|||
track = self._parse_track(track)
|
||||
if not track:
|
||||
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':
|
||||
status['state'] = 'play'
|
||||
track = self._parse_track(track)
|
||||
if not track:
|
||||
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 (
|
||||
event == 'playback_state_changed'
|
||||
and msg.get('new_state') == 'stopped'):
|
||||
event == 'playback_state_changed' and msg.get('new_state') == 'stopped'
|
||||
):
|
||||
status['state'] = 'stop'
|
||||
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':
|
||||
track = self._parse_track(track)
|
||||
if not track:
|
||||
|
@ -154,9 +157,13 @@ class MusicMopidyBackend(Backend):
|
|||
status['state'] = 'play'
|
||||
status['position'] = 0.0
|
||||
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':
|
||||
m = re.match('^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', ''))
|
||||
m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', ''))
|
||||
if not m:
|
||||
return
|
||||
|
||||
|
@ -164,35 +171,78 @@ class MusicMopidyBackend(Backend):
|
|||
track['title'] = m.group(2)
|
||||
status['state'] = 'play'
|
||||
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':
|
||||
status['volume'] = msg.get('volume')
|
||||
self.bus.post(VolumeChangeEvent(volume=status['volume'], status=status, track=track,
|
||||
plugin_name='music.mpd'))
|
||||
self.bus.post(
|
||||
VolumeChangeEvent(
|
||||
volume=status['volume'],
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
elif event == 'mute_changed':
|
||||
status['mute'] = msg.get('mute')
|
||||
self.bus.post(MuteChangeEvent(mute=status['mute'], status=status, track=track,
|
||||
plugin_name='music.mpd'))
|
||||
self.bus.post(
|
||||
MuteChangeEvent(
|
||||
mute=status['mute'],
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
elif event == 'seeked':
|
||||
status['position'] = msg.get('time_position')/1000
|
||||
self.bus.post(SeekChangeEvent(position=status['position'], status=status, track=track,
|
||||
plugin_name='music.mpd'))
|
||||
status['position'] = msg.get('time_position') / 1000
|
||||
self.bus.post(
|
||||
SeekChangeEvent(
|
||||
position=status['position'],
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
elif event == 'tracklist_changed':
|
||||
tracklist = [self._parse_track(t, pos=i)
|
||||
for i, t in enumerate(self._communicate({
|
||||
'method': 'core.tracklist.get_tl_tracks'}))]
|
||||
tracklist = [
|
||||
self._parse_track(t, pos=i)
|
||||
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':
|
||||
new_status = self._get_tracklist_status()
|
||||
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']:
|
||||
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']:
|
||||
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']:
|
||||
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
|
||||
|
||||
|
@ -204,7 +254,7 @@ class MusicMopidyBackend(Backend):
|
|||
try:
|
||||
self._connect()
|
||||
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)
|
||||
|
||||
|
@ -244,17 +294,23 @@ class MusicMopidyBackend(Backend):
|
|||
|
||||
def _connect(self):
|
||||
if not self._ws:
|
||||
self._ws = websocket.WebSocketApp(self.url,
|
||||
self._ws = websocket.WebSocketApp(
|
||||
self.url,
|
||||
on_open=self._on_open(),
|
||||
on_message=self._on_msg(),
|
||||
on_error=self._on_error(),
|
||||
on_close=self._on_close())
|
||||
on_close=self._on_close(),
|
||||
)
|
||||
|
||||
self._ws.run_forever()
|
||||
|
||||
def run(self):
|
||||
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()
|
||||
|
||||
def on_stop(self):
|
||||
|
|
|
@ -2,28 +2,28 @@ import time
|
|||
|
||||
from platypush.backend import Backend
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, \
|
||||
MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, \
|
||||
PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, \
|
||||
PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent
|
||||
from platypush.message.event.music import (
|
||||
MusicPlayEvent,
|
||||
MusicPauseEvent,
|
||||
MusicStopEvent,
|
||||
NewPlayingTrackEvent,
|
||||
PlaylistChangeEvent,
|
||||
VolumeChangeEvent,
|
||||
PlaybackConsumeModeChangeEvent,
|
||||
PlaybackSingleModeChangeEvent,
|
||||
PlaybackRepeatModeChangeEvent,
|
||||
PlaybackRandomModeChangeEvent,
|
||||
)
|
||||
|
||||
|
||||
class MusicMpdBackend(Backend):
|
||||
"""
|
||||
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:
|
||||
* **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):
|
||||
|
@ -81,11 +81,23 @@ class MusicMpdBackend(Backend):
|
|||
|
||||
if state != last_state:
|
||||
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':
|
||||
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':
|
||||
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 last_playlist:
|
||||
|
@ -97,31 +109,66 @@ class MusicMpdBackend(Backend):
|
|||
last_playlist = playlist
|
||||
|
||||
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']:
|
||||
self.bus.post(VolumeChangeEvent(volume=int(status['volume']), status=status, track=track,
|
||||
plugin_name='music.mpd'))
|
||||
if last_status.get('volume') != status['volume']:
|
||||
self.bus.post(
|
||||
VolumeChangeEvent(
|
||||
volume=int(status['volume']),
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
|
||||
if last_status.get('random', None) != status['random']:
|
||||
self.bus.post(PlaybackRandomModeChangeEvent(state=bool(int(status['random'])), status=status,
|
||||
track=track, plugin_name='music.mpd'))
|
||||
if last_status.get('random') != status['random']:
|
||||
self.bus.post(
|
||||
PlaybackRandomModeChangeEvent(
|
||||
state=bool(int(status['random'])),
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
|
||||
if last_status.get('repeat', None) != status['repeat']:
|
||||
self.bus.post(PlaybackRepeatModeChangeEvent(state=bool(int(status['repeat'])), status=status,
|
||||
track=track, plugin_name='music.mpd'))
|
||||
if last_status.get('repeat') != status['repeat']:
|
||||
self.bus.post(
|
||||
PlaybackRepeatModeChangeEvent(
|
||||
state=bool(int(status['repeat'])),
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
|
||||
if last_status.get('consume', None) != status['consume']:
|
||||
self.bus.post(PlaybackConsumeModeChangeEvent(state=bool(int(status['consume'])), status=status,
|
||||
track=track, plugin_name='music.mpd'))
|
||||
if last_status.get('consume') != status['consume']:
|
||||
self.bus.post(
|
||||
PlaybackConsumeModeChangeEvent(
|
||||
state=bool(int(status['consume'])),
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
|
||||
if last_status.get('single', None) != status['single']:
|
||||
self.bus.post(PlaybackSingleModeChangeEvent(state=bool(int(status['single'])), status=status,
|
||||
track=track, plugin_name='music.mpd'))
|
||||
if last_status.get('single') != status['single']:
|
||||
self.bus.post(
|
||||
PlaybackSingleModeChangeEvent(
|
||||
state=bool(int(status['single'])),
|
||||
status=status,
|
||||
track=track,
|
||||
plugin_name='music.mpd',
|
||||
)
|
||||
)
|
||||
|
||||
last_status = status
|
||||
last_state = state
|
||||
last_track = track
|
||||
time.sleep(self.poll_seconds)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -21,19 +21,7 @@ from platypush.message.event.music.snapcast import (
|
|||
class MusicSnapcastBackend(Backend):
|
||||
"""
|
||||
Backend that listens for notification and status changes on one or more
|
||||
[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`
|
||||
`Snapcast <https://github.com/badaix/snapcast>`_ servers.
|
||||
"""
|
||||
|
||||
_DEFAULT_SNAPCAST_PORT = 1705
|
||||
|
|
|
@ -7,8 +7,14 @@ from typing import Optional, Dict, Any
|
|||
from platypush.backend import Backend
|
||||
from platypush.common.spotify import SpotifyMixin
|
||||
from platypush.config import Config
|
||||
from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, MusicStopEvent, \
|
||||
NewPlayingTrackEvent, SeekChangeEvent, VolumeChangeEvent
|
||||
from platypush.message.event.music import (
|
||||
MusicPlayEvent,
|
||||
MusicPauseEvent,
|
||||
MusicStopEvent,
|
||||
NewPlayingTrackEvent,
|
||||
SeekChangeEvent,
|
||||
VolumeChangeEvent,
|
||||
)
|
||||
from platypush.utils import get_redis
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
* **librespot**. Consult the `README <https://github.com/librespot-org/librespot>`_ for instructions.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
def __init__(
|
||||
self,
|
||||
librespot_path: str = 'librespot',
|
||||
device_name: Optional[str] = None,
|
||||
device_type: str = 'speaker',
|
||||
|
@ -63,11 +62,12 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
|
|||
enable_volume_normalization: bool = False,
|
||||
normalization_method: str = 'dynamic',
|
||||
normalization_pre_gain: Optional[float] = None,
|
||||
normalization_threshold: float = -1.,
|
||||
normalization_threshold: float = -1.0,
|
||||
normalization_attack: int = 5,
|
||||
normalization_release: int = 100,
|
||||
normalization_knee: float = 1.,
|
||||
**kwargs):
|
||||
normalization_knee: float = 1.0,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param librespot_path: Librespot path/executable name (default: ``librespot``).
|
||||
: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)
|
||||
self.device_name = device_name or Config.get('device_id')
|
||||
self._librespot_args = [
|
||||
librespot_path, '--name', self.device_name, '--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',
|
||||
librespot_path,
|
||||
'--name',
|
||||
self.device_name,
|
||||
'--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:
|
||||
self._librespot_args += ['--alsa-mixer-device', audio_device]
|
||||
else:
|
||||
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:
|
||||
self._librespot_args += ['--autoplay']
|
||||
|
@ -148,17 +167,30 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
|
|||
if cache_dir:
|
||||
self._librespot_args += ['--cache', os.path.expanduser(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:
|
||||
self._librespot_args += [
|
||||
'--enable-volume-normalisation', '--normalisation-method', normalization_method,
|
||||
'--normalisation-threshold', str(normalization_threshold), '--normalisation-attack',
|
||||
str(normalization_attack), '--normalisation-release', str(normalization_release),
|
||||
'--normalisation-knee', str(normalization_knee),
|
||||
'--enable-volume-normalisation',
|
||||
'--normalisation-method',
|
||||
normalization_method,
|
||||
'--normalisation-threshold',
|
||||
str(normalization_threshold),
|
||||
'--normalisation-attack',
|
||||
str(normalization_attack),
|
||||
'--normalisation-release',
|
||||
str(normalization_release),
|
||||
'--normalisation-knee',
|
||||
str(normalization_knee),
|
||||
]
|
||||
|
||||
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()
|
||||
if username and password:
|
||||
|
@ -227,11 +259,21 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
|
|||
|
||||
def _process_status_msg(self, status):
|
||||
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')
|
||||
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
|
||||
elapsed = int(status['POSITION_MS'])/1000. if status.get('POSITION_MS') is not None else None
|
||||
duration = (
|
||||
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:
|
||||
self.status['volume'] = volume
|
||||
|
@ -275,7 +317,7 @@ class MusicSpotifyBackend(Backend, SpotifyMixin):
|
|||
self._librespot_proc.terminate()
|
||||
|
||||
try:
|
||||
self._librespot_proc.wait(timeout=5.)
|
||||
self._librespot_proc.wait(timeout=5.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning('Librespot has not yet terminated: killing it')
|
||||
self._librespot_proc.kill()
|
||||
|
|
|
@ -11,10 +11,8 @@ class NextcloudBackend(Backend):
|
|||
"""
|
||||
This backend triggers events when new activities occur on a NextCloud instance.
|
||||
|
||||
Triggers:
|
||||
|
||||
- :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``,
|
||||
The field ``activity_type`` in the triggered :class:`platypush.message.event.nextcloud.NextCloudActivityEvent`
|
||||
events identifies the activity type (e.g. ``file_created``, ``file_deleted``,
|
||||
``file_changed``). Example in the case of the creation of new files:
|
||||
|
||||
.. code-block:: json
|
||||
|
@ -24,7 +22,7 @@ class NextcloudBackend(Backend):
|
|||
"app": "files",
|
||||
"activity_type": "file_created",
|
||||
"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": [
|
||||
"You created {file3}, {file2} and {file1}",
|
||||
{
|
||||
|
@ -73,9 +71,16 @@ class NextcloudBackend(Backend):
|
|||
|
||||
_LAST_ACTIVITY_VARNAME = '_NEXTCLOUD_LAST_ACTIVITY_ID'
|
||||
|
||||
def __init__(self, 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., **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
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 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.password = password if password else self.password
|
||||
|
||||
assert self.url and self.username and self.password, \
|
||||
'No configuration provided neither for the NextCloud plugin nor the backend'
|
||||
assert (
|
||||
self.url and self.username and self.password
|
||||
), 'No configuration provided neither for the NextCloud plugin nor the backend'
|
||||
|
||||
@property
|
||||
def last_seen_id(self) -> Optional[int]:
|
||||
if self._last_seen_id is None:
|
||||
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
|
||||
|
||||
return self._last_seen_id
|
||||
|
@ -133,8 +141,14 @@ class NextcloudBackend(Backend):
|
|||
new_last_seen_id = int(last_seen_id)
|
||||
plugin: NextcloudPlugin = get_plugin('nextcloud')
|
||||
# noinspection PyUnresolvedReferences
|
||||
activities = plugin.get_activities(sort='desc', url=self.url, username=self.username, password=self.password,
|
||||
object_type=self.object_type, object_id=self.object_id).output
|
||||
activities = plugin.get_activities(
|
||||
sort='desc',
|
||||
url=self.url,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
object_type=self.object_type,
|
||||
object_id=self.object_id,
|
||||
).output
|
||||
|
||||
events = []
|
||||
for activity in activities:
|
||||
|
|
|
@ -14,18 +14,6 @@ class NfcBackend(Backend):
|
|||
"""
|
||||
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::
|
||||
|
||||
python -m nfc
|
||||
|
|
|
@ -13,11 +13,6 @@ class NoderedBackend(Backend):
|
|||
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
|
||||
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):
|
||||
|
@ -27,7 +22,8 @@ class NoderedBackend(Backend):
|
|||
super().__init__(*args, **kwargs)
|
||||
self.port = port
|
||||
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
|
||||
|
||||
def on_stop(self):
|
||||
|
@ -40,8 +36,16 @@ class NoderedBackend(Backend):
|
|||
super().run()
|
||||
self.register_service(port=self.port, name='node')
|
||||
|
||||
self._server = subprocess.Popen([sys.executable, '-m', 'pynodered.server',
|
||||
'--port', str(self.port), self._runner_path])
|
||||
self._server = subprocess.Popen(
|
||||
[
|
||||
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._server.wait()
|
||||
|
|
|
@ -11,12 +11,6 @@ from platypush.utils.workers import Worker, Workers
|
|||
class PingBackend(Backend):
|
||||
"""
|
||||
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):
|
||||
|
@ -30,7 +24,15 @@ class PingBackend(Backend):
|
|||
response = pinger.ping(host, timeout=self.timeout, count=self.count).output
|
||||
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 timeout: Ping timeout.
|
||||
|
@ -47,7 +49,9 @@ class PingBackend(Backend):
|
|||
|
||||
def run(self):
|
||||
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():
|
||||
workers = Workers(10, self.Pinger, timeout=self.timeout, count=self.count)
|
||||
|
|
|
@ -14,19 +14,16 @@ class PushbulletBackend(Backend):
|
|||
Pushbullet app and/or through Tasker), synchronize clipboards, send pictures
|
||||
and files to other devices etc. You can also wrap Platypush messages as JSON
|
||||
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,
|
||||
proxy_port: Optional[int] = None, **kwargs):
|
||||
def __init__(
|
||||
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 device: Name of the virtual device for Platypush (default: Platypush)
|
||||
|
@ -47,12 +44,15 @@ class PushbulletBackend(Backend):
|
|||
def _initialize(self):
|
||||
# noinspection PyPackageRequirements
|
||||
from pushbullet import Pushbullet
|
||||
|
||||
self.pb = Pushbullet(self.token)
|
||||
|
||||
try:
|
||||
self.device = self.pb.get_device(self.device_name)
|
||||
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.pb_device_id = self.get_device_id()
|
||||
|
@ -98,8 +98,10 @@ class PushbulletBackend(Backend):
|
|||
body = json.loads(body)
|
||||
self.on_message(body)
|
||||
except Exception as e:
|
||||
self.logger.debug('Unexpected message received on the ' +
|
||||
f'Pushbullet backend: {e}. Message: {body}')
|
||||
self.logger.debug(
|
||||
'Unexpected message received on the '
|
||||
+ f'Pushbullet backend: {e}. Message: {body}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
return
|
||||
|
@ -111,8 +113,12 @@ class PushbulletBackend(Backend):
|
|||
try:
|
||||
return self.pb.get_device(self.device_name).device_iden
|
||||
except Exception:
|
||||
device = self.pb.new_device(self.device_name, model='Platypush virtual device',
|
||||
manufacturer='platypush', icon='system')
|
||||
device = self.pb.new_device(
|
||||
self.device_name,
|
||||
model='Platypush virtual device',
|
||||
manufacturer='platypush',
|
||||
icon='system',
|
||||
)
|
||||
|
||||
self.logger.info(f'Created Pushbullet device {self.device_name}')
|
||||
return device.device_iden
|
||||
|
@ -158,14 +164,18 @@ class PushbulletBackend(Backend):
|
|||
def run_listener(self):
|
||||
from .listener import Listener
|
||||
|
||||
self.logger.info(f'Initializing Pushbullet backend - device_id: {self.device_name}')
|
||||
self.listener = Listener(account=self.pb,
|
||||
self.logger.info(
|
||||
f'Initializing Pushbullet backend - device_id: {self.device_name}'
|
||||
)
|
||||
self.listener = Listener(
|
||||
account=self.pb,
|
||||
on_push=self.on_push(),
|
||||
on_open=self.on_open(),
|
||||
on_close=self.on_close(),
|
||||
on_error=self.on_error(),
|
||||
http_proxy_host=self.proxy_host,
|
||||
http_proxy_port=self.proxy_port)
|
||||
http_proxy_port=self.proxy_port,
|
||||
)
|
||||
|
||||
self.listener.run_forever()
|
||||
|
||||
|
|
|
@ -9,23 +9,18 @@ class ScardBackend(Backend):
|
|||
|
||||
Extend this backend to implement more advanced communication with custom
|
||||
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):
|
||||
"""
|
||||
: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
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ATRs = []
|
||||
|
||||
|
@ -35,9 +30,10 @@ class ScardBackend(Backend):
|
|||
elif isinstance(atr, list):
|
||||
self.ATRs = atr
|
||||
else:
|
||||
raise RuntimeError("Unsupported ATR: \"{}\" - type: {}, " +
|
||||
"supported types: string, list".format(
|
||||
atr, type(atr)))
|
||||
raise RuntimeError(
|
||||
f"Unsupported ATR: \"{atr}\" - type: {type(atr)}, "
|
||||
+ "supported types: string, list"
|
||||
)
|
||||
|
||||
self.cardtype = ATRCardType(*[self._to_bytes(atr) for atr in self.ATRs])
|
||||
else:
|
||||
|
@ -56,8 +52,9 @@ class ScardBackend(Backend):
|
|||
|
||||
super().run()
|
||||
|
||||
self.logger.info('Initialized smart card reader backend - ATR filter: {}'.
|
||||
format(self.ATRs))
|
||||
self.logger.info(
|
||||
'Initialized smart card reader backend - ATR filter: {}'.format(self.ATRs)
|
||||
)
|
||||
|
||||
prev_atr = None
|
||||
reader = None
|
||||
|
@ -72,17 +69,19 @@ class ScardBackend(Backend):
|
|||
atr = toHexString(cardservice.connection.getATR())
|
||||
|
||||
if atr != prev_atr:
|
||||
self.logger.info('Smart card detected on reader {}, ATR: {}'.
|
||||
format(reader, atr))
|
||||
self.logger.info(
|
||||
'Smart card detected on reader {}, ATR: {}'.format(reader, atr)
|
||||
)
|
||||
|
||||
self.bus.post(SmartCardDetectedEvent(atr=atr, reader=reader))
|
||||
prev_atr = atr
|
||||
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))
|
||||
else:
|
||||
self.logger.exception(e)
|
||||
|
||||
prev_atr = None
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -14,11 +14,6 @@ class SensorIrZeroborgBackend(Backend):
|
|||
remote by running the scan utility::
|
||||
|
||||
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
|
||||
|
@ -40,20 +35,29 @@ class SensorIrZeroborgBackend(Backend):
|
|||
if self.zb.HasNewIrMessage():
|
||||
message = self.zb.GetIrMessage()
|
||||
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.last_message = message
|
||||
self.last_message_timestamp = time.time()
|
||||
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 \
|
||||
time.time() - self.last_message_timestamp > self.no_message_timeout:
|
||||
if (
|
||||
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.bus.post(IrKeyUpEvent(message=self.last_message))
|
||||
|
||||
self.last_message = None
|
||||
self.last_message_timestamp = None
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -7,8 +7,13 @@ import Leap
|
|||
|
||||
from platypush.backend import Backend
|
||||
from platypush.context import get_backend
|
||||
from platypush.message.event.sensor.leap import LeapFrameEvent, \
|
||||
LeapFrameStartEvent, LeapFrameStopEvent, LeapConnectEvent, LeapDisconnectEvent
|
||||
from platypush.message.event.sensor.leap import (
|
||||
LeapFrameEvent,
|
||||
LeapFrameStartEvent,
|
||||
LeapFrameStopEvent,
|
||||
LeapConnectEvent,
|
||||
LeapDisconnectEvent,
|
||||
)
|
||||
|
||||
|
||||
class SensorLeapBackend(Backend):
|
||||
|
@ -26,28 +31,25 @@ class SensorLeapBackend(Backend):
|
|||
|
||||
Requires:
|
||||
|
||||
* The Redis backend enabled
|
||||
* The Leap Motion SDK compiled with Python 3 support, see my port at https://github.com:BlackLight/leap-sdk-python3.git
|
||||
* The `leapd` daemon to be running and your Leap Motion connected
|
||||
* The Leap Motion SDK compiled with Python 3 support, see my port at
|
||||
https://github.com:BlackLight/leap-sdk-python3.git
|
||||
* 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
|
||||
|
||||
def __init__(self,
|
||||
def __init__(
|
||||
self,
|
||||
position_ranges=None,
|
||||
position_tolerance=0.0, # Position variation tolerance in %
|
||||
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::
|
||||
|
||||
|
@ -59,7 +61,8 @@ class SensorLeapBackend(Backend):
|
|||
|
||||
: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
|
||||
|
||||
:param frames_throttle_secs: If set, the frame events will be throttled
|
||||
|
@ -87,16 +90,20 @@ class SensorLeapBackend(Backend):
|
|||
super().run()
|
||||
|
||||
def _listener_process():
|
||||
listener = LeapListener(position_ranges=self.position_ranges,
|
||||
listener = LeapListener(
|
||||
position_ranges=self.position_ranges,
|
||||
position_tolerance=self.position_tolerance,
|
||||
frames_throttle_secs=self.frames_throttle_secs,
|
||||
logger=self.logger)
|
||||
logger=self.logger,
|
||||
)
|
||||
|
||||
controller = Leap.Controller()
|
||||
|
||||
if not controller:
|
||||
raise RuntimeError('No Leap Motion controller found - is your ' +
|
||||
'device connected and is leapd running?')
|
||||
raise RuntimeError(
|
||||
'No Leap Motion controller found - is your '
|
||||
+ 'device connected and is leapd running?'
|
||||
)
|
||||
|
||||
controller.add_listener(listener)
|
||||
self.logger.info('Leap Motion backend initialized')
|
||||
|
@ -120,12 +127,14 @@ class LeapFuture(Timer):
|
|||
def _callback_wrapper(self):
|
||||
def _callback():
|
||||
self.listener._send_event(self.event)
|
||||
|
||||
return _callback
|
||||
|
||||
|
||||
class LeapListener(Leap.Listener):
|
||||
def __init__(self, position_ranges, position_tolerance, logger,
|
||||
frames_throttle_secs=None):
|
||||
def __init__(
|
||||
self, position_ranges, position_tolerance, logger, frames_throttle_secs=None
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.prev_frame = None
|
||||
|
@ -138,8 +147,11 @@ class LeapListener(Leap.Listener):
|
|||
def _send_event(self, event):
|
||||
backend = get_backend('redis')
|
||||
if not backend:
|
||||
self.logger.warning('Redis backend not configured, I cannot propagate the following event: {}'.
|
||||
format(event))
|
||||
self.logger.warning(
|
||||
'Redis backend not configured, I cannot propagate the following event: {}'.format(
|
||||
event
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
backend.send_message(event)
|
||||
|
@ -147,8 +159,9 @@ class LeapListener(Leap.Listener):
|
|||
def send_event(self, event):
|
||||
if self.frames_throttle_secs:
|
||||
if not self.running_future or not self.running_future.is_alive():
|
||||
self.running_future = LeapFuture(seconds=self.frames_throttle_secs,
|
||||
listener=self, event=event)
|
||||
self.running_future = LeapFuture(
|
||||
seconds=self.frames_throttle_secs, listener=self, event=event
|
||||
)
|
||||
self.running_future.start()
|
||||
else:
|
||||
self._send_event(event)
|
||||
|
@ -193,23 +206,38 @@ class LeapListener(Leap.Listener):
|
|||
'id': hand.id,
|
||||
'is_left': hand.is_left,
|
||||
'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_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,
|
||||
'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,
|
||||
'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,
|
||||
'wrist_position': self._normalize_position(hand.wrist_position),
|
||||
}
|
||||
for i, hand in enumerate(frame.hands)
|
||||
if hand.is_valid and (
|
||||
len(frame.hands) != len(self.prev_frame.hands) or
|
||||
self._position_changed(
|
||||
if hand.is_valid
|
||||
and (
|
||||
len(frame.hands) != len(self.prev_frame.hands)
|
||||
or self._position_changed(
|
||||
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
|
||||
else True
|
||||
)
|
||||
|
@ -220,25 +248,38 @@ class LeapListener(Leap.Listener):
|
|||
# having x_range = z_range = [-100, 100], y_range = [0, 100]
|
||||
|
||||
return [
|
||||
self._scale_scalar(value=position[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]),
|
||||
self._scale_scalar(
|
||||
value=position[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
|
||||
def _scale_scalar(value, range, new_range):
|
||||
if value < range[0]:
|
||||
value=range[0]
|
||||
value = range[0]
|
||||
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):
|
||||
return (
|
||||
abs(old_position[0]-new_position[0]) > self.position_tolerance or
|
||||
abs(old_position[1]-new_position[1]) > self.position_tolerance or
|
||||
abs(old_position[2]-new_position[2]) > self.position_tolerance)
|
||||
abs(old_position[0] - new_position[0]) > self.position_tolerance
|
||||
or abs(old_position[1] - new_position[1]) > self.position_tolerance
|
||||
or abs(old_position[2] - new_position[2]) > self.position_tolerance
|
||||
)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -10,7 +10,18 @@ from platypush.message import Message
|
|||
|
||||
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
|
||||
|
|
|
@ -3,31 +3,21 @@ import time
|
|||
|
||||
from platypush.backend import Backend
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.todoist import NewItemEvent, RemovedItemEvent, ModifiedItemEvent, CheckedItemEvent, \
|
||||
ItemContentChangeEvent, TodoistSyncRequiredEvent
|
||||
from platypush.message.event.todoist import (
|
||||
NewItemEvent,
|
||||
RemovedItemEvent,
|
||||
ModifiedItemEvent,
|
||||
CheckedItemEvent,
|
||||
ItemContentChangeEvent,
|
||||
TodoistSyncRequiredEvent,
|
||||
)
|
||||
|
||||
from platypush.plugins.todoist import TodoistPlugin
|
||||
|
||||
|
||||
class TodoistBackend(Backend):
|
||||
"""
|
||||
This backend listens for events on a remote 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.
|
||||
|
||||
This backend listens for events on a Todoist account.
|
||||
"""
|
||||
|
||||
def __init__(self, api_token: str = None, **kwargs):
|
||||
|
@ -35,7 +25,9 @@ class TodoistBackend(Backend):
|
|||
self._plugin: TodoistPlugin = get_plugin('todoist')
|
||||
|
||||
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
|
||||
else:
|
||||
self.api_token = api_token
|
||||
|
@ -97,16 +89,15 @@ class TodoistBackend(Backend):
|
|||
import websocket
|
||||
|
||||
if not self._ws:
|
||||
self._ws = websocket.WebSocketApp(self.url,
|
||||
self._ws = websocket.WebSocketApp(
|
||||
self.url,
|
||||
on_message=self._on_msg(),
|
||||
on_error=self._on_error(),
|
||||
on_close=self._on_close())
|
||||
on_close=self._on_close(),
|
||||
)
|
||||
|
||||
def _refresh_items(self):
|
||||
new_items = {
|
||||
i['id']: i
|
||||
for i in self._plugin.get_items().output
|
||||
}
|
||||
new_items = {i['id']: i for i in self._plugin.get_items().output}
|
||||
|
||||
if self._todoist_initialized:
|
||||
for id, item in new_items.items():
|
||||
|
|
|
@ -34,13 +34,6 @@ class TrelloBackend(Backend):
|
|||
|
||||
* 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}'
|
||||
|
|
|
@ -2,7 +2,10 @@ import time
|
|||
|
||||
from platypush.backend import Backend
|
||||
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
|
||||
|
||||
|
||||
|
@ -10,10 +13,6 @@ class WeatherBuienradarBackend(Backend):
|
|||
"""
|
||||
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:
|
||||
|
||||
* The :mod:`platypush.plugins.weather.buienradar` plugin configured
|
||||
|
@ -37,16 +36,24 @@ class WeatherBuienradarBackend(Backend):
|
|||
del weather['measured']
|
||||
|
||||
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'),
|
||||
total=precip.get('total'),
|
||||
time_frame=precip.get('time_frame')))
|
||||
time_frame=precip.get('time_frame'),
|
||||
)
|
||||
)
|
||||
|
||||
if weather != self.last_weather:
|
||||
self.bus.post(NewWeatherConditionEvent(**{
|
||||
self.bus.post(
|
||||
NewWeatherConditionEvent(
|
||||
**{
|
||||
**weather,
|
||||
'plugin_name': 'weather.buienradar',
|
||||
}))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.last_weather = weather
|
||||
self.last_precip = precip
|
||||
|
|
|
@ -5,10 +5,6 @@ class WeatherDarkskyBackend(WeatherBackend):
|
|||
"""
|
||||
Weather forecast backend that leverages the DarkSky API.
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update
|
||||
|
||||
Requires:
|
||||
|
||||
* 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).
|
||||
"""
|
||||
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:
|
||||
|
|
|
@ -5,10 +5,6 @@ class WeatherOpenweathermapBackend(WeatherBackend):
|
|||
"""
|
||||
Weather forecast backend that leverages the OpenWeatherMap API.
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update
|
||||
|
||||
Requires:
|
||||
|
||||
* 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).
|
||||
"""
|
||||
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:
|
||||
|
|
|
@ -13,14 +13,10 @@ class WiimoteBackend(Backend):
|
|||
"""
|
||||
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:
|
||||
|
||||
* **python3-wiimote** (follow instructions at https://github.com/azzra/python3-wiimote)
|
||||
|
||||
"""
|
||||
|
||||
_wiimote = None
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
manifest:
|
||||
events:
|
||||
platypush.message.event.wiimote.WiimoteEvent: when the state of the Wiimote (battery,
|
||||
buttons, acceleration etc.) changes
|
||||
- platypush.message.event.wiimote.WiimoteEvent
|
||||
install:
|
||||
apt:
|
||||
- libcwiid1
|
||||
|
|
|
@ -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:
|
|
@ -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
|
|
@ -18,11 +18,6 @@ class AdafruitIoPlugin(Plugin):
|
|||
You can send values to feeds on your Adafruit IO account and read the
|
||||
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::
|
||||
|
||||
# Send the temperature value for a connected sensor to the "temperature" feed
|
||||
|
@ -63,6 +58,7 @@ class AdafruitIoPlugin(Plugin):
|
|||
"""
|
||||
|
||||
from Adafruit_IO import Client
|
||||
|
||||
global data_throttler_lock
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
@ -109,15 +105,19 @@ class AdafruitIoPlugin(Plugin):
|
|||
while True:
|
||||
try:
|
||||
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():
|
||||
data.setdefault(key, []).append(value)
|
||||
except QueueTimeoutError:
|
||||
pass
|
||||
|
||||
if data and (last_processed_batch_timestamp is None or
|
||||
time.time() - last_processed_batch_timestamp >= self.throttle_seconds):
|
||||
if data and (
|
||||
last_processed_batch_timestamp is None
|
||||
or time.time() - last_processed_batch_timestamp
|
||||
>= self.throttle_seconds
|
||||
):
|
||||
last_processed_batch_timestamp = time.time()
|
||||
self.logger.info('Processing feeds batch for Adafruit IO')
|
||||
|
||||
|
@ -128,8 +128,10 @@ class AdafruitIoPlugin(Plugin):
|
|||
try:
|
||||
self.send(feed, value, enqueue=False)
|
||||
except ThrottlingError:
|
||||
self.logger.warning('Adafruit IO throttling threshold hit, taking a nap ' +
|
||||
'before retrying')
|
||||
self.logger.warning(
|
||||
'Adafruit IO throttling threshold hit, taking a nap '
|
||||
+ 'before retrying'
|
||||
)
|
||||
time.sleep(self.throttle_seconds)
|
||||
|
||||
data = {}
|
||||
|
@ -184,11 +186,15 @@ class AdafruitIoPlugin(Plugin):
|
|||
: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,
|
||||
'lon': lon,
|
||||
'ele': ele,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _cast_value(cls, value):
|
||||
|
@ -205,9 +211,12 @@ class AdafruitIoPlugin(Plugin):
|
|||
return [
|
||||
{
|
||||
attr: self._cast_value(getattr(i, attr))
|
||||
if attr == 'value' else getattr(i, attr)
|
||||
for attr in DATA_FIELDS if getattr(i, attr) is not None
|
||||
} for i in data
|
||||
if attr == 'value'
|
||||
else getattr(i, attr)
|
||||
for attr in DATA_FIELDS
|
||||
if getattr(i, attr) is not None
|
||||
}
|
||||
for i in data
|
||||
]
|
||||
|
||||
@action
|
||||
|
|
|
@ -58,17 +58,6 @@ class ArduinoPlugin(SensorPlugin):
|
|||
Download and flash the
|
||||
`Standard Firmata <https://github.com/firmata/arduino/blob/master/examples/StandardFirmata/StandardFirmata.ino>`_
|
||||
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__(
|
||||
|
|
|
@ -25,18 +25,6 @@ class AssistantEchoPlugin(AssistantPlugin):
|
|||
4. Log in to your Amazon account
|
||||
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__(
|
||||
|
|
|
@ -20,22 +20,6 @@ from platypush.plugins.assistant import AssistantPlugin
|
|||
class AssistantGooglePushtotalkPlugin(AssistantPlugin):
|
||||
"""
|
||||
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'
|
||||
|
|
|
@ -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"]
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -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:
|
|
@ -9,11 +9,6 @@ from platypush.plugins.calendar import CalendarInterface
|
|||
class CalendarIcalPlugin(Plugin, CalendarInterface):
|
||||
"""
|
||||
iCal calendars plugin. Interact with remote calendars in iCal format.
|
||||
|
||||
Requires:
|
||||
|
||||
* **icalendar** (``pip install icalendar``)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
|
|
|
@ -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``,
|
||||
``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
|
||||
|
|
|
@ -7,16 +7,15 @@ from platypush.plugins.camera.model.writer.cv import CvFileWriter
|
|||
class CameraCvPlugin(CameraPlugin):
|
||||
"""
|
||||
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',
|
||||
video_writer: str = 'ffmpeg', **kwargs):
|
||||
def __init__(
|
||||
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 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`.
|
||||
"""
|
||||
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':
|
||||
self._video_writer_class = CvFileWriter
|
||||
|
||||
|
@ -60,12 +61,15 @@ class CameraCvPlugin(CameraPlugin):
|
|||
def capture_frame(self, camera: Camera, *args, **kwargs):
|
||||
import cv2
|
||||
from PIL import Image
|
||||
|
||||
ret, frame = camera.object.read()
|
||||
assert ret, 'Cannot retrieve frame from {}'.format(camera.info.device)
|
||||
|
||||
color_transform = camera.info.color_transform
|
||||
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:
|
||||
frame = cv2.cvtColor(frame, color_transform)
|
||||
|
||||
|
|
|
@ -12,18 +12,18 @@ from platypush.plugins.camera.ffmpeg.model import FFmpegCamera, FFmpegCameraInfo
|
|||
class CameraFfmpegPlugin(CameraPlugin):
|
||||
"""
|
||||
Plugin to interact with a camera over FFmpeg.
|
||||
|
||||
Requires:
|
||||
|
||||
* **ffmpeg** package installed on the system.
|
||||
|
||||
"""
|
||||
|
||||
_camera_class = FFmpegCamera
|
||||
_camera_info_class = FFmpegCameraInfo
|
||||
|
||||
def __init__(self, device: Optional[str] = '/dev/video0', input_format: str = 'v4l2', ffmpeg_args: Tuple[str] = (),
|
||||
**opts):
|
||||
def __init__(
|
||||
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 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:
|
||||
warmup_seconds = self._get_warmup_seconds(camera)
|
||||
ffmpeg = [camera.info.ffmpeg_bin, '-y', '-f', camera.info.input_format, '-i', camera.info.device, '-s',
|
||||
'{}x{}'.format(*camera.info.resolution), '-ss', str(warmup_seconds),
|
||||
ffmpeg = [
|
||||
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 ()),
|
||||
'-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)))
|
||||
proc = subprocess.Popen(ffmpeg, stdout=subprocess.PIPE)
|
||||
|
@ -46,7 +61,9 @@ class CameraFfmpegPlugin(CameraPlugin):
|
|||
proc.send_signal(signal.SIGSTOP)
|
||||
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)
|
||||
if camera.object:
|
||||
camera.object.send_signal(signal.SIGCONT)
|
||||
|
@ -65,7 +82,9 @@ class CameraFfmpegPlugin(CameraPlugin):
|
|||
except Exception as 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
|
||||
data = camera.object.stdout.read(raw_size)
|
||||
if len(data) < raw_size:
|
||||
|
|
|
@ -11,20 +11,6 @@ from platypush.common.gstreamer import Pipeline
|
|||
class CameraGstreamerPlugin(CameraPlugin):
|
||||
"""
|
||||
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
|
||||
|
|
|
@ -25,15 +25,15 @@ class CameraIrMlx90640Plugin(CameraPlugin):
|
|||
$ make bcm2835
|
||||
$ 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),
|
||||
warmup_frames: Optional[int] = 5, **kwargs):
|
||||
def __init__(
|
||||
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
|
||||
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 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:
|
||||
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))
|
||||
|
||||
assert os.path.isfile(rawrgb_path),\
|
||||
'rawrgb executable not found. Please follow the documentation of this plugin to build it'
|
||||
assert os.path.isfile(
|
||||
rawrgb_path
|
||||
), 'rawrgb executable not found. Please follow the documentation of this plugin to build it'
|
||||
|
||||
self.rawrgb_path = rawrgb_path
|
||||
self._capture_proc = None
|
||||
|
@ -59,8 +67,11 @@ class CameraIrMlx90640Plugin(CameraPlugin):
|
|||
|
||||
def prepare_device(self, device: Camera):
|
||||
if not self._is_capture_running():
|
||||
self._capture_proc = subprocess.Popen([self.rawrgb_path, '{}'.format(device.info.fps)],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
self._capture_proc = subprocess.Popen(
|
||||
[self.rawrgb_path, '{}'.format(device.info.fps)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
||||
return self._capture_proc
|
||||
|
||||
|
@ -77,11 +88,14 @@ class CameraIrMlx90640Plugin(CameraPlugin):
|
|||
from PIL import Image
|
||||
|
||||
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)
|
||||
|
||||
def to_grayscale(self, image):
|
||||
from PIL import Image
|
||||
|
||||
new_image = Image.new('L', image.size)
|
||||
|
||||
for i in range(0, image.size[0]):
|
||||
|
|
|
@ -12,30 +12,45 @@ class CameraPiPlugin(CameraPlugin):
|
|||
"""
|
||||
Plugin to control a Pi camera.
|
||||
|
||||
Requires:
|
||||
|
||||
* **picamera** (``pip install picamera``)
|
||||
* **numpy** (``pip install numpy``)
|
||||
* **Pillow** (``pip install Pillow``)
|
||||
.. warning::
|
||||
This plugin is **DEPRECATED**, as it relies on the old ``picamera`` module.
|
||||
On recent systems, it should be possible to access the Pi Camera through
|
||||
the ffmpeg or gstreamer integrations.
|
||||
|
||||
"""
|
||||
|
||||
_camera_class = PiCamera
|
||||
_camera_info_class = PiCameraInfo
|
||||
|
||||
def __init__(self, device: int = 0, fps: float = 30., warmup_seconds: float = 2., 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,
|
||||
def __init__(
|
||||
self,
|
||||
device: int = 0,
|
||||
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,
|
||||
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
|
||||
for a detailed reference about the Pi camera options.
|
||||
|
||||
: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.contrast = contrast
|
||||
|
@ -56,8 +71,12 @@ class CameraPiPlugin(CameraPlugin):
|
|||
# noinspection PyUnresolvedReferences
|
||||
import picamera
|
||||
|
||||
camera = picamera.PiCamera(camera_num=device.info.device, resolution=device.info.resolution,
|
||||
framerate=device.info.fps, led_pin=device.info.led_pin)
|
||||
camera = picamera.PiCamera(
|
||||
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.vflip = device.info.vertical_flip
|
||||
|
@ -97,9 +116,11 @@ class CameraPiPlugin(CameraPlugin):
|
|||
import numpy as np
|
||||
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),
|
||||
3)
|
||||
3,
|
||||
)
|
||||
|
||||
frame = np.empty(shape, dtype=np.uint8)
|
||||
camera.object.capture(frame, 'rgb')
|
||||
|
@ -121,7 +142,9 @@ class CameraPiPlugin(CameraPlugin):
|
|||
self.logger.warning(str(e))
|
||||
|
||||
@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)
|
||||
self.start_preview(camera)
|
||||
|
||||
|
@ -132,11 +155,15 @@ class CameraPiPlugin(CameraPlugin):
|
|||
|
||||
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)
|
||||
sock = None
|
||||
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:
|
||||
while camera.stream_event.is_set():
|
||||
|
@ -161,7 +188,9 @@ class CameraPiPlugin(CameraPlugin):
|
|||
try:
|
||||
sock.close()
|
||||
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)
|
||||
finally:
|
||||
|
@ -169,7 +198,9 @@ class CameraPiPlugin(CameraPlugin):
|
|||
self.logger.info('Stopped camera stream')
|
||||
|
||||
@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)
|
||||
return self._start_streaming(camera, duration, stream_format)
|
||||
|
||||
|
|
|
@ -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
|
||||
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):
|
||||
|
|
|
@ -4,21 +4,28 @@ import os
|
|||
from threading import RLock
|
||||
from typing import Optional, Union
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
from telegram.ext import Updater
|
||||
# noinspection PyPackageRequirements
|
||||
from telegram.message import Message as TelegramMessage
|
||||
# noinspection PyPackageRequirements
|
||||
from telegram.user import User as TelegramUser
|
||||
|
||||
from platypush.message.response.chat.telegram import TelegramMessageResponse, TelegramFileResponse, \
|
||||
TelegramChatResponse, TelegramUserResponse, TelegramUsersResponse
|
||||
from platypush.message.response.chat.telegram import (
|
||||
TelegramMessageResponse,
|
||||
TelegramFileResponse,
|
||||
TelegramChatResponse,
|
||||
TelegramUserResponse,
|
||||
TelegramUsersResponse,
|
||||
)
|
||||
from platypush.plugins import action
|
||||
from platypush.plugins.chat import ChatPlugin
|
||||
|
||||
|
||||
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'
|
||||
self.file_id = file_id
|
||||
self.url = url
|
||||
|
@ -27,12 +34,14 @@ class Resource:
|
|||
|
||||
def __enter__(self):
|
||||
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_id or self.url
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
def __exit__(self, *_, **__):
|
||||
if self._file:
|
||||
self._file.close()
|
||||
|
||||
|
@ -47,10 +56,6 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
3. Copy the provided API token in the configuration of this plugin.
|
||||
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):
|
||||
|
@ -117,7 +122,7 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
contact_user_id=msg.contact.user_id if msg.contact else None,
|
||||
contact_vcard=msg.contact.vcard if msg.contact else None,
|
||||
link=msg.link,
|
||||
media_group_id=msg.media_group_id
|
||||
media_group_id=msg.media_group_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
@ -129,13 +134,19 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
language_code=user.language_code,
|
||||
link=user.link
|
||||
link=user.link,
|
||||
)
|
||||
|
||||
@action
|
||||
def send_message(self, 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:
|
||||
def send_message(
|
||||
self,
|
||||
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.
|
||||
|
||||
|
@ -152,17 +163,21 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
msg = telegram.bot.send_message(chat_id=chat_id,
|
||||
msg = telegram.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
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)
|
||||
|
||||
@action
|
||||
def send_photo(self, chat_id: Union[str, int],
|
||||
def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
file_id: Optional[int] = None,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
|
@ -170,7 +185,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
parse_mode: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send a picture to a chat.
|
||||
|
||||
|
@ -198,17 +214,22 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
caption=caption,
|
||||
disable_notification=disable_notification,
|
||||
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)
|
||||
|
||||
@action
|
||||
def send_audio(self, chat_id: Union[str, int],
|
||||
def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
file_id: Optional[int] = None,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
|
@ -219,7 +240,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
parse_mode: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send audio to a chat.
|
||||
|
||||
|
@ -250,7 +272,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
caption=caption,
|
||||
disable_notification=disable_notification,
|
||||
|
@ -259,12 +282,15 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
duration=duration,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout,
|
||||
parse_mode=parse_mode)
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_document(self, chat_id: Union[str, int],
|
||||
def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
file_id: Optional[int] = None,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
|
@ -273,7 +299,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
parse_mode: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send a document to a chat.
|
||||
|
||||
|
@ -302,19 +329,23 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
filename=filename,
|
||||
caption=caption,
|
||||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout,
|
||||
parse_mode=parse_mode)
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_video(self, chat_id: Union[str, int],
|
||||
def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
file_id: Optional[int] = None,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
|
@ -325,7 +356,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
parse_mode: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send a video to a chat.
|
||||
|
||||
|
@ -356,7 +388,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
duration=duration,
|
||||
caption=caption,
|
||||
|
@ -365,12 +398,15 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout,
|
||||
parse_mode=parse_mode)
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_animation(self, chat_id: Union[str, int],
|
||||
def send_animation(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
file_id: Optional[int] = None,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
|
@ -381,7 +417,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
parse_mode: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
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.
|
||||
|
||||
|
@ -412,7 +449,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
duration=duration,
|
||||
caption=caption,
|
||||
|
@ -421,12 +459,15 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout,
|
||||
parse_mode=parse_mode)
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_voice(self, chat_id: Union[str, int],
|
||||
def send_voice(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
file_id: Optional[int] = None,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
|
@ -435,7 +476,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
parse_mode: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
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
|
||||
(other formats may be sent as Audio or Document).
|
||||
|
@ -465,25 +507,31 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
caption=caption,
|
||||
disable_notification=disable_notification,
|
||||
duration=duration,
|
||||
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)
|
||||
|
||||
@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,
|
||||
url: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
duration: Optional[int] = None,
|
||||
disable_notification: bool = False,
|
||||
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
|
||||
1 minute long.
|
||||
|
@ -511,22 +559,27 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
telegram = self.get_telegram()
|
||||
|
||||
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,
|
||||
duration=duration,
|
||||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout)
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_location(self, chat_id: Union[str, int],
|
||||
def send_location(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send a location to a chat.
|
||||
|
||||
|
@ -543,17 +596,21 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
msg = telegram.bot.send_location(chat_id=chat_id,
|
||||
msg = telegram.bot.send_location(
|
||||
chat_id=chat_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout)
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_venue(self, chat_id: Union[str, int],
|
||||
def send_venue(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
title: str,
|
||||
|
@ -562,7 +619,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
foursquare_type: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send the address of a venue to a chat.
|
||||
|
||||
|
@ -583,7 +641,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
msg = telegram.bot.send_venue(chat_id=chat_id,
|
||||
msg = telegram.bot.send_venue(
|
||||
chat_id=chat_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
title=title,
|
||||
|
@ -592,19 +651,23 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
foursquare_type=foursquare_type,
|
||||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout)
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
@action
|
||||
def send_contact(self, chat_id: Union[str, int],
|
||||
def send_contact(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
phone_number: str,
|
||||
first_name: str,
|
||||
last_name: Optional[str] = None,
|
||||
vcard: Optional[str] = None,
|
||||
disable_notification: bool = False,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
timeout: int = 20) -> TelegramMessageResponse:
|
||||
timeout: int = 20,
|
||||
) -> TelegramMessageResponse:
|
||||
"""
|
||||
Send a contact to a chat.
|
||||
|
||||
|
@ -623,14 +686,16 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
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,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
vcard=vcard,
|
||||
disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=timeout)
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return self.parse_msg(msg)
|
||||
|
||||
|
@ -645,10 +710,14 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
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
|
||||
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.
|
||||
|
||||
|
@ -658,7 +727,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
chat = telegram.bot.get_chat(chat_id, timeout=timeout)
|
||||
return TelegramChatResponse(chat_id=chat.id,
|
||||
return TelegramChatResponse(
|
||||
chat_id=chat.id,
|
||||
link=chat.link,
|
||||
username=chat.username,
|
||||
invite_link=chat.invite_link,
|
||||
|
@ -666,10 +736,13 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
description=chat.description,
|
||||
type=chat.type,
|
||||
first_name=chat.first_name,
|
||||
last_name=chat.last_name)
|
||||
last_name=chat.last_name,
|
||||
)
|
||||
|
||||
@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.
|
||||
|
||||
|
@ -680,16 +753,20 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
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,
|
||||
username=user.user.username,
|
||||
first_name=user.user.first_name,
|
||||
last_name=user.user.last_name,
|
||||
is_bot=user.user.is_bot,
|
||||
language_code=user.user.language_code)
|
||||
language_code=user.user.language_code,
|
||||
)
|
||||
|
||||
@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.
|
||||
|
||||
|
@ -699,7 +776,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
admins = telegram.bot.get_chat_administrators(chat_id, timeout=timeout)
|
||||
return TelegramUsersResponse([
|
||||
return TelegramUsersResponse(
|
||||
[
|
||||
TelegramUserResponse(
|
||||
user_id=user.user.id,
|
||||
link=user.user.link,
|
||||
|
@ -708,11 +786,15 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
last_name=user.user.last_name,
|
||||
is_bot=user.user.is_bot,
|
||||
language_code=user.user.language_code,
|
||||
) for user in admins
|
||||
])
|
||||
)
|
||||
for user in admins
|
||||
]
|
||||
)
|
||||
|
||||
@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.
|
||||
|
||||
|
@ -723,10 +805,13 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
return telegram.bot.get_chat_members_count(chat_id, timeout=timeout)
|
||||
|
||||
@action
|
||||
def kick_chat_member(self, chat_id: Union[str, int],
|
||||
def kick_chat_member(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
user_id: int,
|
||||
until_date: Optional[datetime.datetime] = None,
|
||||
timeout: int = 20):
|
||||
timeout: int = 20,
|
||||
):
|
||||
"""
|
||||
Kick a user from a chat.
|
||||
|
||||
|
@ -742,15 +827,13 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.kick_chat_member(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
until_date=until_date,
|
||||
timeout=timeout)
|
||||
chat_id=chat_id, user_id=user_id, until_date=until_date, timeout=timeout
|
||||
)
|
||||
|
||||
@action
|
||||
def unban_chat_member(self, chat_id: Union[str, int],
|
||||
user_id: int,
|
||||
timeout: int = 20):
|
||||
def unban_chat_member(
|
||||
self, chat_id: Union[str, int], user_id: int, timeout: int = 20
|
||||
):
|
||||
"""
|
||||
Lift the ban from a chat member.
|
||||
|
||||
|
@ -765,12 +848,13 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.unban_chat_member(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
timeout=timeout)
|
||||
chat_id=chat_id, user_id=user_id, timeout=timeout
|
||||
)
|
||||
|
||||
@action
|
||||
def promote_chat_member(self, chat_id: Union[str, int],
|
||||
def promote_chat_member(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
user_id: int,
|
||||
can_change_info: Optional[bool] = None,
|
||||
can_post_messages: Optional[bool] = None,
|
||||
|
@ -780,7 +864,8 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
can_restrict_members: Optional[bool] = None,
|
||||
can_promote_members: Optional[bool] = None,
|
||||
can_pin_messages: Optional[bool] = None,
|
||||
timeout: int = 20):
|
||||
timeout: int = 20,
|
||||
):
|
||||
"""
|
||||
Promote or demote a member.
|
||||
|
||||
|
@ -813,12 +898,11 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
can_restrict_members=can_restrict_members,
|
||||
can_promote_members=can_promote_members,
|
||||
can_pin_messages=can_pin_messages,
|
||||
timeout=timeout)
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@action
|
||||
def set_chat_title(self, chat_id: Union[str, int],
|
||||
title: str,
|
||||
timeout: int = 20):
|
||||
def set_chat_title(self, chat_id: Union[str, int], title: str, timeout: int = 20):
|
||||
"""
|
||||
Set the title of a channel/group.
|
||||
|
||||
|
@ -832,15 +916,12 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.set_chat_title(
|
||||
chat_id=chat_id,
|
||||
description=title,
|
||||
timeout=timeout)
|
||||
telegram.bot.set_chat_title(chat_id=chat_id, description=title, timeout=timeout)
|
||||
|
||||
@action
|
||||
def set_chat_description(self, chat_id: Union[str, int],
|
||||
description: str,
|
||||
timeout: int = 20):
|
||||
def set_chat_description(
|
||||
self, chat_id: Union[str, int], description: str, timeout: int = 20
|
||||
):
|
||||
"""
|
||||
Set the description of a channel/group.
|
||||
|
||||
|
@ -855,14 +936,11 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.set_chat_description(
|
||||
chat_id=chat_id,
|
||||
description=description,
|
||||
timeout=timeout)
|
||||
chat_id=chat_id, description=description, timeout=timeout
|
||||
)
|
||||
|
||||
@action
|
||||
def set_chat_photo(self, chat_id: Union[str, int],
|
||||
path: str,
|
||||
timeout: int = 20):
|
||||
def set_chat_photo(self, chat_id: Union[str, int], path: str, timeout: int = 20):
|
||||
"""
|
||||
Set the photo of a channel/group.
|
||||
|
||||
|
@ -879,13 +957,11 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
|
||||
with Resource(path=path) as resource:
|
||||
telegram.bot.set_chat_photo(
|
||||
chat_id=chat_id,
|
||||
photo=resource,
|
||||
timeout=timeout)
|
||||
chat_id=chat_id, photo=resource, timeout=timeout
|
||||
)
|
||||
|
||||
@action
|
||||
def delete_chat_photo(self, chat_id: Union[str, int],
|
||||
timeout: int = 20):
|
||||
def delete_chat_photo(self, chat_id: Union[str, int], timeout: int = 20):
|
||||
"""
|
||||
Delete the photo of a channel/group.
|
||||
|
||||
|
@ -898,15 +974,16 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.delete_chat_photo(
|
||||
chat_id=chat_id,
|
||||
timeout=timeout)
|
||||
telegram.bot.delete_chat_photo(chat_id=chat_id, timeout=timeout)
|
||||
|
||||
@action
|
||||
def pin_chat_message(self, chat_id: Union[str, int],
|
||||
def pin_chat_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
message_id: int,
|
||||
disable_notification: Optional[bool] = None,
|
||||
timeout: int = 20):
|
||||
timeout: int = 20,
|
||||
):
|
||||
"""
|
||||
Pin a message in a chat.
|
||||
|
||||
|
@ -925,11 +1002,11 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
disable_notification=disable_notification,
|
||||
timeout=timeout)
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@action
|
||||
def unpin_chat_message(self, chat_id: Union[str, int],
|
||||
timeout: int = 20):
|
||||
def unpin_chat_message(self, chat_id: Union[str, int], timeout: int = 20):
|
||||
"""
|
||||
Unpin the message of a chat.
|
||||
|
||||
|
@ -942,13 +1019,10 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.unpin_chat_message(
|
||||
chat_id=chat_id,
|
||||
timeout=timeout)
|
||||
telegram.bot.unpin_chat_message(chat_id=chat_id, timeout=timeout)
|
||||
|
||||
@action
|
||||
def leave_chat(self, chat_id: Union[str, int],
|
||||
timeout: int = 20):
|
||||
def leave_chat(self, chat_id: Union[str, int], timeout: int = 20):
|
||||
"""
|
||||
Leave a chat.
|
||||
|
||||
|
@ -961,9 +1035,7 @@ class ChatTelegramPlugin(ChatPlugin):
|
|||
"""
|
||||
|
||||
telegram = self.get_telegram()
|
||||
telegram.bot.leave_chat(
|
||||
chat_id=chat_id,
|
||||
timeout=timeout)
|
||||
telegram.bot.leave_chat(chat_id=chat_id, timeout=timeout)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -10,15 +10,6 @@ class ClipboardPlugin(RunnablePlugin):
|
|||
"""
|
||||
Plugin to programmatically copy strings to your system clipboard,
|
||||
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):
|
||||
|
|
|
@ -27,7 +27,7 @@ class BusType(enum.Enum):
|
|||
SESSION = 'session'
|
||||
|
||||
|
||||
class DBusService():
|
||||
class DBusService:
|
||||
"""
|
||||
<node>
|
||||
<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
|
||||
: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__(
|
||||
self, signals: Optional[Iterable[dict]] = None,
|
||||
self,
|
||||
signals: Optional[Iterable[dict]] = None,
|
||||
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
|
||||
|
@ -138,8 +131,7 @@ class DbusPlugin(RunnablePlugin):
|
|||
self._loop = None
|
||||
self._signals = DbusSignalSchema().load(signals or [], many=True)
|
||||
self._signal_handlers = [
|
||||
self._get_signal_handler(**signal)
|
||||
for signal in self._signals
|
||||
self._get_signal_handler(**signal) for signal in self._signals
|
||||
]
|
||||
|
||||
self.service_name = service_name
|
||||
|
@ -150,8 +142,12 @@ class DbusPlugin(RunnablePlugin):
|
|||
def handler(sender, path, interface, signal, params):
|
||||
get_bus().post(
|
||||
DbusSignalEvent(
|
||||
bus=bus, signal=signal, path=path,
|
||||
interface=interface, sender=sender, params=params
|
||||
bus=bus,
|
||||
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]:
|
||||
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:
|
||||
paths = {}
|
||||
if service_dict is None:
|
||||
|
@ -212,10 +210,14 @@ class DbusPlugin(RunnablePlugin):
|
|||
obj = bus.get(service, object_path)
|
||||
interface = obj['org.freedesktop.DBus.Introspectable']
|
||||
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 {}
|
||||
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 {}
|
||||
|
||||
xml_string = interface.Introspect()
|
||||
|
@ -226,7 +228,9 @@ class DbusPlugin(RunnablePlugin):
|
|||
if object_path == '/':
|
||||
object_path = ''
|
||||
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:
|
||||
if not object_path:
|
||||
object_path = '/'
|
||||
|
@ -253,8 +257,9 @@ class DbusPlugin(RunnablePlugin):
|
|||
return service_dict
|
||||
|
||||
@action
|
||||
def query(self, service: Optional[str] = None, bus=tuple(t.value for t in BusType)) \
|
||||
-> Dict[str, dict]:
|
||||
def query(
|
||||
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.
|
||||
|
||||
|
@ -433,7 +438,7 @@ class DbusPlugin(RunnablePlugin):
|
|||
method_name: str,
|
||||
bus: str = BusType.SESSION.value,
|
||||
path: str = '/',
|
||||
args: Optional[list] = None
|
||||
args: Optional[list] = None,
|
||||
):
|
||||
"""
|
||||
Execute a method exposed on DBus.
|
||||
|
|
|
@ -7,10 +7,6 @@ from platypush.plugins import Plugin, action
|
|||
class DropboxPlugin(Plugin):
|
||||
"""
|
||||
Plugin to manage a Dropbox account and its files and folders.
|
||||
|
||||
Requires:
|
||||
|
||||
* **dropbox** (``pip install dropbox``)
|
||||
"""
|
||||
|
||||
def __init__(self, access_token, **kwargs):
|
||||
|
@ -101,15 +97,26 @@ class DropboxPlugin(Plugin):
|
|||
for item in files:
|
||||
entry = {
|
||||
attr: getattr(item, attr)
|
||||
for attr in ['id', 'name', 'path_display', 'path_lower',
|
||||
'parent_shared_folder_id', 'property_groups']
|
||||
for attr in [
|
||||
'id',
|
||||
'name',
|
||||
'path_display',
|
||||
'path_lower',
|
||||
'parent_shared_folder_id',
|
||||
'property_groups',
|
||||
]
|
||||
}
|
||||
|
||||
if item.sharing_info:
|
||||
entry['sharing_info'] = {
|
||||
attr: getattr(item.sharing_info, attr)
|
||||
for attr in ['no_access', 'parent_shared_folder_id', 'read_only',
|
||||
'shared_folder_id', 'traverse_only']
|
||||
for attr in [
|
||||
'no_access',
|
||||
'parent_shared_folder_id',
|
||||
'read_only',
|
||||
'shared_folder_id',
|
||||
'traverse_only',
|
||||
]
|
||||
}
|
||||
else:
|
||||
entry['sharing_info'] = {}
|
||||
|
@ -118,7 +125,13 @@ class DropboxPlugin(Plugin):
|
|||
entry['client_modified'] = item.client_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):
|
||||
entry[attr] = getattr(item, attr)
|
||||
|
||||
|
@ -127,8 +140,14 @@ class DropboxPlugin(Plugin):
|
|||
return entries
|
||||
|
||||
@action
|
||||
def copy(self, from_path: str, to_path: str, allow_shared_folder=True, autorename=False,
|
||||
allow_ownership_transfer=False):
|
||||
def copy(
|
||||
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
|
||||
its contents will be copied.
|
||||
|
@ -148,12 +167,23 @@ class DropboxPlugin(Plugin):
|
|||
"""
|
||||
|
||||
dbx = self._get_instance()
|
||||
dbx.files_copy_v2(from_path, to_path, allow_shared_folder=allow_shared_folder,
|
||||
autorename=autorename, allow_ownership_transfer=allow_ownership_transfer)
|
||||
dbx.files_copy_v2(
|
||||
from_path,
|
||||
to_path,
|
||||
allow_shared_folder=allow_shared_folder,
|
||||
autorename=autorename,
|
||||
allow_ownership_transfer=allow_ownership_transfer,
|
||||
)
|
||||
|
||||
@action
|
||||
def move(self, from_path: str, to_path: str, allow_shared_folder=True, autorename=False,
|
||||
allow_ownership_transfer=False):
|
||||
def move(
|
||||
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
|
||||
contents will be moved.
|
||||
|
@ -173,8 +203,13 @@ class DropboxPlugin(Plugin):
|
|||
"""
|
||||
|
||||
dbx = self._get_instance()
|
||||
dbx.files_move_v2(from_path, to_path, allow_shared_folder=allow_shared_folder,
|
||||
autorename=autorename, allow_ownership_transfer=allow_ownership_transfer)
|
||||
dbx.files_move_v2(
|
||||
from_path,
|
||||
to_path,
|
||||
allow_shared_folder=allow_shared_folder,
|
||||
autorename=autorename,
|
||||
allow_ownership_transfer=allow_ownership_transfer,
|
||||
)
|
||||
|
||||
@action
|
||||
def delete(self, path: str):
|
||||
|
@ -251,7 +286,9 @@ class DropboxPlugin(Plugin):
|
|||
|
||||
if 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:
|
||||
f.write(response.content)
|
||||
|
@ -350,8 +387,13 @@ class DropboxPlugin(Plugin):
|
|||
from dropbox.files import SearchMode
|
||||
|
||||
dbx = self._get_instance()
|
||||
response = dbx.files_search(query=query, path=path, start=start, max_results=max_results,
|
||||
mode=SearchMode.filename_and_content if content else SearchMode.filename)
|
||||
response = dbx.files_search(
|
||||
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]
|
||||
|
||||
|
@ -397,8 +439,12 @@ class DropboxPlugin(Plugin):
|
|||
else:
|
||||
raise SyntaxError('Please specify either a file or text to be uploaded')
|
||||
|
||||
metadata = dbx.files_upload(content, path, autorename=autorename,
|
||||
mode=WriteMode.overwrite if overwrite else WriteMode.add)
|
||||
metadata = dbx.files_upload(
|
||||
content,
|
||||
path,
|
||||
autorename=autorename,
|
||||
mode=WriteMode.overwrite if overwrite else WriteMode.add,
|
||||
)
|
||||
|
||||
return self._parse_metadata(metadata)
|
||||
|
||||
|
|
|
@ -9,15 +9,11 @@ from platypush.plugins import Plugin, action
|
|||
class FfmpegPlugin(Plugin):
|
||||
"""
|
||||
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)
|
||||
self.ffmpeg_cmd = ffmpeg_cmd
|
||||
self.ffprobe_cmd = ffprobe_cmd
|
||||
|
@ -102,14 +98,19 @@ class FfmpegPlugin(Plugin):
|
|||
"""
|
||||
# noinspection PyPackageRequirements
|
||||
import ffmpeg
|
||||
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
info = ffmpeg.probe(filename, cmd=self.ffprobe_cmd, **kwargs)
|
||||
return info
|
||||
|
||||
@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_close: Optional[Callable[[], None]] = None):
|
||||
on_close: Optional[Callable[[], None]] = None,
|
||||
):
|
||||
try:
|
||||
if on_open:
|
||||
on_open()
|
||||
|
@ -122,25 +123,49 @@ class FfmpegPlugin(Plugin):
|
|||
on_close()
|
||||
|
||||
@action
|
||||
def start(self, 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):
|
||||
def start(
|
||||
self,
|
||||
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
|
||||
import ffmpeg
|
||||
|
||||
stream = ffmpeg
|
||||
|
||||
for step in pipeline:
|
||||
args = step.pop('args') if 'args' in step else []
|
||||
stream = getattr(stream, step.pop('method'))(*args, **step)
|
||||
|
||||
self.logger.info('Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args()))
|
||||
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)
|
||||
self.logger.info(
|
||||
'Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args())
|
||||
)
|
||||
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:
|
||||
with self._thread_lock:
|
||||
self._threads[self._next_thread_id] = threading.Thread(target=self._poll_thread, kwargs=dict(
|
||||
proc=proc, on_packet=on_packet, packet_size=packet_size))
|
||||
self._threads[self._next_thread_id] = threading.Thread(
|
||||
target=self._poll_thread,
|
||||
kwargs={
|
||||
'proc': proc,
|
||||
'on_packet': on_packet,
|
||||
'packet_size': packet_size,
|
||||
},
|
||||
)
|
||||
|
||||
self._threads[self._next_thread_id].start()
|
||||
self._next_thread_id += 1
|
||||
|
||||
|
|
|
@ -22,11 +22,6 @@ class GooglePlugin(Plugin):
|
|||
python -m platypush.plugins.google.credentials \
|
||||
'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):
|
||||
|
|
|
@ -7,13 +7,7 @@ from platypush.plugins.calendar import CalendarInterface
|
|||
|
||||
class GoogleCalendarPlugin(GooglePlugin, CalendarInterface):
|
||||
"""
|
||||
Google calendar plugin.
|
||||
|
||||
Requires:
|
||||
|
||||
* **google-api-python-client** (``pip install google-api-python-client``)
|
||||
* **oauth2client** (``pip install oauth2client``)
|
||||
|
||||
Google Calendar plugin.
|
||||
"""
|
||||
|
||||
scopes = ['https://www.googleapis.com/auth/calendar.readonly']
|
||||
|
|
|
@ -10,17 +10,13 @@ from platypush.message.response.google.drive import GoogleDriveFile
|
|||
class GoogleDrivePlugin(GooglePlugin):
|
||||
"""
|
||||
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.photos.readonly']
|
||||
'https://www.googleapis.com/auth/drive.photos.readonly',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(scopes=self.scopes, *args, **kwargs)
|
||||
|
@ -30,13 +26,15 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
|
||||
# noinspection PyShadowingBuiltins
|
||||
@action
|
||||
def files(self,
|
||||
def files(
|
||||
self,
|
||||
filter: Optional[str] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
limit: Optional[int] = 100,
|
||||
drive_id: Optional[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.
|
||||
|
||||
|
@ -90,7 +88,9 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
filter += "'{}' in parents".format(folder_id)
|
||||
|
||||
while True:
|
||||
results = service.files().list(
|
||||
results = (
|
||||
service.files()
|
||||
.list(
|
||||
q=filter,
|
||||
driveId=drive_id,
|
||||
pageSize=limit,
|
||||
|
@ -98,17 +98,22 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
fields="nextPageToken, files(id, name, kind, mimeType)",
|
||||
pageToken=page_token,
|
||||
spaces=spaces,
|
||||
).execute()
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
page_token = results.get('nextPageToken')
|
||||
files.extend([
|
||||
files.extend(
|
||||
[
|
||||
GoogleDriveFile(
|
||||
id=f.get('id'),
|
||||
name=f.get('name'),
|
||||
type=f.get('kind').split('#')[1],
|
||||
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):
|
||||
break
|
||||
|
@ -131,14 +136,16 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
)
|
||||
|
||||
@action
|
||||
def upload(self,
|
||||
def upload(
|
||||
self,
|
||||
path: str,
|
||||
mime_type: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
parents: Optional[List[str]] = None,
|
||||
starred: bool = False,
|
||||
target_mime_type: Optional[str] = None) -> GoogleDriveFile:
|
||||
target_mime_type: Optional[str] = None,
|
||||
) -> GoogleDriveFile:
|
||||
"""
|
||||
Upload a file to Google Drive.
|
||||
|
||||
|
@ -171,11 +178,11 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
|
||||
media = MediaFileUpload(path, mimetype=mime_type)
|
||||
service = self.get_service()
|
||||
file = service.files().create(
|
||||
body=metadata,
|
||||
media_body=media,
|
||||
fields='*'
|
||||
).execute()
|
||||
file = (
|
||||
service.files()
|
||||
.create(body=metadata, media_body=media, fields='*')
|
||||
.execute()
|
||||
)
|
||||
|
||||
return GoogleDriveFile(
|
||||
type=file.get('kind').split('#')[1],
|
||||
|
@ -216,12 +223,14 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
return path
|
||||
|
||||
@action
|
||||
def create(self,
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
parents: Optional[List[str]] = None,
|
||||
starred: bool = False) -> GoogleDriveFile:
|
||||
starred: bool = False,
|
||||
) -> GoogleDriveFile:
|
||||
"""
|
||||
Create a file.
|
||||
|
||||
|
@ -242,10 +251,7 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
metadata['mimeType'] = mime_type
|
||||
|
||||
service = self.get_service()
|
||||
file = service.files().create(
|
||||
body=metadata,
|
||||
fields='*'
|
||||
).execute()
|
||||
file = service.files().create(body=metadata, fields='*').execute()
|
||||
|
||||
return GoogleDriveFile(
|
||||
type=file.get('kind').split('#')[1],
|
||||
|
@ -255,7 +261,8 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
)
|
||||
|
||||
@action
|
||||
def update(self,
|
||||
def update(
|
||||
self,
|
||||
file_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
|
@ -263,7 +270,8 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
remove_parents: Optional[List[str]] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
starred: bool = None,
|
||||
trashed: bool = None) -> GoogleDriveFile:
|
||||
trashed: bool = None,
|
||||
) -> GoogleDriveFile:
|
||||
"""
|
||||
Update the metadata or the content of a file.
|
||||
|
||||
|
@ -293,11 +301,9 @@ class GoogleDrivePlugin(GooglePlugin):
|
|||
metadata['trashed'] = trashed
|
||||
|
||||
service = self.get_service()
|
||||
file = service.files().update(
|
||||
fileId=file_id,
|
||||
body=metadata,
|
||||
fields='*'
|
||||
).execute()
|
||||
file = (
|
||||
service.files().update(fileId=file_id, body=metadata, fields='*').execute()
|
||||
)
|
||||
|
||||
return GoogleDriveFile(
|
||||
type=file.get('kind').split('#')[1],
|
||||
|
|
|
@ -5,20 +5,16 @@ from platypush.plugins.google import GooglePlugin
|
|||
class GoogleFitPlugin(GooglePlugin):
|
||||
"""
|
||||
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_temperature.read',
|
||||
'https://www.googleapis.com/auth/fitness.heart_rate.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):
|
||||
"""
|
||||
|
@ -30,7 +26,6 @@ class GoogleFitPlugin(GooglePlugin):
|
|||
super().__init__(scopes=self.scopes, *args, **kwargs)
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
@action
|
||||
def get_data_sources(self, user_id=None):
|
||||
"""
|
||||
|
@ -38,8 +33,9 @@ class GoogleFitPlugin(GooglePlugin):
|
|||
"""
|
||||
|
||||
service = self.get_service(service='fitness', version='v1')
|
||||
sources = service.users().dataSources(). \
|
||||
list(userId=user_id or self.user_id).execute()
|
||||
sources = (
|
||||
service.users().dataSources().list(userId=user_id or self.user_id).execute()
|
||||
)
|
||||
|
||||
return sources['dataSource']
|
||||
|
||||
|
@ -64,11 +60,19 @@ class GoogleFitPlugin(GooglePlugin):
|
|||
kwargs['limit'] = limit
|
||||
data_points = []
|
||||
|
||||
for data_point in service.users().dataSources().dataPointChanges(). \
|
||||
list(**kwargs).execute().get('insertedDataPoint', []):
|
||||
data_point['startTime'] = float(data_point.pop('startTimeNanos'))/1e9
|
||||
data_point['endTime'] = float(data_point.pop('endTimeNanos'))/1e9
|
||||
data_point['modifiedTime'] = float(data_point.pop('modifiedTimeMillis'))/1e6
|
||||
for data_point in (
|
||||
service.users()
|
||||
.dataSources()
|
||||
.dataPointChanges()
|
||||
.list(**kwargs)
|
||||
.execute()
|
||||
.get('insertedDataPoint', [])
|
||||
):
|
||||
data_point['startTime'] = float(data_point.pop('startTimeNanos')) / 1e9
|
||||
data_point['endTime'] = float(data_point.pop('endTimeNanos')) / 1e9
|
||||
data_point['modifiedTime'] = (
|
||||
float(data_point.pop('modifiedTimeMillis')) / 1e6
|
||||
)
|
||||
values = []
|
||||
|
||||
for value in data_point.pop('value'):
|
||||
|
@ -81,9 +85,11 @@ class GoogleFitPlugin(GooglePlugin):
|
|||
elif value.get('mapVal'):
|
||||
value = {
|
||||
v['key']: v['value'].get(
|
||||
'intVal', v['value'].get(
|
||||
'fpVal', v['value'].get('stringVal')))
|
||||
for v in value['mapVal'] }
|
||||
'intVal',
|
||||
v['value'].get('fpVal', v['value'].get('stringVal')),
|
||||
)
|
||||
for v in value['mapVal']
|
||||
}
|
||||
|
||||
values.append(value)
|
||||
|
||||
|
|
|
@ -17,12 +17,6 @@ from platypush.plugins.google import GooglePlugin
|
|||
class GoogleMailPlugin(GooglePlugin):
|
||||
"""
|
||||
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']
|
||||
|
|
|
@ -14,12 +14,6 @@ datetime_types = Union[str, int, float, datetime]
|
|||
class GoogleMapsPlugin(GooglePlugin):
|
||||
"""
|
||||
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 = []
|
||||
|
|
|
@ -19,19 +19,13 @@ class GooglePubsubPlugin(Plugin):
|
|||
3. Download the JSON service credentials file. By default platypush will look for the credentials file under
|
||||
~/.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'
|
||||
subscriber_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber'
|
||||
default_credentials_file = os.path.join(os.path.expanduser('~'),
|
||||
'.credentials', 'platypush', 'google', 'pubsub.json')
|
||||
default_credentials_file = os.path.join(
|
||||
os.path.expanduser('~'), '.credentials', 'platypush', 'google', 'pubsub.json'
|
||||
)
|
||||
|
||||
def __init__(self, credentials_file: str = default_credentials_file, **kwargs):
|
||||
"""
|
||||
|
@ -43,13 +37,15 @@ class GooglePubsubPlugin(Plugin):
|
|||
self.project_id = self.get_project_id()
|
||||
|
||||
def get_project_id(self):
|
||||
credentials = json.load(open(self.credentials_file))
|
||||
return credentials.get('project_id')
|
||||
with open(self.credentials_file) as f:
|
||||
return json.load(f).get('project_id')
|
||||
|
||||
def get_credentials(self, audience: str):
|
||||
# noinspection PyPackageRequirements
|
||||
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
|
||||
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 kwargs: Extra arguments to be passed to .publish()
|
||||
"""
|
||||
# noinspection PyPackageRequirements
|
||||
from google.cloud import pubsub_v1
|
||||
# noinspection PyPackageRequirements
|
||||
from google.api_core.exceptions import AlreadyExists
|
||||
|
||||
credentials = self.get_credentials(self.publisher_audience)
|
||||
|
@ -79,9 +73,9 @@ class GooglePubsubPlugin(Plugin):
|
|||
except AlreadyExists:
|
||||
pass
|
||||
|
||||
if isinstance(msg, int) or isinstance(msg, float):
|
||||
if isinstance(msg, (int, float)):
|
||||
msg = str(msg)
|
||||
if isinstance(msg, dict) or isinstance(msg, list):
|
||||
if isinstance(msg, (dict, list)):
|
||||
msg = json.dumps(msg)
|
||||
if isinstance(msg, str):
|
||||
msg = msg.encode()
|
||||
|
|
|
@ -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
|
||||
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
|
||||
default_credentials_file = os.path.join(os.path.expanduser('~'), '.credentials', 'platypush', 'google',
|
||||
'translate.json')
|
||||
default_credentials_file = os.path.join(
|
||||
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 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
|
||||
|
||||
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):
|
||||
self.credentials_file = self.default_credentials_file
|
||||
|
||||
|
@ -59,11 +61,11 @@ class GoogleTranslatePlugin(Plugin):
|
|||
|
||||
@staticmethod
|
||||
def _nearest_delimiter_index(text: str, pos: int) -> int:
|
||||
for i in range(min(pos, len(text)-1), -1, -1):
|
||||
for i in range(min(pos, len(text) - 1), -1, -1):
|
||||
if text[i] in [' ', '\t', ',', '.', ')', '>']:
|
||||
return i
|
||||
elif text[i] in ['(', '<']:
|
||||
return i-1 if i > 0 else 0
|
||||
return i - 1 if i > 0 else 0
|
||||
|
||||
return 0
|
||||
|
||||
|
@ -77,17 +79,22 @@ class GoogleTranslatePlugin(Plugin):
|
|||
parts.append(text)
|
||||
text = ''
|
||||
else:
|
||||
part = text[:i+1]
|
||||
part = text[: i + 1]
|
||||
if part:
|
||||
parts.append(part.strip())
|
||||
text = text[i+1:]
|
||||
text = text[i + 1 :]
|
||||
|
||||
return parts
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
@action
|
||||
def translate(self, text: str, target_language: Optional[str] = None, source_language: Optional[str] = None,
|
||||
format: Optional[str] = None) -> TranslateResponse:
|
||||
def translate(
|
||||
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.
|
||||
|
||||
|
|
|
@ -5,12 +5,6 @@ from platypush.plugins.google import GooglePlugin
|
|||
class GoogleYoutubePlugin(GooglePlugin):
|
||||
"""
|
||||
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']
|
||||
|
|
|
@ -17,11 +17,6 @@ class GotifyPlugin(RunnablePlugin):
|
|||
|
||||
`Gotify <https://gotify.net>`_ allows you process messages and notifications asynchronously
|
||||
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):
|
||||
|
@ -47,11 +42,13 @@ class GotifyPlugin(RunnablePlugin):
|
|||
rs = getattr(requests, method)(
|
||||
f'{self.server_url}/{endpoint}',
|
||||
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',
|
||||
**kwargs.pop('headers', {}),
|
||||
},
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
rs.raise_for_status()
|
||||
|
@ -65,7 +62,9 @@ class GotifyPlugin(RunnablePlugin):
|
|||
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):
|
||||
if self._ws_app:
|
||||
|
@ -78,7 +77,9 @@ class GotifyPlugin(RunnablePlugin):
|
|||
self._ws_listener.join(5)
|
||||
|
||||
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()
|
||||
|
||||
if self._ws_listener:
|
||||
|
@ -92,13 +93,18 @@ class GotifyPlugin(RunnablePlugin):
|
|||
if self.should_stop() or self._connected_event.is_set():
|
||||
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(
|
||||
f'{ws_url}/stream?token={self.client_token}',
|
||||
on_open=self._on_open(),
|
||||
on_message=self._on_msg(),
|
||||
on_error=self._on_error(),
|
||||
on_close=self._on_close()
|
||||
on_close=self._on_close(),
|
||||
)
|
||||
|
||||
def server():
|
||||
|
@ -144,7 +150,13 @@ class GotifyPlugin(RunnablePlugin):
|
|||
return hndl
|
||||
|
||||
@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.
|
||||
|
||||
|
@ -155,12 +167,16 @@ class GotifyPlugin(RunnablePlugin):
|
|||
:return: .. schema:: gotify.GotifyMessageSchema
|
||||
"""
|
||||
return GotifyMessageSchema().dump(
|
||||
self._execute('post', 'message', json={
|
||||
self._execute(
|
||||
'post',
|
||||
'message',
|
||||
json={
|
||||
'message': message,
|
||||
'title': title,
|
||||
'priority': priority,
|
||||
'extras': extras or {},
|
||||
})
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
|
@ -174,11 +190,14 @@ class GotifyPlugin(RunnablePlugin):
|
|||
"""
|
||||
return GotifyMessageSchema().dump(
|
||||
self._execute(
|
||||
'get', 'message', params={
|
||||
'get',
|
||||
'message',
|
||||
params={
|
||||
'limit': limit,
|
||||
**({'since': since} if since else {}),
|
||||
}
|
||||
).get('messages', []), many=True
|
||||
},
|
||||
).get('messages', []),
|
||||
many=True,
|
||||
)
|
||||
|
||||
@action
|
||||
|
|
|
@ -10,16 +10,6 @@ class GpioPlugin(RunnablePlugin):
|
|||
"""
|
||||
This plugin can be used to interact with custom electronic devices
|
||||
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__(
|
||||
|
|
|
@ -22,12 +22,6 @@ class GpioZeroborgPlugin(Plugin):
|
|||
ZeroBorg plugin. It allows you to control a ZeroBorg
|
||||
(https://www.piborg.org/motor-control-1135/zeroborg) motor controller and
|
||||
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):
|
||||
|
@ -72,6 +66,7 @@ class GpioZeroborgPlugin(Plugin):
|
|||
directions = {}
|
||||
|
||||
import platypush.plugins.gpio.zeroborg.lib as ZeroBorg
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.directions = directions
|
||||
|
@ -109,13 +104,19 @@ class GpioZeroborgPlugin(Plugin):
|
|||
if self._direction in self.directions:
|
||||
self._motors = self.directions[self._direction]
|
||||
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:
|
||||
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
|
||||
|
||||
for i, power in enumerate(self._motors):
|
||||
method = getattr(self.zb, 'SetMotor{}'.format(i+1))
|
||||
method = getattr(self.zb, 'SetMotor{}'.format(i + 1))
|
||||
method(power)
|
||||
finally:
|
||||
self.zb.MotorsOff()
|
||||
|
@ -129,7 +130,11 @@ class GpioZeroborgPlugin(Plugin):
|
|||
drive_thread.start()
|
||||
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}
|
||||
|
||||
@action
|
||||
|
@ -163,7 +168,9 @@ class GpioZeroborgPlugin(Plugin):
|
|||
return {
|
||||
'status': 'running' if self._direction else 'stopped',
|
||||
'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)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -46,15 +46,6 @@ class HidPlugin(RunnablePlugin):
|
|||
|
||||
# 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__(
|
||||
|
|
|
@ -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:
|
|
@ -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
|
|
@ -71,8 +71,6 @@ class HttpWebpagePlugin(Plugin):
|
|||
|
||||
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``)
|
||||
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
interface) to be running - it won't work in console mode.
|
||||
|
||||
Requires:
|
||||
|
||||
* **pyuserinput** (``pip install pyuserinput``)
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_keyboard():
|
||||
# noinspection PyPackageRequirements
|
||||
from pykeyboard import PyKeyboard
|
||||
|
||||
return PyKeyboard()
|
||||
|
||||
@staticmethod
|
||||
def _get_mouse():
|
||||
# noinspection PyPackageRequirements
|
||||
from pymouse import PyMouse
|
||||
|
||||
return PyMouse()
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -8,14 +8,6 @@ from platypush.plugins import Plugin, action
|
|||
class KafkaPlugin(Plugin):
|
||||
"""
|
||||
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):
|
||||
|
@ -30,8 +22,9 @@ class KafkaPlugin(Plugin):
|
|||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.server = '{server}:{port}'.format(server=server, port=port) \
|
||||
if server else None
|
||||
self.server = (
|
||||
'{server}:{port}'.format(server=server, port=port) if server else None
|
||||
)
|
||||
|
||||
self.producer = None
|
||||
|
||||
|
@ -60,13 +53,15 @@ class KafkaPlugin(Plugin):
|
|||
kafka_backend = get_backend('kafka')
|
||||
server = kafka_backend.server
|
||||
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:
|
||||
server = self.server
|
||||
|
||||
if isinstance(msg, dict) or isinstance(msg, list):
|
||||
if isinstance(msg, (dict, list)):
|
||||
msg = json.dumps(msg)
|
||||
msg = str(msg).encode('utf-8')
|
||||
msg = str(msg).encode()
|
||||
|
||||
producer = KafkaProducer(bootstrap_servers=server)
|
||||
producer.send(topic, msg)
|
||||
|
|
|
@ -8,10 +8,6 @@ class LastfmPlugin(Plugin):
|
|||
"""
|
||||
Plugin to interact with your Last.FM (https://last.fm) account, update your
|
||||
current track and your scrobbles.
|
||||
|
||||
Requires:
|
||||
|
||||
* **pylast** (``pip install pylast``)
|
||||
"""
|
||||
|
||||
def __init__(self, api_key, api_secret, username, password):
|
||||
|
|
|
@ -7,13 +7,8 @@ from platypush.plugins import Plugin, action
|
|||
class LcdPlugin(Plugin, ABC):
|
||||
"""
|
||||
Abstract class for plugins to communicate with LCD displays.
|
||||
|
||||
Requires:
|
||||
|
||||
* **RPLCD** (``pip install RPLCD``)
|
||||
* **RPi.GPIO** (``pip install RPi.GPIO``)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.lcd = None
|
||||
|
@ -21,9 +16,12 @@ class LcdPlugin(Plugin, ABC):
|
|||
@staticmethod
|
||||
def _get_pin_mode(pin_mode: str) -> int:
|
||||
import RPi.GPIO
|
||||
|
||||
pin_modes = ['BOARD', 'BCM']
|
||||
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
|
||||
|
||||
@abstractmethod
|
||||
|
@ -105,7 +103,8 @@ class LcdPlugin(Plugin, ABC):
|
|||
modes = ['left', 'right']
|
||||
mode = mode.lower()
|
||||
assert mode in modes, 'Unsupported text mode: {}. Supported modes: {}'.format(
|
||||
mode, modes)
|
||||
mode, modes
|
||||
)
|
||||
|
||||
self._init_lcd()
|
||||
self.lcd.text_align_mode = mode
|
||||
|
|
|
@ -6,23 +6,26 @@ from platypush.plugins.lcd import LcdPlugin
|
|||
class LcdGpioPlugin(LcdPlugin):
|
||||
"""
|
||||
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],
|
||||
pin_rw: Optional[int] = None, pin_mode: str = 'BOARD',
|
||||
def __init__(
|
||||
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,
|
||||
cols: int = 16, rows: int = 2,
|
||||
cols: int = 16,
|
||||
rows: int = 2,
|
||||
backlight_enabled: bool = True,
|
||||
backlight_mode: str = 'active_low',
|
||||
dotsize: int = 8, charmap: str = 'A02',
|
||||
dotsize: int = 8,
|
||||
charmap: str = 'A02',
|
||||
auto_linebreaks: bool = True,
|
||||
compat_mode: bool = False, **kwargs):
|
||||
compat_mode: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param pin_rs: Pin for register select (RS).
|
||||
:param pin_e: Pin to start data read or write (E).
|
||||
|
@ -70,15 +73,23 @@ class LcdGpioPlugin(LcdPlugin):
|
|||
|
||||
def _get_lcd(self):
|
||||
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,
|
||||
numbering_mode=self.pin_mode, pin_rw=self.pin_rw,
|
||||
|
||||
return CharLCD(
|
||||
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,
|
||||
backlight_enabled=self.backlight_enabled,
|
||||
backlight_mode=self.backlight_mode,
|
||||
dotsize=self.dotsize, charmap=self.charmap,
|
||||
dotsize=self.dotsize,
|
||||
charmap=self.charmap,
|
||||
auto_linebreaks=self.auto_linebreaks,
|
||||
compat_mode=self.compat_mode)
|
||||
compat_mode=self.compat_mode,
|
||||
)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -8,47 +8,63 @@ class LcdI2cPlugin(LcdPlugin):
|
|||
Plugin to write to an LCD display connected via I2C.
|
||||
Adafruit I2C/SPI LCD Backback is supported.
|
||||
|
||||
Warning: You might need a level shifter (that supports i2c)
|
||||
between the 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
|
||||
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.
|
||||
Warning: You might need a level shifter (that supports i2c) between the
|
||||
SCL/SDA connections on the MCP chip / backpack and the Raspberry Pi.
|
||||
|
||||
Otherwise, 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 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:
|
||||
https://learn.adafruit.com/i2c-spi-lcd-backpack/
|
||||
4-bit operation. I2C only supported.
|
||||
|
||||
4-bit operations. I2C only supported.
|
||||
|
||||
Pin mapping::
|
||||
|
||||
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0
|
||||
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,
|
||||
port: int = 1, cols: int = 16, rows: int = 2,
|
||||
port: int = 1,
|
||||
cols: int = 16,
|
||||
rows: int = 2,
|
||||
backlight_enabled: bool = True,
|
||||
dotsize: int = 8, charmap: str = 'A02',
|
||||
auto_linebreaks: bool = True, **kwargs):
|
||||
dotsize: int = 8,
|
||||
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 expander_params: Parameters for expanders, in a dictionary. Only needed for MCP23017
|
||||
gpio_bank - This must be either ``A`` or ``B``. If you have a HAT, A is usually marked 1 and B is 2.
|
||||
Example: ``expander_params={'gpio_bank': 'A'}``
|
||||
:param expander_params: Parameters for expanders, in a dictionary. Only
|
||||
needed for MCP23017 gpio_bank - This must be either ``A`` or ``B``.
|
||||
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 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 backlight_enabled: Whether the backlight is enabled initially. Default: ``True``. Has no effect if pin_backlight is ``None``
|
||||
:param dotsize: Some 1 line displays allow a font height of 10px. 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``.
|
||||
:param backlight_enabled: Whether the backlight is enabled initially.
|
||||
Default: ``True``. Has no effect if pin_backlight is ``None``
|
||||
:param dotsize: Some 1 line displays allow a font height of 10px.
|
||||
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)
|
||||
|
||||
|
@ -65,12 +81,18 @@ class LcdI2cPlugin(LcdPlugin):
|
|||
|
||||
def _get_lcd(self):
|
||||
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,
|
||||
address=self.address, port=self.port,
|
||||
address=self.address,
|
||||
port=self.port,
|
||||
backlight_enabled=self.backlight_enabled,
|
||||
dotsize=self.dotsize, charmap=self.charmap,
|
||||
auto_linebreaks=self.auto_linebreaks)
|
||||
dotsize=self.dotsize,
|
||||
charmap=self.charmap,
|
||||
auto_linebreaks=self.auto_linebreaks,
|
||||
)
|
||||
|
||||
|
||||
class LcdI2CPlugin(LcdI2cPlugin):
|
||||
|
|
|
@ -34,18 +34,6 @@ from platypush.plugins import RunnablePlugin, action
|
|||
class LightHuePlugin(RunnablePlugin, LightEntityManager):
|
||||
"""
|
||||
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
|
||||
|
@ -88,7 +76,7 @@ class LightHuePlugin(RunnablePlugin, LightEntityManager):
|
|||
"""
|
||||
:param bridge: Bridge address or hostname
|
||||
: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
|
||||
updates (default: 20 seconds).
|
||||
: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
Loading…
Reference in a new issue