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

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

View file

@ -49,7 +49,7 @@ steps:
commands:
- 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

View file

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

View file

@ -20,7 +20,6 @@ Backends
platypush/backend/google.pubsub.rst
platypush/backend/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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,7 +50,6 @@ Plugins
platypush/plugins/graphite.rst
platypush/plugins/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

View file

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

View file

@ -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,
audio_volume: Optional[Union[int, float]] = None,
snooze_interval: float = 300.0, enabled: bool = True):
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,
):
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)

View file

@ -31,40 +31,6 @@ class AssistantGoogleBackend(AssistantBackend):
https://developers.google.com/assistant/sdk/reference/library/python/. This backend still works on most of the
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()

View file

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

View file

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

View file

@ -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,
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):
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
):
"""
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')

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
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()}
))
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()
}
)
)
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:

View file

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

View file

@ -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'),
rev=report.get('rev'),
proto_major=report.get('proto_major'),
proto_minor=report.get('proto_minor'))
return GPSVersionEvent(
release=report.get('release'),
rev=report.get('rev'),
proto_major=report.get('proto_major'),
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)

View file

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

View file

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

View file

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

View file

@ -8,14 +8,6 @@ class JoystickBackend(Backend):
"""
This backend will listen for events from a joystick device and post a
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:

View file

@ -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,
device: str = '/dev/input/js0',
jstest_path: str = '/usr/bin/jstest',
**kwargs):
def __init__(
self,
device: str = '/dev/input/js0',
jstest_path: str = '/usr/bin/jstest',
**kwargs,
):
"""
:param device: Path to the joystick device (default: ``/dev/input/js0``).
:param jstest_path: Path to the ``jstest`` executable that comes with the ``joystick`` system package
@ -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

View file

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

View file

@ -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,39 +153,54 @@ 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
# Joystick event loop
while not self.should_stop():
try:
evbuf = jsdev.read(8)
if evbuf:
_, value, evt_type, number = struct.unpack('IhBB', evbuf)
try:
# Joystick event loop
while not self.should_stop():
try:
evbuf = jsdev.read(8)
if evbuf:
_, value, evt_type, number = struct.unpack('IhBB', evbuf)
if evt_type & 0x80: # Initial state notification
continue
if evt_type & 0x80: # Initial state notification
continue
if evt_type & 0x01:
button = self._button_map[number]
if button:
self._button_states[button] = value
evt_class = JoystickButtonPressedEvent if value else JoystickButtonReleasedEvent
# noinspection PyTypeChecker
self.bus.post(evt_class(device=self.device, button=button))
if evt_type & 0x01:
button = self._button_map[number]
if button:
self._button_states[button] = value
evt_class = (
JoystickButtonPressedEvent
if value
else JoystickButtonReleasedEvent
)
# noinspection PyTypeChecker
self.bus.post(
evt_class(device=self.device, button=button)
)
if evt_type & 0x02:
axis = self._axis_map[number]
if axis:
fvalue = value / 32767.0
self._axis_states[axis] = fvalue
# noinspection PyTypeChecker
self.bus.post(JoystickAxisEvent(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
if evt_type & 0x02:
axis = self._axis_map[number]
if axis:
fvalue = value / 32767.0
self._axis_states[axis] = fvalue
# noinspection PyTypeChecker
self.bus.post(
JoystickAxisEvent(
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()

View file

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

View file

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

View file

@ -60,14 +60,6 @@ class MailBackend(Backend):
It requires at least one plugin that extends :class:`platypush.plugins.mail.MailInPlugin` (e.g. ``mail.imap``) to
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__(

View file

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

View file

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

View file

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

View file

@ -5,11 +5,20 @@ import threading
import websocket
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,
on_open=self._on_open(),
on_message=self._on_msg(),
on_error=self._on_error(),
on_close=self._on_close())
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(),
)
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):

View file

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

View file

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

View file

@ -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,53 +27,47 @@ 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,
librespot_path: str = 'librespot',
device_name: Optional[str] = None,
device_type: str = 'speaker',
audio_backend: str = 'alsa',
audio_device: Optional[str] = None,
mixer: str = 'softvol',
mixer_name: str = 'PCM',
mixer_card: str = 'default',
mixer_index: int = 0,
volume: int = 100,
volume_ctrl: str = 'linear',
bitrate: int = 160,
autoplay: bool = False,
disable_gapless: bool = False,
username: Optional[str] = None,
password: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
proxy: Optional[str] = None,
ap_port: Optional[int] = None,
disable_discovery: bool = False,
cache_dir: Optional[str] = None,
system_cache_dir: Optional[str] = None,
disable_audio_cache=False,
enable_volume_normalization: bool = False,
normalization_method: str = 'dynamic',
normalization_pre_gain: Optional[float] = None,
normalization_threshold: float = -1.,
normalization_attack: int = 5,
normalization_release: int = 100,
normalization_knee: float = 1.,
**kwargs):
def __init__(
self,
librespot_path: str = 'librespot',
device_name: Optional[str] = None,
device_type: str = 'speaker',
audio_backend: str = 'alsa',
audio_device: Optional[str] = None,
mixer: str = 'softvol',
mixer_name: str = 'PCM',
mixer_card: str = 'default',
mixer_index: int = 0,
volume: int = 100,
volume_ctrl: str = 'linear',
bitrate: int = 160,
autoplay: bool = False,
disable_gapless: bool = False,
username: Optional[str] = None,
password: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
proxy: Optional[str] = None,
ap_port: Optional[int] = None,
disable_discovery: bool = False,
cache_dir: Optional[str] = None,
system_cache_dir: Optional[str] = None,
disable_audio_cache=False,
enable_volume_normalization: bool = False,
normalization_method: str = 'dynamic',
normalization_pre_gain: Optional[float] = None,
normalization_threshold: float = -1.0,
normalization_attack: int = 5,
normalization_release: int = 100,
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()

View file

@ -11,71 +11,76 @@ class NextcloudBackend(Backend):
"""
This backend triggers events when new activities occur on a NextCloud instance.
Triggers:
The field ``activity_type`` in the triggered :class:`platypush.message.event.nextcloud.NextCloudActivityEvent`
events identifies the activity type (e.g. ``file_created``, ``file_deleted``,
``file_changed``). Example in the case of the creation of new files:
- :class:`platypush.message.event.nextcloud.NextCloudActivityEvent` when new activity occurs on the instance.
The field ``activity_type`` identifies the activity type (e.g. ``file_created``, ``file_deleted``,
``file_changed``). Example in the case of the creation of new files:
.. code-block:: json
.. code-block:: json
{
"activity_id": 387,
"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_rich": [
"You created {file3}, {file2} and {file1}",
{
"file1": {
"type": "file",
"id": "41994",
"name": "IMG_0100.jpg",
"path": "InstantUpload/Camera/IMG_0100.jpg",
"link": "https://your-domain/nextcloud/index.php/f/41994"
},
"file2": {
"type": "file",
"id": "42005",
"name": "IMG_0101.jpg",
"path": "InstantUpload/Camera/IMG_0102.jpg",
"link": "https://your-domain/nextcloud/index.php/f/42005"
},
"file3": {
"type": "file",
"id": "42014",
"name": "IMG_0102.jpg",
"path": "InstantUpload/Camera/IMG_0102.jpg",
"link": "https://your-domain/nextcloud/index.php/f/42014"
}
}
],
"message": "",
"message_rich": [
"",
[]
],
"object_type": "files",
"object_id": 41994,
"object_name": "/InstantUpload/Camera/IMG_0102.jpg",
"objects": {
"42014": "/InstantUpload/Camera/IMG_0100.jpg",
"42005": "/InstantUpload/Camera/IMG_0101.jpg",
"41994": "/InstantUpload/Camera/IMG_0102.jpg"
},
"link": "https://your-domain/nextcloud/index.php/apps/files/?dir=/InstantUpload/Camera",
"icon": "https://your-domain/nextcloud/apps/files/img/add-color.svg",
"datetime": "2020-09-07T17:04:29+00:00"
{
"activity_id": 387,
"app": "files",
"activity_type": "file_created",
"user": "your-user",
"subject": "You created InstantUpload/Camera/IMG_0100.jpg",
"subject_rich": [
"You created {file3}, {file2} and {file1}",
{
"file1": {
"type": "file",
"id": "41994",
"name": "IMG_0100.jpg",
"path": "InstantUpload/Camera/IMG_0100.jpg",
"link": "https://your-domain/nextcloud/index.php/f/41994"
},
"file2": {
"type": "file",
"id": "42005",
"name": "IMG_0101.jpg",
"path": "InstantUpload/Camera/IMG_0102.jpg",
"link": "https://your-domain/nextcloud/index.php/f/42005"
},
"file3": {
"type": "file",
"id": "42014",
"name": "IMG_0102.jpg",
"path": "InstantUpload/Camera/IMG_0102.jpg",
"link": "https://your-domain/nextcloud/index.php/f/42014"
}
}
],
"message": "",
"message_rich": [
"",
[]
],
"object_type": "files",
"object_id": 41994,
"object_name": "/InstantUpload/Camera/IMG_0102.jpg",
"objects": {
"42014": "/InstantUpload/Camera/IMG_0100.jpg",
"42005": "/InstantUpload/Camera/IMG_0101.jpg",
"41994": "/InstantUpload/Camera/IMG_0102.jpg"
},
"link": "https://your-domain/nextcloud/index.php/apps/files/?dir=/InstantUpload/Camera",
"icon": "https://your-domain/nextcloud/apps/files/img/add-color.svg",
"datetime": "2020-09-07T17:04:29+00:00"
}
"""
_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:

View file

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

View file

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

View file

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

View file

@ -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,
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)
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,
)
self.listener.run_forever()

View file

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

View file

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

View file

@ -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,40 +31,38 @@ 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,
position_ranges=None,
position_tolerance=0.0, # Position variation tolerance in %
frames_throttle_secs=None,
*args, **kwargs):
def __init__(
self,
position_ranges=None,
position_tolerance=0.0, # Position variation tolerance in %
frames_throttle_secs=None,
*args,
**kwargs
):
"""
:param position_ranges: It specifies how wide the hand space (x, y and z axes) should be in millimiters.
:param position_ranges: It specifies how wide the hand space (x, y and
z axes) should be in millimiters.
Default::
Default::
[
[-300.0, 300.0], # x axis
[25.0, 600.0], # y axis
[-300.0, 300.0], # z axis
]
[
[-300.0, 300.0], # x axis
[25.0, 600.0], # y axis
[-300.0, 300.0], # z axis
]
: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,
position_tolerance=self.position_tolerance,
frames_throttle_secs=self.frames_throttle_secs,
logger=self.logger)
listener = LeapListener(
position_ranges=self.position_ranges,
position_tolerance=self.position_tolerance,
frames_throttle_secs=self.frames_throttle_secs,
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:

View file

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

View file

@ -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,
on_message=self._on_msg(),
on_error=self._on_error(),
on_close=self._on_close())
self._ws = websocket.WebSocketApp(
self.url,
on_message=self._on_msg(),
on_error=self._on_error(),
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():

View file

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

View file

@ -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',
average=precip.get('average'),
total=precip.get('total'),
time_frame=precip.get('time_frame')))
self.bus.post(
NewPrecipitationForecastEvent(
plugin_name='weather.buienradar',
average=precip.get('average'),
total=precip.get('total'),
time_frame=precip.get('time_frame'),
)
)
if weather != self.last_weather:
self.bus.post(NewWeatherConditionEvent(**{
**weather,
'plugin_name': 'weather.buienradar',
}))
self.bus.post(
NewWeatherConditionEvent(
**{
**weather,
'plugin_name': 'weather.buienradar',
}
)
)
self.last_weather = weather
self.last_precip = precip

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,11 +18,6 @@ class AdafruitIoPlugin(Plugin):
You can send values to feeds on your Adafruit IO account and read the
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={
'lat': lat,
'lon': lon,
'ele': ele,
})
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -60,25 +60,6 @@ class CameraPlugin(Plugin, ABC):
Both the endpoints support the same parameters of the constructor of this class (e.g. ``device``, ``warmup_frames``,
``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

View file

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

View file

@ -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),
*(('-r', str(camera.info.fps)) if camera.info.fps else ()),
'-pix_fmt', 'rgb24', '-f', 'rawvideo', *camera.info.ffmpeg_args, '-']
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,
'-',
]
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:

