forked from platypush/platypush
Compare commits
No commits in common. "259-pwa" and "master" have entirely different histories.
|
@ -22,5 +22,3 @@ platypush/requests
|
|||
.coverage
|
||||
coverage.xml
|
||||
Session.vim
|
||||
/jsconfig.json
|
||||
/package.json
|
||||
|
|
|
@ -6,12 +6,11 @@ repos:
|
|||
hooks:
|
||||
# - id: trailing-whitespace
|
||||
# - id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-xml
|
||||
- id: check-symlinks
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1500']
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-xml
|
||||
- id: check-symlinks
|
||||
- id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks-nodejs
|
||||
rev: v1.1.2
|
||||
|
|
125
CHANGELOG.md
125
CHANGELOG.md
|
@ -1,133 +1,10 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
Given the high speed of development in the first phase, changes are being
|
||||
reported only starting from v0.20.2.
|
||||
Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Migrated many integrations to the new [entities
|
||||
framework](https://git.platypush.tech/platypush/platypush/pulls/230).
|
||||
This is a very large change to the foundations of the platform, and there's
|
||||
still a lot of work in progress. A detailed description of all the changes
|
||||
will follow shortly.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Migrated the `clipboard` integration from `pyperclip` to `pyclip` (see
|
||||
[#240](https://git.platypush.tech/platypush/platypush/issues/240)).
|
||||
`pyperclip` is unmaintained and largely broken, and `pyclip` seems to be a
|
||||
viable drop-in alternative.
|
||||
|
||||
## [0.24.5] - 2023-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added `hid` plugin to support discoverability and data interaction with
|
||||
generic HID devices - like Bluetooth/USB peripherals, joysticks, dongles and
|
||||
any other type of devices that supports the HID interface.
|
||||
|
||||
- Added `timeout` parameter to `websocket.send` to prevent messages sent on a
|
||||
non-responsive websocket from getting the websocket loop stuck
|
||||
|
||||
### Fixed
|
||||
|
||||
- Running the Zeroconf registration logic in another thread in `backend.http`,
|
||||
so failures in the Zeroconf logic don't affect the startup of the web server.
|
||||
|
||||
- (Temporarily) introduced `sqlalchemy < 2.0.0` as a requirement - a PR with a
|
||||
migration to the new stable version of SQLAlchemy is in TODO.
|
||||
|
||||
## [0.24.4] - 2022-12-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed cronjobs potentially being triggered even if it wasn't their slot in
|
||||
case of clock synchronization events.
|
||||
|
||||
## [0.24.3] - 2022-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added `[-v|--verbose]` command-line option to override the default logging
|
||||
configuration and enable debug logging.
|
||||
- Added `--version` command-line option to print the current version and exit.
|
||||
- [[#236](https://git.platypush.tech/platypush/platypush/issues/236)] Added
|
||||
support for `author` and `tags` attributes on feed entries.
|
||||
|
||||
## [0.24.2] - 2022-12-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- The `main.db` configuration should use the configured `workdir` when no
|
||||
values are specified.
|
||||
|
||||
- The `zwave.mqtt` is now compatible both with older (i.e. `zwavejs2mqtt`) and
|
||||
newer (i.e. `ZwaveJS`) versions of the backend.
|
||||
|
||||
## [0.24.1] - 2022-12-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed a parenthesized context manager that broke compatibility with
|
||||
Python < 3.10.
|
||||
|
||||
## [0.24.0] - 2022-11-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added [Wallabag integration](https://git.platypush.tech/platypush/platypush/issues/224).
|
||||
- Added [Mimic3 TTS integration](https://git.platypush.tech/platypush/platypush/issues/226).
|
||||
- Added `qos` attribute to `mqtt.publish` and all the plugins derived from `mqtt`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced PyJWT dependency with the Python-native `rsa` package. This will
|
||||
make the installation much lighter, compatible with more systems and less
|
||||
dependent on the platform-specific libraries required by `cryptography`.
|
||||
|
||||
> **NOTE**: This is a breaking change for those who use the `backend.http` API
|
||||
> with JWT tokens. The new logic encrypts and encodes the payload in a
|
||||
> different format, therefore previously generated tokens are no longer
|
||||
> compatible.
|
||||
|
||||
## [0.23.6] - 2022-09-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed album_id and list of tracks on `music.tidal.get_album`.
|
||||
|
||||
## [0.23.5] - 2022-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for web hooks returning their hook method responses back to the
|
||||
HTTP client.
|
||||
|
||||
- Added [Tidal integration](https://git.platypush.tech/platypush/platypush/pulls/223)
|
||||
|
||||
- Added support for [OPML
|
||||
subscriptions](https://git.platypush.tech/platypush/platypush/pulls/220) to
|
||||
the `rss` plugin.
|
||||
|
||||
- Better support for bulk database operations on the `db` plugin.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Now supporting YAML sections with empty configurations.
|
||||
|
||||
## [0.23.4] - 2022-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added `matrix` integration
|
||||
([issue](https://git.platypush.tech/platypush/platypush/issues/2),
|
||||
[PR](https://git.platypush.tech/platypush/platypush/pulls/217)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed `clipboard` backend. Enabling the `clipboard` plugin will also enable
|
||||
clipboard monitoring, with no need for an additional backend.
|
||||
|
||||
|
|
737
README.md
737
README.md
|
@ -4,16 +4,24 @@ Platypush
|
|||
[![Build Status](https://ci.platypush.tech/status.svg)](https://ci.platypush.tech/latest.log)
|
||||
[![Documentation Status](https://ci.platypush.tech/docs/status.svg)](https://ci.platypush.tech/docs/latest.log)
|
||||
[![pip version](https://img.shields.io/pypi/v/platypush.svg?style=flat)](https://pypi.python.org/pypi/platypush/)
|
||||
[![License](https://img.shields.io/github/license/BlackLight/platypush.svg)](https://git.platypush.tech/platypush/platypush/src/branch/master/LICENSE.txt)
|
||||
[![Last Commit](https://img.shields.io/github/last-commit/BlackLight/platypush.svg)](https://git.platypush.tech/platypush/platypush/commits/branch/master)
|
||||
[![License](https://img.shields.io/github/license/BlackLight/platypush.svg)](https://git.platypush.tech/platypush/platypush/-/blob/master/LICENSE.txt)
|
||||
[![Last Commit](https://img.shields.io/github/last-commit/BlackLight/platypush.svg)](https://git.platypush.tech/platypush/platypush/-/commits/master/)
|
||||
[![Join chat on Matrix](https://img.shields.io/matrix/:platypush?server_fqdn=matrix.platypush.tech)](https://matrix.to/#/#platypush:matrix.platypush.tech)
|
||||
[![Contributions](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://git.platypush.tech/platypush/platypush/src/branch/master/CONTRIBUTING.md)
|
||||
[![Contributions](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://git.platypush.tech/platypush/platypush/-/blob/master/CONTRIBUTING.md)
|
||||
[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/BlackLight/platypush.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/BlackLight/platypush/context:python)
|
||||
[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/BlackLight/platypush.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/BlackLight/platypush/context:javascript)
|
||||
|
||||
<!-- toc -->
|
||||
|
||||
- [Useful links](#useful-links)
|
||||
- [Introduction](#introduction)
|
||||
+ [What it can do](#what-it-can-do)
|
||||
- [Architecture](#architecture)
|
||||
* [Plugins](#plugins)
|
||||
* [Actions](#actions)
|
||||
* [Backends](#backends)
|
||||
* [Events](#events)
|
||||
* [Hooks](#hooks)
|
||||
* [Procedures](#procedures)
|
||||
* [Cronjobs](#cronjobs)
|
||||
* [The web interface](#the-web-interface)
|
||||
- [Installation](#installation)
|
||||
* [System installation](#system-installation)
|
||||
+ [Install through `pip`](#install-through-pip)
|
||||
|
@ -25,27 +33,17 @@ Platypush
|
|||
+ [Check the instructions reported in the documentation](#check-the-instructions-reported-in-the-documentation)
|
||||
* [Virtual environment installation](#virtual-environment-installation)
|
||||
* [Docker installation](#docker-installation)
|
||||
- [Architecture](#architecture)
|
||||
* [Plugins](#plugins)
|
||||
* [Actions](#actions)
|
||||
* [Backends](#backends)
|
||||
* [Events](#events)
|
||||
* [Hooks](#hooks)
|
||||
* [Procedures](#procedures)
|
||||
* [Cronjobs](#cronjobs)
|
||||
* [Entities](#entities)
|
||||
* [The web interface](#the-web-interface)
|
||||
- [Mobile app](#mobile-app)
|
||||
- [Tests](#tests)
|
||||
- [Funding](#funding)
|
||||
|
||||
<!-- tocstop -->
|
||||
|
||||
## Useful links
|
||||
- Recommended read: [**Getting started with Platypush**](https://blog.platypush.tech/article/Ultimate-self-hosted-automation-with-Platypush).
|
||||
|
||||
- The [blog](https://blog.platypush.tech) is a good place to get more insights
|
||||
and inspiration on what you can build.
|
||||
- The [blog](https://blog.platypush.tech) is in general a good place to get
|
||||
more insights on what you can build with it and inspiration about possible
|
||||
usages.
|
||||
|
||||
- The [wiki](https://git.platypush.tech/platypush/platypush/wiki) also
|
||||
contains many resources on getting started.
|
||||
|
@ -53,19 +51,19 @@ Platypush
|
|||
- Extensive documentation for all the available integrations and messages [is
|
||||
available](https://docs.platypush.tech/).
|
||||
|
||||
- If you have issues/feature requests/enhancements please [create an
|
||||
issue](https://git.platypush.tech/platypush/platypush/issues).
|
||||
- If you have issues/feature requests/enhancement ideas please [create an
|
||||
issue](https://git.platypush.tech/platypush/platypush/-/issues).
|
||||
|
||||
- A [Reddit channel](https://www.reddit.com/r/platypush) is also available for
|
||||
more general questions.
|
||||
|
||||
- A [Matrix instance](https://matrix.to/#/#platypush:matrix.platypush.tech) is
|
||||
available if you are looking for interactive support.
|
||||
also available if you are looking for more interactive support.
|
||||
|
||||
- A [Reddit channel](https://www.reddit.com/r/platypush) is available for
|
||||
general questions.
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Platypush is a general-purpose extensible platform for automation across
|
||||
multiple services and devices.
|
||||
Platypush is a general-purpose extensible platform for automation and
|
||||
integration across multiple services and devices.
|
||||
|
||||
It enables users to create their own self-hosted pieces of automation based on
|
||||
events (*if this happens then do that*)
|
||||
|
@ -74,15 +72,16 @@ everything you need to visualize and control under one roof.
|
|||
|
||||
It takes some concepts from [IFTTT](https://ifttt.com),
|
||||
[Tasker](https://tasker.joaoapps.com/), [Microsoft
|
||||
Flow](https://flow.microsoft.com) and [Home
|
||||
Assistant](https://www.home-assistant.io/) to provide an environment where the
|
||||
user can easily connect things together.
|
||||
Flow](https://flow.microsoft.com), [PushBullet](https://pushbullet.com) and
|
||||
[Home Assistant](https://www.home-assistant.io/) to provide an environment
|
||||
where the user can easily connect things together.
|
||||
|
||||
It's built with compatibility and flexibility in mind, and it can easily run on
|
||||
any device that can run a Python interpreter - from a Raspberry Pi zero, to an
|
||||
old smartphone, to a beefy server.
|
||||
|
||||
#### What it can do
|
||||
Its ideal home is a single-board computer like a RaspberryPi that you can
|
||||
configure to orchestrate any home automation and cloud automation in your own
|
||||
living room or garage, but it can easily run on any device that can run a
|
||||
Python interpreter, and the bar for the hardware requirements is very low as
|
||||
well - I use it to run pieces of automation on devices as powerful as a
|
||||
RaspberryPi Zero or an old Nokia N900 with Linux.
|
||||
|
||||
You can use Platypush to do things like:
|
||||
|
||||
|
@ -113,7 +112,325 @@ You can use Platypush to do things like:
|
|||
- [Create a custom single hub for Zigbee and Z-Wave smart devices](https://blog.platypush.tech/article/Transform-a-RaspberryPi-into-a-universal-Zigbee-and-Z-Wave-bridge)
|
||||
- Build your own web dashboard with calendar, weather, news and music controls
|
||||
(basically, anything that has a Platypush web widget)
|
||||
- ...and much more (basically, anything that comes with a [Platypush plugin](https://docs.platypush.tech))
|
||||
- ...and much more (basically, anything that comes with a [Platypush plugin](https://docs.platypush.tech/en/latest/plugins.html))
|
||||
|
||||
## Architecture
|
||||
|
||||
The architecture of Platypush consists of a few simple pieces, orchestrated by
|
||||
a configuration file stored by default under
|
||||
[`~/.config/platypush/config.yaml`](https://git.platypush.tech/platypush/platypush/-/blob/master/examples/conf/config.yaml):
|
||||
|
||||
### Plugins
|
||||
|
||||
[Full list](https://docs.platypush.tech/en/latest/plugins.html)
|
||||
|
||||
Plugins are integrations that do things - like [modify
|
||||
files](https://docs.platypush.tech/en/latest/platypush/plugins/file.html),
|
||||
[train and evaluate machine learning
|
||||
models](https://docs.platypush.tech/en/latest/platypush/plugins/tensorflow.html),
|
||||
[control
|
||||
cameras](https://docs.platypush.tech/en/latest/platypush/plugins/camera.pi.html),
|
||||
[read
|
||||
sensors](https://docs.platypush.tech/en/latest/platypush/plugins/gpio.sensor.dht.html),
|
||||
[parse a web
|
||||
page](https://docs.platypush.tech/en/latest/platypush/plugins/http.webpage.html),
|
||||
[control
|
||||
lights](https://docs.platypush.tech/en/latest/platypush/plugins/light.hue.html),
|
||||
[send
|
||||
emails](https://docs.platypush.tech/en/latest/platypush/plugins/mail.smtp.html),
|
||||
[control
|
||||
Chromecasts](https://docs.platypush.tech/en/latest/platypush/plugins/media.chromecast.html),
|
||||
[run voice
|
||||
queries](https://docs.platypush.tech/en/latest/platypush/plugins/assistant.google.html),
|
||||
[handle torrent
|
||||
transfers](https://docs.platypush.tech/en/latest/platypush/plugins/torrent.html)
|
||||
or control
|
||||
[Zigbee](https://docs.platypush.tech/en/latest/platypush/plugins/zigbee.mqtt.html)
|
||||
or [Z-Wave](https://docs.platypush.tech/en/latest/platypush/plugins/zwave.html)
|
||||
devices.
|
||||
|
||||
The configuration of a plugin matches one-on-one that of its documented class
|
||||
constructor, so it's very straightforward to write a configuration for a plugin
|
||||
by reading its documentation:
|
||||
|
||||
```yaml
|
||||
light.hue:
|
||||
# Groups that will be controlled by default
|
||||
groups:
|
||||
- Living Room
|
||||
- Hall
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
Plugins expose *actions*, that match one-on-one the plugin class methods
|
||||
denoted by `@action`, so it's very straightforward to invoke plugin actions by
|
||||
just reading the plugin documentation. They can be invoked directly from your
|
||||
own scripts or they can be sent to the platform through any supported channel
|
||||
as simple JSON messages:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "request",
|
||||
"action": "light.hue.on",
|
||||
"args": {
|
||||
"lights": ["Entrance Bulb"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backends
|
||||
|
||||
[Full list](https://docs.platypush.tech/en/latest/backends.html)
|
||||
|
||||
They are background services that either listen for messages on channels (like
|
||||
an [HTTP
|
||||
backend](https://docs.platypush.tech/en/latest/platypush/backend/http.html), an
|
||||
[MQTT
|
||||
instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a
|
||||
[Kafka
|
||||
instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html),
|
||||
a [Websocket
|
||||
service](https://docs.platypush.tech/en/latest/platypush/backend/websocket.html),
|
||||
[Pushbullet](https://docs.platypush.tech/en/latest/platypush/backend/pushbullet.html)
|
||||
etc.) or monitor a device or a service for events (like a
|
||||
[sensor](https://docs.platypush.tech/en/latest/platypush/backend/sensor.html),
|
||||
a custom [voice
|
||||
assistant](https://docs.platypush.tech/en/latest/platypush/backend/assistant.google.html),
|
||||
a bridge running on a
|
||||
[Zigbee](https://docs.platypush.tech/en/latest/platypush/backend/zigbee.mqtt.html)
|
||||
or
|
||||
[Z-Wave](https://docs.platypush.tech/en/latest/platypush/backend/zwave.html),
|
||||
an [NFC card
|
||||
reader](https://docs.platypush.tech/en/latest/platypush/backend/nfc.html), a
|
||||
[MIDI
|
||||
device](https://docs.platypush.tech/en/latest/platypush/backend/midi.html), a
|
||||
[Telegram
|
||||
channel](https://docs.platypush.tech/en/latest/platypush/backend/chat.telegram.html),
|
||||
a [Bluetooth
|
||||
scanner](https://docs.platypush.tech/en/latest/platypush/backend/bluetooth.scanner.ble.html)
|
||||
etc.).
|
||||
|
||||
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.
|
||||
For example, in the case of the HTTP backend:
|
||||
|
||||
```shell
|
||||
# Get a token
|
||||
curl -XPOST -H 'Content-Type: application/json' -d '
|
||||
{
|
||||
"username": "$YOUR_USER",
|
||||
"password": "$YOUR_PASSWORD"
|
||||
}' http://host:8008/auth
|
||||
|
||||
# Execute a request
|
||||
|
||||
curl -XPOST -H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $YOUR_TOKEN" -d '
|
||||
{
|
||||
"type": "request",
|
||||
"action": "tts.say",
|
||||
"args": {
|
||||
"text": "This is a test"
|
||||
}
|
||||
}' http://host:8008/execute
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
[Full list](https://docs.platypush.tech/en/latest/events.html)
|
||||
|
||||
When a certain event occurs (e.g. a JSON request is received, or a [Bluetooth
|
||||
device is
|
||||
connected](https://docs.platypush.tech/en/latest/platypush/events/bluetooth.html#platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent),
|
||||
or a [Flic button is
|
||||
pressed](https://docs.platypush.tech/en/latest/platypush/events/button.flic.html#platypush.message.event.button.flic.FlicButtonEvent),
|
||||
or some [speech is detected on the voice assistant
|
||||
service](https://docs.platypush.tech/en/latest/platypush/events/assistant.html#platypush.message.event.assistant.SpeechRecognizedEvent),
|
||||
or an [RSS feed has new
|
||||
items](https://docs.platypush.tech/en/latest/platypush/events/http.rss.html#platypush.message.event.http.rss.NewFeedEvent),
|
||||
or a [new email is
|
||||
received](https://docs.platypush.tech/en/latest/platypush/events/mail.html#platypush.message.event.mail.MailReceivedEvent),
|
||||
or a [new track is
|
||||
played](https://docs.platypush.tech/en/latest/platypush/events/music.html#platypush.message.event.music.NewPlayingTrackEvent),
|
||||
or an [NFC tag is
|
||||
detected](https://docs.platypush.tech/en/latest/platypush/events/nfc.html#platypush.message.event.nfc.NFCTagDetectedEvent),
|
||||
or [new sensor data is
|
||||
available](https://docs.platypush.tech/en/latest/platypush/events/sensor.html#platypush.message.event.sensor.SensorDataChangeEvent),
|
||||
or [a value of a Zigbee device
|
||||
changes](https://docs.platypush.tech/en/latest/platypush/events/zigbee.mqtt.html#platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePropertySetEvent),
|
||||
etc.), the associated backend will trigger an
|
||||
[event](https://docs.platypush.tech/en/latest/events.html).
|
||||
|
||||
### Hooks
|
||||
|
||||
Event hooks are custom pieces of logic that will be run when a certain event is
|
||||
triggered. Hooks are the glue that connects events to actions, exposing a
|
||||
paradigm similar to IFTTT (_if a certain event happens then run these
|
||||
actions_). They can declared as:
|
||||
|
||||
- Sections of the [`config.yaml`](https://git.platypush.tech/platypush/platypush/-/blob/master/examples/conf/config.yaml).
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
event.hook.SearchSongVoiceCommand:
|
||||
if:
|
||||
type: platypush.message.event.assistant.SpeechRecognizedEvent
|
||||
phrase: "play ${title} by ${artist}"
|
||||
then:
|
||||
- action: music.mpd.clear
|
||||
- action: music.mpd.search
|
||||
args:
|
||||
filter:
|
||||
artist: ${artist}
|
||||
title: ${title}
|
||||
|
||||
- if ${len(output)}:
|
||||
- action: music.mpd.play
|
||||
args:
|
||||
resource: ${output[0]['file']}
|
||||
```
|
||||
|
||||
- Stand-alone Python scripts stored under `~/.config/platypush/scripts` and
|
||||
will be dynamically imported at start time.
|
||||
[Example](https://git.platypush.tech/platypush/platypush/-/blob/master/examples/conf/hook.py):
|
||||
|
||||
```python
|
||||
from platypush.event.hook import hook
|
||||
from platypush.utils import run
|
||||
from platypush.message.event.assistant import SpeechRecognizedEvent
|
||||
|
||||
@hook(SpeechRecognizedEvent, phrase='play ${title} by ${artist}')
|
||||
def on_music_play_command(event, title=None, artist=None, **context):
|
||||
results = run('music.mpd.search', filter={
|
||||
'artist': artist,
|
||||
'title': title,
|
||||
})
|
||||
|
||||
if results:
|
||||
run('music.mpd.play', results[0]['file'])
|
||||
```
|
||||
|
||||
### Procedures
|
||||
|
||||
Procedures are pieces of custom logic that can be executed as atomic actions
|
||||
using `procedure.<name>` as an action name.
|
||||
|
||||
They can be defined either in the `config.yaml` or as Python scripts stored
|
||||
under `~/.config/platypush/scripts` - provided that the procedure is also
|
||||
imported in `~/.config/platypush/scripts/__init__.py` so it can be discovered
|
||||
by the service.
|
||||
|
||||
YAML example for a procedure that can be executed when we arrive home and turns
|
||||
on the lights if the luminosity is lower that a certain thresholds, says a
|
||||
welcome home message using the TTS engine and starts playing the music:
|
||||
|
||||
```yaml
|
||||
procedure.at_home:
|
||||
# Get luminosity data from a sensor - e.g. LTR559
|
||||
- action: gpio.sensor.ltr559.get_data
|
||||
|
||||
# If it's lower than a certain threshold, turn on the lights
|
||||
- if ${int(light or 0) < 110}:
|
||||
- action: light.hue.on
|
||||
|
||||
# Say a welcome home message
|
||||
- action: tts.google.say
|
||||
args:
|
||||
text: Welcome home
|
||||
|
||||
# Play the music
|
||||
- action: music.mpd.play
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```python
|
||||
# Content of ~/.config/platypush/scripts/home.py
|
||||
from platypush.procedure import procedure
|
||||
from platypush.utils import run
|
||||
|
||||
@procedure
|
||||
def at_home(**context):
|
||||
sensor_data = run('gpio.sensor.ltr559.get_data')
|
||||
if sensor_data['light'] < 110:
|
||||
run('light.hue.on')
|
||||
|
||||
run('tts.google.say', text='Welcome home')
|
||||
run('music.mpd.play')
|
||||
```
|
||||
|
||||
In either case, you can easily trigger the at-home procedure by sending an
|
||||
action request message to a backend - for example, over the HTTP backend:
|
||||
|
||||
```shell
|
||||
curl -XPOST -H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $YOUR_TOKEN" -d '
|
||||
{
|
||||
"type": "request",
|
||||
"action": "procedure.at_home"
|
||||
}' http://host:8008/execute
|
||||
```
|
||||
|
||||
### Cronjobs
|
||||
|
||||
Cronjobs are pieces of logic that will be run at regular intervals, expressed
|
||||
in crontab-compatible syntax. They can be defined either in the `config.yaml`
|
||||
or as Python scripts stored under `~/.config/platypush/scripts` as functions
|
||||
labelled by the `@cron` decorator.
|
||||
|
||||
Note that seconds are also supported (unlike the standard crontab definition),
|
||||
but, for back-compatibility with the standard crontab format, they are at the
|
||||
end of the cron expression, so the expression is actually in the format
|
||||
`<minute> <hour> <day_of_month> <month> <day_of_week> <second>`.
|
||||
|
||||
YAML example for a cronjob that is executed every 30 seconds and checks if a
|
||||
Bluetooth device is nearby:
|
||||
|
||||
```yaml
|
||||
cron.check_bt_device:
|
||||
cron_expression: '* * * * * */30'
|
||||
actions:
|
||||
- action: bluetooth.lookup_name
|
||||
args:
|
||||
addr: XX:XX:XX:XX:XX:XX
|
||||
|
||||
- if ${name}:
|
||||
- action: procedure.on_device_on
|
||||
- else:
|
||||
- action: procedure.on_device_off
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```python
|
||||
# Content of ~/.config/platypush/scripts/bt_cron.py
|
||||
from platypush.cron import cron
|
||||
from platypush.utils import run
|
||||
|
||||
@cron('* * * * * */30')
|
||||
def check_bt_device(**context):
|
||||
name = run('bluetooth.lookup_name').get('name')
|
||||
if name:
|
||||
# on_device_on logic here
|
||||
else:
|
||||
# on_device_off logic here
|
||||
```
|
||||
|
||||
### The web interface
|
||||
|
||||
If
|
||||
[`backend.http`](https://docs.platypush.tech/en/latest/platypush/backend/http.html)
|
||||
is enabled then a web interface will be provided by default on
|
||||
`http://host:8008/`. Besides using the `/execute` endpoint for running
|
||||
requests, the built-in web server also provides a full-featured interface that
|
||||
groups together the controls for most of the plugins - e.g. sensors, switches,
|
||||
music controls and search, media library and torrent management, lights,
|
||||
Zigbee/Z-Wave devices and so on. The UI is responsive and mobile-friendly.
|
||||
|
||||
The web service also provides means for the user to create [custom
|
||||
dashboards](https://git.platypush.tech/platypush/platypush/-/blob/master/examples/conf/dashboard.xml)
|
||||
that can be used to show information from multiple sources on a large screen.
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -122,10 +439,10 @@ You can use Platypush to do things like:
|
|||
Platypush uses Redis to deliver and store requests and temporary messages:
|
||||
|
||||
```yaml
|
||||
# Example for Debian-based distributions
|
||||
# Example for Debian-based distributions
|
||||
[sudo] apt-get install redis-server
|
||||
|
||||
# Enable and start the service
|
||||
# Enable and start the service
|
||||
[sudo] systemctl enable redis
|
||||
[sudo] systemctl start redis
|
||||
```
|
||||
|
@ -162,7 +479,7 @@ or tags.
|
|||
git clone https://git.platypush.tech/platypush/platypush.git
|
||||
cd platypush
|
||||
[sudo] pip install .
|
||||
# Or
|
||||
# Or
|
||||
[sudo] python3 setup.py install
|
||||
```
|
||||
|
||||
|
@ -176,7 +493,7 @@ ways to check the dependencies required by an extension:
|
|||
|
||||
All the extensions that require extra dependencies are listed in the
|
||||
[`extras_require` section under
|
||||
`setup.py`](https://git.platypush.tech/platypush/platypush/src/branch/master/setup.py#L84).
|
||||
`setup.py`](https://git.platypush.tech/platypush/platypush/-/blob/master/setup.py#L72).
|
||||
|
||||
#### Install via `manifest.yaml`
|
||||
|
||||
|
@ -219,7 +536,7 @@ platypush
|
|||
|
||||
It's advised to run it as a systemd service though - simply copy the provided
|
||||
[`.service`
|
||||
file](https://git.platypush.tech/platypush/platypush/src/branch/master/examples/systemd/platypush.service)
|
||||
file](https://git.platypush.tech/platypush/platypush/-/blob/master/examples/systemd/platypush.service)
|
||||
to `~/.config/systemd/user`, check if the path of `platypush` matches the path
|
||||
where it's installed on your system, and start the service via `systemctl`:
|
||||
|
||||
|
@ -300,336 +617,6 @@ directory in the same folder as the `config.yaml`.
|
|||
|
||||
[Wiki instructions](https://git.platypush.tech/platypush/platypush/wiki/Run-platypush-in-a-container)
|
||||
|
||||
## Architecture
|
||||
|
||||
The architecture of Platypush consists of a few simple pieces, orchestrated by
|
||||
a configuration file stored by default under
|
||||
[`~/.config/platypush/config.yaml`](https://git.platypush.tech/platypush/platypush/src/branch/master/examples/conf/config.yaml):
|
||||
|
||||
### Plugins
|
||||
|
||||
[Full list](https://docs.platypush.tech/en/latest/plugins.html)
|
||||
|
||||
Plugins are integrations that do things - like [modify
|
||||
files](https://docs.platypush.tech/en/latest/platypush/plugins/file.html),
|
||||
[train and evaluate machine learning
|
||||
models](https://docs.platypush.tech/en/latest/platypush/plugins/tensorflow.html),
|
||||
[control
|
||||
cameras](https://docs.platypush.tech/en/latest/platypush/plugins/camera.pi.html),
|
||||
[read
|
||||
sensors](https://docs.platypush.tech/en/latest/platypush/plugins/gpio.sensor.dht.html),
|
||||
[parse a web
|
||||
page](https://docs.platypush.tech/en/latest/platypush/plugins/http.webpage.html),
|
||||
[control
|
||||
lights](https://docs.platypush.tech/en/latest/platypush/plugins/light.hue.html),
|
||||
[send
|
||||
emails](https://docs.platypush.tech/en/latest/platypush/plugins/mail.smtp.html),
|
||||
[control
|
||||
Chromecasts](https://docs.platypush.tech/en/latest/platypush/plugins/media.chromecast.html),
|
||||
[run voice
|
||||
queries](https://docs.platypush.tech/en/latest/platypush/plugins/assistant.google.html),
|
||||
[handle torrent
|
||||
transfers](https://docs.platypush.tech/en/latest/platypush/plugins/torrent.html)
|
||||
or control
|
||||
[Zigbee](https://docs.platypush.tech/en/latest/platypush/plugins/zigbee.mqtt.html)
|
||||
or [Z-Wave](https://docs.platypush.tech/en/latest/platypush/plugins/zwave.html)
|
||||
devices.
|
||||
|
||||
The configuration of a plugin matches one-on-one that of its documented class
|
||||
constructor, so it's very straightforward to write a configuration for a plugin
|
||||
by reading its documentation:
|
||||
|
||||
```yaml
|
||||
light.hue:
|
||||
# Groups that will be controlled by default
|
||||
groups:
|
||||
- Living Room
|
||||
- Hall
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
Plugins expose *actions*, that match one-on-one the plugin class methods
|
||||
denoted by `@action`, so it's very straightforward to invoke plugin actions by
|
||||
just reading the plugin documentation. They can be invoked directly from your
|
||||
own scripts or they can be sent to the platform through any supported channel
|
||||
as simple JSON messages:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "request",
|
||||
"action": "light.hue.on",
|
||||
"args": {
|
||||
"lights": ["Entrance Bulb"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backends
|
||||
|
||||
[Full list](https://docs.platypush.tech/en/latest/backends.html)
|
||||
|
||||
They are background services that listen for messages on channels (like
|
||||
an [HTTP
|
||||
backend](https://docs.platypush.tech/en/latest/platypush/backend/http.html), an
|
||||
[MQTT
|
||||
instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a
|
||||
[Kafka
|
||||
instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html),
|
||||
[Pushbullet](https://docs.platypush.tech/en/latest/platypush/backend/pushbullet.html)
|
||||
etc.).
|
||||
|
||||
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.
|
||||
For example, in the case of the HTTP backend:
|
||||
|
||||
```shell
|
||||
# Get a token
|
||||
curl -XPOST -H 'Content-Type: application/json' -d '
|
||||
{
|
||||
"username": "$YOUR_USER",
|
||||
"password": "$YOUR_PASSWORD"
|
||||
}' http://host:8008/auth
|
||||
|
||||
# Execute a request
|
||||
curl -XPOST -H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $YOUR_TOKEN" -d '
|
||||
{
|
||||
"type": "request",
|
||||
"action": "tts.say",
|
||||
"args": {
|
||||
"text": "This is a test"
|
||||
}
|
||||
}' http://host:8008/execute
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
[Full list](https://docs.platypush.tech/en/latest/events.html)
|
||||
|
||||
When a certain event occurs (e.g. a JSON request is received, or a [Bluetooth
|
||||
device is
|
||||
connected](https://docs.platypush.tech/en/latest/platypush/events/bluetooth.html#platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent),
|
||||
or a [Flic button is
|
||||
pressed](https://docs.platypush.tech/en/latest/platypush/events/button.flic.html#platypush.message.event.button.flic.FlicButtonEvent),
|
||||
or some [speech is detected on the voice assistant
|
||||
service](https://docs.platypush.tech/en/latest/platypush/events/assistant.html#platypush.message.event.assistant.SpeechRecognizedEvent),
|
||||
or an [RSS feed has new
|
||||
items](https://docs.platypush.tech/en/latest/platypush/events/http.rss.html#platypush.message.event.http.rss.NewFeedEvent),
|
||||
or a [new email is
|
||||
received](https://docs.platypush.tech/en/latest/platypush/events/mail.html#platypush.message.event.mail.MailReceivedEvent),
|
||||
or a [new track is
|
||||
played](https://docs.platypush.tech/en/latest/platypush/events/music.html#platypush.message.event.music.NewPlayingTrackEvent),
|
||||
or an [NFC tag is
|
||||
detected](https://docs.platypush.tech/en/latest/platypush/events/nfc.html#platypush.message.event.nfc.NFCTagDetectedEvent),
|
||||
or [new sensor data is
|
||||
available](https://docs.platypush.tech/en/latest/platypush/events/sensor.html#platypush.message.event.sensor.SensorDataChangeEvent),
|
||||
or [a value of a Zigbee device
|
||||
changes](https://docs.platypush.tech/en/latest/platypush/events/zigbee.mqtt.html#platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePropertySetEvent),
|
||||
etc.), the associated backend will trigger an
|
||||
[event](https://docs.platypush.tech/en/latest/events.html).
|
||||
|
||||
### Hooks
|
||||
|
||||
Event hooks are custom pieces of logic that will be run when a certain event is
|
||||
triggered. Hooks are the glue that connects events to actions, exposing a
|
||||
paradigm similar to IFTTT (_if a certain event happens then run these
|
||||
actions_). They can declared as:
|
||||
|
||||
- Sections of the [`config.yaml`](https://git.platypush.tech/platypush/platypush/src/branch/master/examples/conf/config.yaml).
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
event.hook.SearchSongVoiceCommand:
|
||||
if:
|
||||
type: platypush.message.event.assistant.SpeechRecognizedEvent
|
||||
phrase: "play ${title} by ${artist}"
|
||||
then:
|
||||
- action: music.mpd.clear
|
||||
- action: music.mpd.search
|
||||
args:
|
||||
filter:
|
||||
artist: ${artist}
|
||||
title: ${title}
|
||||
|
||||
- if ${len(output)}:
|
||||
- action: music.mpd.play
|
||||
args:
|
||||
resource: ${output[0]['file']}
|
||||
```
|
||||
|
||||
- Stand-alone Python scripts stored under `~/.config/platypush/scripts` and
|
||||
will be dynamically imported at start time.
|
||||
[Example](https://git.platypush.tech/platypush/platypush/src/branch/master/examples/conf/hook.py):
|
||||
|
||||
```python
|
||||
from platypush.event.hook import hook
|
||||
from platypush.utils import run
|
||||
from platypush.message.event.assistant import SpeechRecognizedEvent
|
||||
|
||||
@hook(SpeechRecognizedEvent, phrase='play ${title} by ${artist}')
|
||||
def on_music_play_command(event, title=None, artist=None, **context):
|
||||
results = run('music.mpd.search', filter={
|
||||
'artist': artist,
|
||||
'title': title,
|
||||
})
|
||||
|
||||
if results:
|
||||
run('music.mpd.play', results[0]['file'])
|
||||
```
|
||||
|
||||
### Procedures
|
||||
|
||||
Procedures are pieces of custom logic that can be executed as atomic actions
|
||||
using `procedure.<name>` as an action name.
|
||||
|
||||
They can be defined either in the `config.yaml` or as Python scripts stored
|
||||
under `~/.config/platypush/scripts` - provided that the procedure is also
|
||||
imported in `~/.config/platypush/scripts/__init__.py` so it can be discovered
|
||||
by the service.
|
||||
|
||||
YAML example for a procedure that can be executed when we arrive home and turns
|
||||
on the lights if the luminosity is lower that a certain thresholds, says a
|
||||
welcome home message using the TTS engine and starts playing the music:
|
||||
|
||||
```yaml
|
||||
procedure.at_home:
|
||||
# Get luminosity data from a sensor - e.g. LTR559
|
||||
- action: gpio.sensor.ltr559.get_data
|
||||
|
||||
# If it's lower than a certain threshold, turn on the lights
|
||||
- if ${int(light or 0) < 110}:
|
||||
- action: light.hue.on
|
||||
|
||||
# Say a welcome home message
|
||||
- action: tts.google.say
|
||||
args:
|
||||
text: Welcome home
|
||||
|
||||
# Play the music
|
||||
- action: music.mpd.play
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```python
|
||||
# Content of ~/.config/platypush/scripts/home.py
|
||||
from platypush.procedure import procedure
|
||||
from platypush.utils import run
|
||||
|
||||
@procedure
|
||||
def at_home(**context):
|
||||
sensor_data = run('gpio.sensor.ltr559.get_data')
|
||||
if sensor_data['light'] < 110:
|
||||
run('light.hue.on')
|
||||
|
||||
run('tts.google.say', text='Welcome home')
|
||||
run('music.mpd.play')
|
||||
```
|
||||
|
||||
In either case, you can easily trigger the at-home procedure by sending an
|
||||
action request message to a backend - for example, over the HTTP backend:
|
||||
|
||||
```shell
|
||||
curl -XPOST -H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $YOUR_TOKEN" -d '
|
||||
{
|
||||
"type": "request",
|
||||
"action": "procedure.at_home"
|
||||
}' http://host:8008/execute
|
||||
```
|
||||
|
||||
### Cronjobs
|
||||
|
||||
Cronjobs are pieces of logic that will be run at regular intervals, expressed
|
||||
in crontab-compatible syntax. They can be defined either in the `config.yaml`
|
||||
or as Python scripts stored under `~/.config/platypush/scripts` as functions
|
||||
labelled by the `@cron` decorator.
|
||||
|
||||
Note that seconds are also supported (unlike the standard crontab definition),
|
||||
but, for back-compatibility with the standard crontab format, they are at the
|
||||
end of the cron expression, so the expression is actually in the format
|
||||
`<minute> <hour> <day_of_month> <month> <day_of_week> <second>`.
|
||||
|
||||
YAML example for a cronjob that is executed every 30 seconds and checks if a
|
||||
Bluetooth device is nearby:
|
||||
|
||||
```yaml
|
||||
cron.check_bt_device:
|
||||
cron_expression: '* * * * * */30'
|
||||
actions:
|
||||
- action: bluetooth.lookup_name
|
||||
args:
|
||||
addr: XX:XX:XX:XX:XX:XX
|
||||
|
||||
- if ${name}:
|
||||
- action: procedure.on_device_on
|
||||
- else:
|
||||
- action: procedure.on_device_off
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```python
|
||||
# Content of ~/.config/platypush/scripts/bt_cron.py
|
||||
from platypush.cron import cron
|
||||
from platypush.utils import run
|
||||
|
||||
@cron('* * * * * */30')
|
||||
def check_bt_device(**context):
|
||||
name = run('bluetooth.lookup_name').get('name')
|
||||
if name:
|
||||
# on_device_on logic here
|
||||
else:
|
||||
# on_device_off logic here
|
||||
```
|
||||
|
||||
### Entities
|
||||
|
||||
Entities are a fundamental building block of Platypush. Most of the
|
||||
integrations will store their state or connected devices in the form of
|
||||
entities - e.g. the sensors detected by the Z-Wave/Zigbee/Bluetooth
|
||||
integration, or the lights connected to a Hue bridge, or your cloud nodes, or
|
||||
your custom Arduino/ESP machinery, and so on.
|
||||
|
||||
Entities provide a consistent interface to interact with your integrations
|
||||
regardless of their type and the plugin that handles them. For instance, all
|
||||
temperature sensors will expose the same interface, regardless if they are
|
||||
Bluetooth or Zigbee sensors, and all the media plugins will expose the same
|
||||
interface, regardless if they manage Chromecasts, Kodi, Plex, Jellyfin or a
|
||||
local VLC player.
|
||||
|
||||
Once you enable the HTTP backend and a few integrations that export entities
|
||||
and register a user, you can query the detected entities via:
|
||||
|
||||
```shell
|
||||
curl -XPOST -H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $YOUR_TOKEN" \
|
||||
-d '{"type":"request", "action":"entities.get"}' \
|
||||
http://localhost:8008/execute
|
||||
```
|
||||
|
||||
All the entities expose the same interface and can be manipulated through the
|
||||
same API. Also, when an entity is updated it always emits an
|
||||
[`EntityUpdateEvent`](https://docs.platypush.tech/platypush/events/entities.html#platypush.message.event.entities.EntityUpdateEvent),
|
||||
so you can easily create hooks that react to these events and act on multiple
|
||||
types of entities.
|
||||
|
||||
### The web interface
|
||||
|
||||
If
|
||||
[`backend.http`](https://docs.platypush.tech/en/latest/platypush/backend/http.html)
|
||||
is enabled then a web interface will be provided by default on
|
||||
`http://host:8008/`. Besides using the `/execute` endpoint for running
|
||||
requests, the built-in web server also provides a full-featured interface that
|
||||
groups together the controls for most of the plugins - e.g. sensors, switches,
|
||||
music controls and search, media library and torrent management, lights,
|
||||
Zigbee/Z-Wave devices and so on. The UI is responsive and mobile-friendly.
|
||||
|
||||
The web service also provides means for the user to create [custom
|
||||
dashboards](https://git.platypush.tech/platypush/platypush/src/branch/master/examples/conf/dashboard.xml)
|
||||
that can be used to show information from multiple sources on a large screen.
|
||||
|
||||
## Mobile app
|
||||
|
||||
An [official Android
|
||||
|
@ -641,7 +628,11 @@ of Platypush to your fingertips.
|
|||
## Tests
|
||||
|
||||
To run the tests simply run `pytest` either from the project root folder or the
|
||||
`tests/` folder.
|
||||
`tests/` folder. Or run the following command from the project root folder:
|
||||
|
||||
```shell
|
||||
python -m tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -3,13 +3,17 @@ Backends
|
|||
========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:maxdepth: 2
|
||||
:caption: Backends:
|
||||
|
||||
platypush/backend/adafruit.io.rst
|
||||
platypush/backend/alarm.rst
|
||||
platypush/backend/assistant.google.rst
|
||||
platypush/backend/assistant.snowboy.rst
|
||||
platypush/backend/bluetooth.fileserver.rst
|
||||
platypush/backend/bluetooth.pushserver.rst
|
||||
platypush/backend/bluetooth.scanner.rst
|
||||
platypush/backend/bluetooth.scanner.ble.rst
|
||||
platypush/backend/button.flic.rst
|
||||
platypush/backend/camera.pi.rst
|
||||
platypush/backend/chat.telegram.rst
|
||||
|
@ -28,6 +32,7 @@ Backends
|
|||
platypush/backend/joystick.linux.rst
|
||||
platypush/backend/kafka.rst
|
||||
platypush/backend/light.hue.rst
|
||||
platypush/backend/linode.rst
|
||||
platypush/backend/log.http.rst
|
||||
platypush/backend/mail.rst
|
||||
platypush/backend/midi.rst
|
||||
|
@ -43,8 +48,20 @@ Backends
|
|||
platypush/backend/pushbullet.rst
|
||||
platypush/backend/redis.rst
|
||||
platypush/backend/scard.rst
|
||||
platypush/backend/sensor.accelerometer.rst
|
||||
platypush/backend/sensor.arduino.rst
|
||||
platypush/backend/sensor.battery.rst
|
||||
platypush/backend/sensor.bme280.rst
|
||||
platypush/backend/sensor.dht.rst
|
||||
platypush/backend/sensor.distance.rst
|
||||
platypush/backend/sensor.distance.vl53l1x.rst
|
||||
platypush/backend/sensor.envirophat.rst
|
||||
platypush/backend/sensor.ir.zeroborg.rst
|
||||
platypush/backend/sensor.leap.rst
|
||||
platypush/backend/sensor.ltr559.rst
|
||||
platypush/backend/sensor.mcp3008.rst
|
||||
platypush/backend/sensor.motion.pmw3901.rst
|
||||
platypush/backend/sensor.serial.rst
|
||||
platypush/backend/stt.deepspeech.rst
|
||||
platypush/backend/stt.picovoice.hotword.rst
|
||||
platypush/backend/stt.picovoice.speech.rst
|
||||
|
@ -55,6 +72,8 @@ Backends
|
|||
platypush/backend/weather.buienradar.rst
|
||||
platypush/backend/weather.darksky.rst
|
||||
platypush/backend/weather.openweathermap.rst
|
||||
platypush/backend/websocket.rst
|
||||
platypush/backend/wiimote.rst
|
||||
platypush/backend/zigbee.mqtt.rst
|
||||
platypush/backend/zwave.rst
|
||||
platypush/backend/zwave.mqtt.rst
|
||||
|
|
|
@ -71,7 +71,7 @@ master_doc = 'index'
|
|||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = 'en'
|
||||
language = None
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
|
@ -103,7 +103,6 @@ html_domain_indices = True
|
|||
html_theme_options = {
|
||||
'toc_title': 'Platypush documentation',
|
||||
'repository_url': 'https://git.platypush.tech/platypush/platypush',
|
||||
'repository_provider': 'github',
|
||||
'use_repository_button': True,
|
||||
'use_issues_button': True,
|
||||
'use_fullscreen_button': True,
|
||||
|
@ -139,12 +138,15 @@ latex_elements = {
|
|||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
|
@ -154,7 +156,8 @@ latex_elements = {
|
|||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'platypush.tex', 'platypush Documentation', 'BlackLight', 'manual'),
|
||||
(master_doc, 'platypush.tex', 'platypush Documentation',
|
||||
'BlackLight', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
|
@ -162,7 +165,10 @@ latex_documents = [
|
|||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [(master_doc, 'platypush', 'platypush Documentation', [author], 1)]
|
||||
man_pages = [
|
||||
(master_doc, 'platypush', 'platypush Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
|
||||
# -- Options for Texinfo output ----------------------------------------------
|
||||
|
@ -171,15 +177,9 @@ man_pages = [(master_doc, 'platypush', 'platypush Documentation', [author], 1)]
|
|||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(
|
||||
master_doc,
|
||||
'platypush',
|
||||
'platypush Documentation',
|
||||
author,
|
||||
'platypush',
|
||||
'A general-purpose platform for automation.',
|
||||
'Miscellaneous',
|
||||
),
|
||||
(master_doc, 'platypush', 'platypush Documentation',
|
||||
author, 'platypush', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
|
||||
|
@ -188,7 +188,7 @@ texinfo_documents = [
|
|||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
|
@ -196,117 +196,102 @@ intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
|
|||
todo_include_todos = True
|
||||
|
||||
autodoc_default_options = {
|
||||
'members': True,
|
||||
'show-inheritance': True,
|
||||
'inherited-members': True,
|
||||
}
|
||||
|
||||
autodoc_mock_imports = [
|
||||
'gunicorn',
|
||||
'googlesamples.assistant.grpc.audio_helpers',
|
||||
'google.assistant.embedded',
|
||||
'google.assistant.library',
|
||||
'google.assistant.library.event',
|
||||
'google.assistant.library.file_helpers',
|
||||
'google.oauth2.credentials',
|
||||
'oauth2client',
|
||||
'apiclient',
|
||||
'tenacity',
|
||||
'smartcard',
|
||||
'Leap',
|
||||
'oauth2client',
|
||||
'rtmidi',
|
||||
'bluetooth',
|
||||
'gevent.wsgi',
|
||||
'Adafruit_IO',
|
||||
'pyclip',
|
||||
'pydbus',
|
||||
'inputs',
|
||||
'inotify',
|
||||
'omxplayer',
|
||||
'plexapi',
|
||||
'cwiid',
|
||||
'sounddevice',
|
||||
'soundfile',
|
||||
'numpy',
|
||||
'cv2',
|
||||
'nfc',
|
||||
'ndef',
|
||||
'bcrypt',
|
||||
'google',
|
||||
'feedparser',
|
||||
'kafka',
|
||||
'googlesamples',
|
||||
'icalendar',
|
||||
'httplib2',
|
||||
'mpd',
|
||||
'serial',
|
||||
'pyHS100',
|
||||
'grpc',
|
||||
'envirophat',
|
||||
'gps',
|
||||
'picamera',
|
||||
'pmw3901',
|
||||
'PIL',
|
||||
'croniter',
|
||||
'pyaudio',
|
||||
'avs',
|
||||
'PyOBEX',
|
||||
'PyOBEX.client',
|
||||
'todoist',
|
||||
'trello',
|
||||
'telegram',
|
||||
'telegram.ext',
|
||||
'pyfirmata2',
|
||||
'cups',
|
||||
'graphyte',
|
||||
'cpuinfo',
|
||||
'psutil',
|
||||
'openzwave',
|
||||
'deepspeech',
|
||||
'wave',
|
||||
'pvporcupine ',
|
||||
'pvcheetah',
|
||||
'pyotp',
|
||||
'linode_api4',
|
||||
'pyzbar',
|
||||
'tensorflow',
|
||||
'keras',
|
||||
'pandas',
|
||||
'samsungtvws',
|
||||
'paramiko',
|
||||
'luma',
|
||||
'zeroconf',
|
||||
'dbus',
|
||||
'gi',
|
||||
'gi.repository',
|
||||
'twilio',
|
||||
'Adafruit_Python_DHT',
|
||||
'RPi.GPIO',
|
||||
'RPLCD',
|
||||
'imapclient',
|
||||
'pysmartthings',
|
||||
'aiohttp',
|
||||
'watchdog',
|
||||
'pyngrok',
|
||||
'irc',
|
||||
'irc.bot',
|
||||
'irc.strings',
|
||||
'irc.client',
|
||||
'irc.connection',
|
||||
'irc.events',
|
||||
'defusedxml',
|
||||
'nio',
|
||||
'aiofiles',
|
||||
'aiofiles.os',
|
||||
'async_lru',
|
||||
'bleak',
|
||||
'bluetooth_numbers',
|
||||
'TheengsDecoder',
|
||||
'simple_websocket',
|
||||
'uvicorn',
|
||||
'websockets',
|
||||
'docutils',
|
||||
]
|
||||
autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
|
||||
'google.assistant.embedded',
|
||||
'google.assistant.library',
|
||||
'google.assistant.library.event',
|
||||
'google.assistant.library.file_helpers',
|
||||
'google.oauth2.credentials',
|
||||
'oauth2client',
|
||||
'apiclient',
|
||||
'tenacity',
|
||||
'smartcard',
|
||||
'Leap',
|
||||
'oauth2client',
|
||||
'rtmidi',
|
||||
'bluetooth',
|
||||
'gevent.wsgi',
|
||||
'Adafruit_IO',
|
||||
'pyperclip',
|
||||
'pydbus',
|
||||
'inputs',
|
||||
'inotify',
|
||||
'omxplayer',
|
||||
'plexapi',
|
||||
'cwiid',
|
||||
'sounddevice',
|
||||
'soundfile',
|
||||
'numpy',
|
||||
'cv2',
|
||||
'nfc',
|
||||
'ndef',
|
||||
'bcrypt',
|
||||
'google',
|
||||
'feedparser',
|
||||
'kafka',
|
||||
'googlesamples',
|
||||
'icalendar',
|
||||
'httplib2',
|
||||
'mpd',
|
||||
'serial',
|
||||
'pyHS100',
|
||||
'grpc',
|
||||
'envirophat',
|
||||
'gps',
|
||||
'picamera',
|
||||
'pmw3901',
|
||||
'PIL',
|
||||
'croniter',
|
||||
'pyaudio',
|
||||
'avs',
|
||||
'PyOBEX',
|
||||
'todoist',
|
||||
'trello',
|
||||
'telegram',
|
||||
'telegram.ext',
|
||||
'pyfirmata2',
|
||||
'cups',
|
||||
'graphyte',
|
||||
'cpuinfo',
|
||||
'psutil',
|
||||
'openzwave',
|
||||
'deepspeech',
|
||||
'wave',
|
||||
'pvporcupine ',
|
||||
'pvcheetah',
|
||||
'pyotp',
|
||||
'linode_api4',
|
||||
'pyzbar',
|
||||
'tensorflow',
|
||||
'keras',
|
||||
'pandas',
|
||||
'samsungtvws',
|
||||
'paramiko',
|
||||
'luma',
|
||||
'zeroconf',
|
||||
'dbus',
|
||||
'gi',
|
||||
'gi.repository',
|
||||
'twilio',
|
||||
'Adafruit_Python_DHT',
|
||||
'RPi.GPIO',
|
||||
'RPLCD',
|
||||
'imapclient',
|
||||
'pysmartthings',
|
||||
'aiohttp',
|
||||
'watchdog',
|
||||
'pyngrok',
|
||||
'irc',
|
||||
'irc.bot',
|
||||
'irc.strings',
|
||||
'irc.client',
|
||||
'irc.connection',
|
||||
'irc.events',
|
||||
'defusedxml',
|
||||
]
|
||||
|
||||
sys.path.insert(0, os.path.abspath('../..'))
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ Events
|
|||
======
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:maxdepth: 2
|
||||
:caption: Events:
|
||||
|
||||
platypush/events/adafruit.rst
|
||||
|
@ -20,7 +20,6 @@ Events
|
|||
platypush/events/custom.rst
|
||||
platypush/events/dbus.rst
|
||||
platypush/events/distance.rst
|
||||
platypush/events/entities.rst
|
||||
platypush/events/file.rst
|
||||
platypush/events/foursquare.rst
|
||||
platypush/events/geo.rst
|
||||
|
@ -31,7 +30,6 @@ Events
|
|||
platypush/events/gotify.rst
|
||||
platypush/events/gpio.rst
|
||||
platypush/events/gps.rst
|
||||
platypush/events/hid.rst
|
||||
platypush/events/http.rst
|
||||
platypush/events/http.hook.rst
|
||||
platypush/events/http.rss.rst
|
||||
|
@ -43,13 +41,11 @@ Events
|
|||
platypush/events/linode.rst
|
||||
platypush/events/log.http.rst
|
||||
platypush/events/mail.rst
|
||||
platypush/events/matrix.rst
|
||||
platypush/events/media.rst
|
||||
platypush/events/midi.rst
|
||||
platypush/events/mqtt.rst
|
||||
platypush/events/music.rst
|
||||
platypush/events/music.snapcast.rst
|
||||
platypush/events/music.tidal.rst
|
||||
platypush/events/nextcloud.rst
|
||||
platypush/events/nfc.rst
|
||||
platypush/events/ngrok.rst
|
||||
|
@ -76,7 +72,6 @@ Events
|
|||
platypush/events/weather.rst
|
||||
platypush/events/web.rst
|
||||
platypush/events/web.widget.rst
|
||||
platypush/events/websocket.rst
|
||||
platypush/events/wiimote.rst
|
||||
platypush/events/zeroborg.rst
|
||||
platypush/events/zeroconf.rst
|
||||
|
|
|
@ -16,7 +16,7 @@ For more information on Platypush check out:
|
|||
.. _Blog articles: https://blog.platypush.tech
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:maxdepth: 3
|
||||
:caption: Contents:
|
||||
|
||||
backends
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
``bluetooth.fileserver``
|
||||
==========================================
|
||||
|
||||
.. automodule:: platypush.backend.bluetooth.fileserver
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``bluetooth.pushserver``
|
||||
==========================================
|
||||
|
||||
.. automodule:: platypush.backend.bluetooth.pushserver
|
||||
:members:
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``bluetooth.scanner.ble``
|
||||
===========================================
|
||||
|
||||
.. automodule:: platypush.backend.bluetooth.scanner.ble
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``bluetooth.scanner``
|
||||
=======================================
|
||||
|
||||
.. automodule:: platypush.backend.bluetooth.scanner
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``linode``
|
||||
============================
|
||||
|
||||
.. automodule:: platypush.backend.linode
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``sensor.accelerometer``
|
||||
==========================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.accelerometer
|
||||
:members:
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``sensor.arduino``
|
||||
====================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.arduino
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``sensor.battery``
|
||||
====================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.battery
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``sensor.bme280``
|
||||
===================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.bme280
|
||||
:members:
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``sensor.dht``
|
||||
================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.dht
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``sensor.distance``
|
||||
=====================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.distance
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``sensor.distance.vl53l1x``
|
||||
=============================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.distance.vl53l1x
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``sensor.envirophat``
|
||||
=======================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.envirophat
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``sensor.ltr559``
|
||||
===================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.ltr559
|
||||
:members:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
``sensor.mcp3008``
|
||||
====================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.mcp3008
|
||||
:members:
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``sensor.motion.pmw3901``
|
||||
=========================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.motion.pmw3901
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``sensor.serial``
|
||||
===================================
|
||||
|
||||
.. automodule:: platypush.backend.sensor.serial
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``websocket``
|
||||
===============================
|
||||
|
||||
.. automodule:: platypush.backend.websocket
|
||||
:members:
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``zigbee.mqtt``
|
||||
=================================
|
||||
|
||||
.. automodule:: platypush.backend.zigbee.mqtt
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``entities``
|
||||
============
|
||||
|
||||
.. automodule:: platypush.message.event.entities
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``hid``
|
||||
=======
|
||||
|
||||
.. automodule:: platypush.message.event.hid
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``matrix``
|
||||
==========
|
||||
|
||||
.. automodule:: platypush.message.event.matrix
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``music.tidal``
|
||||
===============
|
||||
|
||||
.. automodule:: platypush.message.event.music.tidal
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``websocket``
|
||||
=============
|
||||
|
||||
.. automodule:: platypush.message.event.websocket
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``bluetooth.ble``
|
||||
===================================
|
||||
|
||||
.. automodule:: platypush.plugins.bluetooth.ble
|
||||
:members:
|
||||
|
|
@ -2,4 +2,4 @@
|
|||
==========================
|
||||
|
||||
.. automodule:: platypush.plugins.dbus
|
||||
:exclude-members: DBusService, BusType
|
||||
:members:
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
``entities``
|
||||
============
|
||||
|
||||
.. automodule:: platypush.plugins.entities
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``gpio.sensor.accelerometer``
|
||||
===============================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.accelerometer
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``gpio.sensor.bme280``
|
||||
========================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.bme280
|
||||
:members:
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``gpio.sensor.dht``
|
||||
=====================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.dht
|
||||
:members:
|
|
@ -0,0 +1,6 @@
|
|||
``gpio.sensor.distance``
|
||||
==========================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.distance
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``gpio.sensor.distance.vl53l1x``
|
||||
==================================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.distance.vl53l1x
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``gpio.sensor.envirophat``
|
||||
============================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.envirophat
|
||||
:members:
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
``gpio.sensor.ltr559``
|
||||
========================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.ltr559
|
||||
:members:
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
``gpio.sensor.mcp3008``
|
||||
=========================================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.mcp3008
|
||||
:members:
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
``gpio.sensor.motion.pmw3901``
|
||||
==============================
|
||||
|
||||
.. automodule:: platypush.plugins.gpio.sensor.motion.pmw3901
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``hid``
|
||||
=======
|
||||
|
||||
.. automodule:: platypush.plugins.hid
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``matrix``
|
||||
==========
|
||||
|
||||
.. automodule:: platypush.plugins.matrix
|
||||
:members: MatrixPlugin
|
|
@ -1,5 +0,0 @@
|
|||
``music.tidal``
|
||||
===============
|
||||
|
||||
.. automodule:: platypush.plugins.music.tidal
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.bme280``
|
||||
=================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.bme280
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.dht``
|
||||
==============
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.dht
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.distance.vl53l1x``
|
||||
===========================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.distance.vl53l1x
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.envirophat``
|
||||
=====================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.envirophat
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.hcsr04``
|
||||
=================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.hcsr04
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.lis3dh``
|
||||
=================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.lis3dh
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.ltr559``
|
||||
=================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.ltr559
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.mcp3008``
|
||||
==================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.mcp3008
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``sensor.pmw3901``
|
||||
==================
|
||||
|
||||
.. automodule:: platypush.plugins.sensor.pmw3901
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``switchbot.bluetooth``
|
||||
=========================================
|
||||
|
||||
.. automodule:: platypush.plugins.switchbot.bluetooth
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``tts.mimic3``
|
||||
==============
|
||||
|
||||
.. automodule:: platypush.plugins.tts.mimic3
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``wallabag``
|
||||
============
|
||||
|
||||
.. automodule:: platypush.plugins.wallabag
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``bluetooth``
|
||||
========================================
|
||||
|
||||
.. automodule:: platypush.message.response.bluetooth
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``linode``
|
||||
=====================================
|
||||
|
||||
.. automodule:: platypush.message.response.linode
|
||||
:members:
|
|
@ -0,0 +1,5 @@
|
|||
``system``
|
||||
=====================================
|
||||
|
||||
.. automodule:: platypush.message.response.system
|
||||
:members:
|
|
@ -3,7 +3,7 @@ Plugins
|
|||
=======
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:maxdepth: 2
|
||||
:caption: Plugins:
|
||||
|
||||
platypush/plugins/adafruit.io.rst
|
||||
|
@ -14,6 +14,7 @@ Plugins
|
|||
platypush/plugins/assistant.google.pushtotalk.rst
|
||||
platypush/plugins/autoremote.rst
|
||||
platypush/plugins/bluetooth.rst
|
||||
platypush/plugins/bluetooth.ble.rst
|
||||
platypush/plugins/calendar.rst
|
||||
platypush/plugins/calendar.ical.rst
|
||||
platypush/plugins/camera.android.ipcam.rst
|
||||
|
@ -31,7 +32,6 @@ Plugins
|
|||
platypush/plugins/db.rst
|
||||
platypush/plugins/dbus.rst
|
||||
platypush/plugins/dropbox.rst
|
||||
platypush/plugins/entities.rst
|
||||
platypush/plugins/esp.rst
|
||||
platypush/plugins/ffmpeg.rst
|
||||
platypush/plugins/file.rst
|
||||
|
@ -46,9 +46,17 @@ Plugins
|
|||
platypush/plugins/google.youtube.rst
|
||||
platypush/plugins/gotify.rst
|
||||
platypush/plugins/gpio.rst
|
||||
platypush/plugins/gpio.sensor.accelerometer.rst
|
||||
platypush/plugins/gpio.sensor.bme280.rst
|
||||
platypush/plugins/gpio.sensor.dht.rst
|
||||
platypush/plugins/gpio.sensor.distance.rst
|
||||
platypush/plugins/gpio.sensor.distance.vl53l1x.rst
|
||||
platypush/plugins/gpio.sensor.envirophat.rst
|
||||
platypush/plugins/gpio.sensor.ltr559.rst
|
||||
platypush/plugins/gpio.sensor.mcp3008.rst
|
||||
platypush/plugins/gpio.sensor.motion.pmw3901.rst
|
||||
platypush/plugins/gpio.zeroborg.rst
|
||||
platypush/plugins/graphite.rst
|
||||
platypush/plugins/hid.rst
|
||||
platypush/plugins/http.request.rst
|
||||
platypush/plugins/http.request.rss.rst
|
||||
platypush/plugins/http.webpage.rst
|
||||
|
@ -67,7 +75,6 @@ Plugins
|
|||
platypush/plugins/mail.smtp.rst
|
||||
platypush/plugins/mailgun.rst
|
||||
platypush/plugins/mastodon.rst
|
||||
platypush/plugins/matrix.rst
|
||||
platypush/plugins/media.chromecast.rst
|
||||
platypush/plugins/media.gstreamer.rst
|
||||
platypush/plugins/media.jellyfin.rst
|
||||
|
@ -86,7 +93,6 @@ Plugins
|
|||
platypush/plugins/music.mpd.rst
|
||||
platypush/plugins/music.snapcast.rst
|
||||
platypush/plugins/music.spotify.rst
|
||||
platypush/plugins/music.tidal.rst
|
||||
platypush/plugins/nextcloud.rst
|
||||
platypush/plugins/ngrok.rst
|
||||
platypush/plugins/nmap.rst
|
||||
|
@ -101,15 +107,6 @@ Plugins
|
|||
platypush/plugins/redis.rst
|
||||
platypush/plugins/rss.rst
|
||||
platypush/plugins/rtorrent.rst
|
||||
platypush/plugins/sensor.bme280.rst
|
||||
platypush/plugins/sensor.dht.rst
|
||||
platypush/plugins/sensor.distance.vl53l1x.rst
|
||||
platypush/plugins/sensor.envirophat.rst
|
||||
platypush/plugins/sensor.hcsr04.rst
|
||||
platypush/plugins/sensor.lis3dh.rst
|
||||
platypush/plugins/sensor.ltr559.rst
|
||||
platypush/plugins/sensor.mcp3008.rst
|
||||
platypush/plugins/sensor.pmw3901.rst
|
||||
platypush/plugins/serial.rst
|
||||
platypush/plugins/shell.rst
|
||||
platypush/plugins/slack.rst
|
||||
|
@ -123,6 +120,7 @@ Plugins
|
|||
platypush/plugins/switch.tplink.rst
|
||||
platypush/plugins/switch.wemo.rst
|
||||
platypush/plugins/switchbot.rst
|
||||
platypush/plugins/switchbot.bluetooth.rst
|
||||
platypush/plugins/system.rst
|
||||
platypush/plugins/tcp.rst
|
||||
platypush/plugins/tensorflow.rst
|
||||
|
@ -132,14 +130,12 @@ Plugins
|
|||
platypush/plugins/trello.rst
|
||||
platypush/plugins/tts.rst
|
||||
platypush/plugins/tts.google.rst
|
||||
platypush/plugins/tts.mimic3.rst
|
||||
platypush/plugins/tv.samsung.ws.rst
|
||||
platypush/plugins/twilio.rst
|
||||
platypush/plugins/udp.rst
|
||||
platypush/plugins/user.rst
|
||||
platypush/plugins/utils.rst
|
||||
platypush/plugins/variable.rst
|
||||
platypush/plugins/wallabag.rst
|
||||
platypush/plugins/weather.buienradar.rst
|
||||
platypush/plugins/weather.darksky.rst
|
||||
platypush/plugins/weather.openweathermap.rst
|
||||
|
|
|
@ -3,19 +3,22 @@ Responses
|
|||
=========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:maxdepth: 2
|
||||
:caption: Responses:
|
||||
|
||||
platypush/responses/bluetooth.rst
|
||||
platypush/responses/camera.rst
|
||||
platypush/responses/camera.android.rst
|
||||
platypush/responses/chat.telegram.rst
|
||||
platypush/responses/google.drive.rst
|
||||
platypush/responses/linode.rst
|
||||
platypush/responses/pihole.rst
|
||||
platypush/responses/ping.rst
|
||||
platypush/responses/printer.cups.rst
|
||||
platypush/responses/qrcode.rst
|
||||
platypush/responses/ssh.rst
|
||||
platypush/responses/stt.rst
|
||||
platypush/responses/system.rst
|
||||
platypush/responses/tensorflow.rst
|
||||
platypush/responses/todoist.rst
|
||||
platypush/responses/translate.rst
|
||||
|
|
|
@ -109,6 +109,8 @@ calendar:
|
|||
backend.http:
|
||||
# Listening port
|
||||
port: 8008
|
||||
# Websocket port
|
||||
websocket_port: 8009
|
||||
|
||||
# Through resource_dirs you can specify external folders whose content can be accessed on
|
||||
# the web server through a custom URL. In the case below we have a Dropbox folder containing
|
||||
|
@ -163,6 +165,10 @@ backend.mqtt:
|
|||
#backend.tcp:
|
||||
# port: 3333
|
||||
|
||||
# Websocket backend. Install required dependencies through 'pip install "platypush[http]"'
|
||||
#backend.websocket:
|
||||
# port: 8765
|
||||
|
||||
## --
|
||||
## Assistant configuration examples
|
||||
## --
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
# An nginx configuration that can be used to reverse proxy connections to your
|
||||
# Platypush' HTTP service.
|
||||
|
||||
server {
|
||||
server_name my-platypush-host.domain.com;
|
||||
|
||||
# Proxy standard HTTP connections to your Platypush IP
|
||||
location / {
|
||||
proxy_pass http://my-platypush-host:8008/;
|
||||
|
||||
client_max_body_size 5M;
|
||||
proxy_read_timeout 60;
|
||||
proxy_connect_timeout 60;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Proxy websocket connections
|
||||
location ~ ^/ws/(.*)$ {
|
||||
proxy_pass http://10.0.0.2:8008/ws/$1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
proxy_http_version 1.1;
|
||||
client_max_body_size 200M;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
# Optional SSL configuration - using Let's Encrypt certificates in this case
|
||||
# listen 443 ssl;
|
||||
# ssl_certificate /etc/letsencrypt/live/my-platypush-host.domain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/my-platypush-host.domain.com/privkey.pem;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.context import get_plugin
|
||||
|
@ -14,11 +13,21 @@ def _get_inspect_plugin():
|
|||
|
||||
|
||||
def get_all_plugins():
|
||||
return sorted([mf.component_name for mf in get_manifests(Plugin)])
|
||||
manifests = {mf.component_name for mf in get_manifests(Plugin)}
|
||||
return {
|
||||
plugin_name: plugin_info
|
||||
for plugin_name, plugin_info in _get_inspect_plugin().get_all_plugins().output.items()
|
||||
if plugin_name in manifests
|
||||
}
|
||||
|
||||
|
||||
def get_all_backends():
|
||||
return sorted([mf.component_name for mf in get_manifests(Backend)])
|
||||
manifests = {mf.component_name for mf in get_manifests(Backend)}
|
||||
return {
|
||||
backend_name: backend_info
|
||||
for backend_name, backend_info in _get_inspect_plugin().get_all_backends().output.items()
|
||||
if backend_name in manifests
|
||||
}
|
||||
|
||||
|
||||
def get_all_events():
|
||||
|
@ -29,122 +38,142 @@ def get_all_responses():
|
|||
return _get_inspect_plugin().get_all_responses().output
|
||||
|
||||
|
||||
def _generate_components_doc(
|
||||
index_name: str,
|
||||
package_name: str,
|
||||
components: Iterable[str],
|
||||
doc_dir: Optional[str] = None,
|
||||
):
|
||||
if not doc_dir:
|
||||
doc_dir = index_name
|
||||
# noinspection DuplicatedCode
|
||||
def generate_plugins_doc():
|
||||
plugins_index = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'plugins.rst')
|
||||
plugins_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'platypush', 'plugins')
|
||||
all_plugins = sorted(plugin for plugin in get_all_plugins().keys())
|
||||
|
||||
index_file = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
'docs',
|
||||
'source',
|
||||
f'{index_name}.rst',
|
||||
)
|
||||
docs_dir = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
'docs',
|
||||
'source',
|
||||
'platypush',
|
||||
doc_dir,
|
||||
)
|
||||
|
||||
for comp in components:
|
||||
comp_file = os.path.join(docs_dir, comp + '.rst')
|
||||
if not os.path.exists(comp_file):
|
||||
comp = f'platypush.{package_name}.{comp}'
|
||||
header = '``' + '.'.join(comp.split('.')[2:]) + '``'
|
||||
for plugin in all_plugins:
|
||||
plugin_file = os.path.join(plugins_dir, plugin + '.rst')
|
||||
if not os.path.exists(plugin_file):
|
||||
plugin = 'platypush.plugins.' + plugin
|
||||
header = '``{}``'.format('.'.join(plugin.split('.')[2:]))
|
||||
divider = '=' * len(header)
|
||||
body = f'\n.. automodule:: {comp}\n :members:\n'
|
||||
body = '\n.. automodule:: {}\n :members:\n'.format(plugin)
|
||||
out = '\n'.join([header, divider, body])
|
||||
|
||||
with open(comp_file, 'w') as f:
|
||||
with open(plugin_file, 'w') as f:
|
||||
f.write(out)
|
||||
|
||||
with open(index_file, 'w') as f:
|
||||
f.write(
|
||||
f'''
|
||||
{index_name.title()}
|
||||
{''.join(['='] * len(index_name))}
|
||||
with open(plugins_index, 'w') as f:
|
||||
f.write('''
|
||||
Plugins
|
||||
=======
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: {index_name.title()}:
|
||||
:maxdepth: 2
|
||||
:caption: Plugins:
|
||||
|
||||
'''
|
||||
)
|
||||
''')
|
||||
|
||||
for comp in components:
|
||||
f.write(f' platypush/{doc_dir}/{comp}.rst\n')
|
||||
|
||||
_cleanup_removed_components_docs(docs_dir, components)
|
||||
|
||||
|
||||
def _cleanup_removed_components_docs(docs_dir: str, components: Iterable[str]):
|
||||
new_components = set(components)
|
||||
existing_files = {
|
||||
os.path.join(root, file)
|
||||
for root, _, files in os.walk(docs_dir)
|
||||
for file in files
|
||||
if file.endswith('.rst')
|
||||
}
|
||||
|
||||
files_to_remove = {
|
||||
file
|
||||
for file in existing_files
|
||||
if os.path.basename(file).removesuffix('.rst') not in new_components
|
||||
}
|
||||
|
||||
for file in files_to_remove:
|
||||
print(f'Removing unlinked component {file}')
|
||||
os.unlink(file)
|
||||
|
||||
|
||||
def generate_plugins_doc():
|
||||
_generate_components_doc(
|
||||
index_name='plugins', package_name='plugins', components=get_all_plugins()
|
||||
)
|
||||
for plugin in all_plugins:
|
||||
f.write(' platypush/plugins/' + plugin + '.rst\n')
|
||||
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
def generate_backends_doc():
|
||||
_generate_components_doc(
|
||||
index_name='backends',
|
||||
package_name='backend',
|
||||
components=get_all_backends(),
|
||||
doc_dir='backend',
|
||||
)
|
||||
backends_index = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'backends.rst')
|
||||
backends_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'platypush', 'backend')
|
||||
all_backends = sorted(backend for backend in get_all_backends().keys())
|
||||
|
||||
for backend in all_backends:
|
||||
backend_file = os.path.join(backends_dir, backend + '.rst')
|
||||
if not os.path.exists(backend_file):
|
||||
backend = 'platypush.backend.' + backend
|
||||
header = '``{}``'.format('.'.join(backend.split('.')[2:]))
|
||||
divider = '=' * len(header)
|
||||
body = '\n.. automodule:: {}\n :members:\n'.format(backend)
|
||||
out = '\n'.join([header, divider, body])
|
||||
|
||||
with open(backend_file, 'w') as f:
|
||||
f.write(out)
|
||||
|
||||
with open(backends_index, 'w') as f:
|
||||
f.write('''
|
||||
Backends
|
||||
========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Backends:
|
||||
|
||||
''')
|
||||
|
||||
for backend in all_backends:
|
||||
f.write(' platypush/backend/' + backend + '.rst\n')
|
||||
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
def generate_events_doc():
|
||||
_generate_components_doc(
|
||||
index_name='events',
|
||||
package_name='message.event',
|
||||
components=sorted(event for event in get_all_events().keys() if event),
|
||||
)
|
||||
from platypush.message import event as event_module
|
||||
events_index = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'events.rst')
|
||||
events_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'platypush', 'events')
|
||||
all_events = sorted(event for event in get_all_events().keys() if event)
|
||||
|
||||
for event in all_events:
|
||||
event_file = os.path.join(events_dir, event + '.rst')
|
||||
if not os.path.exists(event_file):
|
||||
header = '``{}``'.format(event)
|
||||
divider = '=' * len(header)
|
||||
body = '\n.. automodule:: {}.{}\n :members:\n'.format(event_module.__name__, event)
|
||||
out = '\n'.join([header, divider, body])
|
||||
|
||||
with open(event_file, 'w') as f:
|
||||
f.write(out)
|
||||
|
||||
with open(events_index, 'w') as f:
|
||||
f.write('''
|
||||
Events
|
||||
======
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Events:
|
||||
|
||||
''')
|
||||
|
||||
for event in all_events:
|
||||
f.write(' platypush/events/' + event + '.rst\n')
|
||||
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
def generate_responses_doc():
|
||||
_generate_components_doc(
|
||||
index_name='responses',
|
||||
package_name='message.response',
|
||||
components=sorted(
|
||||
response for response in get_all_responses().keys() if response
|
||||
),
|
||||
)
|
||||
from platypush.message import response as response_module
|
||||
responses_index = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'responses.rst')
|
||||
responses_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs', 'source', 'platypush', 'responses')
|
||||
all_responses = sorted(response for response in get_all_responses().keys() if response)
|
||||
|
||||
for response in all_responses:
|
||||
response_file = os.path.join(responses_dir, response + '.rst')
|
||||
if not os.path.exists(response_file):
|
||||
header = '``{}``'.format(response)
|
||||
divider = '=' * len(header)
|
||||
body = '\n.. automodule:: {}.{}\n :members:\n'.format(response_module.__name__, response)
|
||||
out = '\n'.join([header, divider, body])
|
||||
|
||||
with open(response_file, 'w') as f:
|
||||
f.write(out)
|
||||
|
||||
with open(responses_index, 'w') as f:
|
||||
f.write('''
|
||||
Responses
|
||||
=========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Responses:
|
||||
|
||||
''')
|
||||
|
||||
for response in all_responses:
|
||||
f.write(' platypush/responses/' + response + '.rst\n')
|
||||
|
||||
|
||||
def main():
|
||||
generate_plugins_doc()
|
||||
generate_backends_doc()
|
||||
generate_events_doc()
|
||||
generate_responses_doc()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
generate_plugins_doc()
|
||||
generate_backends_doc()
|
||||
generate_events_doc()
|
||||
generate_responses_doc()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -9,13 +9,11 @@ import argparse
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from .bus.redis import RedisBus
|
||||
from .config import Config
|
||||
from .context import register_backends, register_plugins
|
||||
from .cron.scheduler import CronScheduler
|
||||
from .entities import init_entities_engine, EntitiesEngine
|
||||
from .event.processor import EventProcessor
|
||||
from .logger import Logger
|
||||
from .message.event import Event
|
||||
|
@ -25,9 +23,9 @@ from .message.response import Response
|
|||
from .utils import set_thread_name, get_enabled_plugins
|
||||
|
||||
__author__ = 'Fabio Manganiello <info@fabiomanganiello.com>'
|
||||
__version__ = '0.24.5'
|
||||
__version__ = '0.23.3'
|
||||
|
||||
log = logging.getLogger('platypush')
|
||||
logger = logging.getLogger('platypush')
|
||||
|
||||
|
||||
class Daemon:
|
||||
|
@ -61,7 +59,6 @@ class Daemon:
|
|||
no_capture_stdout=False,
|
||||
no_capture_stderr=False,
|
||||
redis_queue=None,
|
||||
verbose=False,
|
||||
):
|
||||
"""
|
||||
Constructor
|
||||
|
@ -77,7 +74,6 @@ class Daemon:
|
|||
no_capture_stderr -- Set to true if you want to disable the stderr
|
||||
capture by the logging system
|
||||
redis_queue -- Name of the (Redis) queue used for dispatching messages (default: platypush/bus).
|
||||
verbose -- Enable debug/verbose logging, overriding the stored configuration (default: False).
|
||||
"""
|
||||
|
||||
if pidfile:
|
||||
|
@ -88,10 +84,7 @@ class Daemon:
|
|||
self.redis_queue = redis_queue or self._default_redis_queue
|
||||
self.config_file = config_file
|
||||
Config.init(self.config_file)
|
||||
logging_conf = Config.get('logging') or {}
|
||||
if verbose:
|
||||
logging_conf['level'] = logging.DEBUG
|
||||
logging.basicConfig(**logging_conf)
|
||||
logging.basicConfig(**Config.get('logging'))
|
||||
|
||||
redis_conf = Config.get('backend.redis') or {}
|
||||
self.bus = RedisBus(
|
||||
|
@ -103,7 +96,6 @@ class Daemon:
|
|||
self.no_capture_stdout = no_capture_stdout
|
||||
self.no_capture_stderr = no_capture_stderr
|
||||
self.event_processor = EventProcessor()
|
||||
self.entities_engine: Optional[EntitiesEngine] = None
|
||||
self.requests_to_process = requests_to_process
|
||||
self.processed_requests = 0
|
||||
self.cron_scheduler = None
|
||||
|
@ -124,21 +116,6 @@ class Daemon:
|
|||
default=None,
|
||||
help=cls.config_file.__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
dest='version',
|
||||
required=False,
|
||||
action='store_true',
|
||||
help="Print the current version and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
'-v',
|
||||
dest='verbose',
|
||||
required=False,
|
||||
action='store_true',
|
||||
help="Enable verbose/debug logging",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pidfile',
|
||||
'-P',
|
||||
|
@ -177,18 +154,12 @@ class Daemon:
|
|||
)
|
||||
|
||||
opts, args = parser.parse_known_args(args)
|
||||
|
||||
if opts.version:
|
||||
print(__version__)
|
||||
sys.exit(0)
|
||||
|
||||
return cls(
|
||||
config_file=opts.config,
|
||||
pidfile=opts.pidfile,
|
||||
no_capture_stdout=opts.no_capture_stdout,
|
||||
no_capture_stderr=opts.no_capture_stderr,
|
||||
redis_queue=opts.redis_queue,
|
||||
verbose=opts.verbose,
|
||||
)
|
||||
|
||||
def on_message(self):
|
||||
|
@ -207,7 +178,7 @@ class Daemon:
|
|||
try:
|
||||
msg.execute(n_tries=self.n_tries)
|
||||
except PermissionError:
|
||||
log.info('Dropped unauthorized request: {}'.format(msg))
|
||||
logger.info('Dropped unauthorized request: {}'.format(msg))
|
||||
|
||||
self.processed_requests += 1
|
||||
if (
|
||||
|
@ -216,9 +187,10 @@ class Daemon:
|
|||
):
|
||||
self.stop_app()
|
||||
elif isinstance(msg, Response):
|
||||
msg.log()
|
||||
logger.info('Received response: {}'.format(msg))
|
||||
elif isinstance(msg, Event):
|
||||
msg.log()
|
||||
if not msg.disable_logging:
|
||||
logger.info('Received event: {}'.format(msg))
|
||||
self.event_processor.process_event(msg)
|
||||
|
||||
return _f
|
||||
|
@ -227,35 +199,26 @@ class Daemon:
|
|||
"""Stops the backends and the bus"""
|
||||
from .plugins import RunnablePlugin
|
||||
|
||||
if self.backends:
|
||||
for backend in self.backends.values():
|
||||
backend.stop()
|
||||
for backend in self.backends.values():
|
||||
backend.stop()
|
||||
|
||||
for plugin in get_enabled_plugins().values():
|
||||
if isinstance(plugin, RunnablePlugin):
|
||||
plugin.stop()
|
||||
|
||||
if self.bus:
|
||||
self.bus.stop()
|
||||
self.bus = None
|
||||
|
||||
self.bus.stop()
|
||||
if self.cron_scheduler:
|
||||
self.cron_scheduler.stop()
|
||||
self.cron_scheduler = None
|
||||
|
||||
if self.entities_engine:
|
||||
self.entities_engine.stop()
|
||||
self.entities_engine = None
|
||||
|
||||
def run(self):
|
||||
"""Start the daemon"""
|
||||
if not self.no_capture_stdout:
|
||||
sys.stdout = Logger(log.info)
|
||||
sys.stdout = Logger(logger.info)
|
||||
if not self.no_capture_stderr:
|
||||
sys.stderr = Logger(log.warning)
|
||||
sys.stderr = Logger(logger.warning)
|
||||
|
||||
set_thread_name('platypush')
|
||||
log.info('---- Starting platypush v.{}'.format(__version__))
|
||||
logger.info('---- Starting platypush v.{}'.format(__version__))
|
||||
|
||||
# Initialize the backends and link them to the bus
|
||||
self.backends = register_backends(bus=self.bus, global_scope=True)
|
||||
|
@ -267,22 +230,18 @@ class Daemon:
|
|||
# Initialize the plugins
|
||||
register_plugins(bus=self.bus)
|
||||
|
||||
# Initialize the entities engine
|
||||
self.entities_engine = init_entities_engine()
|
||||
|
||||
# Start the cron scheduler
|
||||
if Config.get_cronjobs():
|
||||
self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs())
|
||||
self.cron_scheduler.start()
|
||||
|
||||
assert self.bus, 'The bus is not running'
|
||||
self.bus.post(ApplicationStartedEvent())
|
||||
|
||||
# Poll for messages on the bus
|
||||
try:
|
||||
self.bus.poll()
|
||||
except KeyboardInterrupt:
|
||||
log.info('SIGINT received, terminating application')
|
||||
logger.info('SIGINT received, terminating application')
|
||||
finally:
|
||||
self.stop_app()
|
||||
|
||||
|
|
|
@ -8,22 +8,11 @@ import os
|
|||
import time
|
||||
|
||||
from platypush.backend.assistant import AssistantBackend
|
||||
from platypush.message.event.assistant import (
|
||||
ConversationStartEvent,
|
||||
ConversationEndEvent,
|
||||
ConversationTimeoutEvent,
|
||||
ResponseEvent,
|
||||
NoResponseEvent,
|
||||
SpeechRecognizedEvent,
|
||||
AlarmStartedEvent,
|
||||
AlarmEndEvent,
|
||||
TimerStartedEvent,
|
||||
TimerEndEvent,
|
||||
AlertStartedEvent,
|
||||
AlertEndEvent,
|
||||
MicMutedEvent,
|
||||
MicUnmutedEvent,
|
||||
)
|
||||
from platypush.message.event.assistant import \
|
||||
ConversationStartEvent, ConversationEndEvent, ConversationTimeoutEvent, \
|
||||
ResponseEvent, NoResponseEvent, SpeechRecognizedEvent, AlarmStartedEvent, \
|
||||
AlarmEndEvent, TimerStartedEvent, TimerEndEvent, AlertStartedEvent, \
|
||||
AlertEndEvent, MicMutedEvent, MicUnmutedEvent
|
||||
|
||||
|
||||
class AssistantGoogleBackend(AssistantBackend):
|
||||
|
@ -68,30 +57,22 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
|
||||
* **google-assistant-library** (``pip install google-assistant-library``)
|
||||
* **google-assistant-sdk[samples]** (``pip install google-assistant-sdk[samples]``)
|
||||
* **google-auth** (``pip install google-auth``)
|
||||
|
||||
"""
|
||||
|
||||
_default_credentials_file = os.path.join(
|
||||
os.path.expanduser('~/.config'), 'google-oauthlib-tool', 'credentials.json'
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credentials_file=_default_credentials_file,
|
||||
device_model_id='Platypush',
|
||||
**kwargs
|
||||
):
|
||||
def __init__(self,
|
||||
credentials_file=os.path.join(
|
||||
os.path.expanduser('~/.config'),
|
||||
'google-oauthlib-tool', 'credentials.json'),
|
||||
device_model_id='Platypush', **kwargs):
|
||||
"""
|
||||
:param credentials_file: Path to the Google OAuth credentials file
|
||||
(default: ~/.config/google-oauthlib-tool/credentials.json).
|
||||
See
|
||||
https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials
|
||||
:param credentials_file: Path to the Google OAuth credentials file \
|
||||
(default: ~/.config/google-oauthlib-tool/credentials.json). \
|
||||
See https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials \
|
||||
for instructions to get your own credentials file.
|
||||
|
||||
:type credentials_file: str
|
||||
|
||||
:param device_model_id: Device model ID to use for the assistant
|
||||
:param device_model_id: Device model ID to use for the assistant \
|
||||
(default: Platypush)
|
||||
:type device_model_id: str
|
||||
"""
|
||||
|
@ -121,23 +102,17 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
self.bus.post(ConversationTimeoutEvent(assistant=self))
|
||||
elif event.type == EventType.ON_NO_RESPONSE:
|
||||
self.bus.post(NoResponseEvent(assistant=self))
|
||||
elif (
|
||||
hasattr(EventType, 'ON_RENDER_RESPONSE')
|
||||
and event.type == EventType.ON_RENDER_RESPONSE
|
||||
):
|
||||
self.bus.post(
|
||||
ResponseEvent(assistant=self, response_text=event.args.get('text'))
|
||||
)
|
||||
elif hasattr(EventType, 'ON_RENDER_RESPONSE') and \
|
||||
event.type == EventType.ON_RENDER_RESPONSE:
|
||||
self.bus.post(ResponseEvent(assistant=self, response_text=event.args.get('text')))
|
||||
tts, args = self._get_tts_plugin()
|
||||
|
||||
if tts and 'text' in event.args:
|
||||
self.stop_conversation()
|
||||
tts.say(text=event.args['text'], **args)
|
||||
elif (
|
||||
hasattr(EventType, 'ON_RESPONDING_STARTED')
|
||||
and event.type == EventType.ON_RESPONDING_STARTED
|
||||
and event.args.get('is_error_response', False) is True
|
||||
):
|
||||
elif hasattr(EventType, 'ON_RESPONDING_STARTED') and \
|
||||
event.type == EventType.ON_RESPONDING_STARTED and \
|
||||
event.args.get('is_error_response', False) is True:
|
||||
self.logger.warning('Assistant response error')
|
||||
elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED:
|
||||
phrase = event.args['text'].lower().strip()
|
||||
|
@ -169,12 +144,12 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
self.bus.post(event)
|
||||
|
||||
def start_conversation(self):
|
||||
"""Starts an assistant conversation"""
|
||||
""" Starts an assistant conversation """
|
||||
if self.assistant:
|
||||
self.assistant.start_conversation()
|
||||
|
||||
def stop_conversation(self):
|
||||
"""Stops an assistant conversation"""
|
||||
""" Stops an assistant conversation """
|
||||
if self.assistant:
|
||||
self.assistant.stop_conversation()
|
||||
|
||||
|
@ -202,9 +177,7 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
super().run()
|
||||
|
||||
with open(self.credentials_file, 'r') as f:
|
||||
self.credentials = google.oauth2.credentials.Credentials(
|
||||
token=None, **json.load(f)
|
||||
)
|
||||
self.credentials = google.oauth2.credentials.Credentials(token=None, **json.load(f))
|
||||
|
||||
while not self.should_stop():
|
||||
self._has_error = False
|
||||
|
@ -213,16 +186,12 @@ class AssistantGoogleBackend(AssistantBackend):
|
|||
self.assistant = assistant
|
||||
for event in assistant.start():
|
||||
if not self.is_detecting():
|
||||
self.logger.info(
|
||||
'Assistant event received but detection is currently paused'
|
||||
)
|
||||
self.logger.info('Assistant event received but detection is currently paused')
|
||||
continue
|
||||
|
||||
self._process_event(event)
|
||||
if self._has_error:
|
||||
self.logger.info(
|
||||
'Restarting the assistant after an unrecoverable error'
|
||||
)
|
||||
self.logger.info('Restarting the assistant after an unrecoverable error')
|
||||
time.sleep(5)
|
||||
break
|
||||
|
||||
|
|
|
@ -22,6 +22,5 @@ manifest:
|
|||
pip:
|
||||
- google-assistant-library
|
||||
- google-assistant-sdk[samples]
|
||||
- google-auth
|
||||
package: platypush.backend.assistant.google
|
||||
type: backend
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
import os
|
||||
import time
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
from PyOBEX import headers, requests, responses
|
||||
# noinspection PyPackageRequirements
|
||||
from PyOBEX.server import Server
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.message.event.bluetooth import BluetoothDeviceConnectedEvent, BluetoothFileReceivedEvent, \
|
||||
BluetoothDeviceDisconnectedEvent, BluetoothFilePutRequestEvent
|
||||
|
||||
|
||||
class BluetoothBackend(Backend, Server):
|
||||
_sleep_on_error = 10.0
|
||||
|
||||
def __init__(self, address: str = '', port: int = None, directory: str = None, whitelisted_addresses=None,
|
||||
**kwargs):
|
||||
Backend.__init__(self, **kwargs)
|
||||
Server.__init__(self, address=address)
|
||||
self.port = port
|
||||
self.directory = os.path.join(os.path.expanduser(directory))
|
||||
self.whitelisted_addresses = whitelisted_addresses or []
|
||||
self._sock = None
|
||||
|
||||
def run(self):
|
||||
self.logger.info('Starting bluetooth service [address={}] [port={}]'.format(
|
||||
self.address, self.port))
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
# noinspection PyArgumentList
|
||||
self._sock = self.start_service(self.port)
|
||||
self.serve(self._sock)
|
||||
except Exception as e:
|
||||
self.logger.error('Error on bluetooth connection [address={}] [port={}]: {}'.format(
|
||||
self.address, self.port, str(e)))
|
||||
time.sleep(self._sleep_on_error)
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
if self._sock:
|
||||
self.stop_service(self._sock)
|
||||
self._sock = None
|
||||
|
||||
def put(self, socket, request):
|
||||
name = ""
|
||||
body = ""
|
||||
|
||||
while True:
|
||||
for header in request.header_data:
|
||||
if isinstance(header, headers.Name):
|
||||
name = header.decode()
|
||||
self.logger.info("Receiving {}".format(name))
|
||||
elif isinstance(header, headers.Length):
|
||||
length = header.decode()
|
||||
self.logger.info("Content length: {} bytes".format(length))
|
||||
elif isinstance(header, headers.Body):
|
||||
body += header.decode()
|
||||
elif isinstance(header, headers.End_Of_Body):
|
||||
body += header.decode()
|
||||
|
||||
if request.is_final():
|
||||
break
|
||||
|
||||
# Ask for more data.
|
||||
Server.send_response(self, socket, responses.Continue())
|
||||
|
||||
# Get the next part of the data.
|
||||
request = self.request_handler.decode(socket)
|
||||
|
||||
Server.send_response(self, socket, responses.Success())
|
||||
name = os.path.basename(name.strip("\x00"))
|
||||
path = os.path.join(self.directory, name)
|
||||
|
||||
self.logger.info("Writing file {}" .format(path))
|
||||
open(path, "wb").write(body.encode())
|
||||
self.bus.post(BluetoothFileReceivedEvent(path=path))
|
||||
|
||||
def process_request(self, connection, request, *address):
|
||||
if isinstance(request, requests.Connect):
|
||||
self.connect(connection, request)
|
||||
self.bus.post(BluetoothDeviceConnectedEvent(address=address[0], port=address[1]))
|
||||
elif isinstance(request, requests.Disconnect):
|
||||
self.disconnect(connection, request)
|
||||
self.bus.post(BluetoothDeviceDisconnectedEvent(address=address[0], port=address[1]))
|
||||
elif isinstance(request, requests.Put):
|
||||
self.bus.post(BluetoothFilePutRequestEvent(address=address[0], port=address[1]))
|
||||
self.put(connection, request)
|
||||
else:
|
||||
self._reject(connection)
|
||||
self.bus.post(BluetoothFilePutRequestEvent(address=address[0], port=address[1]))
|
||||
|
||||
def accept_connection(self, address, port):
|
||||
return address in self.whitelisted_addresses
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -0,0 +1,92 @@
|
|||
import os
|
||||
import stat
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
from PyOBEX import requests, responses, headers
|
||||
# noinspection PyPackageRequirements
|
||||
from PyOBEX.server import BrowserServer
|
||||
|
||||
from platypush.backend.bluetooth import BluetoothBackend
|
||||
from platypush.message.event.bluetooth import BluetoothFileGetRequestEvent
|
||||
|
||||
|
||||
class BluetoothFileserverBackend(BluetoothBackend, BrowserServer):
|
||||
"""
|
||||
Bluetooth OBEX file server.
|
||||
Enable it to allow bluetooth devices to browse files on this machine.
|
||||
|
||||
If you run platypush as a non-root user (and you should) then you to change the group owner of the
|
||||
service discovery protocol file (/var/run/sdp) and add your user to that group. See
|
||||
`here <https://stackoverflow.com/questions/34599703/rfcomm-bluetooth-permission-denied-error-raspberry-pi>`_
|
||||
for details.
|
||||
|
||||
Requires:
|
||||
|
||||
* **pybluez** (``pip install pybluez``)
|
||||
* **pyobex** (``pip install git+https://github.com/BlackLight/PyOBEX``)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, port: int, address: str = '', directory: str = os.path.expanduser('~'),
|
||||
whitelisted_addresses: list = None, **kwargs):
|
||||
"""
|
||||
:param port: Bluetooth listen port
|
||||
:param address: Bluetooth address to bind the server to (default: any)
|
||||
:param directory: Directory to share (default: HOME directory)
|
||||
:param whitelisted_addresses: If set then only accept connections from the listed device addresses
|
||||
"""
|
||||
BluetoothBackend.__init__(self, address=address, port=port, directory=directory,
|
||||
whitelisted_addresses=whitelisted_addresses, **kwargs)
|
||||
|
||||
if not os.path.isdir(self.directory):
|
||||
raise FileNotFoundError(self.directory)
|
||||
|
||||
def process_request(self, socket, request, *address):
|
||||
if isinstance(request, requests.Get):
|
||||
self.bus.post(BluetoothFileGetRequestEvent(address=address[0], port=address[1]))
|
||||
self.get(socket, request)
|
||||
else:
|
||||
super().process_request(socket, request, *address)
|
||||
|
||||
def get(self, socket, request):
|
||||
name = ""
|
||||
req_type = ""
|
||||
|
||||
for header in request.header_data:
|
||||
if isinstance(header, headers.Name):
|
||||
name = header.decode().strip("\x00")
|
||||
self.logger.info("Receiving request for {}".format(name))
|
||||
elif isinstance(header, headers.Type):
|
||||
req_type = header.decode().strip("\x00")
|
||||
self.logger.info("Request type: {}".format(req_type))
|
||||
|
||||
path = os.path.abspath(os.path.join(self.directory, name))
|
||||
|
||||
if os.path.isdir(path) or req_type == "x-obex/folder-listing":
|
||||
if path.startswith(self.directory):
|
||||
filelist = os.listdir(path)
|
||||
s = '<?xml version="1.0"?>\n<folder-listing>\n'
|
||||
|
||||
for i in filelist:
|
||||
objpath = os.path.join(path, i)
|
||||
if os.path.isdir(objpath):
|
||||
s += ' <folder name="{}" created="{}" />'.format(i, os.stat(objpath)[stat.ST_CTIME])
|
||||
else:
|
||||
s += ' <file name="{}" created="{}" size="{}" />'.format(
|
||||
i, os.stat(objpath)[stat.ST_CTIME], os.stat(objpath)[stat.ST_SIZE])
|
||||
|
||||
s += "</folder-listing>\n"
|
||||
self.logger.debug('Bluetooth get XML output:\n' + s)
|
||||
|
||||
response = responses.Success()
|
||||
response_headers = [headers.Name(name.encode("utf8")),
|
||||
headers.Length(len(s)),
|
||||
headers.Body(s.encode("utf8"))]
|
||||
BrowserServer.send_response(self, socket, response, response_headers)
|
||||
else:
|
||||
self._reject(socket)
|
||||
else:
|
||||
self._reject(socket)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -0,0 +1,8 @@
|
|||
manifest:
|
||||
events: {}
|
||||
install:
|
||||
pip:
|
||||
- pybluez
|
||||
- pyobex
|
||||
package: platypush.backend.bluetooth.fileserver
|
||||
type: backend
|
|
@ -0,0 +1,47 @@
|
|||
import os
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
from PyOBEX.server import PushServer
|
||||
|
||||
from platypush.backend.bluetooth import BluetoothBackend
|
||||
|
||||
|
||||
class BluetoothPushserverBackend(BluetoothBackend, PushServer):
|
||||
"""
|
||||
Bluetooth OBEX push server.
|
||||
Enable it to allow bluetooth file transfers from other devices.
|
||||
|
||||
If you run platypush as a non-root user (and you should) then you to change the group owner of the
|
||||
service discovery protocol file (/var/run/sdp) and add your user to that group. See
|
||||
`here <https://stackoverflow.com/questions/34599703/rfcomm-bluetooth-permission-denied-error-raspberry-pi>`_
|
||||
for details.
|
||||
|
||||
Requires:
|
||||
|
||||
* **pybluez** (``pip install pybluez``)
|
||||
* **pyobex** (``pip install git+https://github.com/BlackLight/PyOBEX``)
|
||||
|
||||
"""
|
||||
|
||||
_sleep_on_error = 10.0
|
||||
|
||||
def __init__(self, port: int, address: str = '',
|
||||
directory: str = os.path.join(os.path.expanduser('~'), 'bluetooth'),
|
||||
whitelisted_addresses: list = None, **kwargs):
|
||||
"""
|
||||
:param port: Bluetooth listen port
|
||||
:param address: Bluetooth address to bind the server to (default: any)
|
||||
:param directory: Destination directory where files will be downloaded (default: ~/bluetooth)
|
||||
:param whitelisted_addresses: If set then only accept connections from the listed device addresses
|
||||
"""
|
||||
BluetoothBackend.__init__(self, address=address, port=port, directory=directory,
|
||||
whitelisted_addresses=whitelisted_addresses, **kwargs)
|
||||
|
||||
def run(self):
|
||||
if not os.path.isdir(self.directory):
|
||||
os.makedirs(self.directory, exist_ok=True)
|
||||
|
||||
super().run()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -0,0 +1,8 @@
|
|||
manifest:
|
||||
events: {}
|
||||
install:
|
||||
pip:
|
||||
- pybluez
|
||||
- pyobex
|
||||
package: platypush.backend.bluetooth.pushserver
|
||||
type: backend
|
|
@ -0,0 +1,109 @@
|
|||
import time
|
||||
from threading import Thread, RLock
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from platypush.backend.sensor import SensorBackend
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.bluetooth import BluetoothDeviceFoundEvent, BluetoothDeviceLostEvent
|
||||
|
||||
|
||||
class BluetoothScannerBackend(SensorBackend):
|
||||
"""
|
||||
This backend periodically scans for available bluetooth devices and returns events when a devices enter or exits
|
||||
the range.
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent` when a new bluetooth device is found.
|
||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent` when a bluetooth device is lost.
|
||||
|
||||
Requires:
|
||||
|
||||
* The :class:`platypush.plugins.bluetooth.BluetoothPlugin` plugin working.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: Optional[int] = None, scan_duration: int = 10,
|
||||
track_devices: Optional[List[str]] = None, **kwargs):
|
||||
"""
|
||||
:param device_id: Bluetooth adapter ID to use (default configured on the ``bluetooth`` plugin if None).
|
||||
:param scan_duration: How long the scan should run (default: 10 seconds).
|
||||
:param track_devices: List of addresses of devices to actively track, even if they aren't discoverable.
|
||||
"""
|
||||
super().__init__(plugin='bluetooth', plugin_args={
|
||||
'device_id': device_id,
|
||||
'duration': scan_duration,
|
||||
}, **kwargs)
|
||||
|
||||
self._last_seen_devices = {}
|
||||
self._tracking_thread: Optional[Thread] = None
|
||||
self._bt_lock = RLock()
|
||||
self.track_devices = set(track_devices or [])
|
||||
self.scan_duration = scan_duration
|
||||
|
||||
def _add_last_seen_device(self, dev):
|
||||
addr = dev.pop('addr')
|
||||
if addr not in self._last_seen_devices:
|
||||
self.bus.post(BluetoothDeviceFoundEvent(address=addr, **dev))
|
||||
self._last_seen_devices[addr] = {'addr': addr, **dev}
|
||||
|
||||
def _remove_last_seen_device(self, addr: str):
|
||||
dev = self._last_seen_devices.get(addr)
|
||||
if not dev:
|
||||
return
|
||||
|
||||
self.bus.post(BluetoothDeviceLostEvent(address=addr, **dev))
|
||||
del self._last_seen_devices[addr]
|
||||
|
||||
def _addr_tracker(self, addr):
|
||||
with self._bt_lock:
|
||||
name = get_plugin('bluetooth').lookup_name(addr, timeout=self.scan_duration).name
|
||||
|
||||
if name is None:
|
||||
self._remove_last_seen_device(addr)
|
||||
else:
|
||||
self._add_last_seen_device({'addr': addr, 'name': name})
|
||||
|
||||
def _bt_tracker(self):
|
||||
self.logger.info('Starting Bluetooth tracker')
|
||||
while not self.should_stop():
|
||||
trackers = []
|
||||
for addr in self.track_devices:
|
||||
tracker = Thread(target=self._addr_tracker, args=(addr,))
|
||||
tracker.start()
|
||||
trackers.append(tracker)
|
||||
|
||||
for tracker in trackers:
|
||||
tracker.join(timeout=self.scan_duration)
|
||||
|
||||
time.sleep(self.scan_duration)
|
||||
|
||||
self.logger.info('Bluetooth tracker stopped')
|
||||
|
||||
def get_measurement(self):
|
||||
with self._bt_lock:
|
||||
return super().get_measurement()
|
||||
|
||||
def process_data( # lgtm [py/inheritance/signature-mismatch]
|
||||
self, data: Dict[str, dict], new_data: Optional[Dict[str, dict]] = None, **_
|
||||
):
|
||||
for addr, dev in data.items():
|
||||
self._add_last_seen_device(dev)
|
||||
|
||||
for addr, dev in self._last_seen_devices.copy().items():
|
||||
if addr not in data and addr not in self.track_devices:
|
||||
self._remove_last_seen_device(addr)
|
||||
|
||||
def run(self):
|
||||
self._tracking_thread = Thread(target=self._bt_tracker)
|
||||
self._tracking_thread.start()
|
||||
super().run()
|
||||
|
||||
def on_stop(self):
|
||||
super().on_stop()
|
||||
if self._tracking_thread and self._tracking_thread.is_alive():
|
||||
self.logger.info('Waiting for the Bluetooth tracking thread to stop')
|
||||
self._tracking_thread.join(timeout=self.scan_duration)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -0,0 +1,33 @@
|
|||
from typing import Optional
|
||||
|
||||
from platypush.backend.bluetooth.scanner import BluetoothScannerBackend
|
||||
|
||||
|
||||
class BluetoothBleScannerBackend(BluetoothScannerBackend):
|
||||
"""
|
||||
This backend periodically scans for available bluetooth low-energy devices and returns events when a devices enter
|
||||
or exits the range.
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent` when a new bluetooth device is found.
|
||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent` when a bluetooth device is lost.
|
||||
|
||||
Requires:
|
||||
|
||||
* The :class:`platypush.plugins.bluetooth.BluetoothBlePlugin` plugin working.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, interface: Optional[int] = None, scan_duration: int = 10, **kwargs):
|
||||
"""
|
||||
:param interface: Bluetooth adapter name to use (default configured on the ``bluetooth.ble`` plugin if None).
|
||||
:param scan_duration: How long the scan should run (default: 10 seconds).
|
||||
"""
|
||||
super().__init__(plugin='bluetooth.ble', plugin_args={
|
||||
'interface': interface,
|
||||
'duration': scan_duration,
|
||||
}, **kwargs)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -0,0 +1,10 @@
|
|||
manifest:
|
||||
events:
|
||||
platypush.message.event.bluetooth.BluetoothDeviceFoundEvent: when a new bluetooth
|
||||
device is found.
|
||||
platypush.message.event.bluetooth.BluetoothDeviceLostEvent: when a bluetooth device
|
||||
is lost.
|
||||
install:
|
||||
pip: []
|
||||
package: platypush.backend.bluetooth.scanner.ble
|
||||
type: backend
|
|
@ -0,0 +1,10 @@
|
|||
manifest:
|
||||
events:
|
||||
platypush.message.event.bluetooth.BluetoothDeviceFoundEvent: when a new bluetooth
|
||||
device is found.
|
||||
platypush.message.event.bluetooth.BluetoothDeviceLostEvent: when a bluetooth device
|
||||
is lost.
|
||||
install:
|
||||
pip: []
|
||||
package: platypush.backend.bluetooth.scanner
|
||||
type: backend
|
|
@ -3,7 +3,5 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- picamera
|
||||
- numpy
|
||||
- Pillow
|
||||
package: platypush.backend.camera.pi
|
||||
type: backend
|
||||
|
|
|
@ -4,9 +4,9 @@ from typing import Optional, Union, List, Dict, Any
|
|||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.common.db import declarative_base
|
||||
from platypush.config import Config
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.covid19 import Covid19UpdateEvent
|
||||
|
@ -17,10 +17,10 @@ Session = scoped_session(sessionmaker())
|
|||
|
||||
|
||||
class Covid19Update(Base):
|
||||
"""Models the Covid19Data table"""
|
||||
""" Models the Covid19Data table """
|
||||
|
||||
__tablename__ = 'covid19data'
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
|
||||
country = Column(String, primary_key=True)
|
||||
confirmed = Column(Integer, nullable=False, default=0)
|
||||
|
@ -40,12 +40,7 @@ class Covid19Backend(Backend):
|
|||
"""
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
def __init__(
|
||||
self,
|
||||
country: Optional[Union[str, List[str]]],
|
||||
poll_seconds: Optional[float] = 3600.0,
|
||||
**kwargs
|
||||
):
|
||||
def __init__(self, country: Optional[Union[str, List[str]]], poll_seconds: Optional[float] = 3600.0, **kwargs):
|
||||
"""
|
||||
:param country: Default country (or list of countries) to retrieve the stats for. It can either be the full
|
||||
country name or the country code. Special values:
|
||||
|
@ -61,9 +56,7 @@ class Covid19Backend(Backend):
|
|||
super().__init__(poll_seconds=poll_seconds, **kwargs)
|
||||
self._plugin: Covid19Plugin = get_plugin('covid19')
|
||||
self.country: List[str] = self._plugin._get_countries(country)
|
||||
self.workdir = os.path.join(
|
||||
os.path.expanduser(Config.get('workdir')), 'covid19'
|
||||
)
|
||||
self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'covid19')
|
||||
self.dbfile = os.path.join(self.workdir, 'data.db')
|
||||
os.makedirs(self.workdir, exist_ok=True)
|
||||
|
||||
|
@ -74,30 +67,22 @@ class Covid19Backend(Backend):
|
|||
self.logger.info('Stopped Covid19 backend')
|
||||
|
||||
def _process_update(self, summary: Dict[str, Any], session: Session):
|
||||
update_time = datetime.datetime.fromisoformat(
|
||||
summary['Date'].replace('Z', '+00:00')
|
||||
)
|
||||
update_time = datetime.datetime.fromisoformat(summary['Date'].replace('Z', '+00:00'))
|
||||
|
||||
self.bus.post(
|
||||
Covid19UpdateEvent(
|
||||
country=summary['Country'],
|
||||
country_code=summary['CountryCode'],
|
||||
confirmed=summary['TotalConfirmed'],
|
||||
deaths=summary['TotalDeaths'],
|
||||
recovered=summary['TotalRecovered'],
|
||||
update_time=update_time,
|
||||
)
|
||||
)
|
||||
self.bus.post(Covid19UpdateEvent(
|
||||
country=summary['Country'],
|
||||
country_code=summary['CountryCode'],
|
||||
confirmed=summary['TotalConfirmed'],
|
||||
deaths=summary['TotalDeaths'],
|
||||
recovered=summary['TotalRecovered'],
|
||||
update_time=update_time,
|
||||
))
|
||||
|
||||
session.merge(
|
||||
Covid19Update(
|
||||
country=summary['CountryCode'],
|
||||
confirmed=summary['TotalConfirmed'],
|
||||
deaths=summary['TotalDeaths'],
|
||||
recovered=summary['TotalRecovered'],
|
||||
last_updated_at=update_time,
|
||||
)
|
||||
)
|
||||
session.merge(Covid19Update(country=summary['CountryCode'],
|
||||
confirmed=summary['TotalConfirmed'],
|
||||
deaths=summary['TotalDeaths'],
|
||||
recovered=summary['TotalRecovered'],
|
||||
last_updated_at=update_time))
|
||||
|
||||
def loop(self):
|
||||
# noinspection PyUnresolvedReferences
|
||||
|
@ -105,30 +90,23 @@ class Covid19Backend(Backend):
|
|||
if not summaries:
|
||||
return
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///{}'.format(self.dbfile),
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
|
||||
Base.metadata.create_all(engine)
|
||||
Session.configure(bind=engine)
|
||||
session = Session()
|
||||
|
||||
last_records = {
|
||||
record.country: record
|
||||
for record in session.query(Covid19Update)
|
||||
.filter(Covid19Update.country.in_(self.country))
|
||||
.all()
|
||||
for record in session.query(Covid19Update).filter(Covid19Update.country.in_(self.country)).all()
|
||||
}
|
||||
|
||||
for summary in summaries:
|
||||
country = summary['CountryCode']
|
||||
last_record = last_records.get(country)
|
||||
if (
|
||||
not last_record
|
||||
or summary['TotalConfirmed'] != last_record.confirmed
|
||||
or summary['TotalDeaths'] != last_record.deaths
|
||||
or summary['TotalRecovered'] != last_record.recovered
|
||||
):
|
||||
if not last_record or \
|
||||
summary['TotalConfirmed'] != last_record.confirmed or \
|
||||
summary['TotalDeaths'] != last_record.deaths or \
|
||||
summary['TotalRecovered'] != last_record.recovered:
|
||||
self._process_update(summary=summary, session=session)
|
||||
|
||||
session.commit()
|
||||
|
|
|
@ -6,29 +6,15 @@ from typing import Optional, List
|
|||
|
||||
import requests
|
||||
from sqlalchemy import create_engine, Column, String, DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.common.db import declarative_base
|
||||
from platypush.config import Config
|
||||
from platypush.message.event.github import (
|
||||
GithubPushEvent,
|
||||
GithubCommitCommentEvent,
|
||||
GithubCreateEvent,
|
||||
GithubDeleteEvent,
|
||||
GithubEvent,
|
||||
GithubForkEvent,
|
||||
GithubWikiEvent,
|
||||
GithubIssueCommentEvent,
|
||||
GithubIssueEvent,
|
||||
GithubMemberEvent,
|
||||
GithubPublicEvent,
|
||||
GithubPullRequestEvent,
|
||||
GithubPullRequestReviewCommentEvent,
|
||||
GithubReleaseEvent,
|
||||
GithubSponsorshipEvent,
|
||||
GithubWatchEvent,
|
||||
)
|
||||
from platypush.message.event.github import GithubPushEvent, GithubCommitCommentEvent, GithubCreateEvent, \
|
||||
GithubDeleteEvent, GithubEvent, GithubForkEvent, GithubWikiEvent, GithubIssueCommentEvent, GithubIssueEvent, \
|
||||
GithubMemberEvent, GithubPublicEvent, GithubPullRequestEvent, GithubPullRequestReviewCommentEvent, \
|
||||
GithubReleaseEvent, GithubSponsorshipEvent, GithubWatchEvent
|
||||
|
||||
Base = declarative_base()
|
||||
Session = scoped_session(sessionmaker())
|
||||
|
@ -85,17 +71,8 @@ class GithubBackend(Backend):
|
|||
|
||||
_base_url = 'https://api.github.com'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user: str,
|
||||
user_token: str,
|
||||
repos: Optional[List[str]] = None,
|
||||
org: Optional[str] = None,
|
||||
poll_seconds: int = 60,
|
||||
max_events_per_scan: Optional[int] = 10,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
def __init__(self, user: str, user_token: str, repos: Optional[List[str]] = None, org: Optional[str] = None,
|
||||
poll_seconds: int = 60, max_events_per_scan: Optional[int] = 10, *args, **kwargs):
|
||||
"""
|
||||
If neither ``repos`` nor ``org`` is specified then the backend will monitor all new events on user level.
|
||||
|
||||
|
@ -125,23 +102,17 @@ class GithubBackend(Backend):
|
|||
|
||||
def _request(self, uri: str, method: str = 'get') -> dict:
|
||||
method = getattr(requests, method.lower())
|
||||
return method(
|
||||
self._base_url + uri,
|
||||
auth=(self.user, self.user_token),
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
).json()
|
||||
return method(self._base_url + uri, auth=(self.user, self.user_token),
|
||||
headers={'Accept': 'application/vnd.github.v3+json'}).json()
|
||||
|
||||
def _init_db(self):
|
||||
engine = create_engine(
|
||||
'sqlite:///{}'.format(self.dbfile),
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
|
||||
Base.metadata.create_all(engine)
|
||||
Session.configure(bind=engine)
|
||||
|
||||
@staticmethod
|
||||
def _to_datetime(time_string: str) -> datetime.datetime:
|
||||
"""Convert ISO 8061 string format with leading 'Z' into something understandable by Python"""
|
||||
""" Convert ISO 8061 string format with leading 'Z' into something understandable by Python """
|
||||
return datetime.datetime.fromisoformat(time_string[:-1] + '+00:00')
|
||||
|
||||
@staticmethod
|
||||
|
@ -157,11 +128,7 @@ class GithubBackend(Backend):
|
|||
def _get_last_event_time(self, uri: str):
|
||||
with self.db_lock:
|
||||
record = self._get_or_create_resource(uri=uri, session=Session())
|
||||
return (
|
||||
record.last_updated_at.replace(tzinfo=datetime.timezone.utc)
|
||||
if record.last_updated_at
|
||||
else None
|
||||
)
|
||||
return record.last_updated_at.replace(tzinfo=datetime.timezone.utc) if record.last_updated_at else None
|
||||
|
||||
def _update_last_event_time(self, uri: str, last_updated_at: datetime.datetime):
|
||||
with self.db_lock:
|
||||
|
@ -191,18 +158,9 @@ class GithubBackend(Backend):
|
|||
'WatchEvent': GithubWatchEvent,
|
||||
}
|
||||
|
||||
event_type = (
|
||||
event_mapping[event['type']]
|
||||
if event['type'] in event_mapping
|
||||
else GithubEvent
|
||||
)
|
||||
return event_type(
|
||||
event_type=event['type'],
|
||||
actor=event['actor'],
|
||||
repo=event.get('repo', {}),
|
||||
payload=event['payload'],
|
||||
created_at=cls._to_datetime(event['created_at']),
|
||||
)
|
||||
event_type = event_mapping[event['type']] if event['type'] in event_mapping else GithubEvent
|
||||
return event_type(event_type=event['type'], actor=event['actor'], repo=event.get('repo', {}),
|
||||
payload=event['payload'], created_at=cls._to_datetime(event['created_at']))
|
||||
|
||||
def _events_monitor(self, uri: str, method: str = 'get'):
|
||||
def thread():
|
||||
|
@ -217,10 +175,7 @@ class GithubBackend(Backend):
|
|||
fired_events = []
|
||||
|
||||
for event in events:
|
||||
if (
|
||||
self.max_events_per_scan
|
||||
and len(fired_events) >= self.max_events_per_scan
|
||||
):
|
||||
if self.max_events_per_scan and len(fired_events) >= self.max_events_per_scan:
|
||||
break
|
||||
|
||||
event_time = self._to_datetime(event['created_at'])
|
||||
|
@ -234,19 +189,14 @@ class GithubBackend(Backend):
|
|||
for event in fired_events:
|
||||
self.bus.post(event)
|
||||
|
||||
self._update_last_event_time(
|
||||
uri=uri, last_updated_at=new_last_event_time
|
||||
)
|
||||
self._update_last_event_time(uri=uri, last_updated_at=new_last_event_time)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
'Encountered exception while fetching events from {}: {}'.format(
|
||||
uri, str(e)
|
||||
)
|
||||
)
|
||||
self.logger.warning('Encountered exception while fetching events from {}: {}'.format(
|
||||
uri, str(e)))
|
||||
self.logger.exception(e)
|
||||
|
||||
if self.wait_stop(timeout=self.poll_seconds):
|
||||
break
|
||||
finally:
|
||||
if self.wait_stop(timeout=self.poll_seconds):
|
||||
break
|
||||
|
||||
return thread
|
||||
|
||||
|
@ -256,30 +206,12 @@ class GithubBackend(Backend):
|
|||
|
||||
if self.repos:
|
||||
for repo in self.repos:
|
||||
monitors.append(
|
||||
threading.Thread(
|
||||
target=self._events_monitor(
|
||||
'/networks/{repo}/events'.format(repo=repo)
|
||||
)
|
||||
)
|
||||
)
|
||||
monitors.append(threading.Thread(target=self._events_monitor('/networks/{repo}/events'.format(repo=repo))))
|
||||
if self.org:
|
||||
monitors.append(
|
||||
threading.Thread(
|
||||
target=self._events_monitor(
|
||||
'/orgs/{org}/events'.format(org=self.org)
|
||||
)
|
||||
)
|
||||
)
|
||||
monitors.append(threading.Thread(target=self._events_monitor('/orgs/{org}/events'.format(org=self.org))))
|
||||
|
||||
if not (self.repos or self.org):
|
||||
monitors.append(
|
||||
threading.Thread(
|
||||
target=self._events_monitor(
|
||||
'/users/{user}/events'.format(user=self.user)
|
||||
)
|
||||
)
|
||||
)
|
||||
monitors.append(threading.Thread(target=self._events_monitor('/users/{user}/events'.format(user=self.user))))
|
||||
|
||||
for monitor in monitors:
|
||||
monitor.start()
|
||||
|
@ -290,5 +222,4 @@ class GithubBackend(Backend):
|
|||
|
||||
self.logger.info('Github backend terminated')
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1,27 +1,19 @@
|
|||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from multiprocessing import Process
|
||||
from time import time
|
||||
from typing import List, Mapping, Optional
|
||||
from tornado.httpserver import HTTPServer
|
||||
|
||||
from tornado.netutil import bind_sockets
|
||||
from tornado.process import cpu_count, fork_processes
|
||||
from tornado.wsgi import WSGIContainer
|
||||
from tornado.web import Application, FallbackHandler
|
||||
try:
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets import serve as websocket_serve
|
||||
except ImportError:
|
||||
from websockets import ConnectionClosed, serve as websocket_serve
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.backend.http.app import application
|
||||
from platypush.backend.http.app.utils import get_ws_routes
|
||||
from platypush.backend.http.app.ws.events import events_redis_topic
|
||||
|
||||
from platypush.bus.redis import RedisBus
|
||||
from platypush.config import Config
|
||||
from platypush.utils import get_redis
|
||||
from platypush.context import get_or_create_event_loop
|
||||
from platypush.utils import get_ssl_server_context, set_thread_name
|
||||
|
||||
|
||||
class HttpBackend(Backend):
|
||||
|
@ -35,6 +27,8 @@ class HttpBackend(Backend):
|
|||
backend.http:
|
||||
# Default HTTP listen port
|
||||
port: 8008
|
||||
# Default websocket port
|
||||
websocket_port: 8009
|
||||
# External folders that will be exposed over `/resources/<name>`
|
||||
resource_dirs:
|
||||
photos: /mnt/hd/photos
|
||||
|
@ -45,11 +39,9 @@ class HttpBackend(Backend):
|
|||
|
||||
* To execute Platypush commands via HTTP calls. In order to do so:
|
||||
|
||||
* Register a user to Platypush through the web panel (usually
|
||||
served on ``http://host:8008/``).
|
||||
* Register a user to Platypush through the web panel (usually served on ``http://host:8008/``).
|
||||
|
||||
* Generate a token for your user, either through the web panel
|
||||
(Settings -> Generate Token) or via API:
|
||||
* Generate a token for your user, either through the web panel (Settings -> Generate Token) or via API:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
|
@ -72,35 +64,16 @@ class HttpBackend(Backend):
|
|||
}
|
||||
}' http://host:8008/execute
|
||||
|
||||
* To interact with your system (and control plugins and backends)
|
||||
through the Platypush web panel, by default available on
|
||||
``http://host:8008/``. Any configured plugin that has an available
|
||||
panel plugin will be automatically added to the web panel.
|
||||
|
||||
* To create asynchronous integrations with Platypush over websockets.
|
||||
Two routes are available:
|
||||
|
||||
* ``/ws/events`` - Subscribe to this websocket to receive the
|
||||
events generated by the application.
|
||||
* ``/ws/requests`` - Subscribe to this websocket to send commands
|
||||
to Platypush and receive the response asynchronously.
|
||||
|
||||
You will have to authenticate your connection to these websockets,
|
||||
just like the ``/execute`` endpoint. In both cases, you can pass the
|
||||
token either via ``Authorization: Bearer``, via the ``token`` query
|
||||
string or body parameter, or leverage ``Authorization: Basic`` with
|
||||
username and password (not advised), or use a valid ``session_token``
|
||||
cookie from an authenticated web panel session.
|
||||
* To interact with your system (and control plugins and backends) through the Platypush web panel,
|
||||
by default available on ``http://host:8008/``. Any configured plugin that has an available panel
|
||||
plugin will be automatically added to the web panel.
|
||||
|
||||
* To display a fullscreen dashboard with custom widgets.
|
||||
|
||||
* Widgets are available as Vue.js components under
|
||||
``platypush/backend/http/webapp/src/components/widgets``.
|
||||
* Widgets are available as Vue.js components under ``platypush/backend/http/webapp/src/components/widgets``.
|
||||
|
||||
* Explore their options (some may require some plugins or backends
|
||||
to be configured in order to work) and create a new dashboard
|
||||
template under ``~/.config/platypush/dashboards``- e.g.
|
||||
``main.xml``:
|
||||
* Explore their options (some may require some plugins or backends to be configured in order to work) and
|
||||
create a new dashboard template under ``~/.config/platypush/dashboards``- e.g. ``main.xml``:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
|
@ -136,17 +109,13 @@ class HttpBackend(Backend):
|
|||
</Row>
|
||||
</Dashboard>
|
||||
|
||||
* The dashboard will be accessible under
|
||||
``http://host:8008/dashboard/<name>``, where ``name=main`` if for
|
||||
example you stored your template under
|
||||
``~/.config/platypush/dashboards/main.xml``.
|
||||
* The dashboard will be accessible under ``http://host:8008/dashboard/<name>``, where ``name=main`` if for
|
||||
example you stored your template under ``~/.config/platypush/dashboards/main.xml``.
|
||||
|
||||
* To expose custom endpoints that can be called as web hooks by other
|
||||
applications and run some custom logic. All you have to do in this case
|
||||
is to create a hook on a
|
||||
:class:`platypush.message.event.http.hook.WebhookEvent` with the
|
||||
endpoint that you want to expose and store it under e.g.
|
||||
``~/.config/platypush/scripts/hooks.py``:
|
||||
* To expose custom endpoints that can be called as web hooks by other applications and run some custom logic.
|
||||
All you have to do in this case is to create a hook on a
|
||||
:class:`platypush.message.event.http.hook.WebhookEvent` with the endpoint that you want to expose and store
|
||||
it under e.g. ``~/.config/platypush/scripts/hooks.py``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
|
@ -171,54 +140,117 @@ class HttpBackend(Backend):
|
|||
module can expose lists of routes to the main webapp through the
|
||||
``__routes__`` object (a list of Flask blueprints).
|
||||
|
||||
Security: Access to the endpoints requires at least one user to be
|
||||
registered. Access to the endpoints is regulated in the following ways
|
||||
(with the exception of event hooks, whose logic is up to the user):
|
||||
Security: Access to the endpoints requires at least one user to be registered. Access to the endpoints is regulated
|
||||
in the following ways (with the exception of event hooks, whose logic is up to the user):
|
||||
|
||||
* **Simple authentication** - i.e. registered username and password.
|
||||
* **JWT token** provided either over as ``Authorization: Bearer``
|
||||
header or ``GET`` ``?token=<TOKEN>`` parameter. A JWT token can be
|
||||
generated either through the web panel or over the ``/auth``
|
||||
endpoint.
|
||||
* **Global platform token**, usually configured on the root of the
|
||||
``config.yaml`` as ``token: <VALUE>``. It can provided either over on
|
||||
the ``X-Token`` header or as a ``GET`` ``?token=<TOKEN>`` parameter.
|
||||
* **Session token**, generated upon login, it can be used to
|
||||
authenticate requests through the ``Cookie`` header (cookie name:
|
||||
``session_token``).
|
||||
* **JWT token** provided either over as ``Authorization: Bearer`` header or ``GET`` ``?token=<TOKEN>``
|
||||
parameter. A JWT token can be generated either through the web panel or over the ``/auth`` endpoint.
|
||||
* **Global platform token**, usually configured on the root of the ``config.yaml`` as ``token: <VALUE>``.
|
||||
It can provided either over on the ``X-Token`` header or as a ``GET`` ``?token=<TOKEN>`` parameter.
|
||||
* **Session token**, generated upon login, it can be used to authenticate requests through the ``Cookie`` header
|
||||
(cookie name: ``session_token``).
|
||||
|
||||
Requires:
|
||||
|
||||
* **gunicorn** (``pip install gunicorn``) - optional, to run the Platypush webapp over uWSGI.
|
||||
|
||||
By default the Platypush web server will run in a
|
||||
process spawned on the fly by the HTTP backend. However, being a
|
||||
Flask app, it will serve clients in a single thread and it won't
|
||||
support many features of a full-blown web server. gunicorn allows
|
||||
you to easily spawn the web server in a uWSGI wrapper, separate
|
||||
from the main Platypush daemon, and the uWSGI layer can be easily
|
||||
exposed over an nginx/lighttpd web server.
|
||||
|
||||
Command to run the web server over a gunicorn uWSGI wrapper::
|
||||
|
||||
gunicorn -w <n_workers> -b <bind_address>:8008 platypush.backend.http.uwsgi
|
||||
|
||||
"""
|
||||
|
||||
_DEFAULT_HTTP_PORT = 8008
|
||||
"""The default listen port for the webserver."""
|
||||
_DEFAULT_WEBSOCKET_PORT = 8009
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: int = _DEFAULT_HTTP_PORT,
|
||||
bind_address: str = '0.0.0.0',
|
||||
resource_dirs: Optional[Mapping[str, str]] = None,
|
||||
secret_key_file: Optional[str] = None,
|
||||
num_workers: Optional[int] = None,
|
||||
**kwargs,
|
||||
port=_DEFAULT_HTTP_PORT,
|
||||
websocket_port=_DEFAULT_WEBSOCKET_PORT,
|
||||
bind_address='0.0.0.0',
|
||||
disable_websocket=False,
|
||||
resource_dirs=None,
|
||||
ssl_cert=None,
|
||||
ssl_key=None,
|
||||
ssl_cafile=None,
|
||||
ssl_capath=None,
|
||||
maps=None,
|
||||
run_externally=False,
|
||||
uwsgi_args=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param port: Listen port for the web server (default: 8008)
|
||||
:type port: int
|
||||
|
||||
:param websocket_port: Listen port for the websocket server (default: 8009)
|
||||
:type websocket_port: int
|
||||
|
||||
:param bind_address: Address/interface to bind to (default: 0.0.0.0, accept connection from any IP)
|
||||
:type bind_address: str
|
||||
|
||||
:param disable_websocket: Disable the websocket interface (default: False)
|
||||
:type disable_websocket: bool
|
||||
|
||||
:param ssl_cert: Set it to the path of your certificate file if you want to enable HTTPS (default: None)
|
||||
:type ssl_cert: str
|
||||
|
||||
:param ssl_key: Set it to the path of your key file if you want to enable HTTPS (default: None)
|
||||
:type ssl_key: str
|
||||
|
||||
:param ssl_cafile: Set it to the path of your certificate authority file if you want to enable HTTPS
|
||||
(default: None)
|
||||
:type ssl_cafile: str
|
||||
|
||||
:param ssl_capath: Set it to the path of your certificate authority directory if you want to enable HTTPS
|
||||
(default: None)
|
||||
:type ssl_capath: str
|
||||
|
||||
:param resource_dirs: Static resources directories that will be
|
||||
accessible through ``/resources/<path>``. It is expressed as a map
|
||||
where the key is the relative path under ``/resources`` to expose and
|
||||
the value is the absolute path to expose.
|
||||
:param secret_key_file: Path to the file containing the secret key that will be used by Flask
|
||||
(default: ``~/.local/share/platypush/flask.secret.key``).
|
||||
:param num_workers: Number of worker processes to use (default: ``(cpu_count * 2) + 1``).
|
||||
:type resource_dirs: dict[str, str]
|
||||
|
||||
:param run_externally: If set, then the HTTP backend will not directly
|
||||
spawn the web server. Set this option if you plan to run the webapp
|
||||
in a separate web server (recommended), like uwsgi or uwsgi+nginx.
|
||||
:type run_externally: bool
|
||||
|
||||
:param uwsgi_args: If ``run_externally`` is set and you would like the
|
||||
HTTP backend to directly spawn and control the uWSGI application
|
||||
server instance, then pass the list of uWSGI arguments through
|
||||
this parameter. Some examples include::
|
||||
|
||||
# Start uWSGI instance listening on HTTP port 8008 with 4
|
||||
# processes
|
||||
['--plugin', 'python', '--http-socket', ':8008', '--master', '--processes', '4']
|
||||
|
||||
# Start uWSGI instance listening on uWSGI socket on port 3031.
|
||||
# You can then use another full-blown web server, like nginx
|
||||
# or Apache, to communicate with the uWSGI instance
|
||||
['--plugin', 'python', '--socket', '127.0.0.1:3031', '--master', '--processes', '4']
|
||||
:type uwsgi_args: list[str]
|
||||
"""
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.port = port
|
||||
self._server_proc: Optional[Process] = None
|
||||
self._workers: List[Process] = []
|
||||
self._service_registry_thread = None
|
||||
self.websocket_port = websocket_port
|
||||
self.maps = maps or {}
|
||||
self.server_proc = None
|
||||
self.disable_websocket = disable_websocket
|
||||
self.websocket_thread = None
|
||||
self._websocket_loop = None
|
||||
self.bind_address = bind_address
|
||||
|
||||
if resource_dirs:
|
||||
|
@ -229,14 +261,36 @@ class HttpBackend(Backend):
|
|||
else:
|
||||
self.resource_dirs = {}
|
||||
|
||||
self.secret_key_file = os.path.expanduser(
|
||||
secret_key_file
|
||||
or os.path.join(Config.get('workdir'), 'flask.secret.key') # type: ignore
|
||||
self.active_websockets = set()
|
||||
self.run_externally = run_externally
|
||||
self.uwsgi_args = uwsgi_args or []
|
||||
self.ssl_context = (
|
||||
get_ssl_server_context(
|
||||
ssl_cert=ssl_cert,
|
||||
ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile,
|
||||
ssl_capath=ssl_capath,
|
||||
)
|
||||
if ssl_cert
|
||||
else None
|
||||
)
|
||||
self.local_base_url = f'http://localhost:{self.port}'
|
||||
self.num_workers = num_workers or (cpu_count() * 2) + 1
|
||||
|
||||
def send_message(self, *_, **__):
|
||||
if self.uwsgi_args:
|
||||
self.uwsgi_args = [str(_) for _ in self.uwsgi_args] + [
|
||||
'--module',
|
||||
'platypush.backend.http.uwsgi',
|
||||
'--enable-threads',
|
||||
]
|
||||
|
||||
self.local_base_url = '{proto}://localhost:{port}'.format(
|
||||
proto=('https' if ssl_cert else 'http'), port=self.port
|
||||
)
|
||||
|
||||
self._websocket_lock_timeout = 10
|
||||
self._websocket_lock = threading.RLock()
|
||||
self._websocket_locks = {}
|
||||
|
||||
def send_message(self, msg, **kwargs):
|
||||
self.logger.warning('Use cURL or any HTTP client to query the HTTP backend')
|
||||
|
||||
def on_stop(self):
|
||||
|
@ -244,120 +298,190 @@ class HttpBackend(Backend):
|
|||
super().on_stop()
|
||||
self.logger.info('Received STOP event on HttpBackend')
|
||||
|
||||
start_time = time()
|
||||
timeout = 5
|
||||
workers = self._workers.copy()
|
||||
if self.server_proc:
|
||||
if isinstance(self.server_proc, subprocess.Popen):
|
||||
self.server_proc.kill()
|
||||
self.server_proc.wait(timeout=10)
|
||||
if self.server_proc.poll() is not None:
|
||||
self.logger.info(
|
||||
'HTTP server process may be still alive at termination'
|
||||
)
|
||||
else:
|
||||
self.logger.info('HTTP server process terminated')
|
||||
else:
|
||||
self.server_proc.terminate()
|
||||
self.server_proc.join(timeout=10)
|
||||
if self.server_proc.is_alive():
|
||||
self.server_proc.kill()
|
||||
if self.server_proc.is_alive():
|
||||
self.logger.info(
|
||||
'HTTP server process may be still alive at termination'
|
||||
)
|
||||
else:
|
||||
self.logger.info('HTTP server process terminated')
|
||||
|
||||
for i, worker in enumerate(workers[::-1]):
|
||||
if worker and worker.is_alive():
|
||||
worker.terminate()
|
||||
worker.join(timeout=max(0, start_time + timeout - time()))
|
||||
if (
|
||||
self.websocket_thread
|
||||
and self.websocket_thread.is_alive()
|
||||
and self._websocket_loop
|
||||
):
|
||||
self._websocket_loop.stop()
|
||||
self.logger.info('HTTP websocket service terminated')
|
||||
|
||||
if worker and worker.is_alive():
|
||||
worker.kill()
|
||||
self._workers.pop(i)
|
||||
def _acquire_websocket_lock(self, ws):
|
||||
try:
|
||||
acquire_ok = self._websocket_lock.acquire(
|
||||
timeout=self._websocket_lock_timeout
|
||||
)
|
||||
if not acquire_ok:
|
||||
raise TimeoutError('Websocket lock acquire timeout')
|
||||
|
||||
if self._server_proc:
|
||||
self._server_proc.terminate()
|
||||
self._server_proc.join(timeout=5)
|
||||
self._server_proc = None
|
||||
addr = ws.remote_address
|
||||
if addr not in self._websocket_locks:
|
||||
self._websocket_locks[addr] = threading.RLock()
|
||||
finally:
|
||||
self._websocket_lock.release()
|
||||
|
||||
if self._server_proc and self._server_proc.is_alive():
|
||||
self._server_proc.kill()
|
||||
acquire_ok = self._websocket_locks[addr].acquire(
|
||||
timeout=self._websocket_lock_timeout
|
||||
)
|
||||
if not acquire_ok:
|
||||
raise TimeoutError(
|
||||
'Websocket on address {} not ready to receive data'.format(addr)
|
||||
)
|
||||
|
||||
self._server_proc = None
|
||||
self.logger.info('HTTP server terminated')
|
||||
def _release_websocket_lock(self, ws):
|
||||
try:
|
||||
acquire_ok = self._websocket_lock.acquire(
|
||||
timeout=self._websocket_lock_timeout
|
||||
)
|
||||
if not acquire_ok:
|
||||
raise TimeoutError('Websocket lock acquire timeout')
|
||||
|
||||
if self._service_registry_thread and self._service_registry_thread.is_alive():
|
||||
self._service_registry_thread.join(timeout=5)
|
||||
self._service_registry_thread = None
|
||||
addr = ws.remote_address
|
||||
if addr in self._websocket_locks:
|
||||
self._websocket_locks[addr].release()
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
'Unhandled exception while releasing websocket lock: {}'.format(str(e))
|
||||
)
|
||||
finally:
|
||||
self._websocket_lock.release()
|
||||
|
||||
def notify_web_clients(self, event):
|
||||
"""Notify all the connected web clients (over websocket) of a new event"""
|
||||
get_redis().publish(events_redis_topic, str(event))
|
||||
|
||||
def _get_secret_key(self, _create=False):
|
||||
if _create:
|
||||
self.logger.info('Creating web server secret key')
|
||||
pathlib.Path(self.secret_key_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.secret_key_file, 'w') as f:
|
||||
f.write(secrets.token_urlsafe(32))
|
||||
async def send_event(ws):
|
||||
try:
|
||||
self._acquire_websocket_lock(ws)
|
||||
await ws.send(str(event))
|
||||
except Exception as e:
|
||||
self.logger.warning('Error on websocket send_event: {}'.format(e))
|
||||
finally:
|
||||
self._release_websocket_lock(ws)
|
||||
|
||||
os.chmod(self.secret_key_file, 0o600)
|
||||
return secrets.token_urlsafe(32)
|
||||
loop = get_or_create_event_loop()
|
||||
wss = self.active_websockets.copy()
|
||||
|
||||
try:
|
||||
with open(self.secret_key_file, 'r') as f:
|
||||
return f.read()
|
||||
except IOError as e:
|
||||
if not _create:
|
||||
return self._get_secret_key(_create=True)
|
||||
for _ws in wss:
|
||||
try:
|
||||
loop.run_until_complete(send_event(_ws))
|
||||
except ConnectionClosed:
|
||||
self.logger.warning(
|
||||
'Websocket client {} connection lost'.format(_ws.remote_address)
|
||||
)
|
||||
self.active_websockets.remove(_ws)
|
||||
if _ws.remote_address in self._websocket_locks:
|
||||
del self._websocket_locks[_ws.remote_address]
|
||||
|
||||
raise e
|
||||
def websocket(self):
|
||||
"""Websocket main server"""
|
||||
set_thread_name('WebsocketServer')
|
||||
|
||||
def _register_service(self):
|
||||
async def register_websocket(websocket, path):
|
||||
address = (
|
||||
websocket.remote_address
|
||||
if websocket.remote_address
|
||||
else '<unknown client>'
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
'New websocket connection from {} on path {}'.format(address, path)
|
||||
)
|
||||
self.active_websockets.add(websocket)
|
||||
|
||||
try:
|
||||
await websocket.recv()
|
||||
except ConnectionClosed:
|
||||
self.logger.info(
|
||||
'Websocket client {} closed connection'.format(address)
|
||||
)
|
||||
self.active_websockets.remove(websocket)
|
||||
if address in self._websocket_locks:
|
||||
del self._websocket_locks[address]
|
||||
|
||||
websocket_args = {}
|
||||
if self.ssl_context:
|
||||
websocket_args['ssl'] = self.ssl_context
|
||||
|
||||
self._websocket_loop = get_or_create_event_loop()
|
||||
self._websocket_loop.run_until_complete(
|
||||
websocket_serve(
|
||||
register_websocket,
|
||||
self.bind_address,
|
||||
self.websocket_port,
|
||||
**websocket_args
|
||||
)
|
||||
)
|
||||
self._websocket_loop.run_forever()
|
||||
|
||||
def _start_web_server(self):
|
||||
def proc():
|
||||
self.logger.info('Starting local web server on port {}'.format(self.port))
|
||||
kwargs = {
|
||||
'host': self.bind_address,
|
||||
'port': self.port,
|
||||
'use_reloader': False,
|
||||
'debug': False,
|
||||
}
|
||||
|
||||
application.config['redis_queue'] = self.bus.redis_queue
|
||||
if self.ssl_context:
|
||||
kwargs['ssl_context'] = self.ssl_context
|
||||
|
||||
application.run(**kwargs)
|
||||
|
||||
return proc
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
try:
|
||||
self.register_service(port=self.port)
|
||||
except Exception as e:
|
||||
self.logger.warning('Could not register the Zeroconf service')
|
||||
self.logger.exception(e)
|
||||
|
||||
def _start_zeroconf_service(self):
|
||||
self._service_registry_thread = threading.Thread(
|
||||
target=self._register_service,
|
||||
name='ZeroconfService',
|
||||
)
|
||||
self._service_registry_thread.start()
|
||||
if not self.disable_websocket:
|
||||
self.logger.info('Initializing websocket interface')
|
||||
self.websocket_thread = threading.Thread(target=self.websocket)
|
||||
self.websocket_thread.start()
|
||||
|
||||
async def _post_fork_main(self, sockets):
|
||||
assert isinstance(
|
||||
self.bus, RedisBus
|
||||
), 'The HTTP backend only works if backed by a Redis bus'
|
||||
|
||||
application.config['redis_queue'] = self.bus.redis_queue
|
||||
application.secret_key = self._get_secret_key()
|
||||
container = WSGIContainer(application)
|
||||
tornado_app = Application(
|
||||
[
|
||||
*[(route.path(), route) for route in get_ws_routes()],
|
||||
(r'.*', FallbackHandler, {'fallback': container}),
|
||||
]
|
||||
)
|
||||
|
||||
server = HTTPServer(tornado_app)
|
||||
server.add_sockets(sockets)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
return
|
||||
|
||||
def _web_server_proc(self):
|
||||
self.logger.info(
|
||||
'Starting local web server on port %s with %d service workers',
|
||||
self.port,
|
||||
self.num_workers,
|
||||
)
|
||||
|
||||
sockets = bind_sockets(self.port, address=self.bind_address, reuse_port=True)
|
||||
|
||||
try:
|
||||
fork_processes(self.num_workers)
|
||||
future = self._post_fork_main(sockets)
|
||||
asyncio.run(future)
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
return
|
||||
|
||||
def _start_web_server(self):
|
||||
self._server_proc = Process(target=self._web_server_proc)
|
||||
self._server_proc.start()
|
||||
self._server_proc.join()
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
|
||||
self._start_zeroconf_service()
|
||||
self._start_web_server()
|
||||
if not self.run_externally:
|
||||
self.server_proc = Process(
|
||||
target=self._start_web_server(), name='WebServer'
|
||||
)
|
||||
self.server_proc.start()
|
||||
self.server_proc.join()
|
||||
elif self.uwsgi_args:
|
||||
uwsgi_cmd = ['uwsgi'] + self.uwsgi_args
|
||||
self.logger.info('Starting uWSGI with arguments {}'.format(uwsgi_cmd))
|
||||
self.server_proc = subprocess.Popen(uwsgi_cmd)
|
||||
else:
|
||||
self.logger.info(
|
||||
'The web server is configured to be launched externally but '
|
||||
+ 'no uwsgi_args were provided. Make sure that you run another external service'
|
||||
+ 'for the webserver (e.g. nginx)'
|
||||
)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -50,6 +50,7 @@ def auth_endpoint():
|
|||
except Exception as e:
|
||||
log.warning('Invalid payload passed to the auth endpoint: ' + str(e))
|
||||
abort(400)
|
||||
return jsonify({'token': None})
|
||||
|
||||
expiry_days = payload.get('expiry_days')
|
||||
expires_at = None
|
||||
|
@ -64,3 +65,4 @@ def auth_endpoint():
|
|||
})
|
||||
except UserException as e:
|
||||
abort(401, str(e))
|
||||
return jsonify({'token': None})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from flask import Blueprint, render_template
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import authenticate
|
||||
from platypush.backend.http.app.utils import authenticate, get_websocket_port
|
||||
from platypush.backend.http.utils import HttpUtils
|
||||
|
||||
dashboard = Blueprint('dashboard', __name__, template_folder=template_folder)
|
||||
|
@ -16,11 +16,10 @@ __routes__ = [
|
|||
@dashboard.route('/dashboard/<name>', methods=['GET'])
|
||||
@authenticate()
|
||||
def render_dashboard(name):
|
||||
"""Route for the dashboard"""
|
||||
return render_template(
|
||||
'index.html',
|
||||
utils=HttpUtils,
|
||||
)
|
||||
""" Route for the dashboard """
|
||||
return render_template('index.html',
|
||||
utils=HttpUtils,
|
||||
websocket_port=get_websocket_port())
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import json
|
||||
|
||||
from flask import Blueprint, abort, request
|
||||
from flask.wrappers import Response
|
||||
from flask import Blueprint, abort, request, Response
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import authenticate, logger, send_message
|
||||
|
@ -15,8 +14,8 @@ __routes__ = [
|
|||
|
||||
|
||||
@execute.route('/execute', methods=['POST'])
|
||||
@authenticate(json=True)
|
||||
def execute_route():
|
||||
@authenticate()
|
||||
def execute():
|
||||
"""Endpoint to execute commands"""
|
||||
try:
|
||||
msg = json.loads(request.data.decode('utf-8'))
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import json
|
||||
|
||||
from flask import Blueprint, abort, request, make_response
|
||||
from flask import Blueprint, abort, request, Response
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import logger, send_message
|
||||
from platypush.config import Config
|
||||
from platypush.event.hook import EventCondition
|
||||
from platypush.message.event.http.hook import WebhookEvent
|
||||
|
||||
|
||||
|
@ -17,23 +15,9 @@ __routes__ = [
|
|||
]
|
||||
|
||||
|
||||
def matches_condition(event: WebhookEvent, hook):
|
||||
if isinstance(hook, dict):
|
||||
if_ = hook['if'].copy()
|
||||
if_['type'] = '.'.join([event.__module__, event.__class__.__qualname__])
|
||||
|
||||
condition = EventCondition.build(if_)
|
||||
else:
|
||||
condition = hook.condition
|
||||
|
||||
return event.matches_condition(condition)
|
||||
|
||||
|
||||
@hook.route(
|
||||
'/hook/<hook_name>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
|
||||
)
|
||||
def hook_route(hook_name):
|
||||
"""Endpoint for custom webhooks"""
|
||||
@hook.route('/hook/<hook_name>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
def _hook(hook_name):
|
||||
""" Endpoint for custom webhooks """
|
||||
|
||||
event_args = {
|
||||
'hook': hook_name,
|
||||
|
@ -44,54 +28,20 @@ def hook_route(hook_name):
|
|||
}
|
||||
|
||||
if event_args['data']:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
event_args['data'] = json.loads(event_args['data'])
|
||||
except Exception as e:
|
||||
logger().warning(
|
||||
'Not a valid JSON string: %s: %s', event_args['data'], str(e)
|
||||
)
|
||||
logger().warning('Not a valid JSON string: {}: {}'.format(event_args['data'], str(e)))
|
||||
|
||||
event = WebhookEvent(**event_args)
|
||||
matching_hooks = [
|
||||
hook
|
||||
for hook in Config.get_event_hooks().values()
|
||||
if matches_condition(event, hook)
|
||||
]
|
||||
|
||||
try:
|
||||
send_message(event)
|
||||
rs = default_rs = make_response(json.dumps({'status': 'ok', **event_args}))
|
||||
headers = {}
|
||||
status_code = 200
|
||||
|
||||
# If there are matching hooks, wait for their completion before returning
|
||||
if matching_hooks:
|
||||
rs = event.wait_response(timeout=60)
|
||||
try:
|
||||
rs = json.loads(rs.decode()) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(rs, dict) and '___data___' in rs:
|
||||
# data + http_code + custom_headers return format
|
||||
headers = rs.get('___headers___', {})
|
||||
status_code = rs.get('___code___', status_code)
|
||||
rs = rs['___data___']
|
||||
|
||||
if rs is None:
|
||||
rs = default_rs
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
rs = make_response(rs)
|
||||
else:
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
rs.status_code = status_code
|
||||
rs.headers.update(headers)
|
||||
return rs
|
||||
return Response(json.dumps({'status': 'ok', **event_args}), mimetype='application/json')
|
||||
except Exception as e:
|
||||
logger().exception(e)
|
||||
logger().error('Error while dispatching webhook event %s: %s', event, str(e))
|
||||
logger().error('Error while dispatching webhook event {}: {}'.format(event, str(e)))
|
||||
abort(500, str(e))
|
||||
|
||||
|
||||
|
|
|
@ -1,185 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
from flask import Blueprint, make_response, request
|
||||
|
||||
|
||||
logo = Blueprint('logo', __name__)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
logo,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gear:
|
||||
"""
|
||||
A utility class used to model the gears in the application's logo.
|
||||
"""
|
||||
|
||||
center: Tuple[float, float]
|
||||
outer_radius: float
|
||||
inner_radius: float
|
||||
color: str = "currentColor"
|
||||
background: str = ""
|
||||
num_spikes: int = 0
|
||||
spike_max_base: float = 0
|
||||
spike_min_base: float = 0
|
||||
spike_height: float = 0
|
||||
alpha_offset: float = 0
|
||||
|
||||
def to_svg(self) -> str:
|
||||
"""
|
||||
Returns the SVG representation of a gear.
|
||||
"""
|
||||
|
||||
# Generate the basic circle
|
||||
svg = f"""
|
||||
<circle
|
||||
cx="{self.center[0]}" cy="{self.center[1]}"
|
||||
r="{self.outer_radius - (self.inner_radius / math.pi)}"
|
||||
stroke-width="{self.inner_radius}"
|
||||
stroke="{self.color}"
|
||||
fill="none" />
|
||||
"""
|
||||
|
||||
# Generate the spikes
|
||||
for i in range(self.num_spikes):
|
||||
# Iterate for alpha -> [0, 2*pi]
|
||||
alpha = (2 * math.pi * i) / self.num_spikes
|
||||
# Calculate the base angle for the major base of the gear polygon
|
||||
maj_delta_alpha = math.asin(self.spike_max_base / (2 * self.outer_radius))
|
||||
# Calculate the points of the gear polygon's major base
|
||||
maj_base = (
|
||||
(
|
||||
self.center[0]
|
||||
+ self.outer_radius
|
||||
* math.cos(alpha + maj_delta_alpha + self.alpha_offset),
|
||||
self.center[1]
|
||||
+ self.outer_radius
|
||||
* math.sin(alpha + maj_delta_alpha + self.alpha_offset),
|
||||
),
|
||||
(
|
||||
self.center[0]
|
||||
+ self.outer_radius
|
||||
* math.cos(alpha - maj_delta_alpha + self.alpha_offset),
|
||||
self.center[1]
|
||||
+ self.outer_radius
|
||||
* math.sin(alpha - maj_delta_alpha + self.alpha_offset),
|
||||
),
|
||||
)
|
||||
|
||||
# Height of the gear relative to the circle's center
|
||||
h = self.outer_radius * math.cos(maj_delta_alpha) + self.spike_height
|
||||
# Calculate the base angle for the minor base of the gear polygon
|
||||
min_delta_alpha = math.asin(self.spike_min_base / (2 * h))
|
||||
# Calculate the points of the gear polygon's minor base
|
||||
min_base = (
|
||||
(
|
||||
self.center[0]
|
||||
+ h * math.cos(alpha - min_delta_alpha + self.alpha_offset),
|
||||
self.center[1]
|
||||
+ h * math.sin(alpha - min_delta_alpha + self.alpha_offset),
|
||||
),
|
||||
(
|
||||
self.center[0]
|
||||
+ h * math.cos(alpha + min_delta_alpha + self.alpha_offset),
|
||||
self.center[1]
|
||||
+ h * math.sin(alpha + min_delta_alpha + self.alpha_offset),
|
||||
),
|
||||
)
|
||||
|
||||
# Flatten the polygon's points
|
||||
svg_points = " ".join(
|
||||
[f"{point[0]},{point[1]}" for point in [*maj_base, *min_base]]
|
||||
)
|
||||
|
||||
# Serialize the gear polygon to SVG
|
||||
svg += f"""
|
||||
<polygon points="{svg_points}" stroke="{self.color}" fill="{self.color}" />"""
|
||||
|
||||
return svg
|
||||
|
||||
|
||||
# Properties of the two gears on the logo
|
||||
gears = [
|
||||
Gear(
|
||||
center=(32.9, 34.5),
|
||||
outer_radius=22.6,
|
||||
inner_radius=12.4,
|
||||
num_spikes=12,
|
||||
spike_max_base=9,
|
||||
spike_min_base=4.3,
|
||||
spike_height=10.16,
|
||||
),
|
||||
Gear(
|
||||
center=(65.5, 70.5),
|
||||
outer_radius=14.4,
|
||||
inner_radius=8.5,
|
||||
num_spikes=7,
|
||||
spike_max_base=9,
|
||||
spike_min_base=4.3,
|
||||
spike_height=7.5,
|
||||
alpha_offset=math.pi / 6.6,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
template_start = """
|
||||
<svg version="1.1"
|
||||
width="{width}" height="{height}"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="triangleGradient">
|
||||
<stop offset="0%" stop-color="#8acb45" />
|
||||
<stop offset="50%" stop-color="#6bbb4c" />
|
||||
<stop offset="100%" stop-color="#5cb450" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill="{bg_color}" />
|
||||
"""
|
||||
|
||||
template_end = "\n</svg>"
|
||||
|
||||
|
||||
@logo.route('/logo.svg', methods=['GET'])
|
||||
def logo_path():
|
||||
"""
|
||||
This path dynamically generates the logo image as a parametrizable vector SVG.
|
||||
|
||||
Parameters:
|
||||
|
||||
- ``size``: Size of the image in pixels (default: 256)
|
||||
- ``bg``: Background color (default: "none")
|
||||
- ``fg``: Foreground color (default: "currentColor")
|
||||
|
||||
"""
|
||||
size = request.args.get("size", 256)
|
||||
bg = request.args.get("bg", "none")
|
||||
fg = request.args.get("fg", "currentColor")
|
||||
svg = template_start.format(
|
||||
width=size,
|
||||
height=size,
|
||||
bg_color=bg,
|
||||
)
|
||||
|
||||
for gear in gears:
|
||||
gear.color = fg
|
||||
gear.background = bg
|
||||
svg += gear.to_svg()
|
||||
|
||||
# "Play" triangle on the logo
|
||||
svg += """\n\t\t<polygon points="67,47 67,3 99,25.3" fill="url(#triangleGradient)" />"""
|
||||
svg += template_end
|
||||
|
||||
rs = make_response(svg)
|
||||
rs.headers.update({"Content-Type": "image/svg+xml"})
|
||||
return rs
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,46 +0,0 @@
|
|||
import requests
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from flask import abort, request, Blueprint
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
|
||||
mimic3 = Blueprint('mimic3', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
mimic3,
|
||||
]
|
||||
|
||||
|
||||
@mimic3.route('/tts/mimic3/say', methods=['GET'])
|
||||
def proxy_tts_request():
|
||||
"""
|
||||
This route is used to proxy the POST request to the Mimic3 TTS server
|
||||
through a GET, so it can be easily processed as a URL through a media
|
||||
plugin.
|
||||
"""
|
||||
required_args = {
|
||||
'text',
|
||||
'server_url',
|
||||
'voice',
|
||||
}
|
||||
|
||||
missing_args = required_args.difference(set(request.args.keys()))
|
||||
if missing_args:
|
||||
abort(400, f'Missing parameters: {missing_args}')
|
||||
|
||||
args = {arg: request.args[arg] for arg in required_args}
|
||||
|
||||
rs = requests.post(
|
||||
urljoin(args['server_url'], '/api/tts'),
|
||||
data=args['text'],
|
||||
params={
|
||||
'voice': args['voice'],
|
||||
},
|
||||
)
|
||||
|
||||
return rs.content
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,113 +0,0 @@
|
|||
from flask import Blueprint, jsonify, send_from_directory
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.backend.http.app import template_folder
|
||||
|
||||
pwa = Blueprint('pwa', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
pwa,
|
||||
]
|
||||
|
||||
|
||||
@pwa.route('/manifest.json', methods=['GET'])
|
||||
def manifest_json():
|
||||
"""Generated manifest file for the PWA"""
|
||||
return jsonify(
|
||||
{
|
||||
"name": f'Platypush @ {Config.get("device_id")}',
|
||||
"short_name": Config.get('device_id'),
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/apple-touch-icon-60x60.png",
|
||||
"sizes": "60x60",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/apple-touch-icon-76x76.png",
|
||||
"sizes": "76x76",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/apple-touch-icon-120x120.png",
|
||||
"sizes": "120x120",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/msapplication-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/mstile-150x150.png",
|
||||
"sizes": "150x150",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/apple-touch-icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/apple-touch-icon-180x180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/android-chrome-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/logo-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"gcm_sender_id": "",
|
||||
"gcm_user_visible_only": True,
|
||||
"start_url": "/",
|
||||
"permissions": ["gcm"],
|
||||
"orientation": "portrait",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pwa.route('/service-worker.js', methods=['GET'])
|
||||
def service_worker_js():
|
||||
"""URL that serves the service worker for the PWA"""
|
||||
return send_from_directory(template_folder, 'service-worker.js')
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -8,27 +8,22 @@ from platypush.backend.http.app import template_folder
|
|||
|
||||
|
||||
img_folder = os.path.join(template_folder, 'img')
|
||||
fonts_folder = os.path.join(template_folder, 'fonts')
|
||||
icons_folder = os.path.join(template_folder, 'icons')
|
||||
resources = Blueprint('resources', __name__, template_folder=template_folder)
|
||||
favicon = Blueprint('favicon', __name__, template_folder=template_folder)
|
||||
img = Blueprint('img', __name__, template_folder=template_folder)
|
||||
icons = Blueprint('icons', __name__, template_folder=template_folder)
|
||||
fonts = Blueprint('fonts', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
resources,
|
||||
favicon,
|
||||
img,
|
||||
icons,
|
||||
fonts,
|
||||
]
|
||||
|
||||
|
||||
@resources.route('/resources/<path:path>', methods=['GET'])
|
||||
def resources_path(path):
|
||||
"""Custom static resources"""
|
||||
""" Custom static resources """
|
||||
path_tokens = path.split('/')
|
||||
http_conf = Config.get('backend.http')
|
||||
resource_dirs = http_conf.get('resource_dirs', {})
|
||||
|
@ -47,11 +42,9 @@ def resources_path(path):
|
|||
real_path = real_base_path
|
||||
|
||||
file_path = [
|
||||
s
|
||||
for s in re.sub(
|
||||
r'^{}(.*)$'.format(base_path), '\\1', path # lgtm [py/regex-injection]
|
||||
).split('/')
|
||||
if s
|
||||
s for s in re.sub(
|
||||
r'^{}(.*)$'.format(base_path), '\\1', path # lgtm [py/regex-injection]
|
||||
).split('/') if s
|
||||
]
|
||||
|
||||
for p in file_path[:-1]:
|
||||
|
@ -68,26 +61,20 @@ def resources_path(path):
|
|||
|
||||
@favicon.route('/favicon.ico', methods=['GET'])
|
||||
def serve_favicon():
|
||||
"""favicon.ico icon"""
|
||||
""" favicon.ico icon """
|
||||
return send_from_directory(template_folder, 'favicon.ico')
|
||||
|
||||
|
||||
@img.route('/img/<path:path>', methods=['GET'])
|
||||
def imgpath(path):
|
||||
"""Default static images"""
|
||||
""" Default static images """
|
||||
return send_from_directory(img_folder, path)
|
||||
|
||||
|
||||
@icons.route('/icons/<path:path>', methods=['GET'])
|
||||
@img.route('/icons/<path:path>', methods=['GET'])
|
||||
def iconpath(path):
|
||||
"""Default static icons"""
|
||||
""" Default static icons """
|
||||
return send_from_directory(icons_folder, path)
|
||||
|
||||
|
||||
@fonts.route('/fonts/<path:path>', methods=['GET'])
|
||||
def fontpath(path):
|
||||
"""Default fonts"""
|
||||
return send_from_directory(fonts_folder, path)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
import importlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from functools import wraps
|
||||
from flask import abort, request, redirect, Response, current_app
|
||||
from redis import Redis
|
||||
|
||||
# NOTE: The HTTP service will *only* work on top of a Redis bus. The default
|
||||
# internal bus service won't work as the web server will run in a different process.
|
||||
from platypush.bus.redis import RedisBus
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.message import Message
|
||||
from platypush.message.request import Request
|
||||
from platypush.user import UserManager
|
||||
from platypush.utils import get_redis_queue_name_by_message, get_ip_or_hostname
|
||||
|
||||
_bus = None
|
||||
_logger = None
|
||||
|
||||
|
||||
def bus():
|
||||
global _bus
|
||||
if _bus is None:
|
||||
_bus = RedisBus(redis_queue=current_app.config.get('redis_queue'))
|
||||
return _bus
|
||||
|
||||
|
||||
def logger():
|
||||
global _logger
|
||||
if not _logger:
|
||||
log_args = {
|
||||
'level': logging.INFO,
|
||||
'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s',
|
||||
}
|
||||
|
||||
level = (Config.get('backend.http') or {}).get('logging') or \
|
||||
(Config.get('logging') or {}).get('level')
|
||||
filename = (Config.get('backend.http') or {}).get('filename')
|
||||
|
||||
if level:
|
||||
log_args['level'] = getattr(logging, level.upper()) \
|
||||
if isinstance(level, str) else level
|
||||
if filename:
|
||||
log_args['filename'] = filename
|
||||
|
||||
logging.basicConfig(**log_args)
|
||||
_logger = logging.getLogger('platypush:web')
|
||||
|
||||
return _logger
|
||||
|
||||
|
||||
def get_message_response(msg):
|
||||
redis = Redis(**bus().redis_args)
|
||||
response = redis.blpop(get_redis_queue_name_by_message(msg), timeout=60)
|
||||
if response and len(response) > 1:
|
||||
response = Message.build(response[1])
|
||||
else:
|
||||
response = None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
def get_http_port():
|
||||
from platypush.backend.http import HttpBackend
|
||||
http_conf = Config.get('backend.http')
|
||||
return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT)
|
||||
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
def get_websocket_port():
|
||||
from platypush.backend.http import HttpBackend
|
||||
http_conf = Config.get('backend.http')
|
||||
return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT)
|
||||
|
||||
|
||||
def send_message(msg, wait_for_response=True):
|
||||
msg = Message.build(msg)
|
||||
|
||||
if isinstance(msg, Request):
|
||||
msg.origin = 'http'
|
||||
|
||||
if Config.get('token'):
|
||||
msg.token = Config.get('token')
|
||||
|
||||
bus().post(msg)
|
||||
|
||||
if isinstance(msg, Request) and wait_for_response:
|
||||
response = get_message_response(msg)
|
||||
logger().debug('Processing response on the HTTP backend: {}'.
|
||||
format(response))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def send_request(action, wait_for_response=True, **kwargs):
|
||||
msg = {
|
||||
'type': 'request',
|
||||
'action': action
|
||||
}
|
||||
|
||||
if kwargs:
|
||||
msg['args'] = kwargs
|
||||
|
||||
return send_message(msg, wait_for_response=wait_for_response)
|
||||
|
||||
|
||||
def _authenticate_token():
|
||||
token = Config.get('token')
|
||||
user_manager = UserManager()
|
||||
|
||||
if 'X-Token' in request.headers:
|
||||
user_token = request.headers['X-Token']
|
||||
elif 'Authorization' in request.headers and request.headers['Authorization'].startswith('Bearer '):
|
||||
user_token = request.headers['Authorization'][len('Bearer '):]
|
||||
elif 'token' in request.args:
|
||||
user_token = request.args.get('token')
|
||||
else:
|
||||
return False
|
||||
|
||||
try:
|
||||
user_manager.validate_jwt_token(user_token)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger().debug(str(e))
|
||||
return token and user_token == token
|
||||
|
||||
|
||||
def _authenticate_http():
|
||||
user_manager = UserManager()
|
||||
|
||||
if not request.authorization:
|
||||
return False
|
||||
|
||||
username = request.authorization.username
|
||||
password = request.authorization.password
|
||||
return user_manager.authenticate_user(username, password)
|
||||
|
||||
|
||||
def _authenticate_session():
|
||||
user_manager = UserManager()
|
||||
user_session_token = None
|
||||
user = None
|
||||
|
||||
if 'X-Session-Token' in request.headers:
|
||||
user_session_token = request.headers['X-Session-Token']
|
||||
elif 'session_token' in request.args:
|
||||
user_session_token = request.args.get('session_token')
|
||||
elif 'session_token' in request.cookies:
|
||||
user_session_token = request.cookies.get('session_token')
|
||||
|
||||
if user_session_token:
|
||||
user, session = user_manager.authenticate_user_session(user_session_token)
|
||||
|
||||
return user is not None
|
||||
|
||||
|
||||
def _authenticate_csrf_token():
|
||||
user_manager = UserManager()
|
||||
user_session_token = None
|
||||
|
||||
if 'X-Session-Token' in request.headers:
|
||||
user_session_token = request.headers['X-Session-Token']
|
||||
elif 'session_token' in request.args:
|
||||
user_session_token = request.args.get('session_token')
|
||||
elif 'session_token' in request.cookies:
|
||||
user_session_token = request.cookies.get('session_token')
|
||||
|
||||
if user_session_token:
|
||||
user, session = user_manager.authenticate_user_session(user_session_token)
|
||||
else:
|
||||
return False
|
||||
|
||||
if user is None:
|
||||
return False
|
||||
|
||||
return session.csrf_token is None or request.form.get('csrf_token') == session.csrf_token
|
||||
|
||||
|
||||
def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=False):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
user_manager = UserManager()
|
||||
n_users = user_manager.get_user_count()
|
||||
skip_methods = skip_auth_methods or []
|
||||
|
||||
# User/pass HTTP authentication
|
||||
http_auth_ok = True
|
||||
if n_users > 0 and 'http' not in skip_methods:
|
||||
http_auth_ok = _authenticate_http()
|
||||
if http_auth_ok:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Token-based authentication
|
||||
token_auth_ok = True
|
||||
if 'token' not in skip_methods:
|
||||
token_auth_ok = _authenticate_token()
|
||||
if token_auth_ok:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Session token based authentication
|
||||
session_auth_ok = True
|
||||
if n_users > 0 and 'session' not in skip_methods:
|
||||
session_auth_ok = _authenticate_session()
|
||||
if session_auth_ok:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return redirect('/login?redirect=' + (redirect_page or request.url), 307)
|
||||
|
||||
# CSRF token check
|
||||
if check_csrf_token:
|
||||
csrf_check_ok = _authenticate_csrf_token()
|
||||
if not csrf_check_ok:
|
||||
return abort(403, 'Invalid or missing csrf_token')
|
||||
|
||||
if n_users == 0 and 'session' not in skip_methods:
|
||||
return redirect('/register?redirect=' + (redirect_page or request.url), 307)
|
||||
|
||||
if ('http' not in skip_methods and http_auth_ok) or \
|
||||
('token' not in skip_methods and token_auth_ok) or \
|
||||
('session' not in skip_methods and session_auth_ok):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return Response('Authentication required', 401,
|
||||
{'WWW-Authenticate': 'Basic realm="Login required"'})
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_routes():
|
||||
routes_dir = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'routes')
|
||||
routes = []
|
||||
base_module = '.'.join(__name__.split('.')[:-1])
|
||||
|
||||
for path, dirs, files in os.walk(routes_dir):
|
||||
for f in files:
|
||||
if f.endswith('.py'):
|
||||
mod_name = '.'.join(
|
||||
(base_module + '.' + os.path.join(path, f).replace(
|
||||
os.path.dirname(__file__), '')[1:].replace(os.sep, '.')).split('.')
|
||||
[:(-2 if f == '__init__.py' else -1)])
|
||||
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
if hasattr(mod, '__routes__'):
|
||||
routes.extend(mod.__routes__)
|
||||
except Exception as e:
|
||||
logger().warning('Could not import routes from {}/{}: {}: {}'.
|
||||
format(path, f, type(e), str(e)))
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
def get_local_base_url():
|
||||
http_conf = Config.get('backend.http') or {}
|
||||
return '{proto}://localhost:{port}'.format(
|
||||
proto=('https' if http_conf.get('ssl_cert') else 'http'),
|
||||
port=get_http_port())
|
||||
|
||||
|
||||
def get_remote_base_url():
|
||||
http_conf = Config.get('backend.http') or {}
|
||||
return '{proto}://{host}:{port}'.format(
|
||||
proto=('https' if http_conf.get('ssl_cert') else 'http'),
|
||||
host=get_ip_or_hostname(), port=get_http_port())
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,37 +0,0 @@
|
|||
from .auth import (
|
||||
authenticate,
|
||||
authenticate_token,
|
||||
authenticate_user_pass,
|
||||
get_auth_status,
|
||||
)
|
||||
from .bus import bus, get_message_response, send_message, send_request
|
||||
from .logger import logger
|
||||
from .routes import (
|
||||
get_http_port,
|
||||
get_ip_or_hostname,
|
||||
get_local_base_url,
|
||||
get_remote_base_url,
|
||||
get_routes,
|
||||
)
|
||||
from .ws import get_ws_routes
|
||||
|
||||
__all__ = [
|
||||
'authenticate',
|
||||
'authenticate_token',
|
||||
'authenticate_user_pass',
|
||||
'bus',
|
||||
'get_auth_status',
|
||||
'get_http_port',
|
||||
'get_ip_or_hostname',
|
||||
'get_local_base_url',
|
||||
'get_message_response',
|
||||
'get_remote_base_url',
|
||||
'get_routes',
|
||||
'get_ws_routes',
|
||||
'logger',
|
||||
'send_message',
|
||||
'send_request',
|
||||
]
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,196 +0,0 @@
|
|||
import base64
|
||||
from functools import wraps
|
||||
from typing import Optional
|
||||
|
||||
from flask import request, redirect, jsonify
|
||||
from flask.wrappers import Response
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.user import UserManager
|
||||
|
||||
from ..logger import logger
|
||||
from .status import AuthStatus
|
||||
|
||||
user_manager = UserManager()
|
||||
|
||||
|
||||
def get_arg(req, name: str) -> Optional[str]:
|
||||
# The Flask way
|
||||
if hasattr(req, 'args'):
|
||||
return req.args.get(name)
|
||||
|
||||
# The Tornado way
|
||||
if hasattr(req, 'arguments'):
|
||||
arg = req.arguments.get(name)
|
||||
if arg:
|
||||
return arg[0].decode()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_cookie(req, name: str) -> Optional[str]:
|
||||
cookie = req.cookies.get(name)
|
||||
if not cookie:
|
||||
return None
|
||||
|
||||
# The Flask way
|
||||
if isinstance(cookie, str):
|
||||
return cookie
|
||||
|
||||
# The Tornado way
|
||||
return cookie.value
|
||||
|
||||
|
||||
def authenticate_token(req):
|
||||
token = Config.get('token')
|
||||
user_token = None
|
||||
|
||||
if 'X-Token' in req.headers:
|
||||
user_token = req.headers['X-Token']
|
||||
elif 'Authorization' in req.headers and req.headers['Authorization'].startswith(
|
||||
'Bearer '
|
||||
):
|
||||
user_token = req.headers['Authorization'][7:]
|
||||
else:
|
||||
user_token = get_arg(req, 'token')
|
||||
|
||||
if not user_token:
|
||||
return False
|
||||
|
||||
try:
|
||||
user_manager.validate_jwt_token(user_token)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger().debug(str(e))
|
||||
return bool(token and user_token == token)
|
||||
|
||||
|
||||
def authenticate_user_pass(req):
|
||||
# Flask populates request.authorization
|
||||
if hasattr(req, 'authorization'):
|
||||
if not req.authorization:
|
||||
return False
|
||||
|
||||
username = req.authorization.username
|
||||
password = req.authorization.password
|
||||
|
||||
# Otherwise, check the Authorization header
|
||||
elif 'Authorization' in req.headers and req.headers['Authorization'].startswith(
|
||||
'Basic '
|
||||
):
|
||||
auth = req.headers['Authorization'][6:]
|
||||
try:
|
||||
auth = base64.b64decode(auth)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
username, password = auth.decode().split(':', maxsplit=1)
|
||||
else:
|
||||
return False
|
||||
|
||||
return user_manager.authenticate_user(username, password)
|
||||
|
||||
|
||||
def authenticate_session(req):
|
||||
user = None
|
||||
|
||||
# Check the X-Session-Token header
|
||||
user_session_token = req.headers.get('X-Session-Token')
|
||||
|
||||
# Check the `session_token` query/body parameter
|
||||
if not user_session_token:
|
||||
user_session_token = get_arg(req, 'session_token')
|
||||
|
||||
# Check the `session_token` cookie
|
||||
if not user_session_token:
|
||||
user_session_token = get_cookie(req, 'session_token')
|
||||
|
||||
if user_session_token:
|
||||
user, _ = user_manager.authenticate_user_session(user_session_token)
|
||||
|
||||
return user is not None
|
||||
|
||||
|
||||
def authenticate(
|
||||
redirect_page='',
|
||||
skip_auth_methods=None,
|
||||
json=False,
|
||||
):
|
||||
"""
|
||||
Authentication decorator for Flask routes.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
auth_status = get_auth_status(
|
||||
request,
|
||||
skip_auth_methods=skip_auth_methods,
|
||||
)
|
||||
|
||||
if auth_status == AuthStatus.OK:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if json:
|
||||
return jsonify(auth_status.to_dict()), auth_status.value.code
|
||||
|
||||
if auth_status == AuthStatus.NO_USERS:
|
||||
return redirect(
|
||||
f'/register?redirect={redirect_page or request.url}', 307
|
||||
)
|
||||
|
||||
if auth_status == AuthStatus.UNAUTHORIZED:
|
||||
return redirect(f'/login?redirect={redirect_page or request.url}', 307)
|
||||
|
||||
return Response(
|
||||
'Authentication required',
|
||||
401,
|
||||
{'WWW-Authenticate': 'Basic realm="Login required"'},
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def get_auth_status(req, skip_auth_methods=None) -> AuthStatus:
|
||||
"""
|
||||
Check against the available authentication methods (except those listed in
|
||||
``skip_auth_methods``) if the user is properly authenticated.
|
||||
"""
|
||||
|
||||
n_users = user_manager.get_user_count()
|
||||
skip_methods = skip_auth_methods or []
|
||||
|
||||
# User/pass HTTP authentication
|
||||
http_auth_ok = True
|
||||
if n_users > 0 and 'http' not in skip_methods:
|
||||
http_auth_ok = authenticate_user_pass(req)
|
||||
if http_auth_ok:
|
||||
return AuthStatus.OK
|
||||
|
||||
# Token-based authentication
|
||||
token_auth_ok = True
|
||||
if 'token' not in skip_methods:
|
||||
token_auth_ok = authenticate_token(req)
|
||||
if token_auth_ok:
|
||||
return AuthStatus.OK
|
||||
|
||||
# Session token based authentication
|
||||
session_auth_ok = True
|
||||
if n_users > 0 and 'session' not in skip_methods:
|
||||
return AuthStatus.OK if authenticate_session(req) else AuthStatus.UNAUTHORIZED
|
||||
|
||||
# At least a user should be created before accessing an authenticated resource
|
||||
if n_users == 0 and 'session' not in skip_methods:
|
||||
return AuthStatus.NO_USERS
|
||||
|
||||
if ( # pylint: disable=too-many-boolean-expressions
|
||||
('http' not in skip_methods and http_auth_ok)
|
||||
or ('token' not in skip_methods and token_auth_ok)
|
||||
or ('session' not in skip_methods and session_auth_ok)
|
||||
):
|
||||
return AuthStatus.OK
|
||||
|
||||
return AuthStatus.UNAUTHORIZED
|
|
@ -1,21 +0,0 @@
|
|||
from collections import namedtuple
|
||||
from enum import Enum
|
||||
|
||||
|
||||
StatusValue = namedtuple('StatusValue', ['code', 'message'])
|
||||
|
||||
|
||||
class AuthStatus(Enum):
|
||||
"""
|
||||
Models the status of the authentication.
|
||||
"""
|
||||
|
||||
OK = StatusValue(200, 'OK')
|
||||
UNAUTHORIZED = StatusValue(401, 'Unauthorized')
|
||||
NO_USERS = StatusValue(412, 'Please create a user first')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'code': self.value[0],
|
||||
'message': self.value[1],
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
from redis import Redis
|
||||
|
||||
from platypush.bus.redis import RedisBus
|
||||
from platypush.config import Config
|
||||
from platypush.context import get_backend
|
||||
from platypush.message import Message
|
||||
from platypush.message.request import Request
|
||||
from platypush.utils import get_redis_queue_name_by_message
|
||||
|
||||
from .logger import logger
|
||||
|
||||
_bus = None
|
||||
|
||||
|
||||
def bus():
|
||||
global _bus
|
||||
if _bus is None:
|
||||
redis_queue = get_backend('http').bus.redis_queue # type: ignore
|
||||
_bus = RedisBus(redis_queue=redis_queue)
|
||||
return _bus
|
||||
|
||||
|
||||
def send_message(msg, wait_for_response=True):
|
||||
msg = Message.build(msg)
|
||||
if msg is None:
|
||||
return
|
||||
|
||||
if isinstance(msg, Request):
|
||||
msg.origin = 'http'
|
||||
|
||||
if Config.get('token'):
|
||||
msg.token = Config.get('token')
|
||||
|
||||
bus().post(msg)
|
||||
|
||||
if isinstance(msg, Request) and wait_for_response:
|
||||
response = get_message_response(msg)
|
||||
logger().debug('Processing response on the HTTP backend: {}'.format(response))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def send_request(action, wait_for_response=True, **kwargs):
|
||||
msg = {'type': 'request', 'action': action}
|
||||
|
||||
if kwargs:
|
||||
msg['args'] = kwargs
|
||||
|
||||
return send_message(msg, wait_for_response=wait_for_response)
|
||||
|
||||
|
||||
def get_message_response(msg):
|
||||
redis = Redis(**bus().redis_args)
|
||||
redis_queue = get_redis_queue_name_by_message(msg)
|
||||
if not redis_queue:
|
||||
return
|
||||
|
||||
response = redis.blpop(redis_queue, timeout=60)
|
||||
if response and len(response) > 1:
|
||||
response = Message.build(response[1])
|
||||
else:
|
||||
response = None
|
||||
|
||||
return response
|
|
@ -1,31 +0,0 @@
|
|||
import logging
|
||||
|
||||
from platypush.config import Config
|
||||
|
||||
_logger = None
|
||||
|
||||
|
||||
def logger():
|
||||
global _logger
|
||||
if not _logger:
|
||||
log_args = {
|
||||
'level': logging.INFO,
|
||||
'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s',
|
||||
}
|
||||
|
||||
level = (Config.get('backend.http') or {}).get('logging') or (
|
||||
Config.get('logging') or {}
|
||||
).get('level')
|
||||
filename = (Config.get('backend.http') or {}).get('filename')
|
||||
|
||||
if level:
|
||||
log_args['level'] = (
|
||||
getattr(logging, level.upper()) if isinstance(level, str) else level
|
||||
)
|
||||
if filename:
|
||||
log_args['filename'] = filename
|
||||
|
||||
logging.basicConfig(**log_args)
|
||||
_logger = logging.getLogger('platypush:web')
|
||||
|
||||
return _logger
|
|
@ -1,59 +0,0 @@
|
|||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import pkgutil
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.config import Config
|
||||
from platypush.utils import get_ip_or_hostname
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def get_http_port():
|
||||
from platypush.backend.http import HttpBackend
|
||||
|
||||
http_conf = Config.get('backend.http') or {}
|
||||
return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT)
|
||||
|
||||
|
||||
def get_routes():
|
||||
base_pkg = '.'.join([Backend.__module__, 'http', 'app', 'routes'])
|
||||
base_dir = os.path.join(
|
||||
os.path.dirname(inspect.getfile(Backend)), 'http', 'app', 'routes'
|
||||
)
|
||||
routes = []
|
||||
|
||||
for _, mod_name, _ in pkgutil.walk_packages([base_dir], prefix=base_pkg + '.'):
|
||||
try:
|
||||
module = importlib.import_module(mod_name)
|
||||
if hasattr(module, '__routes__'):
|
||||
routes.extend(module.__routes__)
|
||||
except Exception as e:
|
||||
logger.warning('Could not import module %s', mod_name)
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
def get_local_base_url():
|
||||
http_conf = Config.get('backend.http') or {}
|
||||
bind_address = http_conf.get('bind_address')
|
||||
if not bind_address or bind_address == '0.0.0.0':
|
||||
bind_address = 'localhost'
|
||||
|
||||
return '{proto}://{host}:{port}'.format(
|
||||
proto=('https' if http_conf.get('ssl_cert') else 'http'),
|
||||
host=bind_address,
|
||||
port=get_http_port(),
|
||||
)
|
||||
|
||||
|
||||
def get_remote_base_url():
|
||||
http_conf = Config.get('backend.http') or {}
|
||||
return '{proto}://{host}:{port}'.format(
|
||||
proto=('https' if http_conf.get('ssl_cert') else 'http'),
|
||||
host=get_ip_or_hostname(),
|
||||
port=get_http_port(),
|
||||
)
|
|
@ -1,37 +0,0 @@
|
|||
import os
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import List, Type
|
||||
|
||||
import pkgutil
|
||||
|
||||
from ..ws import WSRoute, logger
|
||||
|
||||
|
||||
def get_ws_routes() -> List[Type[WSRoute]]:
|
||||
"""
|
||||
Scans for websocket route objects.
|
||||
"""
|
||||
from platypush.backend.http import HttpBackend
|
||||
|
||||
base_pkg = '.'.join([HttpBackend.__module__, 'app', 'ws'])
|
||||
base_dir = os.path.join(os.path.dirname(inspect.getfile(HttpBackend)), 'app', 'ws')
|
||||
routes = []
|
||||
|
||||
for _, mod_name, _ in pkgutil.walk_packages([base_dir], prefix=base_pkg + '.'):
|
||||
try:
|
||||
module = importlib.import_module(mod_name)
|
||||
except Exception as e:
|
||||
logger.warning('Could not import module %s', mod_name)
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
for _, obj in inspect.getmembers(module):
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and not inspect.isabstract(obj)
|
||||
and issubclass(obj, WSRoute)
|
||||
):
|
||||
routes.append(obj)
|
||||
|
||||
return routes
|
|
@ -1,3 +0,0 @@
|
|||
from ._base import WSRoute, logger, pubsub_redis_topic
|
||||
|
||||
__all__ = ['WSRoute', 'logger', 'pubsub_redis_topic']
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue