diff --git a/README.md b/README.md
index ca567b31a..6ab0333d1 100644
--- a/README.md
+++ b/README.md
@@ -419,9 +419,7 @@ backend](https://docs.platypush.tech/en/latest/platypush/backend/http.html), an
[MQTT
instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a
[Kafka
-instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html),
-[Pushbullet](https://docs.platypush.tech/en/latest/platypush/backend/pushbullet.html)
-etc.).
+instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html).
If a backend supports the execution of requests (e.g. HTTP, MQTT, Kafka,
Websocket and TCP) then you can send requests to these services in JSON format.
diff --git a/docs/source/backends.rst b/docs/source/backends.rst
index 4b9c06aab..26dcef185 100644
--- a/docs/source/backends.rst
+++ b/docs/source/backends.rst
@@ -23,7 +23,6 @@ Backends
platypush/backend/nextcloud.rst
platypush/backend/nfc.rst
platypush/backend/nodered.rst
- platypush/backend/pushbullet.rst
platypush/backend/redis.rst
platypush/backend/scard.rst
platypush/backend/sensor.ir.zeroborg.rst
diff --git a/docs/source/platypush/backend/pushbullet.rst b/docs/source/platypush/backend/pushbullet.rst
deleted file mode 100644
index 7d8f251ea..000000000
--- a/docs/source/platypush/backend/pushbullet.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-``pushbullet``
-================================
-
-.. automodule:: platypush.backend.pushbullet
- :members:
-
diff --git a/platypush/backend/http/webapp/src/components/Pushbullet.vue b/platypush/backend/http/webapp/src/components/Pushbullet.vue
index a3a4894f3..a84391026 100644
--- a/platypush/backend/http/webapp/src/components/Pushbullet.vue
+++ b/platypush/backend/http/webapp/src/components/Pushbullet.vue
@@ -6,26 +6,27 @@
import Utils from "@/Utils";
export default {
- name: "Pushbullet",
mixins: [Utils],
methods: {
onMessage(event) {
- if (event.push_type === 'mirror') {
- this.notify({
- title: event.title,
- text: event.body,
- image: {
- src: event.icon ? 'data:image/png;base64, ' + event.icon : undefined,
- icon: event.icon ? undefined : 'bell',
- },
- });
- }
+ this.notify({
+ title: event.title,
+ text: event.body,
+ image: {
+ src: event.icon ? 'data:image/png;base64, ' + event.icon : undefined,
+ icon: event.icon ? undefined : 'bell',
+ },
+ })
},
},
mounted() {
- this.subscribe(this.onMessage, null, 'platypush.message.event.pushbullet.PushbulletEvent')
+ this.subscribe(
+ this.onMessage,
+ null,
+ 'platypush.message.event.pushbullet.PushbulletNotificationEvent'
+ )
},
}
diff --git a/platypush/backend/pushbullet/__init__.py b/platypush/backend/pushbullet/__init__.py
deleted file mode 100644
index c328697b1..000000000
--- a/platypush/backend/pushbullet/__init__.py
+++ /dev/null
@@ -1,204 +0,0 @@
-import json
-import time
-from typing import Optional
-
-from platypush.backend import Backend
-from platypush.message.event.pushbullet import PushbulletEvent
-
-
-class PushbulletBackend(Backend):
- """
- This backend will listen for events on a Pushbullet (https://pushbullet.com)
- channel and propagate them to the bus. This backend is quite useful if you
- want to synchronize events and actions with your mobile phone (through the
- 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.
- """
-
- 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)
- :param proxy_host: HTTP proxy host (default: None)
- :param proxy_port: HTTP proxy port (default: None)
- """
- super().__init__(**kwargs)
-
- self.token = token
- self.device_name = device
- self.proxy_host = proxy_host
- self.proxy_port = proxy_port
- self.device = None
- self.pb_device_id = None
- self.pb = None
- self.listener = None
-
- 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.device = self.pb.new_device(self.device_name)
-
- self.pb_device_id = self.get_device_id()
-
- def _get_latest_push(self):
- t = int(time.time()) - 5
- pushes = self.pb.get_pushes(modified_after=str(t), limit=1)
- if pushes:
- return pushes[0]
-
- def on_push(self):
- def callback(data):
- try:
- # Parse the push
- try:
- data = json.loads(data) if isinstance(data, str) else data
- except Exception as e:
- self.logger.exception(e)
- return
-
- # If it's a push, get it
- if data['type'] == 'tickle' and data['subtype'] == 'push':
- push = self._get_latest_push()
- elif data['type'] == 'push':
- push = data['push']
- else:
- return # Not a push notification
-
- if not push:
- return
-
- # Post an event, useful to react on mobile notifications if
- # you enabled notification mirroring on your PushBullet app
- event = PushbulletEvent(**push)
- self.on_message(event)
-
- if 'body' not in push:
- return
- self.logger.debug(f'Received push: {push}')
-
- body = push['body']
- try:
- 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}'
- )
- except Exception as e:
- self.logger.exception(e)
- return
-
- return callback
-
- def get_device_id(self):
- # noinspection PyBroadException
- 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',
- )
-
- self.logger.info(f'Created Pushbullet device {self.device_name}')
- return device.device_iden
-
- def close(self):
- if self.listener:
- self.listener.close()
- self.listener = None
-
- def on_stop(self):
- self.logger.info('Received STOP event on the Pushbullet backend')
- super().on_stop()
- self.close()
- self.logger.info('Pushbullet backend terminated')
-
- def on_close(self, err=None):
- def callback(*_):
- self.listener = None
- raise RuntimeError(err or 'Connection closed')
-
- return callback
-
- def on_error(self, *_):
- def callback(*args):
- self.logger.error(f'Pushbullet error: {args}')
- try:
- if self.listener:
- self.listener.close()
- except Exception as e:
- self.logger.error('Error on Pushbullet connection close upon error')
- self.logger.exception(e)
- finally:
- self.listener = None
-
- return callback
-
- def on_open(self):
- def callback(*_):
- self.logger.info('Pushbullet service connected')
-
- return callback
-
- 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.listener.run_forever()
-
- def run(self):
- super().run()
- initialized = False
-
- while not initialized:
- try:
- self._initialize()
- initialized = True
- except Exception as e:
- self.logger.exception(e)
- self.logger.error(f'Pushbullet initialization error: {e}')
- time.sleep(10)
-
- while not self.should_stop():
- try:
- self.run_listener()
- except Exception as e:
- self.logger.exception(e)
- time.sleep(10)
- self.logger.info('Retrying connection')
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/backend/pushbullet/manifest.yaml b/platypush/backend/pushbullet/manifest.yaml
deleted file mode 100644
index 5640c6c48..000000000
--- a/platypush/backend/pushbullet/manifest.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-manifest:
- events:
- platypush.message.event.pushbullet.PushbulletEvent: if a new push is received
- apk:
- - git
- apt:
- - git
- dnf:
- - git
- pacman:
- - git
- install:
- pip:
- - git+https://github.com/rbrcsk/pushbullet.py
- package: platypush.backend.pushbullet
- type: backend
diff --git a/platypush/context/__init__.py b/platypush/context/__init__.py
index d32b9c41e..91131b7ea 100644
--- a/platypush/context/__init__.py
+++ b/platypush/context/__init__.py
@@ -74,7 +74,7 @@ def register_backends(bus=None, global_scope=False, **kwargs):
for name, cfg in Config.get_backends().items():
module = importlib.import_module('platypush.backend.' + name)
- # e.g. backend.pushbullet main class: PushbulletBackend
+ # e.g. backend.http main class: HttpBackend
cls_name = ''
for token in module.__name__.title().split('.')[2:]:
cls_name += token.title()
diff --git a/platypush/message/event/pushbullet.py b/platypush/message/event/pushbullet.py
index 28416fa71..5468e0fed 100644
--- a/platypush/message/event/pushbullet.py
+++ b/platypush/message/event/pushbullet.py
@@ -1,30 +1,184 @@
+from typing import Optional, Union
+
from platypush.message.event import Event
class PushbulletEvent(Event):
"""
- PushBullet event object.
-
- If you have configured the PushBullet backend with your account token,
- and enabled notification mirroring on the PushBullet app on your mobile
- devices, then the backend will trigger a PushbulletEvent whenever
- a new notiification hits your mobile, and you can react to that event
- through hooks that can, for example, log your notifications on a database,
- display them on a dashboard, let the built-in text-to-speech plugin read
- them out loud to you if they match the package name of your news app,
- display them on your smart watch if they are pictures, and so on.
+ Base PushBullet event.
"""
- def __init__(self, *args, **kwargs):
- """ Platypush supports by default the PushBullet notification mirror
- format, https://docs.pushbullet.com/#mirrored-notifications """
+ def __init__(
+ self,
+ *args,
+ notification_id: str,
+ title: Optional[str] = None,
+ body: Optional[Union[str, dict, list]] = None,
+ url: Optional[str] = None,
+ source_device: Optional[str] = None,
+ source_user: Optional[str] = None,
+ target_device: Optional[str] = None,
+ icon: Optional[str] = None,
+ created: float,
+ modified: float,
+ **kwargs
+ ):
+ """
+ :param notification_id: Notification ID.
+ :param title: Notification title.
+ :param body: Notification body.
+ :param url: Notification URL.
+ :param source_device: Source device ID.
+ :param source_user: Source user ID.
+ :param target_device: Target device ID.
+ :param icon: Notification icon.
+ :param created: Notification creation timestamp.
+ :param modified: Notification modification timestamp.
+ :param kwargs: Additional attributes.
+ """
+ super().__init__(
+ *args,
+ notification_id=notification_id,
+ title=title,
+ body=body,
+ url=url,
+ source_device=source_device,
+ source_user=source_user,
+ target_device=target_device,
+ icon=icon,
+ created=created,
+ modified=modified,
+ **kwargs
+ )
- if 'type' in kwargs:
- # Prevent name clash with event type attribute
- kwargs['push_type'] = kwargs.pop('type')
- super().__init__(*args, **kwargs)
+class PushbulletMessageEvent(PushbulletEvent):
+ """
+ Triggered when a new message is received.
+ """
+
+ def __init__(
+ self,
+ *args,
+ sender_id: Optional[str] = None,
+ sender_email: Optional[str] = None,
+ sender_name: Optional[str] = None,
+ receiver_id: Optional[str] = None,
+ receiver_email: Optional[str] = None,
+ receiver_name: Optional[str] = None,
+ **kwargs
+ ):
+ super().__init__(
+ *args,
+ sender_id=sender_id,
+ sender_email=sender_email,
+ sender_name=sender_name,
+ receiver_id=receiver_id,
+ receiver_email=receiver_email,
+ receiver_name=receiver_name,
+ **kwargs
+ )
+
+
+class PushbulletNotificationEvent(PushbulletEvent):
+ """
+ Triggered when a notification is mirrored from another device.
+ """
+
+ def __init__(
+ self,
+ *args,
+ title: str,
+ body: str,
+ dismissible: bool,
+ application_name: Optional[str] = None,
+ package_name: Optional[str] = None,
+ actions: Optional[dict] = None,
+ **kwargs
+ ):
+ """
+ :param title: Mirror notification title.
+ :param body: Mirror notification body.
+ :param dismissible: True if the notification can be dismissed.
+ :param application_name: Application name.
+ :param package_name: Package name.
+ :param actions: Actions associated to the notification. Example:
+
+ .. code-block:: json
+
+ [
+ {
+ "label": "previous",
+ "trigger_key": "com.termux.api_0_6107998_previous"
+ },
+ {
+ "label": "pause",
+ "trigger_key": "com.termux.api_0_6107998_pause"
+ },
+ {
+ "label": "play",
+ "trigger_key": "com.termux.api_0_6107998_play"
+ },
+ {
+ "label": "next",
+ "trigger_key": "com.termux.api_0_6107998_next"
+ }
+ ]
+
+ """
+ super().__init__(
+ *args,
+ title=title,
+ body=body,
+ dismissible=dismissible,
+ application_name=application_name,
+ package_name=package_name,
+ actions=actions,
+ **kwargs
+ )
+
+
+class PushbulletDismissalEvent(PushbulletEvent):
+ """
+ Triggered when a notification is dismissed.
+ """
+
+ def __init__(self, *args, package_name: Optional[str] = None, **kwargs):
+ super().__init__(*args, package_name=package_name, **kwargs)
+
+
+class PushbulletLinkEvent(PushbulletMessageEvent):
+ """
+ Triggered when a push with a link is received.
+ """
+
+
+class PushbulletFileEvent(PushbulletMessageEvent):
+ """
+ Triggered when a push with a file is received.
+ """
+
+ def __init__(
+ self,
+ *args,
+ file_name: str,
+ file_type: str,
+ file_url: str,
+ image_width: Optional[int] = None,
+ image_height: Optional[int] = None,
+ image_url: Optional[str] = None,
+ **kwargs
+ ):
+ super().__init__(
+ *args,
+ file_name=file_name,
+ file_type=file_type,
+ file_url=file_url,
+ image_width=image_width,
+ image_height=image_height,
+ image_url=image_url,
+ **kwargs
+ )
# vim:sw=4:ts=4:et:
-
diff --git a/platypush/plugins/pushbullet/__init__.py b/platypush/plugins/pushbullet/__init__.py
index ab2c6ab21..89c70a309 100644
--- a/platypush/plugins/pushbullet/__init__.py
+++ b/platypush/plugins/pushbullet/__init__.py
@@ -1,70 +1,277 @@
+from dataclasses import dataclass
import json
import os
-from typing import Optional
+import time
+from enum import Enum
+from threading import Event, RLock
+from typing import Optional, Type
import requests
-from platypush.context import get_backend
-from platypush.plugins import Plugin, action
+from platypush.config import Config
+from platypush.message.event.pushbullet import (
+ PushbulletDismissalEvent,
+ PushbulletEvent,
+ PushbulletFileEvent,
+ PushbulletLinkEvent,
+ PushbulletMessageEvent,
+ PushbulletNotificationEvent,
+)
+from platypush.plugins import RunnablePlugin, action
+from platypush.schemas.pushbullet import PushbulletDeviceSchema, PushbulletSchema
-class PushbulletPlugin(Plugin):
+class PushbulletType(Enum):
"""
- This plugin allows you to send pushes and files to your PushBullet account.
- Note: This plugin will only work if the :mod:`platypush.backend.pushbullet`
- backend is configured.
-
- Requires:
-
- * The :class:`platypush.backend.pushbullet.PushbulletBackend` backend enabled
-
+ PushBullet event types.
"""
- def __init__(self, token: Optional[str] = None, **kwargs):
+ DISMISSAL = 'dismissal'
+ FILE = 'file'
+ LINK = 'link'
+ MESSAGE = 'message'
+ MIRROR = 'mirror'
+ NOTE = 'note'
+
+
+@dataclass
+class PushbulletEventType:
+ """
+ PushBullet event type.
+ """
+
+ type: PushbulletType
+ event_class: Type[PushbulletEvent]
+
+
+_push_event_types = [
+ PushbulletEventType(
+ type=PushbulletType.DISMISSAL,
+ event_class=PushbulletDismissalEvent,
+ ),
+ PushbulletEventType(
+ type=PushbulletType.FILE,
+ event_class=PushbulletFileEvent,
+ ),
+ PushbulletEventType(
+ type=PushbulletType.LINK,
+ event_class=PushbulletLinkEvent,
+ ),
+ PushbulletEventType(
+ type=PushbulletType.MESSAGE,
+ event_class=PushbulletMessageEvent,
+ ),
+ PushbulletEventType(
+ type=PushbulletType.MIRROR,
+ event_class=PushbulletNotificationEvent,
+ ),
+ PushbulletEventType(
+ type=PushbulletType.NOTE,
+ event_class=PushbulletMessageEvent,
+ ),
+]
+
+_push_events_by_type = {t.type.value: t for t in _push_event_types}
+
+
+class PushbulletPlugin(RunnablePlugin):
+ """
+ `PushBullet `_ integration.
+
+ Among the other things, this plugin allows you to easily interact with your
+ mobile devices that have the app installed from Platypush.
+
+ If notification mirroring is enabled on your device, then the push
+ notifications will be mirrored to Platypush as well as PushBullet events.
+
+ Since PushBullet also comes with a Tasker integration, you can also use this
+ plugin to send commands to your Android device and trigger actions on it.
+ It can be used to programmatically send files to your devices and manage
+ shared clipboards too.
+ """
+
+ _timeout = 15.0
+ _upload_timeout = 600.0
+
+ def __init__(
+ self,
+ token: str,
+ device: Optional[str] = None,
+ enable_mirroring: bool = True,
+ **kwargs,
+ ):
"""
- :param token: Pushbullet API token. If not set the plugin will try to retrieve it from
- the Pushbullet backend configuration, if available
+ :param token: PushBullet API token, see https://docs.pushbullet.com/#authentication.
+ :param device: Device ID that should be exposed. Default: ``Platypush @
+ ``.
+ :param enable_mirroring: If set to True (default) then the plugin will
+ receive notifications mirrored from other connected devices -
+ these will also be rendered on the connected web clients. Disable
+ it if you don't want to forward your mobile notifications through
+ the plugin.
"""
super().__init__(**kwargs)
- if not token:
- backend = get_backend('pushbullet')
- if not backend or not backend.token:
- raise AttributeError('No Pushbullet token specified')
-
- self.token = backend.token
- else:
- self.token = token
+ if not device:
+ device = f'Platypush @ {Config.get_device_id()}'
+ self.token = token
+ self.device_name = device
+ self.enable_mirroring = enable_mirroring
+ self.listener = None
+ self._initialized = Event()
+ self._device = None
+ self._init_lock = RLock()
+ self._pb = None
+ self._device_id = None
self._devices = []
self._devices_by_id = {}
self._devices_by_name = {}
- @action
- def get_devices(self):
+ def _initialize(self):
+ from pushbullet import Pushbullet
+
+ if self._initialized.is_set():
+ return
+
+ self._pb = Pushbullet(self.token)
+
+ try:
+ self._device = self._pb.get_device(self.device_name)
+ except Exception as e:
+ self.logger.info(
+ 'Device %s does not exist: %s. Creating it',
+ self.device_name,
+ e,
+ )
+ self._device = self._pb.new_device(self.device_name)
+
+ self._device_id = self.get_device_id()
+ self._initialized.set()
+
+ @property
+ def pb(self):
"""
- Get the list of available devices
+ :return: PushBullet API object.
"""
- resp = requests.get(
- 'https://api.pushbullet.com/v2/devices',
+ with self._init_lock:
+ self._initialize()
+
+ assert self._pb
+ return self._pb
+
+ @property
+ def device(self):
+ """
+ :return: Current PushBullet device object.
+ """
+ with self._init_lock:
+ self._initialize()
+
+ assert self._device
+ return self._device
+
+ @property
+ def device_id(self):
+ return self.device.device_iden
+
+ def _request(self, method: str, url: str, **kwargs):
+ meth = getattr(requests, method)
+ resp = meth(
+ 'https://api.pushbullet.com/v2/' + url.lstrip('/'),
+ timeout=self._timeout,
headers={
'Authorization': 'Bearer ' + self.token,
'Content-Type': 'application/json',
},
+ **kwargs,
)
- self._devices = resp.json().get('devices', [])
- self._devices_by_id = {dev['iden']: dev for dev in self._devices}
+ resp.raise_for_status()
+ return resp.json()
- self._devices_by_name = {
- dev['nickname']: dev for dev in self._devices if 'nickname' in dev
- }
+ def get_device_id(self):
+ assert self._pb
- @action
- def get_device(self, device) -> Optional[dict]:
- """
- :param device: Device ID or name
- """
+ 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',
+ )
+
+ self.logger.info('Created Pushbullet device %s', self.device_name)
+ return device.device_iden
+
+ def _get_latest_push(self):
+ t = int(time.time()) - 10
+ pushes = self.pb.get_pushes(modified_after=str(t), limit=1)
+ if pushes:
+ return pushes[0]
+
+ return None
+
+ def on_open(self, *_, **__):
+ self.logger.info('Pushbullet connected')
+
+ def on_close(self, args=None):
+ err = args[0] if args else None
+ self.close()
+ assert not err or self.should_stop(), 'Pushbullet connection closed: ' + str(
+ err or 'unknown error'
+ )
+
+ def on_error(self, *args):
+ raise RuntimeError('Pushbullet error: ' + str(args))
+
+ def on_push(self, data):
+ try:
+ # Parse the push
+ try:
+ data = json.loads(data) if isinstance(data, str) else data
+ except Exception as e:
+ self.logger.exception(e)
+ return
+
+ # If it's a push, get it
+ push = None
+ if data['type'] == 'tickle' and data['subtype'] == 'push':
+ push = self._get_latest_push()
+ elif data['type'] == 'push':
+ push = data['push']
+
+ if not push:
+ self.logger.debug('Not a push notification.\nMessage: %s', data)
+ return
+
+ push_type = push.pop('type', None)
+ push_event_type = _push_events_by_type.get(push_type)
+ if not push_event_type:
+ self.logger.debug(
+ 'Unknown push type: %s.\nMessage: %s', push_type, data
+ )
+ return
+
+ if (
+ not self.enable_mirroring
+ and push_event_type.type == PushbulletType.MIRROR
+ ):
+ return
+
+ push = dict(PushbulletSchema().dump(push))
+ evt_type = push_event_type.event_class
+ self._bus.post(evt_type(**push))
+ except Exception as e:
+ self.logger.warning(
+ 'Error while processing push: %s.\nMessage: %s', e, data
+ )
+ self.logger.exception(e)
+ return
+
+ def _get_device(self, device) -> Optional[dict]:
output = None
refreshed = False
@@ -79,6 +286,78 @@ class PushbulletPlugin(Plugin):
self.get_devices()
refreshed = True
+ return None
+
+ def close(self):
+ if self.listener:
+ try:
+ self.listener.close()
+ except Exception:
+ pass
+
+ self.listener = None
+
+ if self._pb:
+ self._pb = None
+
+ self._initialized.clear()
+
+ def run_listener(self):
+ from .listener import Listener
+
+ 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,
+ )
+
+ self.listener.run_forever()
+
+ @action
+ def get_devices(self):
+ """
+ Get the list of available devices.
+
+ :return: .. schema:: pushbullet.PushbulletDeviceSchema(many=True)
+ """
+ resp = self._request('get', 'devices')
+ self._devices = resp.get('devices', [])
+ self._devices_by_id = {dev['iden']: dev for dev in self._devices}
+ self._devices_by_name = {
+ dev['nickname']: dev for dev in self._devices if 'nickname' in dev
+ }
+
+ return PushbulletDeviceSchema(many=True).dump(self._devices)
+
+ @action
+ def get_device(self, device: str) -> Optional[dict]:
+ """
+ Get a device by ID or name.
+
+ :param device: Device ID or name
+ :return: .. schema:: pushbullet.PushbulletDeviceSchema
+ """
+ dev = self._get_device(device)
+ if not dev:
+ return None
+
+ return dict(PushbulletDeviceSchema().dump(dev))
+
+ @action
+ def get_pushes(self, limit: int = 10):
+ """
+ Get the list of pushes.
+
+ :param limit: Maximum number of pushes to fetch (default: 10).
+ :return: .. schema:: pushbullet.PushbulletSchema(many=True)
+ """
+ return PushbulletSchema().dump(
+ self._request('get', 'pushes', params={'limit': limit}).get('pushes', []),
+ many=True,
+ )
+
@action
def send_note(
self,
@@ -96,13 +375,13 @@ class PushbulletPlugin(Plugin):
:param title: Note title
:param url: URL attached to the note
:param kwargs: Push arguments, see https://docs.pushbullet.com/#create-push
+ :return: .. schema:: pushbullet.PushbulletSchema
"""
dev = None
if device:
- dev = self.get_device(device).output
- if not dev:
- raise RuntimeError(f'No such device: {device}')
+ dev = self._get_device(device)
+ assert dev, f'No such device: {device}'
kwargs['body'] = body
kwargs['title'] = title
@@ -114,19 +393,8 @@ class PushbulletPlugin(Plugin):
if dev:
kwargs['device_iden'] = dev['iden']
- resp = requests.post(
- 'https://api.pushbullet.com/v2/pushes',
- data=json.dumps(kwargs),
- headers={
- 'Authorization': 'Bearer ' + self.token,
- 'Content-Type': 'application/json',
- },
- )
-
- if resp.status_code >= 400:
- raise Exception(
- f'Pushbullet push failed with status {resp.status_code}: {resp.json()}'
- )
+ rs = self._request('post', 'pushes', data=json.dumps(kwargs))
+ return dict(PushbulletSchema().dump(rs))
@action
def send_file(self, filename: str, device: Optional[str] = None):
@@ -139,90 +407,85 @@ class PushbulletPlugin(Plugin):
dev = None
if device:
- dev = self.get_device(device).output
- if not dev:
- raise RuntimeError(f'No such device: {device}')
+ dev = self._get_device(device)
+ assert dev, f'No such device: {device}'
- resp = requests.post(
- 'https://api.pushbullet.com/v2/upload-request',
+ upload_req = self._request(
+ 'post',
+ 'upload-request',
data=json.dumps({'file_name': os.path.basename(filename)}),
- headers={
- 'Authorization': 'Bearer ' + self.token,
- 'Content-Type': 'application/json',
- },
)
- if resp.status_code != 200:
- raise Exception(
- f'Pushbullet file upload request failed with status {resp.status_code}'
- )
-
- r = resp.json()
with open(filename, 'rb') as f:
- resp = requests.post(r['upload_url'], data=r['data'], files={'file': f})
-
- if resp.status_code != 204:
- raise Exception(
- f'Pushbullet file upload failed with status {resp.status_code}'
+ rs = requests.post(
+ upload_req['upload_url'],
+ data=upload_req['data'],
+ files={'file': f},
+ timeout=self._upload_timeout,
)
- resp = requests.post(
- 'https://api.pushbullet.com/v2/pushes',
- headers={
- 'Authorization': 'Bearer ' + self.token,
- 'Content-Type': 'application/json',
- },
+ rs.raise_for_status()
+ self._request(
+ 'post',
+ 'pushes',
data=json.dumps(
{
'type': 'file',
'device_iden': dev['iden'] if dev else None,
- 'file_name': r['file_name'],
- 'file_type': r['file_type'],
- 'file_url': r['file_url'],
+ 'file_name': upload_req['file_name'],
+ 'file_type': upload_req.get('file_type'),
+ 'file_url': upload_req['file_url'],
}
),
)
- if resp.status_code >= 400:
- raise Exception(
- f'Pushbullet file push failed with status {resp.status_code}'
- )
-
return {
- 'filename': r['file_name'],
- 'type': r['file_type'],
- 'url': r['file_url'],
+ 'filename': upload_req['file_name'],
+ 'type': upload_req.get('file_type'),
+ 'url': upload_req['file_url'],
}
@action
def send_clipboard(self, text: str):
"""
- Copy text to the clipboard of a device.
+ Send text to the clipboard of other devices.
:param text: Text to be copied.
"""
- backend = get_backend('pushbullet')
- device_id = backend.get_device_id() if backend else None
-
- resp = requests.post(
- 'https://api.pushbullet.com/v2/ephemerals',
+ self._request(
+ 'post',
+ 'ephemerals',
data=json.dumps(
{
'type': 'push',
'push': {
'body': text,
'type': 'clip',
- 'source_device_iden': device_id,
+ 'source_device_iden': self.device_id,
},
}
),
- headers={
- 'Authorization': 'Bearer ' + self.token,
- 'Content-Type': 'application/json',
- },
)
- resp.raise_for_status()
+ def main(self):
+ while not self.should_stop():
+ while not self._initialized.is_set():
+ try:
+ self._initialize()
+ except Exception as e:
+ self.logger.exception(e)
+ self.logger.error('Pushbullet initialization error: %s', e)
+ self.wait_stop(10)
+
+ while not self.should_stop():
+ try:
+ self.run_listener()
+ except Exception as e:
+ if not self.should_stop():
+ self.logger.exception(e)
+ self.logger.error('Pushbullet listener error: %s', e)
+
+ self.wait_stop(10)
# vim:sw=4:ts=4:et:
diff --git a/platypush/backend/pushbullet/listener.py b/platypush/plugins/pushbullet/listener.py
similarity index 76%
rename from platypush/backend/pushbullet/listener.py
rename to platypush/plugins/pushbullet/listener.py
index 1c3c4b96a..796d0d6c6 100644
--- a/platypush/backend/pushbullet/listener.py
+++ b/platypush/plugins/pushbullet/listener.py
@@ -9,11 +9,14 @@ class Listener(_Listener):
"""
Extends the Pushbullet Listener object by adding ``on_open`` and ``on_close`` handlers.
"""
- def __init__(self,
- *args,
- on_open: Optional[Callable[[], None]] = None,
- on_close: Optional[Callable[[], None]] = None,
- **kwargs):
+
+ def __init__(
+ self,
+ *args,
+ on_open: Optional[Callable[[], None]] = None,
+ on_close: Optional[Callable[[], None]] = None,
+ **kwargs,
+ ):
super().__init__(*args, **kwargs)
self._on_open_hndl = on_open
self._on_close_hndl = on_close
@@ -35,7 +38,7 @@ class Listener(_Listener):
try:
self._on_close_hndl()
except Exception as e:
- self.logger.warning(f'Pushbullet listener close error: {e}')
+ self.logger.warning('Pushbullet listener close error: %s', e)
return callback
diff --git a/platypush/plugins/pushbullet/manifest.yaml b/platypush/plugins/pushbullet/manifest.yaml
index 4e334226f..8c5fa0929 100644
--- a/platypush/plugins/pushbullet/manifest.yaml
+++ b/platypush/plugins/pushbullet/manifest.yaml
@@ -1,6 +1,21 @@
manifest:
- events: {}
- install:
- pip: []
package: platypush.plugins.pushbullet
type: plugin
+ events:
+ - platypush.message.event.pushbullet.PushbulletDismissalEvent
+ - platypush.message.event.pushbullet.PushbulletFileEvent
+ - platypush.message.event.pushbullet.PushbulletLinkEvent
+ - platypush.message.event.pushbullet.PushbulletMessageEvent
+ - platypush.message.event.pushbullet.PushbulletMessageEvent
+ - platypush.message.event.pushbullet.PushbulletNotificationEvent
+ install:
+ apk:
+ - git
+ apt:
+ - git
+ dnf:
+ - git
+ pacman:
+ - git
+ pip:
+ - git+https://github.com/rbrcsk/pushbullet.py
diff --git a/platypush/schemas/pushbullet.py b/platypush/schemas/pushbullet.py
new file mode 100644
index 000000000..c6d88625e
--- /dev/null
+++ b/platypush/schemas/pushbullet.py
@@ -0,0 +1,324 @@
+import json
+
+from marshmallow import EXCLUDE, fields, pre_dump
+from marshmallow.schema import Schema
+
+from platypush.schemas import DateTime
+
+
+class PushbulletActionSchema(Schema):
+ """
+ Schema for Pushbullet notification actions.
+ """
+
+ label = fields.String(
+ required=True,
+ metadata={
+ 'description': 'Label of the action',
+ 'example': 'Example action',
+ },
+ )
+
+ trigger_key = fields.String(
+ required=True,
+ metadata={
+ 'description': 'Key of the action',
+ 'example': 'example_action',
+ },
+ )
+
+
+class PushbulletSchema(Schema):
+ """
+ Schema for Pushbullet API messages.
+ """
+
+ # pylint: disable=too-few-public-methods
+ class Meta: # type: ignore
+ """
+ Exclude unknown fields.
+ """
+
+ unknown = EXCLUDE
+
+ notification_id = fields.String(
+ required=True,
+ metadata={
+ 'description': 'Unique identifier for the notification/message',
+ 'example': '12345',
+ },
+ )
+
+ title = fields.String(
+ metadata={
+ 'description': 'Title of the notification/message',
+ 'example': 'Hello world',
+ },
+ )
+
+ body = fields.Raw(
+ metadata={
+ 'description': 'Body of the notification/message',
+ 'example': 'Example body',
+ },
+ )
+
+ url = fields.Url(
+ metadata={
+ 'description': 'URL attached to the notification/message',
+ 'example': 'https://example.com',
+ },
+ )
+
+ source_user = fields.String(
+ attribute='source_user_iden',
+ metadata={
+ 'description': 'Source user of the notification/message',
+ 'example': 'user123',
+ },
+ )
+
+ source_device = fields.String(
+ attribute='source_device_iden',
+ metadata={
+ 'description': 'Source device of the notification/message',
+ 'example': 'device123',
+ },
+ )
+
+ target_device = fields.String(
+ attribute='target_device_iden',
+ metadata={
+ 'description': 'Target device of the notification/message',
+ 'example': 'device456',
+ },
+ )
+
+ sender_id = fields.String(
+ attribute='sender_iden',
+ metadata={
+ 'description': 'Sender ID of the notification/message',
+ 'example': '12345',
+ },
+ )
+
+ sender_email = fields.Email(
+ attribute='sender_email_normalized',
+ metadata={
+ 'description': 'Sender email of the notification/message',
+ 'example': 'user1@example.com',
+ },
+ )
+
+ sender_name = fields.String(
+ metadata={
+ 'description': 'Sender name of the notification/message',
+ 'example': 'John Doe',
+ },
+ )
+
+ receiver_id = fields.String(
+ attribute='receiver_iden',
+ metadata={
+ 'description': 'Receiver ID of the notification/message',
+ 'example': '12346',
+ },
+ )
+
+ receiver_email = fields.Email(
+ attribute='receiver_email_normalized',
+ metadata={
+ 'description': 'Receiver email of the notification/message',
+ 'example': 'user2@example.com',
+ },
+ )
+
+ dismissible = fields.Boolean(
+ metadata={
+ 'description': 'Whether the notification/message is dismissible',
+ 'example': True,
+ },
+ )
+
+ icon = fields.String(
+ metadata={
+ 'description': 'Base64 encoded icon of the notification/message',
+ 'example': 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABpUlEQVQ4T',
+ },
+ )
+
+ application_name = fields.String(
+ metadata={
+ 'description': 'Name of the application that sent the notification/message',
+ 'example': 'Example app',
+ },
+ )
+
+ package_name = fields.String(
+ metadata={
+ 'description': 'Package name of the application that sent the notification/message',
+ 'example': 'com.example.app',
+ },
+ )
+
+ file_name = fields.String(
+ metadata={
+ 'description': 'Name of the file attached to the notification/message',
+ 'example': 'example.txt',
+ },
+ )
+
+ file_type = fields.String(
+ metadata={
+ 'description': 'Type of the file attached to the notification/message',
+ 'example': 'text/plain',
+ },
+ )
+
+ file_url = fields.Url(
+ metadata={
+ 'description': 'URL of the file attached to the notification/message',
+ 'example': 'https://example.com/example.txt',
+ },
+ )
+
+ image_width = fields.Integer(
+ metadata={
+ 'description': 'Width of the image attached to the notification/message',
+ 'example': 100,
+ },
+ )
+
+ image_height = fields.Integer(
+ metadata={
+ 'description': 'Height of the image attached to the notification/message',
+ 'example': 100,
+ },
+ )
+
+ image_url = fields.Url(
+ metadata={
+ 'description': 'URL of the image attached to the notification/message',
+ 'example': 'https://example.com/example.png',
+ },
+ )
+
+ actions = fields.Nested(
+ PushbulletActionSchema,
+ many=True,
+ metadata={
+ 'description': 'Actions of the notification/message',
+ },
+ )
+
+ created = DateTime(
+ metadata={
+ 'description': 'Creation timestamp of the notification/message',
+ 'example': '2021-01-01T00:00:00',
+ },
+ )
+
+ modified = DateTime(
+ metadata={
+ 'description': 'Last modification timestamp of the notification/message',
+ 'example': '2021-01-01T00:00:00',
+ },
+ )
+
+ @pre_dump
+ def pre_dump(self, data, **_):
+ """
+ Pre-dump hook.
+ """
+
+ data['notification_id'] = str(
+ data.pop('iden', data.pop('notification_id', None))
+ )
+
+ if data.get('body') is not None:
+ try:
+ data['body'] = json.loads(data['body'])
+ except (TypeError, ValueError):
+ pass
+
+ return data
+
+
+class PushbulletDeviceSchema(Schema):
+ """
+ Schema for Pushbullet devices.
+ """
+
+ active = fields.Boolean(
+ metadata={
+ 'description': 'Whether the device is active',
+ 'example': True,
+ },
+ )
+
+ device_id = fields.String(
+ required=True,
+ attribute='iden',
+ metadata={
+ 'description': 'Unique identifier for the device',
+ 'example': '12345',
+ },
+ )
+
+ name = fields.String(
+ attribute='nickname',
+ metadata={
+ 'description': 'Name of the device',
+ 'example': 'Example device',
+ },
+ )
+
+ kind = fields.String(
+ metadata={
+ 'description': 'Kind of the device',
+ 'example': 'android',
+ },
+ )
+
+ manufacturer = fields.String(
+ metadata={
+ 'description': 'Manufacturer of the device',
+ 'example': 'Example manufacturer',
+ },
+ )
+
+ model = fields.String(
+ metadata={
+ 'description': 'Model of the device',
+ 'example': 'Example model',
+ },
+ )
+
+ icon = fields.String(
+ metadata={
+ 'description': 'Device icon type',
+ 'example': 'system',
+ },
+ )
+
+ pushable = fields.Boolean(
+ metadata={
+ 'description': 'Whether it is possible to push notifications and '
+ 'messages to the device',
+ 'example': True,
+ },
+ )
+
+ created = DateTime(
+ metadata={
+ 'description': 'Creation timestamp of the device',
+ 'example': '2021-01-01T00:00:00',
+ },
+ )
+
+ modified = DateTime(
+ metadata={
+ 'description': 'Last modification timestamp of the device',
+ 'example': '2021-01-01T00:00:00',
+ },
+ )
diff --git a/setup.py b/setup.py
index f3fc4fd83..c597e4294 100755
--- a/setup.py
+++ b/setup.py
@@ -91,7 +91,7 @@ setup(
extras_require={
# Support for Kafka backend and plugin
'kafka': ['kafka-python'],
- # Support for Pushbullet backend and plugin
+ # Support for Pushbullet
'pushbullet': [
'pushbullet.py @ https://github.com/rbrcsk/pushbullet.py/tarball/master'
],