View file

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

View file

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

View file

@ -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,
color_effects: Optional[Union[str, List[str]]] = None,
zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0), **camera):
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
):
"""
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),
camera.info.resolution[0] + (camera.info.resolution[0] % 32),
3)
shape = (
camera.info.resolution[1] + (camera.info.resolution[1] % 16),
camera.info.resolution[0] + (camera.info.resolution[0] % 32),
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)

View file

@ -18,32 +18,6 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
This plugin allows you to easily create IRC bots with custom logic that reacts to IRC events
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):

View file

@ -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,25 +163,30 @@ class ChatTelegramPlugin(ChatPlugin):
"""
telegram = self.get_telegram()
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)
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,
)
return self.parse_msg(msg)
@action
def send_photo(self, chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
caption: Optional[str] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_photo(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
caption: Optional[str] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20,
) -> TelegramMessageResponse:
"""
Send a picture to a chat.
@ -198,28 +214,34 @@ 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,
photo=resource,
caption=caption,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout, parse_mode=parse_mode)
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,
)
return self.parse_msg(msg)
@action
def send_audio(self, chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
caption: Optional[str] = None,
performer: Optional[str] = None,
title: Optional[str] = None,
duration: Optional[float] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_audio(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
caption: Optional[str] = None,
performer: Optional[str] = None,
title: Optional[str] = None,
duration: Optional[float] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20,
) -> TelegramMessageResponse:
"""
Send audio to a chat.
@ -250,30 +272,35 @@ 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,
audio=resource,
caption=caption,
disable_notification=disable_notification,
performer=performer,
title=title,
duration=duration,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode)
msg = telegram.bot.send_audio(
chat_id=chat_id,
audio=resource,
caption=caption,
disable_notification=disable_notification,
performer=performer,
title=title,
duration=duration,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode,
)
return self.parse_msg(msg)
@action
def send_document(self, chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
filename: Optional[str] = None,
caption: Optional[str] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_document(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
filename: Optional[str] = None,
caption: Optional[str] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20,
) -> TelegramMessageResponse:
"""
Send a document to a chat.
@ -302,30 +329,35 @@ 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,
document=resource,
filename=filename,
caption=caption,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode)
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,
)
return self.parse_msg(msg)
@action
def send_video(self, chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
duration: Optional[int] = None,
caption: Optional[str] = None,
width: Optional[int] = None,
height: Optional[int] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_video(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
duration: Optional[int] = None,
caption: Optional[str] = None,
width: Optional[int] = None,
height: Optional[int] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20,
) -> TelegramMessageResponse:
"""
Send a video to a chat.
@ -356,32 +388,37 @@ 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,
video=resource,
duration=duration,
caption=caption,
width=width,
height=height,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode)
msg = telegram.bot.send_video(
chat_id=chat_id,
video=resource,
duration=duration,
caption=caption,
width=width,
height=height,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode,
)
return self.parse_msg(msg)
@action
def send_animation(self, chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
duration: Optional[int] = None,
caption: Optional[str] = None,
width: Optional[int] = None,
height: Optional[int] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_animation(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
duration: Optional[int] = None,
caption: Optional[str] = None,
width: Optional[int] = None,
height: Optional[int] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20,
) -> TelegramMessageResponse:
"""
Send an animation (GIF or H.264/MPEG-4 AVC video without sound) to a chat.
@ -412,30 +449,35 @@ 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,
animation=resource,
duration=duration,
caption=caption,
width=width,
height=height,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode)
msg = telegram.bot.send_animation(
chat_id=chat_id,
animation=resource,
duration=duration,
caption=caption,
width=width,
height=height,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
parse_mode=parse_mode,
)
return self.parse_msg(msg)
@action
def send_voice(self, chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
caption: Optional[str] = None,
duration: Optional[float] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_voice(
self,
chat_id: Union[str, int],
file_id: Optional[int] = None,
url: Optional[str] = None,
path: Optional[str] = None,
caption: Optional[str] = None,
duration: Optional[float] = None,
parse_mode: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
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,
voice=resource,
caption=caption,
disable_notification=disable_notification,
duration=duration,
reply_to_message_id=reply_to_message_id,
timeout=timeout, parse_mode=parse_mode)
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,
)
return self.parse_msg(msg)
@action
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:
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:
"""
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,
video=resource,
duration=duration,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout)
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,
)
return self.parse_msg(msg)
@action
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:
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:
"""
Send a location to a chat.
@ -543,26 +596,31 @@ class ChatTelegramPlugin(ChatPlugin):
"""
telegram = self.get_telegram()
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)
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,
)
return self.parse_msg(msg)
@action
def send_venue(self, chat_id: Union[str, int],
latitude: float,
longitude: float,
title: str,
address: str,
foursquare_id: Optional[str] = None,
foursquare_type: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20) -> TelegramMessageResponse:
def send_venue(
self,
chat_id: Union[str, int],
latitude: float,
longitude: float,
title: str,
address: str,
foursquare_id: Optional[str] = None,
foursquare_type: Optional[str] = None,
disable_notification: bool = False,
reply_to_message_id: Optional[int] = None,
timeout: int = 20,
) -> TelegramMessageResponse:
"""
Send the address of a venue to a chat.
@ -583,28 +641,33 @@ class ChatTelegramPlugin(ChatPlugin):
"""
telegram = self.get_telegram()
msg = telegram.bot.send_venue(chat_id=chat_id,
latitude=latitude,
longitude=longitude,
title=title,
address=address,
foursquare_id=foursquare_id,
foursquare_type=foursquare_type,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout)
msg = telegram.bot.send_venue(
chat_id=chat_id,
latitude=latitude,
longitude=longitude,
title=title,
address=address,
foursquare_id=foursquare_id,
foursquare_type=foursquare_type,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
timeout=timeout,
)
return self.parse_msg(msg)
@action
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:
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:
"""
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,
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)
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,
)
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,18 +727,22 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram()
chat = telegram.bot.get_chat(chat_id, timeout=timeout)
return TelegramChatResponse(chat_id=chat.id,
link=chat.link,
username=chat.username,
invite_link=chat.invite_link,
title=chat.title,
description=chat.description,
type=chat.type,
first_name=chat.first_name,
last_name=chat.last_name)
return TelegramChatResponse(
chat_id=chat.id,
link=chat.link,
username=chat.username,
invite_link=chat.invite_link,
title=chat.title,
description=chat.description,
type=chat.type,
first_name=chat.first_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,
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)
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,
)
@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,20 +776,25 @@ class ChatTelegramPlugin(ChatPlugin):
telegram = self.get_telegram()
admins = telegram.bot.get_chat_administrators(chat_id, timeout=timeout)
return TelegramUsersResponse([
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,
) for user in admins
])
return TelegramUsersResponse(
[
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,
)
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],
user_id: int,
until_date: Optional[datetime.datetime] = None,
timeout: int = 20):
def kick_chat_member(
self,
chat_id: Union[str, int],
user_id: int,
until_date: Optional[datetime.datetime] = None,
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,22 +848,24 @@ 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],
user_id: int,
can_change_info: Optional[bool] = None,
can_post_messages: Optional[bool] = None,
can_edit_messages: Optional[bool] = None,
can_delete_messages: Optional[bool] = None,
can_invite_users: Optional[bool] = None,
can_restrict_members: Optional[bool] = None,
can_promote_members: Optional[bool] = None,
can_pin_messages: Optional[bool] = None,
timeout: int = 20):
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,
can_edit_messages: Optional[bool] = None,
can_delete_messages: Optional[bool] = None,
can_invite_users: Optional[bool] = None,
can_restrict_members: Optional[bool] = None,
can_promote_members: Optional[bool] = None,
can_pin_messages: Optional[bool] = None,
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],
message_id: int,
disable_notification: Optional[bool] = None,
timeout: int = 20):
def pin_chat_message(
self,
chat_id: Union[str, int],
message_id: int,
disable_notification: Optional[bool] = None,
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:

View file

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

View file

@ -2,7 +2,7 @@ import enum
import json
from typing import Set, Dict, Optional, Iterable, Callable, Union
from gi.repository import GLib # type: ignore
from gi.repository import GLib # type: ignore
from pydbus import SessionBus, SystemBus
from pydbus.bus import Bus
from defusedxml import ElementTree
@ -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,
service_name: Optional[str] = _default_service_name,
service_path: Optional[str] = _default_service_path, **kwargs
self,
signals: Optional[Iterable[dict]] = None,
service_name: Optional[str] = _default_service_name,
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.
@ -427,13 +432,13 @@ class DbusPlugin(RunnablePlugin):
@action
def execute(
self,
service: str,
interface: str,
method_name: str,
bus: str = BusType.SESSION.value,
path: str = '/',
args: Optional[list] = None
self,
service: str,
interface: str,
method_name: str,
bus: str = BusType.SESSION.value,
path: str = '/',
args: Optional[list] = None,
):
"""
Execute a method exposed on DBus.

