forked from platypush/platypush
Merge branch 'master' into snyk-fix-cbc2d5f1b27baf97088c91fe8a0ed9ad
This commit is contained in:
commit
0dea8c3b44
434 changed files with 11605 additions and 8560 deletions
110
.drone.yml
110
.drone.yml
|
@ -62,7 +62,8 @@ steps:
|
||||||
|
|
||||||
# Backup the original git configuration before changing attributes
|
# Backup the original git configuration before changing attributes
|
||||||
- export GIT_CONF=$PWD/.git/config
|
- 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
|
- git config --global --add safe.directory $PWD
|
||||||
|
|
||||||
# Install the SSH and PGP keys
|
# Install the SSH and PGP keys
|
||||||
|
@ -95,7 +96,7 @@ steps:
|
||||||
- git checkout master
|
- git checkout master
|
||||||
|
|
||||||
# Restore the original git configuration
|
# Restore the original git configuration
|
||||||
- mv /tmp/git.config.orig $GIT_CONF
|
- mv $TMP_GIT_CONF $GIT_CONF
|
||||||
|
|
||||||
when:
|
when:
|
||||||
event:
|
event:
|
||||||
|
@ -187,7 +188,8 @@ steps:
|
||||||
|
|
||||||
# Backup the original git configuration before changing attributes
|
# Backup the original git configuration before changing attributes
|
||||||
- export GIT_CONF=$PWD/.git/config
|
- 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
|
- git config --global --add safe.directory $PWD
|
||||||
- cd platypush/backend/http/webapp
|
- cd platypush/backend/http/webapp
|
||||||
|
@ -203,7 +205,7 @@ steps:
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- rm -rf node_modules
|
- rm -rf dist node_modules
|
||||||
- npm install
|
- npm install
|
||||||
- npm run build
|
- npm run build
|
||||||
- |
|
- |
|
||||||
|
@ -242,7 +244,95 @@ steps:
|
||||||
- git push -f origin master
|
- git push -f origin master
|
||||||
|
|
||||||
# Restore the original git configuration
|
# 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
|
### Update the Arch packages
|
||||||
|
@ -264,7 +354,7 @@ steps:
|
||||||
- push
|
- push
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-ui
|
- update-components-cache
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
|
@ -278,7 +368,7 @@ steps:
|
||||||
- git pull --rebase origin master --tags
|
- git pull --rebase origin master --tags
|
||||||
- export VERSION=$(python setup.py --version)
|
- export VERSION=$(python setup.py --version)
|
||||||
- export HEAD=$(git log --pretty=format:%h HEAD...HEAD~1 | head -1)
|
- 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"
|
- export TAG_URL="https://git.platypush.tech/platypush/platypush/archive/v$VERSION.tar.gz"
|
||||||
|
|
||||||
- echo "--- Preparing environment"
|
- echo "--- Preparing environment"
|
||||||
|
@ -364,7 +454,7 @@ steps:
|
||||||
- push
|
- push
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-ui
|
- update-components-cache
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
|
@ -463,7 +553,7 @@ steps:
|
||||||
- push
|
- push
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-ui
|
- update-components-cache
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
|
@ -774,7 +864,7 @@ steps:
|
||||||
- push
|
- push
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-ui
|
- update-components-cache
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
|
|
|
@ -3,3 +3,4 @@ recursive-include platypush/install *
|
||||||
include platypush/plugins/http/webpage/mercury-parser.js
|
include platypush/plugins/http/webpage/mercury-parser.js
|
||||||
include platypush/config/*.yaml
|
include platypush/config/*.yaml
|
||||||
global-include manifest.yaml
|
global-include manifest.yaml
|
||||||
|
global-include components.json.gz
|
||||||
|
|
|
@ -419,9 +419,7 @@ backend](https://docs.platypush.tech/en/latest/platypush/backend/http.html), an
|
||||||
[MQTT
|
[MQTT
|
||||||
instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a
|
instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a
|
||||||
[Kafka
|
[Kafka
|
||||||
instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html),
|
instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html).
|
||||||
[Pushbullet](https://docs.platypush.tech/en/latest/platypush/backend/pushbullet.html)
|
|
||||||
etc.).
|
|
||||||
|
|
||||||
If a backend supports the execution of requests (e.g. HTTP, MQTT, Kafka,
|
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.
|
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,
|
music controls and search, media library and torrent management, lights,
|
||||||
Zigbee/Z-Wave devices and so on. The UI is responsive and mobile-friendly.
|
Zigbee/Z-Wave devices and so on. The UI is responsive and mobile-friendly.
|
||||||
|
|
||||||
Some screenshots:
|
|
||||||
|
|
||||||
#### The main web panel
|
#### The main web panel
|
||||||
|
|
||||||
This is the default panel available at `http://<host>:<port>` after
|
This is the default panel available at `http://<host>:<port>` after
|
||||||
|
|
|
@ -6,30 +6,13 @@ Backends
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
:caption: Backends:
|
: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/http.rst
|
||||||
platypush/backend/kafka.rst
|
|
||||||
platypush/backend/mail.rst
|
|
||||||
platypush/backend/midi.rst
|
platypush/backend/midi.rst
|
||||||
platypush/backend/music.mopidy.rst
|
platypush/backend/music.mopidy.rst
|
||||||
platypush/backend/music.mpd.rst
|
|
||||||
platypush/backend/music.spotify.rst
|
platypush/backend/music.spotify.rst
|
||||||
platypush/backend/nextcloud.rst
|
|
||||||
platypush/backend/nfc.rst
|
|
||||||
platypush/backend/nodered.rst
|
platypush/backend/nodered.rst
|
||||||
platypush/backend/pushbullet.rst
|
|
||||||
platypush/backend/redis.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.deepspeech.rst
|
||||||
platypush/backend/stt.picovoice.hotword.rst
|
platypush/backend/stt.picovoice.hotword.rst
|
||||||
platypush/backend/stt.picovoice.speech.rst
|
platypush/backend/stt.picovoice.speech.rst
|
||||||
platypush/backend/tcp.rst
|
platypush/backend/tcp.rst
|
||||||
platypush/backend/wiimote.rst
|
|
||||||
|
|
|
@ -11,16 +11,15 @@ Events
|
||||||
platypush/events/application.rst
|
platypush/events/application.rst
|
||||||
platypush/events/assistant.rst
|
platypush/events/assistant.rst
|
||||||
platypush/events/bluetooth.rst
|
platypush/events/bluetooth.rst
|
||||||
platypush/events/button.flic.rst
|
|
||||||
platypush/events/camera.rst
|
platypush/events/camera.rst
|
||||||
platypush/events/chat.slack.rst
|
platypush/events/chat.slack.rst
|
||||||
platypush/events/chat.telegram.rst
|
|
||||||
platypush/events/clipboard.rst
|
platypush/events/clipboard.rst
|
||||||
platypush/events/custom.rst
|
platypush/events/custom.rst
|
||||||
platypush/events/dbus.rst
|
platypush/events/dbus.rst
|
||||||
platypush/events/distance.rst
|
platypush/events/distance.rst
|
||||||
platypush/events/entities.rst
|
platypush/events/entities.rst
|
||||||
platypush/events/file.rst
|
platypush/events/file.rst
|
||||||
|
platypush/events/flic.rst
|
||||||
platypush/events/foursquare.rst
|
platypush/events/foursquare.rst
|
||||||
platypush/events/geo.rst
|
platypush/events/geo.rst
|
||||||
platypush/events/github.rst
|
platypush/events/github.rst
|
||||||
|
@ -66,6 +65,7 @@ Events
|
||||||
platypush/events/sound.rst
|
platypush/events/sound.rst
|
||||||
platypush/events/stt.rst
|
platypush/events/stt.rst
|
||||||
platypush/events/sun.rst
|
platypush/events/sun.rst
|
||||||
|
platypush/events/telegram.rst
|
||||||
platypush/events/tensorflow.rst
|
platypush/events/tensorflow.rst
|
||||||
platypush/events/torrent.rst
|
platypush/events/torrent.rst
|
||||||
platypush/events/trello.rst
|
platypush/events/trello.rst
|
||||||
|
|
|
@ -39,6 +39,7 @@ Wiki
|
||||||
wiki/Configuration
|
wiki/Configuration
|
||||||
wiki/Installing-extensions
|
wiki/Installing-extensions
|
||||||
wiki/A-configuration-example
|
wiki/A-configuration-example
|
||||||
|
wiki/The-Web-interface
|
||||||
|
|
||||||
Reference
|
Reference
|
||||||
=========
|
=========
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``adafruit.io``
|
|
||||||
=================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.adafruit.io
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``button.flic``
|
|
||||||
=================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.button.flic
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``camera.pi``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.camera.pi
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``chat.telegram``
|
|
||||||
===================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.chat.telegram
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
``google.fit``
|
|
||||||
================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.google.fit
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``google.pubsub``
|
|
||||||
===================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.google.pubsub
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
``gps``
|
|
||||||
=========================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.gps
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``kafka``
|
|
||||||
===========================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.kafka
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``mail``
|
|
||||||
==========================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.mail
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
``music.mpd``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.music.mpd
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``nextcloud``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.nextcloud
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
``nfc``
|
|
||||||
=========================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.nfc
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``pushbullet``
|
|
||||||
================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.pushbullet
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``scard``
|
|
||||||
===========================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.scard
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``sensor.ir.zeroborg``
|
|
||||||
========================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.sensor.ir.zeroborg
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
``sensor.leap``
|
|
||||||
=================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.sensor.leap
|
|
||||||
:members:
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``wiimote``
|
|
||||||
=============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.wiimote
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``button.flic``
|
|
||||||
=======================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.message.event.button.flic
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``chat.telegram``
|
|
||||||
=========================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.message.event.chat.telegram
|
|
||||||
:members:
|
|
5
docs/source/platypush/events/flic.rst
Normal file
5
docs/source/platypush/events/flic.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``flic``
|
||||||
|
========
|
||||||
|
|
||||||
|
.. automodule:: platypush.message.event.flic
|
||||||
|
:members:
|
5
docs/source/platypush/events/telegram.rst
Normal file
5
docs/source/platypush/events/telegram.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``telegram``
|
||||||
|
============
|
||||||
|
|
||||||
|
.. automodule:: platypush.message.event.telegram
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/camera.pi.legacy.rst
Normal file
5
docs/source/platypush/plugins/camera.pi.legacy.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``camera.pi.legacy``
|
||||||
|
====================
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.camera.pi.legacy
|
||||||
|
:members:
|
|
@ -1,5 +0,0 @@
|
||||||
``chat.irc``
|
|
||||||
============
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.chat.irc
|
|
||||||
:members:
|
|
|
@ -1,5 +0,0 @@
|
||||||
``chat.telegram``
|
|
||||||
===================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.chat.telegram
|
|
||||||
:members:
|
|
5
docs/source/platypush/plugins/flic.rst
Normal file
5
docs/source/platypush/plugins/flic.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``flic``
|
||||||
|
========
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.flic
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/gps.rst
Normal file
5
docs/source/platypush/plugins/gps.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``gps``
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.gps
|
||||||
|
:members:
|
|
@ -1,7 +0,0 @@
|
||||||
``http.request``
|
|
||||||
==================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.http.request
|
|
||||||
:members:
|
|
||||||
|
|
||||||
|
|
5
docs/source/platypush/plugins/http.rst
Normal file
5
docs/source/platypush/plugins/http.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``http``
|
||||||
|
========
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.http
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/irc.rst
Normal file
5
docs/source/platypush/plugins/irc.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``irc``
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.irc
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/leap.rst
Normal file
5
docs/source/platypush/plugins/leap.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``leap``
|
||||||
|
========
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.leap
|
||||||
|
:members:
|
|
@ -1,5 +0,0 @@
|
||||||
``mail.imap``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.mail.imap
|
|
||||||
:members:
|
|
5
docs/source/platypush/plugins/mail.rst
Normal file
5
docs/source/platypush/plugins/mail.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``mail``
|
||||||
|
========
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.mail
|
||||||
|
:members:
|
|
@ -1,5 +0,0 @@
|
||||||
``mail.smtp``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.mail.smtp
|
|
||||||
:members:
|
|
5
docs/source/platypush/plugins/nfc.rst
Normal file
5
docs/source/platypush/plugins/nfc.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``nfc``
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.nfc
|
||||||
|
:members:
|
5
docs/source/platypush/plugins/telegram.rst
Normal file
5
docs/source/platypush/plugins/telegram.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``telegram``
|
||||||
|
============
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.telegram
|
||||||
|
:members:
|
|
@ -1,6 +0,0 @@
|
||||||
``wiimote``
|
|
||||||
=============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.wiimote
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``chat.telegram``
|
|
||||||
============================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.message.response.chat.telegram
|
|
||||||
:members:
|
|
|
@ -21,8 +21,7 @@ Plugins
|
||||||
platypush/plugins/camera.gstreamer.rst
|
platypush/plugins/camera.gstreamer.rst
|
||||||
platypush/plugins/camera.ir.mlx90640.rst
|
platypush/plugins/camera.ir.mlx90640.rst
|
||||||
platypush/plugins/camera.pi.rst
|
platypush/plugins/camera.pi.rst
|
||||||
platypush/plugins/chat.irc.rst
|
platypush/plugins/camera.pi.legacy.rst
|
||||||
platypush/plugins/chat.telegram.rst
|
|
||||||
platypush/plugins/clipboard.rst
|
platypush/plugins/clipboard.rst
|
||||||
platypush/plugins/config.rst
|
platypush/plugins/config.rst
|
||||||
platypush/plugins/csv.rst
|
platypush/plugins/csv.rst
|
||||||
|
@ -34,6 +33,7 @@ Plugins
|
||||||
platypush/plugins/ffmpeg.rst
|
platypush/plugins/ffmpeg.rst
|
||||||
platypush/plugins/file.rst
|
platypush/plugins/file.rst
|
||||||
platypush/plugins/file.monitor.rst
|
platypush/plugins/file.monitor.rst
|
||||||
|
platypush/plugins/flic.rst
|
||||||
platypush/plugins/foursquare.rst
|
platypush/plugins/foursquare.rst
|
||||||
platypush/plugins/github.rst
|
platypush/plugins/github.rst
|
||||||
platypush/plugins/google.calendar.rst
|
platypush/plugins/google.calendar.rst
|
||||||
|
@ -46,24 +46,26 @@ Plugins
|
||||||
platypush/plugins/gotify.rst
|
platypush/plugins/gotify.rst
|
||||||
platypush/plugins/gpio.rst
|
platypush/plugins/gpio.rst
|
||||||
platypush/plugins/gpio.zeroborg.rst
|
platypush/plugins/gpio.zeroborg.rst
|
||||||
|
platypush/plugins/gps.rst
|
||||||
platypush/plugins/graphite.rst
|
platypush/plugins/graphite.rst
|
||||||
platypush/plugins/hid.rst
|
platypush/plugins/hid.rst
|
||||||
platypush/plugins/http.request.rst
|
platypush/plugins/http.rst
|
||||||
platypush/plugins/http.webpage.rst
|
platypush/plugins/http.webpage.rst
|
||||||
platypush/plugins/ifttt.rst
|
platypush/plugins/ifttt.rst
|
||||||
platypush/plugins/inspect.rst
|
platypush/plugins/inspect.rst
|
||||||
|
platypush/plugins/irc.rst
|
||||||
platypush/plugins/joystick.rst
|
platypush/plugins/joystick.rst
|
||||||
platypush/plugins/kafka.rst
|
platypush/plugins/kafka.rst
|
||||||
platypush/plugins/lastfm.rst
|
platypush/plugins/lastfm.rst
|
||||||
platypush/plugins/lcd.gpio.rst
|
platypush/plugins/lcd.gpio.rst
|
||||||
platypush/plugins/lcd.i2c.rst
|
platypush/plugins/lcd.i2c.rst
|
||||||
|
platypush/plugins/leap.rst
|
||||||
platypush/plugins/light.hue.rst
|
platypush/plugins/light.hue.rst
|
||||||
platypush/plugins/linode.rst
|
platypush/plugins/linode.rst
|
||||||
platypush/plugins/log.http.rst
|
platypush/plugins/log.http.rst
|
||||||
platypush/plugins/logger.rst
|
platypush/plugins/logger.rst
|
||||||
platypush/plugins/luma.oled.rst
|
platypush/plugins/luma.oled.rst
|
||||||
platypush/plugins/mail.imap.rst
|
platypush/plugins/mail.rst
|
||||||
platypush/plugins/mail.smtp.rst
|
|
||||||
platypush/plugins/mailgun.rst
|
platypush/plugins/mailgun.rst
|
||||||
platypush/plugins/mastodon.rst
|
platypush/plugins/mastodon.rst
|
||||||
platypush/plugins/matrix.rst
|
platypush/plugins/matrix.rst
|
||||||
|
@ -87,6 +89,7 @@ Plugins
|
||||||
platypush/plugins/music.spotify.rst
|
platypush/plugins/music.spotify.rst
|
||||||
platypush/plugins/music.tidal.rst
|
platypush/plugins/music.tidal.rst
|
||||||
platypush/plugins/nextcloud.rst
|
platypush/plugins/nextcloud.rst
|
||||||
|
platypush/plugins/nfc.rst
|
||||||
platypush/plugins/ngrok.rst
|
platypush/plugins/ngrok.rst
|
||||||
platypush/plugins/nmap.rst
|
platypush/plugins/nmap.rst
|
||||||
platypush/plugins/ntfy.rst
|
platypush/plugins/ntfy.rst
|
||||||
|
@ -124,6 +127,7 @@ Plugins
|
||||||
platypush/plugins/switchbot.rst
|
platypush/plugins/switchbot.rst
|
||||||
platypush/plugins/system.rst
|
platypush/plugins/system.rst
|
||||||
platypush/plugins/tcp.rst
|
platypush/plugins/tcp.rst
|
||||||
|
platypush/plugins/telegram.rst
|
||||||
platypush/plugins/tensorflow.rst
|
platypush/plugins/tensorflow.rst
|
||||||
platypush/plugins/todoist.rst
|
platypush/plugins/todoist.rst
|
||||||
platypush/plugins/torrent.rst
|
platypush/plugins/torrent.rst
|
||||||
|
@ -141,7 +145,6 @@ Plugins
|
||||||
platypush/plugins/weather.buienradar.rst
|
platypush/plugins/weather.buienradar.rst
|
||||||
platypush/plugins/weather.openweathermap.rst
|
platypush/plugins/weather.openweathermap.rst
|
||||||
platypush/plugins/websocket.rst
|
platypush/plugins/websocket.rst
|
||||||
platypush/plugins/wiimote.rst
|
|
||||||
platypush/plugins/xmpp.rst
|
platypush/plugins/xmpp.rst
|
||||||
platypush/plugins/youtube.rst
|
platypush/plugins/youtube.rst
|
||||||
platypush/plugins/zeroconf.rst
|
platypush/plugins/zeroconf.rst
|
||||||
|
|
|
@ -8,7 +8,6 @@ Responses
|
||||||
|
|
||||||
platypush/responses/camera.rst
|
platypush/responses/camera.rst
|
||||||
platypush/responses/camera.android.rst
|
platypush/responses/camera.android.rst
|
||||||
platypush/responses/chat.telegram.rst
|
|
||||||
platypush/responses/google.drive.rst
|
platypush/responses/google.drive.rst
|
||||||
platypush/responses/pihole.rst
|
platypush/responses/pihole.rst
|
||||||
platypush/responses/printer.cups.rst
|
platypush/responses/printer.cups.rst
|
||||||
|
|
|
@ -274,6 +274,10 @@ class Application:
|
||||||
backend.stop()
|
backend.stop()
|
||||||
|
|
||||||
for plugin in runnable_plugins:
|
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()
|
plugin.stop()
|
||||||
|
|
||||||
for backend in backends:
|
for backend in backends:
|
||||||
|
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -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:
|
|
|
@ -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()
|
|
|
@ -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
|
|
|
@ -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:
|
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -65,8 +65,7 @@ class PubSubMixin:
|
||||||
with self._pubsub_lock:
|
with self._pubsub_lock:
|
||||||
# Close and free the pub/sub object if it has no active subscriptions.
|
# Close and free the pub/sub object if it has no active subscriptions.
|
||||||
if self._pubsub is not None and len(self._subscriptions) == 0:
|
if self._pubsub is not None and len(self._subscriptions) == 0:
|
||||||
self._pubsub.close()
|
self._pubsub_close()
|
||||||
self._pubsub = None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize(data: MessageType) -> bytes:
|
def _serialize(data: MessageType) -> bytes:
|
||||||
|
@ -126,7 +125,7 @@ class PubSubMixin:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
yield Message(data=msg.get('data', b''), channel=channel)
|
yield Message(data=msg.get('data', b''), channel=channel)
|
||||||
except (AttributeError, RedisConnectionError):
|
except (AttributeError, ValueError, RedisConnectionError):
|
||||||
return
|
return
|
||||||
|
|
||||||
def _pubsub_close(self):
|
def _pubsub_close(self):
|
||||||
|
|
|
@ -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:
|
|
|
@ -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:
|
|
|
@ -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
1
platypush/backend/http/webapp/dist/static/css/1420.c29cafe6.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/1420.c29cafe6.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/2140.08e216c1.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/2140.08e216c1.css
vendored
Normal file
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
1
platypush/backend/http/webapp/dist/static/css/5059.e5cd6fe3.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/5059.e5cd6fe3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/5143.084a713f.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/5143.084a713f.css
vendored
Normal file
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
1
platypush/backend/http/webapp/dist/static/css/5833.e1e503a2.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/5833.e1e503a2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/5931.e6c8f94d.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/5931.e6c8f94d.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/6579.04ad63dc.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/6579.04ad63dc.css
vendored
Normal file
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
1
platypush/backend/http/webapp/dist/static/css/7651.9b63654f.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/7651.9b63654f.css
vendored
Normal file
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
1
platypush/backend/http/webapp/dist/static/css/9461.79136fbe.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/9461.79136fbe.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/9751.0631f530.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/9751.0631f530.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/980.b4628099.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/980.b4628099.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
17
platypush/backend/http/webapp/dist/static/css/app.f9c900a8.css
vendored
Normal file
17
platypush/backend/http/webapp/dist/static/css/app.f9c900a8.css
vendored
Normal file
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
Loading…
Reference in a new issue