Merge branch 'master' into snyk-fix-cbc2d5f1b27baf97088c91fe8a0ed9ad

This commit is contained in:
Fabio Manganiello 2024-03-03 23:10:22 +01:00 committed by GitHub
commit 0dea8c3b44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
434 changed files with 11605 additions and 8560 deletions

View file

@ -62,7 +62,8 @@ steps:
# Backup the original git configuration before changing attributes
- export GIT_CONF=$PWD/.git/config
- cp $GIT_CONF /tmp/git.config.orig
- export TMP_GIT_CONF=/tmp/git.config.orig
- cp $GIT_CONF $TMP_GIT_CONF
- git config --global --add safe.directory $PWD
# Install the SSH and PGP keys
@ -95,7 +96,7 @@ steps:
- git checkout master
# Restore the original git configuration
- mv /tmp/git.config.orig $GIT_CONF
- mv $TMP_GIT_CONF $GIT_CONF
when:
event:
@ -187,7 +188,8 @@ steps:
# Backup the original git configuration before changing attributes
- export GIT_CONF=$PWD/.git/config
- cp $GIT_CONF /tmp/git.config.orig
- export TMP_GIT_CONF=/tmp/git.config.orig
- cp $GIT_CONF $TMP_GIT_CONF
- git config --global --add safe.directory $PWD
- cd platypush/backend/http/webapp
@ -203,7 +205,7 @@ steps:
exit 0
fi
- rm -rf node_modules
- rm -rf dist node_modules
- npm install
- npm run build
- |
@ -242,7 +244,95 @@ steps:
- git push -f origin master
# Restore the original git configuration
- mv /tmp/git.config.orig $GIT_CONF
- mv $TMP_GIT_CONF $GIT_CONF
###
### Regenerate the components.json cache
###
- name: update-components-cache
image: alpine
environment:
SSH_PUBKEY:
from_secret: ssh_pubkey
SSH_PRIVKEY:
from_secret: ssh_privkey
PGP_KEY:
from_secret: pgp_key
PGP_KEY_ID:
from_secret: pgp_key_id
when:
branch:
- master
event:
- push
depends_on:
- build-ui
commands:
- export SKIPCI="$PWD/.skipci"
- export CACHEFILE="$PWD/platypush/components.json.gz"
- |
[ -f "$SKIPCI" ] && exit 0
# Only regenerate the components cache if either the plugins, backends,
# events or schemas folders have some changes (excluding the webapp files).
- apk add --update --no-cache git
- |
if [ -z "$(git log --pretty=oneline $DRONE_COMMIT_AFTER...$DRONE_COMMIT_BEFORE -- platypush/backend platypush/plugins platypush/schemas platypush/message/event ':(exclude)platypush/backend/http/webapp')" ]; then
echo 'No changes to the components file'
exit 0
fi
- echo 'Updating components cache'
- apk add --update --no-cache bash gnupg openssh $(cat platypush/install/requirements/alpine.txt)
- pip install . --break-system-packages
- |
python - <<EOF
from platypush import get_plugin
get_plugin('inspect').refresh_cache(force=True)
EOF
# Backup the original git configuration before changing attributes
- export GIT_CONF=$PWD/.git/config
- export TMP_GIT_CONF=/tmp/git.config.orig
- cp $GIT_CONF $TMP_GIT_CONF
- git config --global --add safe.directory $PWD
# Create a .skipci file to mark the fact that the next steps should be skipped
# (we're going to do another push anyway, so another pipeline will be triggered)
- touch "$SKIPCI"
- mkdir -p ~/.ssh
- |
cat <<EOF | gpg --import --armor
$PGP_KEY
EOF
- echo $SSH_PUBKEY > ~/.ssh/id_rsa.pub
- |
cat <<EOF > ~/.ssh/id_rsa
$SSH_PRIVKEY
EOF
- chmod 0600 ~/.ssh/id_rsa
- ssh-keyscan git.platypush.tech >> ~/.ssh/known_hosts 2>/dev/null
- git config user.name "Platypush CI/CD Automation"
- git config user.email "admin@platypush.tech"
- git config commit.gpgsign true
- git config user.signingkey $PGP_KEY_ID
- git add "$CACHEFILE"
- git commit "$CACHEFILE" -S -m "[Automatic] Updated components cache" --no-verify
- git remote rm origin
- git remote add origin git@git.platypush.tech:platypush/platypush.git
- git push -f origin master
# Restore the original git configuration
- mv $TMP_GIT_CONF $GIT_CONF
###
### Update the Arch packages
@ -264,7 +354,7 @@ steps:
- push
depends_on:
- build-ui
- update-components-cache
commands:
- |
@ -278,7 +368,7 @@ steps:
- git pull --rebase origin master --tags
- export VERSION=$(python setup.py --version)
- export HEAD=$(git log --pretty=format:%h HEAD...HEAD~1 | head -1)
- export GIT_VERSION="$VERSION.r$(git log --pretty=oneline HEAD...v$VERSION | wc -l).$HEAD"
- export GIT_VERSION="$VERSION.r$(git log --pretty=oneline HEAD...v$VERSION | wc -l).g$${HEAD}"
- export TAG_URL="https://git.platypush.tech/platypush/platypush/archive/v$VERSION.tar.gz"
- echo "--- Preparing environment"
@ -364,7 +454,7 @@ steps:
- push
depends_on:
- build-ui
- update-components-cache
commands:
- |
@ -463,7 +553,7 @@ steps:
- push
depends_on:
- build-ui
- update-components-cache
commands:
- |
@ -774,7 +864,7 @@ steps:
- push
depends_on:
- build-ui
- update-components-cache
commands:
- |

View file

@ -3,3 +3,4 @@ recursive-include platypush/install *
include platypush/plugins/http/webpage/mercury-parser.js
include platypush/config/*.yaml
global-include manifest.yaml
global-include components.json.gz

View file

@ -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.
@ -707,8 +705,6 @@ groups together the controls for most of the plugins - e.g. sensors, switches,
music controls and search, media library and torrent management, lights,
Zigbee/Z-Wave devices and so on. The UI is responsive and mobile-friendly.
Some screenshots:
#### The main web panel
This is the default panel available at `http://<host>:<port>` after

View file

@ -6,30 +6,13 @@ Backends
:maxdepth: 1
:caption: Backends:
platypush/backend/adafruit.io.rst
platypush/backend/button.flic.rst
platypush/backend/camera.pi.rst
platypush/backend/chat.telegram.rst
platypush/backend/google.fit.rst
platypush/backend/google.pubsub.rst
platypush/backend/gps.rst
platypush/backend/http.rst
platypush/backend/kafka.rst
platypush/backend/mail.rst
platypush/backend/midi.rst
platypush/backend/music.mopidy.rst
platypush/backend/music.mpd.rst
platypush/backend/music.spotify.rst
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
platypush/backend/sensor.leap.rst
platypush/backend/stt.deepspeech.rst
platypush/backend/stt.picovoice.hotword.rst
platypush/backend/stt.picovoice.speech.rst
platypush/backend/tcp.rst
platypush/backend/wiimote.rst

View file

@ -11,16 +11,15 @@ Events
platypush/events/application.rst
platypush/events/assistant.rst
platypush/events/bluetooth.rst
platypush/events/button.flic.rst
platypush/events/camera.rst
platypush/events/chat.slack.rst
platypush/events/chat.telegram.rst
platypush/events/clipboard.rst
platypush/events/custom.rst
platypush/events/dbus.rst
platypush/events/distance.rst
platypush/events/entities.rst
platypush/events/file.rst
platypush/events/flic.rst
platypush/events/foursquare.rst
platypush/events/geo.rst
platypush/events/github.rst
@ -66,6 +65,7 @@ Events
platypush/events/sound.rst
platypush/events/stt.rst
platypush/events/sun.rst
platypush/events/telegram.rst
platypush/events/tensorflow.rst
platypush/events/torrent.rst
platypush/events/trello.rst

View file

@ -39,6 +39,7 @@ Wiki
wiki/Configuration
wiki/Installing-extensions
wiki/A-configuration-example
wiki/The-Web-interface
Reference
=========

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
``chat.telegram``
===================================
.. automodule:: platypush.backend.chat.telegram
:members:

View file

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

View file

@ -1,5 +0,0 @@
``google.pubsub``
===================================
.. automodule:: platypush.backend.google.pubsub
:members:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
``sensor.ir.zeroborg``
========================================
.. automodule:: platypush.backend.sensor.ir.zeroborg
:members:

View file

@ -1,7 +0,0 @@
``sensor.leap``
=================================
.. automodule:: platypush.backend.sensor.leap
:members:

View file

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

View file

@ -1,6 +0,0 @@
``button.flic``
=======================================
.. automodule:: platypush.message.event.button.flic
:members:

View file

@ -1,5 +0,0 @@
``chat.telegram``
=========================================
.. automodule:: platypush.message.event.chat.telegram
:members:

View file

@ -0,0 +1,5 @@
``flic``
========
.. automodule:: platypush.message.event.flic
:members:

View file

@ -0,0 +1,5 @@
``telegram``
============
.. automodule:: platypush.message.event.telegram
:members:

View file

@ -0,0 +1,5 @@
``camera.pi.legacy``
====================
.. automodule:: platypush.plugins.camera.pi.legacy
:members:

View file

@ -1,5 +0,0 @@
``chat.irc``
============
.. automodule:: platypush.plugins.chat.irc
:members:

View file

@ -1,5 +0,0 @@
``chat.telegram``
===================================
.. automodule:: platypush.plugins.chat.telegram
:members:

View file

@ -0,0 +1,5 @@
``flic``
========
.. automodule:: platypush.plugins.flic
:members:

View file

@ -0,0 +1,5 @@
``gps``
=======
.. automodule:: platypush.plugins.gps
:members:

View file

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

View file

@ -0,0 +1,5 @@
``http``
========
.. automodule:: platypush.plugins.http
:members:

View file

@ -0,0 +1,5 @@
``irc``
=======
.. automodule:: platypush.plugins.irc
:members:

View file

@ -0,0 +1,5 @@
``leap``
========
.. automodule:: platypush.plugins.leap
:members:

View file

@ -1,5 +0,0 @@
``mail.imap``
===============================
.. automodule:: platypush.plugins.mail.imap
:members:

View file

@ -0,0 +1,5 @@
``mail``
========
.. automodule:: platypush.plugins.mail
:members:

View file

@ -1,5 +0,0 @@
``mail.smtp``
===============================
.. automodule:: platypush.plugins.mail.smtp
:members:

View file

@ -0,0 +1,5 @@
``nfc``
=======
.. automodule:: platypush.plugins.nfc
:members:

View file

@ -0,0 +1,5 @@
``telegram``
============
.. automodule:: platypush.plugins.telegram
:members:

View file

@ -1,6 +0,0 @@
``wiimote``
=============================
.. automodule:: platypush.plugins.wiimote
:members:

View file

@ -1,5 +0,0 @@
``chat.telegram``
============================================
.. automodule:: platypush.message.response.chat.telegram
:members:

View file

@ -21,8 +21,7 @@ Plugins
platypush/plugins/camera.gstreamer.rst
platypush/plugins/camera.ir.mlx90640.rst
platypush/plugins/camera.pi.rst
platypush/plugins/chat.irc.rst
platypush/plugins/chat.telegram.rst
platypush/plugins/camera.pi.legacy.rst
platypush/plugins/clipboard.rst
platypush/plugins/config.rst
platypush/plugins/csv.rst
@ -34,6 +33,7 @@ Plugins
platypush/plugins/ffmpeg.rst
platypush/plugins/file.rst
platypush/plugins/file.monitor.rst
platypush/plugins/flic.rst
platypush/plugins/foursquare.rst
platypush/plugins/github.rst
platypush/plugins/google.calendar.rst
@ -46,24 +46,26 @@ Plugins
platypush/plugins/gotify.rst
platypush/plugins/gpio.rst
platypush/plugins/gpio.zeroborg.rst
platypush/plugins/gps.rst
platypush/plugins/graphite.rst
platypush/plugins/hid.rst
platypush/plugins/http.request.rst
platypush/plugins/http.rst
platypush/plugins/http.webpage.rst
platypush/plugins/ifttt.rst
platypush/plugins/inspect.rst
platypush/plugins/irc.rst
platypush/plugins/joystick.rst
platypush/plugins/kafka.rst
platypush/plugins/lastfm.rst
platypush/plugins/lcd.gpio.rst
platypush/plugins/lcd.i2c.rst
platypush/plugins/leap.rst
platypush/plugins/light.hue.rst
platypush/plugins/linode.rst
platypush/plugins/log.http.rst
platypush/plugins/logger.rst
platypush/plugins/luma.oled.rst
platypush/plugins/mail.imap.rst
platypush/plugins/mail.smtp.rst
platypush/plugins/mail.rst
platypush/plugins/mailgun.rst
platypush/plugins/mastodon.rst
platypush/plugins/matrix.rst
@ -87,6 +89,7 @@ Plugins
platypush/plugins/music.spotify.rst
platypush/plugins/music.tidal.rst
platypush/plugins/nextcloud.rst
platypush/plugins/nfc.rst
platypush/plugins/ngrok.rst
platypush/plugins/nmap.rst
platypush/plugins/ntfy.rst
@ -124,6 +127,7 @@ Plugins
platypush/plugins/switchbot.rst
platypush/plugins/system.rst
platypush/plugins/tcp.rst
platypush/plugins/telegram.rst
platypush/plugins/tensorflow.rst
platypush/plugins/todoist.rst
platypush/plugins/torrent.rst
@ -141,7 +145,6 @@ Plugins
platypush/plugins/weather.buienradar.rst
platypush/plugins/weather.openweathermap.rst
platypush/plugins/websocket.rst
platypush/plugins/wiimote.rst
platypush/plugins/xmpp.rst
platypush/plugins/youtube.rst
platypush/plugins/zeroconf.rst

View file

@ -8,7 +8,6 @@ Responses
platypush/responses/camera.rst
platypush/responses/camera.android.rst
platypush/responses/chat.telegram.rst
platypush/responses/google.drive.rst
platypush/responses/pihole.rst
platypush/responses/printer.cups.rst

View file

@ -274,6 +274,10 @@ class Application:
backend.stop()
for plugin in runnable_plugins:
# This is required because some plugins may redefine the `stop` method.
# In that case, at the very least the _should_stop event should be
# set to notify the plugin to stop.
plugin._should_stop.set() # pylint: disable=protected-access
plugin.stop()
for backend in backends:

View file

@ -1,97 +0,0 @@
from typing import Optional
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.adafruit import (
ConnectedEvent,
DisconnectedEvent,
FeedUpdateEvent,
)
class AdafruitIoBackend(Backend):
"""
Backend that listens to messages received over the Adafruit IO message queue
Requires:
* The :class:`platypush.plugins.adafruit.io.AdafruitIoPlugin` plugin to
be active and configured.
"""
def __init__(self, feeds, *args, **kwargs):
"""
:param feeds: List of feed IDs to monitor
:type feeds: list[str]
"""
super().__init__(*args, **kwargs)
from Adafruit_IO import MQTTClient
self.feeds = feeds
self._client: Optional[MQTTClient] = None
def _init_client(self):
if self._client:
return
from Adafruit_IO import MQTTClient
plugin = get_plugin('adafruit.io')
if not plugin:
raise RuntimeError('Adafruit IO plugin not configured')
# noinspection PyProtectedMember
self._client = MQTTClient(plugin._username, plugin._key)
self._client.on_connect = self.on_connect()
self._client.on_disconnect = self.on_disconnect()
self._client.on_message = self.on_message()
def on_connect(self):
def _handler(client):
for feed in self.feeds:
client.subscribe(feed)
self.bus.post(ConnectedEvent())
return _handler
def on_disconnect(self):
def _handler(*_, **__):
self.bus.post(DisconnectedEvent())
return _handler
def on_message(self, *_, **__):
# noinspection PyUnusedLocal
def _handler(client, feed, data):
try:
data = float(data)
except Exception as e:
self.logger.debug('Not a number: {}: {}'.format(data, e))
self.bus.post(FeedUpdateEvent(feed=feed, data=data))
return _handler
def run(self):
super().run()
self.logger.info(
('Initialized Adafruit IO backend, listening on ' + 'feeds {}').format(
self.feeds
)
)
while not self.should_stop():
try:
self._init_client()
# noinspection PyUnresolvedReferences
self._client.connect()
# noinspection PyUnresolvedReferences
self._client.loop_blocking()
except Exception as e:
self.logger.exception(e)
self._client = None
# vim:sw=4:ts=4:et:

View file

@ -1,12 +0,0 @@
manifest:
events:
platypush.message.event.adafruit.ConnectedEvent: when thebackend connects to the
Adafruit queue
platypush.message.event.adafruit.DisconnectedEvent: when thebackend disconnects
from the Adafruit queue
platypush.message.event.adafruit.FeedUpdateEvent: when anupdate event is received
on a monitored feed
install:
pip: []
package: platypush.backend.adafruit.io
type: backend

View file

@ -1,131 +0,0 @@
from threading import Timer
from time import time
from platypush.backend import Backend
from platypush.message.event.button.flic import FlicButtonEvent
from .fliclib.fliclib import FlicClient, ButtonConnectionChannel, ClickType
class ButtonFlicBackend(Backend):
"""
Backend that listen for events from the Flic (https://flic.io/) bluetooth
smart buttons.
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.
"""
_long_press_timeout = 0.3
_btn_timeout = 0.5
ShortPressEvent = "ShortPressEvent"
LongPressEvent = "LongPressEvent"
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)
: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)
:type btn_timeout: float
"""
super().__init__(**kwargs)
self.server = server
self.client = FlicClient(server)
self.client.get_info(self._received_info())
self.client.on_new_verified_button = self._got_button()
self._long_press_timeout = long_press_timeout
self._btn_timeout = btn_timeout
self._btn_timer = None
self._btn_addr = None
self._down_pressed_time = None
self._cur_sequence = []
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
)
)
self.client.add_connection_channel(cc)
return _f
def _received_info(self):
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 %s: %s', self._btn_addr, 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):
# _ = channel
# __ = time_diff
def _f(bd_addr, _, click_type, was_queued, __):
if was_queued:
return
if self._btn_timer:
self._btn_timer.cancel()
if click_type == ClickType.ButtonDown:
self._down_pressed_time = time()
return
btn_event = self.ShortPressEvent
if self._down_pressed_time:
if time() - self._down_pressed_time >= self._long_press_timeout:
btn_event = self.LongPressEvent
self._down_pressed_time = None
self._cur_sequence.append(btn_event)
self._btn_addr = bd_addr
self._btn_timer = Timer(self._btn_timeout, self._on_btn_timeout())
self._btn_timer.start()
return _f
def run(self):
super().run()
self.client.handle_events()
# vim:sw=4:ts=4:et:

View file

@ -1,609 +0,0 @@
"""Flic client library for python
Requires python 3.3 or higher.
For detailed documentation, see the protocol documentation.
Notes on the data type used in this python implementation compared to the protocol documentation:
All kind of integers are represented as python integers.
Booleans use the Boolean type.
Enums use the defined python enums below.
Bd addr are represented as standard python strings, e.g. "aa:bb:cc:dd:ee:ff".
"""
from enum import Enum
from collections import namedtuple
import time
import socket
import select
import struct
import itertools
import queue
import threading
class CreateConnectionChannelError(Enum):
NoError = 0
MaxPendingConnectionsReached = 1
class ConnectionStatus(Enum):
Disconnected = 0
Connected = 1
Ready = 2
class DisconnectReason(Enum):
Unspecified = 0
ConnectionEstablishmentFailed = 1
TimedOut = 2
BondingKeysMismatch = 3
class RemovedReason(Enum):
RemovedByThisClient = 0
ForceDisconnectedByThisClient = 1
ForceDisconnectedByOtherClient = 2
ButtonIsPrivate = 3
VerifyTimeout = 4
InternetBackendError = 5
InvalidData = 6
CouldntLoadDevice = 7
class ClickType(Enum):
ButtonDown = 0
ButtonUp = 1
ButtonClick = 2
ButtonSingleClick = 3
ButtonDoubleClick = 4
ButtonHold = 5
class BdAddrType(Enum):
PublicBdAddrType = 0
RandomBdAddrType = 1
class LatencyMode(Enum):
NormalLatency = 0
LowLatency = 1
HighLatency = 2
class BluetoothControllerState(Enum):
Detached = 0
Resetting = 1
Attached = 2
class ScanWizardResult(Enum):
WizardSuccess = 0
WizardCancelledByUser = 1
WizardFailedTimeout = 2
WizardButtonIsPrivate = 3
WizardBluetoothUnavailable = 4
WizardInternetBackendError = 5
WizardInvalidData = 6
class ButtonScanner:
"""ButtonScanner class.
Usage:
scanner = ButtonScanner()
scanner.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified: ...
client.add_scanner(scanner)
"""
_cnt = itertools.count()
def __init__(self):
self._scan_id = next(ButtonScanner._cnt)
self.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified: None
class ScanWizard:
"""ScanWizard class
Usage:
wizard = ScanWizard()
wizard.on_found_private_button = lambda scan_wizard: ...
wizard.on_found_public_button = lambda scan_wizard, bd_addr, name: ...
wizard.on_button_connected = lambda scan_wizard, bd_addr, name: ...
wizard.on_completed = lambda scan_wizard, result, bd_addr, name: ...
client.add_scan_wizard(wizard)
"""
_cnt = itertools.count()
def __init__(self):
self._scan_wizard_id = next(ScanWizard._cnt)
self._bd_addr = None
self._name = None
self.on_found_private_button = lambda scan_wizard: None
self.on_found_public_button = lambda scan_wizard, bd_addr, name: None
self.on_button_connected = lambda scan_wizard, bd_addr, name: None
self.on_completed = lambda scan_wizard, result, bd_addr, name: None
class ButtonConnectionChannel:
"""ButtonConnectionChannel class.
This class represents a connection channel to a Flic button.
Add this button connection channel to a FlicClient by executing client.add_connection_channel(connection_channel).
You may only have this connection channel added to one FlicClient at a time.
Before you add the connection channel to the client, you should set up your callback functions by assigning
the corresponding properties to this object with a function. Each callback function has a channel parameter as the first one,
referencing this object.
Available properties and the function parameters are:
on_create_connection_channel_response: channel, error, connection_status
on_removed: channel, removed_reason
on_connection_status_changed: channel, connection_status, disconnect_reason
on_button_up_or_down / on_button_click_or_hold / on_button_single_or_double_click / on_button_single_or_double_click_or_hold: channel, click_type, was_queued, time_diff
"""
_cnt = itertools.count()
def __init__(self, bd_addr, latency_mode = LatencyMode.NormalLatency, auto_disconnect_time = 511):
self._conn_id = next(ButtonConnectionChannel._cnt)
self._bd_addr = bd_addr
self._latency_mode = latency_mode
self._auto_disconnect_time = auto_disconnect_time
self._client = None
self.on_create_connection_channel_response = lambda channel, error, connection_status: None
self.on_removed = lambda channel, removed_reason: None
self.on_connection_status_changed = lambda channel, connection_status, disconnect_reason: None
self.on_button_up_or_down = lambda channel, click_type, was_queued, time_diff: None
self.on_button_click_or_hold = lambda channel, click_type, was_queued, time_diff: None
self.on_button_single_or_double_click = lambda channel, click_type, was_queued, time_diff: None
self.on_button_single_or_double_click_or_hold = lambda channel, click_type, was_queued, time_diff: None
@property
def bd_addr(self):
return self._bd_addr
@property
def latency_mode(self):
return self._latency_mode
@latency_mode.setter
def latency_mode(self, latency_mode):
if self._client is None:
self._latency_mode = latency_mode
return
with self._client._lock:
self._latency_mode = latency_mode
if not self._client._closed:
self._client._send_command("CmdChangeModeParameters", {"conn_id": self._conn_id, "latency_mode": self._latency_mode, "auto_disconnect_time": self._auto_disconnect_time})
@property
def auto_disconnect_time(self):
return self._auto_disconnect_time
@auto_disconnect_time.setter
def auto_disconnect_time(self, auto_disconnect_time):
if self._client is None:
self._auto_disconnect_time = auto_disconnect_time
return
with self._client._lock:
self._auto_disconnect_time = auto_disconnect_time
if not self._client._closed:
self._client._send_command("CmdChangeModeParameters", {"conn_id": self._conn_id, "latency_mode": self._latency_mode, "auto_disconnect_time": self._auto_disconnect_time})
class FlicClient:
"""FlicClient class.
When this class is constructed, a socket connection is established.
You may then send commands to the server and set timers.
Once you are ready with the initialization you must call the handle_events() method which is a main loop that never exits, unless the socket is closed.
For a more detailed description of all commands, events and enums, check the protocol specification.
All commands are wrapped in more high level functions and events are reported using callback functions.
All methods called on this class will take effect only if you eventually call the handle_events() method.
The ButtonScanner is used to set up a handler for advertisement packets.
The ButtonConnectionChannel is used to interact with connections to flic buttons and receive their events.
Other events are handled by the following callback functions that can be assigned to this object (and a list of the callback function parameters):
on_new_verified_button: bd_addr
on_no_space_for_new_connection: max_concurrently_connected_buttons
on_got_space_for_new_connection: max_concurrently_connected_buttons
on_bluetooth_controller_state_change: state
"""
_EVENTS = [
("EvtAdvertisementPacket", "<I6s17pb??", "scan_id bd_addr name rssi is_private already_verified"),
("EvtCreateConnectionChannelResponse", "<IBB", "conn_id error connection_status"),
("EvtConnectionStatusChanged", "<IBB", "conn_id connection_status disconnect_reason"),
("EvtConnectionChannelRemoved", "<IB", "conn_id removed_reason"),
("EvtButtonUpOrDown", "<IBBI", "conn_id click_type was_queued time_diff"),
("EvtButtonClickOrHold", "<IBBI", "conn_id click_type was_queued time_diff"),
("EvtButtonSingleOrDoubleClick", "<IBBI", "conn_id click_type was_queued time_diff"),
("EvtButtonSingleOrDoubleClickOrHold", "<IBBI", "conn_id click_type was_queued time_diff"),
("EvtNewVerifiedButton", "<6s", "bd_addr"),
("EvtGetInfoResponse", "<B6sBBhBBH", "bluetooth_controller_state my_bd_addr my_bd_addr_type max_pending_connections max_concurrently_connected_buttons current_pending_connections currently_no_space_for_new_connection nb_verified_buttons"),
("EvtNoSpaceForNewConnection", "<B", "max_concurrently_connected_buttons"),
("EvtGotSpaceForNewConnection", "<B", "max_concurrently_connected_buttons"),
("EvtBluetoothControllerStateChange", "<B", "state"),
("EvtPingResponse", "<I", "ping_id"),
("EvtGetButtonUUIDResponse", "<6s16s", "bd_addr uuid"),
("EvtScanWizardFoundPrivateButton", "<I", "scan_wizard_id"),
("EvtScanWizardFoundPublicButton", "<I6s17p", "scan_wizard_id bd_addr name"),
("EvtScanWizardButtonConnected", "<I", "scan_wizard_id"),
("EvtScanWizardCompleted", "<IB", "scan_wizard_id result")
]
_EVENT_STRUCTS = list(map(lambda x: None if x is None else struct.Struct(x[1]), _EVENTS))
_EVENT_NAMED_TUPLES = list(map(lambda x: None if x is None else namedtuple(x[0], x[2]), _EVENTS))
_COMMANDS = [
("CmdGetInfo", "", ""),
("CmdCreateScanner", "<I", "scan_id"),
("CmdRemoveScanner", "<I", "scan_id"),
("CmdCreateConnectionChannel", "<I6sBh", "conn_id bd_addr latency_mode auto_disconnect_time"),
("CmdRemoveConnectionChannel", "<I", "conn_id"),
("CmdForceDisconnect", "<6s", "bd_addr"),
("CmdChangeModeParameters", "<IBh", "conn_id latency_mode auto_disconnect_time"),
("CmdPing", "<I", "ping_id"),
("CmdGetButtonUUID", "<6s", "bd_addr"),
("CmdCreateScanWizard", "<I", "scan_wizard_id"),
("CmdCancelScanWizard", "<I", "scan_wizard_id")
]
_COMMAND_STRUCTS = list(map(lambda x: struct.Struct(x[1]), _COMMANDS))
_COMMAND_NAMED_TUPLES = list(map(lambda x: namedtuple(x[0], x[2]), _COMMANDS))
_COMMAND_NAME_TO_OPCODE = dict((x[0], i) for i, x in enumerate(_COMMANDS))
@staticmethod
def _bdaddr_bytes_to_string(bdaddr_bytes):
return ":".join(map(lambda x: "%02x" % x, reversed(bdaddr_bytes)))
@staticmethod
def _bdaddr_string_to_bytes(bdaddr_string):
return bytearray.fromhex("".join(reversed(bdaddr_string.split(":"))))
def __init__(self, host, port = 5551):
self._sock = socket.create_connection((host, port), None)
self._lock = threading.RLock()
self._scanners = {}
self._scan_wizards = {}
self._connection_channels = {}
self._get_info_response_queue = queue.Queue()
self._get_button_uuid_queue = queue.Queue()
self._timers = queue.PriorityQueue()
self._handle_event_thread_ident = None
self._closed = False
self.on_new_verified_button = lambda bd_addr: None
self.on_no_space_for_new_connection = lambda max_concurrently_connected_buttons: None
self.on_got_space_for_new_connection = lambda max_concurrently_connected_buttons: None
self.on_bluetooth_controller_state_change = lambda state: None
def close(self):
"""Closes the client. The handle_events() method will return."""
with self._lock:
if self._closed:
return
if threading.get_ident() != self._handle_event_thread_ident:
self._send_command("CmdPing", {"ping_id": 0}) # To unblock socket select
self._closed = True
def add_scanner(self, scanner):
"""Add a ButtonScanner object.
The scan will start directly once the scanner is added.
"""
with self._lock:
if scanner._scan_id in self._scanners:
return
self._scanners[scanner._scan_id] = scanner
self._send_command("CmdCreateScanner", {"scan_id": scanner._scan_id})
def remove_scanner(self, scanner):
"""Remove a ButtonScanner object.
You will no longer receive advertisement packets.
"""
with self._lock:
if scanner._scan_id not in self._scanners:
return
del self._scanners[scanner._scan_id]
self._send_command("CmdRemoveScanner", {"scan_id": scanner._scan_id})
def add_scan_wizard(self, scan_wizard):
"""Add a ScanWizard object.
The scan wizard will start directly once the scan wizard is added.
"""
with self._lock:
if scan_wizard._scan_wizard_id in self._scan_wizards:
return
self._scan_wizards[scan_wizard._scan_wizard_id] = scan_wizard
self._send_command("CmdCreateScanWizard", {"scan_wizard_id": scan_wizard._scan_wizard_id})
def cancel_scan_wizard(self, scan_wizard):
"""Cancel a ScanWizard.
Note: The effect of this command will take place at the time the on_completed event arrives on the scan wizard object.
If cancelled due to this command, "result" in the on_completed event will be "WizardCancelledByUser".
"""
with self._lock:
if scan_wizard._scan_wizard_id not in self._scan_wizards:
return
self._send_command("CmdCancelScanWizard", {"scan_wizard_id": scan_wizard._scan_wizard_id})
def add_connection_channel(self, channel):
"""Adds a connection channel to a specific Flic button.
This will start listening for a specific Flic button's connection and button events.
Make sure the Flic is either in public mode (by holding it down for 7 seconds) or already verified before calling this method.
The on_create_connection_channel_response callback property will be called on the
connection channel after this command has been received by the server.
You may have as many connection channels as you wish for a specific Flic Button.
"""
with self._lock:
if channel._conn_id in self._connection_channels:
return
channel._client = self
self._connection_channels[channel._conn_id] = channel
self._send_command("CmdCreateConnectionChannel", {"conn_id": channel._conn_id, "bd_addr": channel.bd_addr, "latency_mode": channel._latency_mode, "auto_disconnect_time": channel._auto_disconnect_time})
def remove_connection_channel(self, channel):
"""Remove a connection channel.
This will stop listening for new events for a specific connection channel that has previously been added.
Note: The effect of this command will take place at the time the on_removed event arrives on the connection channel object.
"""
with self._lock:
if channel._conn_id not in self._connection_channels:
return
self._send_command("CmdRemoveConnectionChannel", {"conn_id": channel._conn_id})
def force_disconnect(self, bd_addr):
"""Force disconnection or cancel pending connection of a specific Flic button.
This removes all connection channels for all clients connected to the server for this specific Flic button.
"""
self._send_command("CmdForceDisconnect", {"bd_addr": bd_addr})
def get_info(self, callback):
"""Get info about the current state of the server.
The server will send back its information directly and the callback will be called once the response arrives.
The callback takes only one parameter: info. This info parameter is a dictionary with the following objects:
bluetooth_controller_state, my_bd_addr, my_bd_addr_type, max_pending_connections, max_concurrently_connected_buttons,
current_pending_connections, currently_no_space_for_new_connection, bd_addr_of_verified_buttons (a list of bd addresses).
"""
self._get_info_response_queue.put(callback)
self._send_command("CmdGetInfo", {})
def get_button_uuid(self, bd_addr, callback):
"""Get button uuid for a verified button.
The server will send back its information directly and the callback will be called once the response arrives.
Responses will arrive in the same order as requested.
The callback takes two parameters: bd_addr, uuid (hex string of 32 characters).
Note: if the button isn't verified, the uuid sent to the callback will rather be None.
"""
with self._lock:
self._get_button_uuid_queue.put(callback)
self._send_command("CmdGetButtonUUID", {"bd_addr": bd_addr})
def set_timer(self, timeout_millis, callback):
"""Set a timer
This timer callback will run after the specified timeout_millis on the thread that handles the events.
"""
point_in_time = time.monotonic() + timeout_millis / 1000.0
self._timers.put((point_in_time, callback))
if threading.get_ident() != self._handle_event_thread_ident:
self._send_command("CmdPing", {"ping_id": 0}) # To unblock socket select
def run_on_handle_events_thread(self, callback):
"""Run a function on the thread that handles the events."""
if threading.get_ident() == self._handle_event_thread_ident:
callback()
else:
self.set_timer(0, callback)
def _send_command(self, name, items):
for key, value in items.items():
if isinstance(value, Enum):
items[key] = value.value
if "bd_addr" in items:
items["bd_addr"] = FlicClient._bdaddr_string_to_bytes(items["bd_addr"])
opcode = FlicClient._COMMAND_NAME_TO_OPCODE[name]
data_bytes = FlicClient._COMMAND_STRUCTS[opcode].pack(*FlicClient._COMMAND_NAMED_TUPLES[opcode](**items))
bytes = bytearray(3)
bytes[0] = (len(data_bytes) + 1) & 0xff
bytes[1] = (len(data_bytes) + 1) >> 8
bytes[2] = opcode
bytes += data_bytes
with self._lock:
if not self._closed:
self._sock.sendall(bytes)
def _dispatch_event(self, data):
if len(data) == 0:
return
opcode = data[0]
if opcode >= len(FlicClient._EVENTS) or FlicClient._EVENTS[opcode] is None:
return
event_name = FlicClient._EVENTS[opcode][0]
data_tuple = FlicClient._EVENT_STRUCTS[opcode].unpack(data[1 : 1 + FlicClient._EVENT_STRUCTS[opcode].size])
items = FlicClient._EVENT_NAMED_TUPLES[opcode]._make(data_tuple)._asdict()
# Process some kind of items whose data type is not supported by struct
if "bd_addr" in items:
items["bd_addr"] = FlicClient._bdaddr_bytes_to_string(items["bd_addr"])
if "name" in items:
items["name"] = items["name"].decode("utf-8")
if event_name == "EvtCreateConnectionChannelResponse":
items["error"] = CreateConnectionChannelError(items["error"])
items["connection_status"] = ConnectionStatus(items["connection_status"])
if event_name == "EvtConnectionStatusChanged":
items["connection_status"] = ConnectionStatus(items["connection_status"])
items["disconnect_reason"] = DisconnectReason(items["disconnect_reason"])
if event_name == "EvtConnectionChannelRemoved":
items["removed_reason"] = RemovedReason(items["removed_reason"])
if event_name.startswith("EvtButton"):
items["click_type"] = ClickType(items["click_type"])
if event_name == "EvtGetInfoResponse":
items["bluetooth_controller_state"] = BluetoothControllerState(items["bluetooth_controller_state"])
items["my_bd_addr"] = FlicClient._bdaddr_bytes_to_string(items["my_bd_addr"])
items["my_bd_addr_type"] = BdAddrType(items["my_bd_addr_type"])
items["bd_addr_of_verified_buttons"] = []
pos = FlicClient._EVENT_STRUCTS[opcode].size
for i in range(items["nb_verified_buttons"]):
items["bd_addr_of_verified_buttons"].append(FlicClient._bdaddr_bytes_to_string(data[1 + pos : 1 + pos + 6]))
pos += 6
if event_name == "EvtBluetoothControllerStateChange":
items["state"] = BluetoothControllerState(items["state"])
if event_name == "EvtGetButtonUUIDResponse":
items["uuid"] = "".join(map(lambda x: "%02x" % x, items["uuid"]))
if items["uuid"] == "00000000000000000000000000000000":
items["uuid"] = None
if event_name == "EvtScanWizardCompleted":
items["result"] = ScanWizardResult(items["result"])
# Process event
if event_name == "EvtAdvertisementPacket":
scanner = self._scanners.get(items["scan_id"])
if scanner is not None:
scanner.on_advertisement_packet(scanner, items["bd_addr"], items["name"], items["rssi"], items["is_private"], items["already_verified"])
if event_name == "EvtCreateConnectionChannelResponse":
channel = self._connection_channels[items["conn_id"]]
if items["error"] != CreateConnectionChannelError.NoError:
del self._connection_channels[items["conn_id"]]
channel.on_create_connection_channel_response(channel, items["error"], items["connection_status"])
if event_name == "EvtConnectionStatusChanged":
channel = self._connection_channels[items["conn_id"]]
channel.on_connection_status_changed(channel, items["connection_status"], items["disconnect_reason"])
if event_name == "EvtConnectionChannelRemoved":
channel = self._connection_channels[items["conn_id"]]
del self._connection_channels[items["conn_id"]]
channel.on_removed(channel, items["removed_reason"])
if event_name == "EvtButtonUpOrDown":
channel = self._connection_channels[items["conn_id"]]
channel.on_button_up_or_down(channel, items["click_type"], items["was_queued"], items["time_diff"])
if event_name == "EvtButtonClickOrHold":
channel = self._connection_channels[items["conn_id"]]
channel.on_button_click_or_hold(channel, items["click_type"], items["was_queued"], items["time_diff"])
if event_name == "EvtButtonSingleOrDoubleClick":
channel = self._connection_channels[items["conn_id"]]
channel.on_button_single_or_double_click(channel, items["click_type"], items["was_queued"], items["time_diff"])
if event_name == "EvtButtonSingleOrDoubleClickOrHold":
channel = self._connection_channels[items["conn_id"]]
channel.on_button_single_or_double_click_or_hold(channel, items["click_type"], items["was_queued"], items["time_diff"])
if event_name == "EvtNewVerifiedButton":
self.on_new_verified_button(items["bd_addr"])
if event_name == "EvtGetInfoResponse":
self._get_info_response_queue.get()(items)
if event_name == "EvtNoSpaceForNewConnection":
self.on_no_space_for_new_connection(items["max_concurrently_connected_buttons"])
if event_name == "EvtGotSpaceForNewConnection":
self.on_got_space_for_new_connection(items["max_concurrently_connected_buttons"])
if event_name == "EvtBluetoothControllerStateChange":
self.on_bluetooth_controller_state_change(items["state"])
if event_name == "EvtGetButtonUUIDResponse":
self._get_button_uuid_queue.get()(items["bd_addr"], items["uuid"])
if event_name == "EvtScanWizardFoundPrivateButton":
scan_wizard = self._scan_wizards[items["scan_wizard_id"]]
scan_wizard.on_found_private_button(scan_wizard)
if event_name == "EvtScanWizardFoundPublicButton":
scan_wizard = self._scan_wizards[items["scan_wizard_id"]]
scan_wizard._bd_addr = items["bd_addr"]
scan_wizard._name = items["name"]
scan_wizard.on_found_public_button(scan_wizard, scan_wizard._bd_addr, scan_wizard._name)
if event_name == "EvtScanWizardButtonConnected":
scan_wizard = self._scan_wizards[items["scan_wizard_id"]]
scan_wizard.on_button_connected(scan_wizard, scan_wizard._bd_addr, scan_wizard._name)
if event_name == "EvtScanWizardCompleted":
scan_wizard = self._scan_wizards[items["scan_wizard_id"]]
del self._scan_wizards[items["scan_wizard_id"]]
scan_wizard.on_completed(scan_wizard, items["result"], scan_wizard._bd_addr, scan_wizard._name)
def _handle_one_event(self):
if len(self._timers.queue) > 0:
current_timer = self._timers.queue[0]
timeout = max(current_timer[0] - time.monotonic(), 0)
if timeout == 0:
self._timers.get()[1]()
return True
if len(select.select([self._sock], [], [], timeout)[0]) == 0:
return True
len_arr = bytearray(2)
view = memoryview(len_arr)
toread = 2
while toread > 0:
nbytes = self._sock.recv_into(view, toread)
if nbytes == 0:
return False
view = view[nbytes:]
toread -= nbytes
packet_len = len_arr[0] | (len_arr[1] << 8)
data = bytearray(packet_len)
view = memoryview(data)
toread = packet_len
while toread > 0:
nbytes = self._sock.recv_into(view, toread)
if nbytes == 0:
return False
view = view[nbytes:]
toread -= nbytes
self._dispatch_event(data)
return True
def handle_events(self):
"""Start the main loop for this client.
This method will not return until the socket has been closed.
Once it has returned, any use of this FlicClient is illegal.
"""
self._handle_event_thread_ident = threading.get_ident()
while not self._closed:
if not self._handle_one_event():
break
self._sock.close()

View file

@ -1,9 +0,0 @@
manifest:
events:
platypush.message.event.button.flic.FlicButtonEvent: when a button is pressed.The
event will also contain the press sequence(e.g. ``["ShortPressEvent", "LongPressEvent",
"ShortPressEvent"]``)
install:
pip: []
package: platypush.backend.button.flic
type: backend

View file

@ -1,214 +0,0 @@
import json
import socket
from enum import Enum
from threading import Thread
from platypush.backend import Backend
from platypush.context import get_backend
class CameraPiBackend(Backend):
"""
Backend to interact with a Raspberry Pi camera. It can start and stop
recordings and take pictures. It can be programmatically controlled through
the :class:`platypush.plugins.camera.pi` plugin. Note that the Redis backend
must be configured and running to enable camera control.
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``.
"""
class CameraAction(Enum):
START_RECORDING = 'START_RECORDING'
STOP_RECORDING = 'STOP_RECORDING'
TAKE_PICTURE = 'TAKE_PICTURE'
def __eq__(self, other):
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
):
"""
See https://www.raspberrypi.org/documentation/usage/camera/python/README.md
for a detailed reference about the Pi camera options.
:param listen_port: Port where the camera process will provide the video output while recording
:type listen_port: int
:param bind_address: Bind address (default: 0.0.0.0).
:type bind_address: str
"""
super().__init__(**kwargs)
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.listen(0)
import picamera
self.camera = picamera.PiCamera()
self.camera.resolution = (x_resolution, y_resolution)
self.camera.framerate = framerate
self.camera.hflip = hflip
self.camera.vflip = vflip
self.camera.sharpness = sharpness
self.camera.contrast = contrast
self.camera.brightness = brightness
self.camera.video_stabilization = video_stabilization
self.camera.ISO = iso
self.camera.exposure_compensation = exposure_compensation
self.camera.exposure_mode = exposure_mode
self.camera.meter_mode = meter_mode
self.camera.awb_mode = awb_mode
self.camera.image_effect = image_effect
self.camera.color_effects = color_effects
self.camera.rotation = rotation
self.camera.crop = crop
self.start_recording_on_startup = start_recording_on_startup
self.redis = None
self.redis_queue = redis_queue
self._recording_thread = None
def send_camera_action(self, action, **kwargs):
action = {'action': action.value, **kwargs}
self.redis.send_message(msg=json.dumps(action), queue_name=self.redis_queue)
def take_picture(self, image_file):
"""
Take a picture.
:param image_file: Output image file
:type image_file: str
"""
self.logger.info('Capturing camera snapshot to {}'.format(image_file))
self.camera.capture(image_file)
self.logger.info('Captured camera snapshot to {}'.format(image_file))
# noinspection PyShadowingBuiltins
def start_recording(self, video_file=None, format='h264'):
"""
Start a recording.
:param video_file: Output video file. If specified, the video will be recorded to file, otherwise it will be
served via TCP/IP on the listen_port. Use ``stop_recording`` to stop the recording.
:type video_file: str
:param format: Video format (default: h264)
:type format: str
"""
# noinspection PyBroadException
def recording_thread():
if video_file:
self.camera.start_recording(video_file, format=format)
while True:
self.camera.wait_recording(2)
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)
)
try:
self.camera.start_recording(connection, format=format)
while True:
self.camera.wait_recording(2)
except ConnectionError:
self.logger.info('Client closed connection')
try:
self.stop_recording()
except Exception as 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.send_camera_action(self.CameraAction.START_RECORDING)
if self._recording_thread:
self.logger.info('Recording already running')
return
self.logger.info('Starting camera recording')
self._recording_thread = Thread(
target=recording_thread, name='PiCameraRecorder'
)
self._recording_thread.start()
def stop_recording(self):
"""Stops recording"""
self.logger.info('Stopping camera recording')
try:
self.camera.stop_recording()
except Exception as e:
self.logger.warning('Failed to stop recording')
self.logger.exception(e)
def run(self):
super().run()
if not self.redis:
self.redis = get_backend('redis')
if self.start_recording_on_startup:
self.send_camera_action(self.CameraAction.START_RECORDING)
self.logger.info('Initialized Pi camera backend')
while not self.should_stop():
try:
msg = self.redis.get_message(self.redis_queue)
if msg.get('action') == self.CameraAction.START_RECORDING:
self.start_recording()
elif msg.get('action') == self.CameraAction.STOP_RECORDING:
self.stop_recording()
elif msg.get('action') == self.CameraAction.TAKE_PICTURE:
self.take_picture(image_file=msg.get('image_file'))
except Exception as e:
self.logger.exception(e)
# vim:sw=4:ts=4:et:

View file

@ -1,164 +0,0 @@
import re
from typing import Type, Optional, Union, List
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.chat.telegram import (
MessageEvent,
CommandMessageEvent,
TextMessageEvent,
PhotoMessageEvent,
VideoMessageEvent,
ContactMessageEvent,
DocumentMessageEvent,
LocationMessageEvent,
GroupChatCreatedEvent,
)
from platypush.plugins.chat.telegram import ChatTelegramPlugin
class ChatTelegramBackend(Backend):
"""
Telegram bot that listens for messages and updates.
Requires:
* The :class:`platypush.plugins.chat.telegram.ChatTelegramPlugin` plugin configured
"""
def __init__(
self, authorized_chat_ids: Optional[List[Union[str, int]]] = None, **kwargs
):
"""
:param authorized_chat_ids: Optional list of chat_id/user_id which are authorized to send messages to
the bot. If nothing is specified then no restrictions are applied.
"""
super().__init__(**kwargs)
self.authorized_chat_ids = set(authorized_chat_ids or [])
self._plugin: ChatTelegramPlugin = get_plugin('chat.telegram') # type: ignore
def _authorize(self, msg):
if not self.authorized_chat_ids:
return
if msg.chat.type == 'private' and msg.chat.id not in self.authorized_chat_ids:
self.logger.info(
'Received message from unauthorized chat_id %s', msg.chat.id
)
self._plugin.send_message(
chat_id=msg.chat.id,
text='You are not allowed to send messages to this bot',
)
raise PermissionError
def _msg_hook(self, cls: Type[MessageEvent]):
# noinspection PyUnusedLocal
def hook(update, _):
msg = update.effective_message
try:
self._authorize(msg)
self.bus.post(
cls(
chat_id=update.effective_chat.id,
message=self._plugin.parse_msg(msg).output,
user=self._plugin.parse_user(update.effective_user).output,
)
)
except PermissionError:
pass
return hook
def _group_hook(self):
def hook(update, context):
msg = update.effective_message
if msg.group_chat_created:
self.bus.post(
GroupChatCreatedEvent(
chat_id=update.effective_chat.id,
message=self._plugin.parse_msg(msg).output,
user=self._plugin.parse_user(update.effective_user).output,
)
)
elif msg.photo:
self._msg_hook(PhotoMessageEvent)(update, context)
elif msg.video:
self._msg_hook(VideoMessageEvent)(update, context)
elif msg.contact:
self._msg_hook(ContactMessageEvent)(update, context)
elif msg.location:
self._msg_hook(LocationMessageEvent)(update, context)
elif msg.document:
self._msg_hook(DocumentMessageEvent)(update, context)
elif msg.text:
if msg.text.startswith('/'):
self._command_hook()(update, context)
else:
self._msg_hook(TextMessageEvent)(update, context)
return hook
def _command_hook(self):
def hook(update, _):
msg = update.effective_message
m = re.match(r'\s*/([0-9a-zA-Z_-]+)\s*(.*)', msg.text)
if not m:
self.logger.warning('Invalid command: %s', msg.text)
return
cmd = m.group(1).lower()
args = [arg for arg in re.split(r'\s+', m.group(2)) if len(arg)]
try:
self._authorize(msg)
self.bus.post(
CommandMessageEvent(
chat_id=update.effective_chat.id,
command=cmd,
cmdargs=args,
message=self._plugin.parse_msg(msg).output,
user=self._plugin.parse_user(update.effective_user).output,
)
)
except PermissionError:
pass
return hook
def run(self):
from telegram.ext import MessageHandler, Filters
super().run()
telegram = self._plugin.get_telegram()
dispatcher = telegram.dispatcher
dispatcher.add_handler(MessageHandler(Filters.group, self._group_hook()))
dispatcher.add_handler(
MessageHandler(Filters.text, self._msg_hook(TextMessageEvent))
)
dispatcher.add_handler(
MessageHandler(Filters.photo, self._msg_hook(PhotoMessageEvent))
)
dispatcher.add_handler(
MessageHandler(Filters.video, self._msg_hook(VideoMessageEvent))
)
dispatcher.add_handler(
MessageHandler(Filters.contact, self._msg_hook(ContactMessageEvent))
)
dispatcher.add_handler(
MessageHandler(Filters.location, self._msg_hook(LocationMessageEvent))
)
dispatcher.add_handler(
MessageHandler(Filters.document, self._msg_hook(DocumentMessageEvent))
)
dispatcher.add_handler(MessageHandler(Filters.command, self._command_hook()))
self.logger.info('Initialized Telegram backend')
telegram.start_polling()
# vim:sw=4:ts=4:et:

View file

@ -1,19 +0,0 @@
manifest:
events:
platypush.message.event.chat.telegram.CommandMessageEvent: when a command message
is received.
platypush.message.event.chat.telegram.ContactMessageEvent: when a contact is received.
platypush.message.event.chat.telegram.DocumentMessageEvent: when a document is
received.
platypush.message.event.chat.telegram.GroupChatCreatedEvent: when the bot is invited
to a new group.
platypush.message.event.chat.telegram.LocationMessageEvent: when a location is
received.
platypush.message.event.chat.telegram.PhotoMessageEvent: when a photo is received.
platypush.message.event.chat.telegram.TextMessageEvent: when a text message is
received.
platypush.message.event.chat.telegram.VideoMessageEvent: when a video is received.
install:
pip: []
package: platypush.backend.chat.telegram
type: backend

View file

@ -1,129 +0,0 @@
import datetime
import time
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.google.fit import GoogleFitEvent
from platypush.utils import camel_case_to_snake_case
class GoogleFitBackend(Backend):
"""
This backend will listen for new Google Fit events (e.g. new weight/height
measurements, new fitness activities etc.) on the specified data streams and
fire an event upon new data.
Requires:
* The **google.fit** plugin
(:class:`platypush.plugins.google.fit.GoogleFitPlugin`) enabled.
"""
_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
):
"""
:param data_sources: Google Fit data source IDs to monitor. You can
get a list of the available data sources through the
:meth:`platypush.plugins.google.fit.GoogleFitPlugin.get_data_sources`
action
:type data_sources: list[str]
:param user_id: Google user ID to track (default: 'me')
:type user_id: str
:param poll_seconds: How often the backend will query the data sources
for new data points (default: 60 seconds)
:type poll_seconds: float
"""
super().__init__(*args, **kwargs)
self.data_sources = data_sources
self.user_id = user_id
self.poll_seconds = poll_seconds
def run(self):
super().run()
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
)
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)),
)
)
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:
dp_time = dp.pop('startTime', 0)
if 'dataSourceId' in dp:
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()
}
)
)
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)),
)
)
get_plugin('variable').set(**{varname: last_timestamp})
except Exception as e:
self.logger.warning('Exception while processing Fit data')
self.logger.exception(e)
continue
time.sleep(self.poll_seconds)
# vim:sw=4:ts=4:et:

View file

@ -1,8 +0,0 @@
manifest:
events:
platypush.message.event.google.fit.GoogleFitEvent: when a newdata point is received
on one of the registered streams.
install:
pip: []
package: platypush.backend.google.fit
type: backend

View file

@ -1,88 +0,0 @@
import json
from typing import Optional, List
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.google.pubsub import GooglePubsubMessageEvent
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.
"""
def __init__(
self, topics: List[str], credentials_file: Optional[str] = None, *args, **kwargs
):
"""
:param topics: List of topics to subscribe. You can either specify the full topic name in the format
``projects/<project_id>/topics/<topic_name>``, where ``<project_id>`` must be the ID of your
Google Pub/Sub project, or just ``<topic_name>`` - in such case it's implied that you refer to the
``topic_name`` under the ``project_id`` of your service credentials.
:param credentials_file: Path to the Pub/Sub service credentials file (default: value configured on the
``google.pubsub`` plugin or ``~/.credentials/platypush/google/pubsub.json``).
"""
super().__init__(*args, name='GooglePubSub', **kwargs)
self.topics = topics
if credentials_file:
self.credentials_file = credentials_file
else:
plugin = self._get_plugin()
self.credentials_file = plugin.credentials_file
@staticmethod
def _get_plugin():
plugin = get_plugin('google.pubsub')
assert plugin, 'google.pubsub plugin not enabled'
return plugin
def _message_callback(self, topic):
def callback(msg):
data = msg.data.decode()
try:
data = json.loads(data)
except Exception as e:
self.logger.debug('Not a valid JSON: %s: %s', data, e)
msg.ack()
self.bus.post(GooglePubsubMessageEvent(topic=topic, msg=data))
return callback
def run(self):
# noinspection PyPackageRequirements
from google.cloud import pubsub_v1
# noinspection PyPackageRequirements
from google.api_core.exceptions import AlreadyExists
super().run()
plugin = self._get_plugin()
project_id = plugin.get_project_id()
credentials = plugin.get_credentials(plugin.subscriber_audience)
subscriber = pubsub_v1.SubscriberClient(credentials=credentials)
for topic in self.topics:
prefix = f'projects/{project_id}/topics/'
if not topic.startswith(prefix):
topic = f'{prefix}{topic}'
subscription_name = '/'.join(
[*topic.split('/')[:2], 'subscriptions', topic.split('/')[-1]]
)
try:
subscriber.create_subscription(name=subscription_name, topic=topic)
except AlreadyExists:
pass
subscriber.subscribe(subscription_name, self._message_callback(topic))
self.wait_stop()
# vim:sw=4:ts=4:et:

View file

@ -1,9 +0,0 @@
manifest:
events:
platypush.message.event.google.pubsub.GooglePubsubMessageEvent: when a new message
is received ona subscribed topic.
install:
pip:
- google-cloud-pubsub
package: platypush.backend.google.pubsub
type: backend

View file

@ -1,145 +0,0 @@
import threading
import time
from platypush.backend import Backend
from platypush.message.event.gps import GPSVersionEvent, GPSDeviceEvent, GPSUpdateEvent
class GpsBackend(Backend):
"""
This backend can interact with a GPS device and listen for events.
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::
[sudo] gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock
The best option is probably to run gpsd at startup as a systemd service.
"""
_fail_sleep_time = 5.0
_lat_lng_tolerance = 1e-5
_alt_tolerance = 0.5
def __init__(self, gpsd_server='localhost', gpsd_port=2947, **kwargs):
"""
:param gpsd_server: gpsd daemon server name/address (default: localhost)
:type gpsd_server: str
:param gpsd_port: Port of the gpsd daemon (default: 2947)
:type gpsd_port: int or str
"""
super().__init__(**kwargs)
self.gpsd_server = gpsd_server
self.gpsd_port = gpsd_port
self._session = None
self._session_lock = threading.RLock()
self._devices = {}
def _get_session(self):
import gps
with self._session_lock:
if not self._session:
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'),
)
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'):
# 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'),
)
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'),
)
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'),
)
def run(self):
super().run()
self.logger.info(
'Initialized GPS backend on {}:{}'.format(self.gpsd_server, self.gpsd_port)
)
last_event = None
while not self.should_stop():
try:
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
):
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'
)
else:
self.logger.exception(e)
self._session = None
time.sleep(self._fail_sleep_time)
# vim:sw=4:ts=4:et:

View file

@ -1,20 +0,0 @@
manifest:
events:
platypush.message.event.gps.GPSDeviceEvent: when a GPS device is connected or
updated
platypush.message.event.gps.GPSUpdateEvent: when a GPS device has new data
platypush.message.event.gps.GPSVersionEvent: when a GPS device advertises its
version data
install:
apk:
- gpsd
apt:
- gpsd
dnf:
- gpsd
pacman:
- gpsd
pip:
- gps
package: platypush.backend.gps
type: backend

View file

@ -65,8 +65,7 @@ class PubSubMixin:
with self._pubsub_lock:
# Close and free the pub/sub object if it has no active subscriptions.
if self._pubsub is not None and len(self._subscriptions) == 0:
self._pubsub.close()
self._pubsub = None
self._pubsub_close()
@staticmethod
def _serialize(data: MessageType) -> bytes:
@ -126,7 +125,7 @@ class PubSubMixin:
continue
yield Message(data=msg.get('data', b''), channel=channel)
except (AttributeError, RedisConnectionError):
except (AttributeError, ValueError, RedisConnectionError):
return
def _pubsub_close(self):

View file

@ -1,152 +0,0 @@
import logging
import re
from threading import Thread
import time
import requests
from frozendict import frozendict
from platypush.message.event.http import HttpEvent
class HttpRequest:
"""
Backend used for polling HTTP resources.
"""
poll_seconds = 60
timeout = 5
class HttpRequestArguments:
"""
Models the properties of an HTTP request.
"""
def __init__(self, url, *args, method='get', **kwargs):
self.method = method.lower()
self.url = url
self.args = args
self.kwargs = kwargs
def __init__(
self, args, bus=None, poll_seconds=None, timeout=None, skip_first_call=True, **_
):
super().__init__()
self.poll_seconds = poll_seconds or self.poll_seconds
self.timeout = timeout or self.timeout
self.bus = bus
self.skip_first_call = skip_first_call
self.last_request_timestamp = 0
self.logger = logging.getLogger('platypush')
if isinstance(args, self.HttpRequestArguments):
self.args = args
elif isinstance(args, dict):
self.args = self.HttpRequestArguments(**args)
else:
raise RuntimeError('{} is neither a dictionary nor an HttpRequest')
if 'timeout' not in self.args.kwargs:
self.args.kwargs['timeout'] = self.timeout
self.request_args = {
'method': self.args.method,
'url': self.args.url,
**self.args.kwargs,
}
def execute(self):
def _thread_func():
is_first_call = self.last_request_timestamp == 0
self.last_request_timestamp = time.time()
try:
method = getattr(requests, self.args.method.lower())
response = method(self.args.url, *self.args.args, **self.args.kwargs)
new_items = self.get_new_items(response)
if isinstance(new_items, HttpEvent):
event = new_items
new_items = event.args['response']
else:
event = HttpEvent(dict(self), new_items)
if (
new_items
and self.bus
and (
not self.skip_first_call
or (self.skip_first_call and not is_first_call)
)
):
self.bus.post(event)
response.raise_for_status()
except Exception as e:
self.logger.exception(e)
self.logger.warning(
'Encountered an error while retrieving %s: %s', self.args.url, e
)
Thread(target=_thread_func, name='HttpPoll').start()
def get_new_items(self, response):
"""Gets new items out of a response"""
raise NotImplementedError(
"get_new_items must be implemented in a derived class"
)
def __iter__(self):
"""
:return: The ``request_args`` as key-value pairs.
"""
for key, value in self.request_args.items():
yield key, value
class JsonHttpRequest(HttpRequest):
"""
Specialization of the HttpRequest class for JSON requests.
"""
def __init__(self, *args, path=None, **kwargs):
super().__init__(*args, **kwargs)
self.path = path
self.seen_entries = set()
def get_new_items(self, response):
response = response.json()
new_entries = []
if self.path:
m = re.match(r'\${\s*(.*)\s*}', self.path)
if m:
response = eval(m.group(1)) # pylint: disable=eval-used
for entry in response:
flattened_entry = deep_freeze(entry)
if flattened_entry not in self.seen_entries:
new_entries.append(entry)
self.seen_entries.add(flattened_entry)
return new_entries
def deep_freeze(x):
"""
Deep freezes a Python object - works for strings, dictionaries, sets and
iterables.
"""
if isinstance(x, str) or not hasattr(x, "__len__"):
return x
if hasattr(x, "keys") and hasattr(x, "values"):
return frozendict({deep_freeze(k): deep_freeze(v) for k, v in x.items()})
if hasattr(x, "__getitem__"):
return tuple(map(deep_freeze, x))
return frozenset(map(deep_freeze, x))
# vim:sw=4:ts=4:et:

View file

@ -1,377 +0,0 @@
import datetime
import enum
import os
from sqlalchemy import (
create_engine,
Column,
Integer,
String,
DateTime,
Enum,
ForeignKey,
)
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.sql.expression import func
from platypush.backend.http.request import HttpRequest
from platypush.common.db import declarative_base
from platypush.config import Config
from platypush.context import get_plugin
from platypush.message.event.http.rss import NewFeedEvent
Base = declarative_base()
Session = scoped_session(sessionmaker())
class RssUpdates(HttpRequest):
"""
Gets new items in an RSS feed. You can use this type of object within the context of the
:class:`platypush.backend.http.poll.HttpPollBackend` backend. Example:
.. code-block:: yaml
backend.http.poll:
requests:
- type: platypush.backend.http.request.rss.RssUpdates
url: https://www.technologyreview.com/feed/
title: MIT Technology Review
poll_seconds: 86400 # Poll once a day
digest_format: html # Generate an HTML feed with the new items
"""
user_agent = (
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/62.0.3202.94 Safari/537.36'
)
def __init__(
self,
url,
title=None,
headers=None,
params=None,
max_entries=None,
extract_content=False,
digest_format=None,
user_agent: str = user_agent,
body_style: str = 'font-size: 22px; '
+ 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;',
title_style: str = 'margin-top: 30px',
subtitle_style: str = 'margin-top: 10px; page-break-after: always',
article_title_style: str = 'page-break-before: always',
article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted',
article_content_style: str = '',
*argv,
**kwargs,
):
"""
:param url: URL to the RSS feed to be monitored.
:param title: Optional title for the feed.
:param headers: Extra headers to be passed to the request.
:param params: Extra GET parameters to be appended to the URL.
:param max_entries: Maximum number of entries that will be returned in a single
:class:`platypush.message.event.http.rss.NewFeedEvent` event.
:param extract_content: Whether the context should also be extracted (through the
:class:`platypush.plugins.http.webpage.HttpWebpagePlugin` plugin) (default: ``False``).
:param digest_format: Format of the digest output file (default: None, text. Other supported types: ``html``
and ``pdf`` (requires the ``weasyprint`` module installed).
:param user_agent: User agent string to be passed on the request.
:param body_style: CSS style for the body.
:param title_style: CSS style for the feed title.
:param subtitle_style: CSS style for the feed subtitle.
:param article_title_style: CSS style for the article titles.
:param article_link_style: CSS style for the article link.
:param article_content_style: CSS style for the article content.
"""
self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'feeds')
self.dbfile = os.path.join(self.workdir, 'rss.db')
self.url = url
self.title = title
self.max_entries = max_entries
self.user_agent = user_agent
self.body_style = body_style
self.title_style = title_style
self.subtitle_style = subtitle_style
self.article_title_style = article_title_style
self.article_link_style = article_link_style
self.article_content_style = article_content_style
# If true, then the http.webpage plugin will be used to parse the content
self.extract_content = extract_content
self.digest_format = (
digest_format.lower() if digest_format else None
) # Supported formats: html, pdf
os.makedirs(os.path.expanduser(os.path.dirname(self.dbfile)), exist_ok=True)
if headers is None:
headers = {}
headers['User-Agent'] = self.user_agent
request_args = {
'method': 'get',
'url': self.url,
'headers': headers,
'params': params or {},
}
super().__init__(skip_first_call=False, args=request_args, *argv, **kwargs)
def _get_or_create_source(self, session):
record = session.query(FeedSource).filter_by(url=self.url).first()
if record is None:
record = FeedSource(url=self.url, title=self.title)
session.add(record)
session.commit()
return record
@staticmethod
def _get_latest_update(session, source_id):
return (
session.query(func.max(FeedEntry.published))
.filter_by(source_id=source_id)
.scalar()
)
def _parse_entry_content(self, link):
self.logger.info('Extracting content from {}'.format(link))
parser = get_plugin('http.webpage')
response = parser.simplify(link)
output = response.output
errors = response.errors
if not output:
self.logger.warning(
'Mercury parser error: {}'.format(errors or '[unknown error]')
)
return
return output.get('content')
def get_new_items(self, response):
import feedparser
engine = create_engine(
'sqlite:///{}'.format(self.dbfile),
connect_args={'check_same_thread': False},
)
Base.metadata.create_all(engine)
Session.configure(bind=engine)
self._get_or_create_source(session=Session())
feed = feedparser.parse(response.text)
session = Session()
source_record = self._get_or_create_source(session=session)
session.add(source_record)
parse_start_time = datetime.datetime.utcnow()
entries = []
latest_update = self._get_latest_update(session, source_record.id)
if not self.title and 'title' in feed.feed:
self.title = feed.feed['title']
source_record.title = self.title
content = u'''
<h1 style="{title_style}">{title}</h1>
<h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.format(
title_style=self.title_style,
title=self.title,
subtitle_style=self.subtitle_style,
creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'),
)
self.logger.info(
'Parsed {:d} items from RSS feed <{}>'.format(len(feed.entries), self.url)
)
for entry in feed.entries:
if not entry.published_parsed:
continue
try:
entry_timestamp = datetime.datetime(*entry.published_parsed[:6])
if latest_update is None or entry_timestamp > latest_update:
self.logger.info(
'Processed new item from RSS feed <{}>'.format(self.url)
)
entry.summary = entry.summary if hasattr(entry, 'summary') else None
if self.extract_content:
entry.content = self._parse_entry_content(entry.link)
elif hasattr(entry, 'summary'):
entry.content = entry.summary
else:
entry.content = None
content += u'''
<h1 style="{article_title_style}">
<a href="{link}" target="_blank" style="{article_link_style}">{title}</a>
</h1>
<div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format(
article_title_style=self.article_title_style,
article_link_style=self.article_link_style,
article_content_style=self.article_content_style,
link=entry.link,
title=entry.title,
content=entry.content,
)
e = {
'entry_id': entry.id,
'title': entry.title,
'link': entry.link,
'summary': entry.summary,
'content': entry.content,
'source_id': source_record.id,
'published': entry_timestamp,
}
entries.append(e)
session.add(FeedEntry(**e))
if self.max_entries and len(entries) > self.max_entries:
break
except Exception as e:
self.logger.warning(
'Exception encountered while parsing RSS '
+ f'RSS feed {entry.link}: {e}'
)
self.logger.exception(e)
source_record.last_updated_at = parse_start_time
digest_filename = None
if entries:
self.logger.info(
'Parsed {} new entries from the RSS feed {}'.format(
len(entries), self.title
)
)
if self.digest_format:
digest_filename = os.path.join(
self.workdir,
'cache',
'{}_{}.{}'.format(
datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
self.title,
self.digest_format,
),
)
os.makedirs(os.path.dirname(digest_filename), exist_ok=True)
if self.digest_format == 'html':
content = '''
<html>
<head>
<title>{title}</title>
</head>
<body style="{body_style}">{content}</body>
</html>
'''.format(
title=self.title, body_style=self.body_style, content=content
)
with open(digest_filename, 'w', encoding='utf-8') as f:
f.write(content)
elif self.digest_format == 'pdf':
from weasyprint import HTML, CSS
try:
from weasyprint.fonts import FontConfiguration
except ImportError:
from weasyprint.document import FontConfiguration
body_style = 'body { ' + self.body_style + ' }'
font_config = FontConfiguration()
css = [
CSS('https://fonts.googleapis.com/css?family=Merriweather'),
CSS(string=body_style, font_config=font_config),
]
HTML(string=content).write_pdf(digest_filename, stylesheets=css)
else:
raise RuntimeError(
f'Unsupported format: {self.digest_format}. Supported formats: html, pdf'
)
digest_entry = FeedDigest(
source_id=source_record.id,
format=self.digest_format,
filename=digest_filename,
)
session.add(digest_entry)
self.logger.info(
'{} digest ready: {}'.format(self.digest_format, digest_filename)
)
session.commit()
self.logger.info('Parsing RSS feed {}: completed'.format(self.title))
return NewFeedEvent(
request=dict(self),
response=entries,
source_id=source_record.id,
source_title=source_record.title,
title=self.title,
digest_format=self.digest_format,
digest_filename=digest_filename,
)
class FeedSource(Base):
"""Models the FeedSource table, containing RSS sources to be parsed"""
__tablename__ = 'FeedSource'
__table_args__ = {'sqlite_autoincrement': True}
id = Column(Integer, primary_key=True)
title = Column(String)
url = Column(String, unique=True)
last_updated_at = Column(DateTime)
class FeedEntry(Base):
"""Models the FeedEntry table, which contains RSS entries"""
__tablename__ = 'FeedEntry'
__table_args__ = {'sqlite_autoincrement': True}
id = Column(Integer, primary_key=True)
entry_id = Column(String)
source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False)
title = Column(String)
link = Column(String)
summary = Column(String)
content = Column(String)
published = Column(DateTime)
class FeedDigest(Base):
"""Models the FeedDigest table, containing feed digests either in HTML
or PDF format"""
class DigestFormat(enum.Enum):
html = 1
pdf = 2
__tablename__ = 'FeedDigest'
__table_args__ = {'sqlite_autoincrement': True}
id = Column(Integer, primary_key=True)
source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False)
format = Column(Enum(DigestFormat), nullable=False)
filename = Column(String, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
# vim:sw=4:ts=4:et:

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" src="/static/js/chunk-vendors.e1112b55.js"></script><script defer="defer" src="/static/js/app.a687cf1f.js"></script><link href="/static/css/chunk-vendors.a2412607.css" rel="stylesheet"><link href="/static/css/app.371d9a4d.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" src="/static/js/chunk-vendors.e1112b55.js"></script><script defer="defer" src="/static/js/app.2b5416b9.js"></script><link href="/static/css/chunk-vendors.a2412607.css" rel="stylesheet"><link href="/static/css/app.f9c900a8.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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