View file

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

View file

@ -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],
on_open: Optional[Callable[[], None]] = None,
on_close: Optional[Callable[[], None]] = 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,
):
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

View file

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

View file

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

View file

@ -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',
'https://www.googleapis.com/auth/drive.appfolder',
'https://www.googleapis.com/auth/drive.photos.readonly']
scopes = [
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.appfolder',
'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,
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]]:
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]]:
"""
Get the list of files.
@ -90,25 +88,32 @@ class GoogleDrivePlugin(GooglePlugin):
filter += "'{}' in parents".format(folder_id)
while True:
results = service.files().list(
q=filter,
driveId=drive_id,
pageSize=limit,
orderBy=order_by,
fields="nextPageToken, files(id, name, kind, mimeType)",
pageToken=page_token,
spaces=spaces,
).execute()
results = (
service.files()
.list(
q=filter,
driveId=drive_id,
pageSize=limit,
orderBy=order_by,
fields="nextPageToken, files(id, name, kind, mimeType)",
pageToken=page_token,
spaces=spaces,
)
.execute()
)
page_token = results.get('nextPageToken')
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', [])
])
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', [])
]
)
if not page_token or (limit and len(files) >= limit):
break
@ -131,14 +136,16 @@ class GoogleDrivePlugin(GooglePlugin):
)
@action
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:
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:
"""
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,
name: str,
description: Optional[str] = None,
mime_type: Optional[str] = None,
parents: Optional[List[str]] = None,
starred: bool = False) -> GoogleDriveFile:
def create(
self,
name: str,
description: Optional[str] = None,
mime_type: Optional[str] = None,
parents: Optional[List[str]] = None,
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,15 +261,17 @@ class GoogleDrivePlugin(GooglePlugin):
)
@action
def update(self,
file_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
add_parents: Optional[List[str]] = None,
remove_parents: Optional[List[str]] = None,
mime_type: Optional[str] = None,
starred: bool = None,
trashed: bool = None) -> GoogleDriveFile:
def update(
self,
file_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
add_parents: Optional[List[str]] = None,
remove_parents: Optional[List[str]] = None,
mime_type: Optional[str] = None,
starred: bool = None,
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],

View file

@ -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',
'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']
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',
]
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)

View file

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

View file

@ -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 = []

View file

@ -19,19 +19,13 @@ class GooglePubsubPlugin(Plugin):
3. Download the JSON service credentials file. By default platypush will look for the credentials file under
~/.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()

View file

@ -24,19 +24,19 @@ class GoogleTranslatePlugin(Plugin):
4. Create a new private JSON key for the service account and download it. By default platypush will look for the
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.

View file

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

View file

@ -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={
'message': message,
'title': title,
'priority': priority,
'extras': extras or {},
})
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

View file

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

View file

@ -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)
],
}

View file

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

View file

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

View file

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

View file

@ -71,8 +71,6 @@ class HttpWebpagePlugin(Plugin):
Requires:
* **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``)
"""

View file

@ -7,23 +7,20 @@ class InputsPlugin(Plugin):
"""
This plugin emulates user input on a keyboard/mouse. It requires the a graphical server (X server or Mac/Win
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

View file

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

View file

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

View file

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

View file

@ -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',
pin_backlight: Optional[int] = None,
cols: int = 16, rows: int = 2,
backlight_enabled: bool = True,
backlight_mode: str = 'active_low',
dotsize: int = 8, charmap: str = 'A02',
auto_linebreaks: bool = True,
compat_mode: bool = False, **kwargs):
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,
backlight_enabled: bool = True,
backlight_mode: str = 'active_low',
dotsize: int = 8,
charmap: str = 'A02',
auto_linebreaks: bool = True,
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,
pin_backlight=self.pin_backlight,
backlight_enabled=self.backlight_enabled,
backlight_mode=self.backlight_mode,
dotsize=self.dotsize, charmap=self.charmap,
auto_linebreaks=self.auto_linebreaks,
compat_mode=self.compat_mode)
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,
auto_linebreaks=self.auto_linebreaks,
compat_mode=self.compat_mode,
)
# vim:sw=4:ts=4:et:

View file

@ -8,47 +8,63 @@ class LcdI2cPlugin(LcdPlugin):
Plugin to write to an LCD display connected via I2C.
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,
expander_params: Optional[dict] = None,
port: int = 1, cols: int = 16, rows: int = 2,
backlight_enabled: bool = True,
dotsize: int = 8, charmap: str = 'A02',
auto_linebreaks: bool = True, **kwargs):
def __init__(
self,
i2c_expander: str,
address: int,
expander_params: Optional[dict] = None,
port: int = 1,
cols: int = 16,
rows: int = 2,
backlight_enabled: bool = True,
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,
i2c_expander=self.i2c_expander,
address=self.address, port=self.port,
backlight_enabled=self.backlight_enabled,
dotsize=self.dotsize, charmap=self.charmap,
auto_linebreaks=self.auto_linebreaks)
return CharLCD(
cols=self.cols,
rows=self.rows,
i2c_expander=self.i2c_expander,
address=self.address,
port=self.port,
backlight_enabled=self.backlight_enabled,
dotsize=self.dotsize,
charmap=self.charmap,
auto_linebreaks=self.auto_linebreaks,
)
class LcdI2CPlugin(LcdI2cPlugin):

View file

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