Compare commits

...

91 Commits

Author SHA1 Message Date
Fabio Manganiello ed697c0ad2
🐛 [CI/CD] Fixed build-ui.sh script.
continuous-integration/drone/push Build is passing Details
After doing "cd $SRCDIR", it should reference the `dist` directory in
the webapp by full relative path.
2024-06-06 22:46:37 +02:00
Fabio Manganiello 22cfe777fa
Merge pull request #428 from blacklight/snyk-upgrade-2f9bcb05344a53203d1db8700a74298c
continuous-integration/drone/push Build is failing Details
[Snyk] Upgrade core-js from 3.37.0 to 3.37.1
2024-06-06 22:32:31 +02:00
Fabio Manganiello 3f2832a077
Merge branch 'master' into snyk-upgrade-2f9bcb05344a53203d1db8700a74298c 2024-06-06 22:32:04 +02:00
Fabio Manganiello 6f8eb397d2
Merge pull request #427 from blacklight/snyk-upgrade-97c24303ee224553f29b460d83c6c780
[Snyk] Upgrade cronstrue from 2.49.0 to 2.50.0
2024-06-06 22:30:59 +02:00
Fabio Manganiello 3163721bf3
Merge pull request #426 from blacklight/snyk-upgrade-26bc4dca62d58f39bfb77f2e69121708
[Snyk] Upgrade sass from 1.75.0 to 1.76.0
2024-06-06 22:30:43 +02:00
Fabio Manganiello d79b8a1de5
Merge pull request #425 from blacklight/snyk-upgrade-a75151e7066361ecbf2a647d6e707a32
[Snyk] Upgrade vue from 3.4.23 to 3.4.24
2024-06-06 22:30:20 +02:00
Fabio Manganiello 3afc6b2271
[Automatic] Updated components cache
continuous-integration/drone/push Build is passing Details
2024-06-06 20:29:13 +00:00
Fabio Manganiello 17b6b02986 Replaced `warnings.warn` with `logging.warnings`.
I couldn't find an easy and reliable way of routing `warnings.warn` to
`logging`.

Closes: #281
2024-06-06 20:28:23 +00:00
Fabio Manganiello 87a902bfa3
[Automatic] Updated components cache
continuous-integration/drone/push Build is passing Details
2024-06-06 01:28:13 +00:00
Fabio Manganiello 421feffd3e
Bump version: 1.0.7 → 1.1.0
continuous-integration/drone/push Build is passing Details
2024-06-06 03:27:04 +02:00
Fabio Manganiello 518dc146d6
Bumped version in CHANGELOG. 2024-06-06 03:27:04 +02:00
Fabio Manganiello 6b11db7afb
[docs] Added `inherited-members`.
Closes: #403
2024-06-06 03:27:03 +02:00
Fabio Manganiello 8814859abc
[zwave.mqtt] Fixed typo in documentation. 2024-06-06 03:27:03 +02:00
Fabio Manganiello 0ccd029ff1
[Automatic] Updated components cache
continuous-integration/drone/push Build is passing Details
2024-06-06 00:25:21 +00:00
Fabio Manganiello e52f5e06f4 [calendar.ical] Fixed timezone/datetime parsing issues.
Closes: #405
2024-06-06 00:24:31 +00:00
snyk-bot 066d71faa3
fix: upgrade core-js from 3.37.0 to 3.37.1
Snyk has created this PR to upgrade core-js from 3.37.0 to 3.37.1.

See this package in npm:
core-js

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-06-04 18:18:54 +00:00
Fabio Manganiello 4f19b45975 Bump version: 1.0.6 → 1.0.7 2024-06-02 15:49:59 +00:00
Fabio Manganiello 7d6ffc76fb [CI/CD] The build-ui script should go back to the source root after running the UI build commands from the UI folder 2024-06-02 15:46:18 +00:00
Fabio Manganiello 7a8f30e5e0 [#384] Added `assistant.openai` and `tts.openai` plugins.
Closes: #384
2024-06-02 15:31:11 +00:00
Fabio Manganiello 3528b3646f [openai] Update documentation to include `assistant` and `tts`. 2024-06-02 15:31:11 +00:00
Fabio Manganiello 9cca928d4b [#348] Added `openai.transcribe` action.
This API is the foundation for the `assistant.openai` plugin.
2024-06-02 15:31:11 +00:00
Fabio Manganiello f356fcd844 Added `tts.stop` method. 2024-06-02 15:31:11 +00:00
Fabio Manganiello fcae7aa3ad Several improvements for `assistant` plugins.
- `stop_conversation_on_speech_match` should default to True.

- `render_response` should also handle conversation follow-ups, set the
  follow-up to True if the response ends with a question mark and the
  value of `with_follow_on_turn` is not set,

- Don't render responses if a `tts_plugin` is not set.
2024-06-02 15:31:11 +00:00
Fabio Manganiello c7d640a1d2 `IntentRecognizedEvent` should stop the current assistant conversation when matched by a hook. 2024-06-02 15:31:11 +00:00
Fabio Manganiello 1cc2aaf5a4 [assistant.picovoice] `_on_response_*` methods should have varargs. 2024-06-02 15:31:11 +00:00
Fabio Manganiello 2acf6ef3e9 Bump version: 1.0.5 → 1.0.6 2024-06-01 09:01:07 +00:00
Fabio Manganiello 1107e526f7 Updated CHANGELOG 2024-06-01 09:01:07 +00:00
Fabio Manganiello 5fc9c1199b Fixed the root cause of the failure on the `time` module.
The previous commit prompted a new error:

```
2024-06-01 10:54:08,310|ERROR|platypush:plugin:bluetooth|module 'platypush.entities.time' has no attribute 'time'
Traceback (most recent call last):
  File "/usr/lib/python3.9/dist-packages/platypush/plugins/__init__.py", line 247, in _runner
    self.main()
  File "/usr/lib/python3.9/dist-packages/platypush/plugins/bluetooth/__init__.py", line 590, in main
    self._refresh_cache()
  File "/usr/lib/python3.9/dist-packages/platypush/plugins/bluetooth/__init__.py", line 146, in _refresh_cache
    get_entities_engine().wait_start()
  File "/usr/lib/python3.9/dist-packages/platypush/entities/__init__.py", line 48, in get_entities_engine
    time_start = time.time()
AttributeError: module 'platypush.entities.time' has no attribute 'time'
```

Which explains even the previous error: `import time` in that module
won't use the `time` module from the Python library, but the `.time`
module within the same directory.

This error only happens when the current directory is part of PYTHONPATH
(and usually it shouldn't), but for sake of keeping things safe I've
replaced `time()` with `utcnow().timestamp()`, with `utcnow` imported
from `platypush.utils`.
2024-06-01 09:01:07 +00:00
Fabio Manganiello b067430cd5 Weird fix for a weird error that suddenly started on one of my machines.
```
Traceback (most recent call last):
  File "/usr/lib/python3.9/dist-packages/platypush/plugins/__init__.py", line 247, in _runner
    self.main()
  File "/usr/lib/python3.9/dist-packages/platypush/plugins/bluetooth/__init__.py", line 590, in main
    self._refresh_cache()
  File "/usr/lib/python3.9/dist-packages/platypush/plugins/bluetooth/__init__.py", line 146, in _refresh_cache
    get_entities_engine().wait_start()
  File "/usr/lib/python3.9/dist-packages/platypush/entities/__init__.py", line 48, in get_entities_engine
    time_start = time()
TypeError: 'module' object is not callable
```

There isn't a single reason in this world for this error to happen.

If I do `from time import time`, then `t = time()` is 100% valid Python.

I have no clue of what may be causing it, but I hope that this will fix
it.
2024-06-01 08:50:30 +00:00
Fabio Manganiello ff60896625
[Automatic] Updated components cache
continuous-integration/drone/push Build is passing Details
2024-05-31 23:55:27 +00:00
Fabio Manganiello 67b6e3a608
Bump version: 1.0.4 → 1.0.5
continuous-integration/drone/push Build is passing Details
2024-06-01 01:43:12 +02:00
Fabio Manganiello c61a1b89d6
Updated CHANGELOG 2024-06-01 01:42:21 +02:00
Fabio Manganiello c9a5c29a4a
🐛 A proper cross-version solution for the `utcnow()` issue.
No need to maintain two different pieces of logic - a `utcnow()` for
Python < 3.11 and `now(datetime.UTC)` for Python >= 3.11.

`datetime.timezone.utc` existed long before datetime.UTC and that's what
the `utcnow` facade should use.

This means that all the `utcnow()` will always have `tzinfo=UTC`
regardless of the Python version.

There's still a problem with the `utcnow()`-generated timestamps that
have been generated by previous versions of Python and stored on the db.

Therefore, when the code performs comparisons with timestamps fetched
from the db, it should always explicitly do a `.replace(tzinfo=utc)` to
ensure that we always compare offset-aware datetime representations.

See blog post for technical details:
https://manganiello.blog/wheres-my-time-again
2024-06-01 01:34:47 +02:00
Fabio Manganiello 1067ab04d9
[tts.picovoice] Adapted to the new `orca.synthesize` API.
The new API no longer returns a list of numeric values alone. Instead,
it returns a tuple where the first element is the raw audio, and the
second element contains extra info on the rendered phonemes.
2024-05-31 21:10:48 +02:00
Fabio Manganiello 709b90fa4b
Merge branch 'master' into 384/assistant-openai 2024-05-31 21:07:06 +02:00
Fabio Manganiello 06f0ac4545
[Automatic] Updated components cache
continuous-integration/drone/push Build is passing Details
2024-05-31 17:59:06 +00:00
Fabio Manganiello 944fd45f9f
Bump version: 1.0.3 → 1.0.4
continuous-integration/drone/push Build is passing Details
2024-05-31 19:57:51 +02:00
Fabio Manganiello 6acdde6164
Updated CHANGELOG 2024-05-31 19:57:43 +02:00
Fabio Manganiello 3583dafbc3
🐛 Partial revert of c18768e61f
`datetime.utcnow` may be deprecated on Python >= 3.12, but
`datetime.UTC` isn't present on older Python versions.

Added a `platypush.utils.utcnow()` method as a workaround compatible
with both.
2024-05-31 19:55:19 +02:00
Fabio Manganiello 4513bb9569
Set a `plugin` argument on `AssistantEvent`s besides `assistant`.
`assistant` contains the assistant plugin object that triggered the
event, but you can't create event hook conditions on attributes that are
plugins.

The event should also store a `plugin` attribute which contains the
unique plugin name, so hooks like these can be built:

```
from platypush import hook
from platypush.events.assistant import ConversationStartEvent

@when(ConversationStartEvent, plugin="assistant.google")
def on_google_conversation_start():
  ...
```

It wouldn't be possible to construct a hook condition like the one above
on the plugin object reported on the `assistant` attribute.
2024-05-31 19:55:19 +02:00
Fabio Manganiello 4e82dd17bb
🐛 Partial revert of c18768e61f
`datetime.utcnow` may be deprecated on Python >= 3.12, but
`datetime.UTC` isn't present on older Python versions.

Added a `platypush.utils.utcnow()` method as a workaround compatible
with both.
2024-05-31 19:52:32 +02:00
Fabio Manganiello e982c02524
Set a `plugin` argument on `AssistantEvent`s besides `assistant`.
`assistant` contains the assistant plugin object that triggered the
event, but you can't create event hook conditions on attributes that are
plugins.

The event should also store a `plugin` attribute which contains the
unique plugin name, so hooks like these can be built:

```
from platypush import hook
from platypush.events.assistant import ConversationStartEvent

@when(ConversationStartEvent, plugin="assistant.google")
def on_google_conversation_start():
  ...
```

It wouldn't be possible to construct a hook condition like the one above
on the plugin object reported on the `assistant` attribute.
2024-05-31 19:29:50 +02:00
Fabio Manganiello d9a5ea1e53
Merge branch 'master' into 384/assistant-openai 2024-05-31 02:58:08 +02:00
Fabio Manganiello 23e02de1d7
Bump version: 1.0.2 → 1.0.3
continuous-integration/drone/push Build is passing Details
2024-05-31 02:57:08 +02:00
Fabio Manganiello 4d0b63a155
Updated CHANGELOG 2024-05-31 02:57:08 +02:00
Fabio Manganiello ce1525e786
[Automatic] Updated components cache
continuous-integration/drone/push Build is passing Details
2024-05-31 00:53:37 +00:00
Fabio Manganiello 67478e7ca1 🐛 Fixed proper support for event package alias `platypush.events`.
Even though `platypush.events` is just a symlink to
`platypush.message.event`, imports from those two modules will be
treated as different imports, thus hook conditions build on
`platypush.events` imports will never match.
2024-05-31 00:52:48 +00:00
Fabio Manganiello c18768e61f Replaced deprecated usages of `datetime.utcnow()` with `datetime.now(UTC)`. 2024-05-31 00:52:48 +00:00
Fabio Manganiello 30362b89e3 [assistant] `tts_plugin_args` should include `join=True` by default.
The assistant by default should be configured to wait for response audio
to be fully rendered before proceeding.
2024-05-31 00:52:48 +00:00
Fabio Manganiello 826a3fa55c CHANGELOG update 2024-05-31 00:52:48 +00:00
Fabio Manganiello 3986549326
🐛 Fixed proper support for event package alias `platypush.events`.
continuous-integration/drone/push Build is passing Details
Even though `platypush.events` is just a symlink to
`platypush.message.event`, imports from those two modules will be
treated as different imports, thus hook conditions build on
`platypush.events` imports will never match.
2024-05-31 02:50:00 +02:00
Fabio Manganiello fa318882a5
Replaced deprecated usages of `datetime.utcnow()` with `datetime.now(UTC)`. 2024-05-31 02:30:48 +02:00
Fabio Manganiello d6185ddb1e
[assistant] `tts_plugin_args` should include `join=True` by default.
The assistant by default should be configured to wait for response audio
to be fully rendered before proceeding.
2024-05-30 01:33:56 +02:00
snyk-bot fee12951d6
fix: upgrade cronstrue from 2.49.0 to 2.50.0
Snyk has created this PR to upgrade cronstrue from 2.49.0 to 2.50.0.

See this package in npm:
cronstrue

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-05-27 17:30:57 +00:00
Fabio Manganiello d2caa989ac
CHANGELOG update 2024-05-27 00:18:27 +02:00
Fabio Manganiello fa3c804b71 [#368] Added Ubuntu release logic to `update-apt-repo` step too. 2024-05-26 20:57:10 +00:00
Fabio Manganiello 4cd0761e78
[#368] Added Ubuntu release logic to `update-apt-repo` step too.
continuous-integration/drone/push Build is passing Details
2024-05-26 22:56:23 +02:00
Fabio Manganiello 16f7b7e12a Merge pull request '[#368] Added Ubuntu packages' (#402) from 368/ubuntu-packages into master
continuous-integration/drone/push Build is passing Details
Reviewed-on: #402

Closes: #368
2024-05-26 22:41:44 +02:00
Fabio Manganiello 22222fab65
[#368] Added Ubuntu packages
continuous-integration/drone/push Build is passing Details
2024-05-26 22:38:22 +02:00
Fabio Manganiello 5b3c0ad1cf
Bump version: 1.0.1 → 1.0.2
continuous-integration/drone/push Build is passing Details
2024-05-26 11:09:51 +02:00
Fabio Manganiello 3758a8d759
Updated CHANGELOG 2024-05-26 11:09:42 +02:00
Fabio Manganiello de2bbc53c6
Support both `@procedure` and `@procedure(name)` notations.
continuous-integration/drone/push Build is passing Details
2024-05-26 11:02:19 +02:00
Fabio Manganiello a4a776986b
Bump version: 1.0.0 → 1.0.1
continuous-integration/drone/push Build is passing Details
2024-05-26 04:27:27 +02:00
Fabio Manganiello 9fef73a746
Bumped version in setup.py (for some reason bumpversion missed it) 2024-05-26 04:27:03 +02:00
Fabio Manganiello 0f6f119089
Bump version: 0.99.11 → 1.0.0
continuous-integration/drone/push Build is passing Details
2024-05-26 04:15:52 +02:00
Fabio Manganiello c64ff40dd3
Bump version: 0.99.10 → 0.99.11
continuous-integration/drone/push Build is passing Details
2024-05-26 04:06:15 +02:00
Fabio Manganiello 5c0f85c311
Don't provide `git+https://` dependencies in `setup.py` extras.
Otherwise Twine will complain with errors like this:

```
HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/
Can't have direct dependency: pybluez@
git+https://github.com/pybluez/pybluez ; extra == "bluetooth". See
https://packaging.python.org/specifications/core-metadata for more
information.
```
2024-05-26 04:04:58 +02:00
Fabio Manganiello e6702398dc
Bump version: 0.99.9 → 0.99.10
continuous-integration/drone/push Build is passing Details
2024-05-26 03:46:51 +02:00
Fabio Manganiello 983bcc240a
[Docs] A more robust interceptor for the grid rendering. 2024-05-26 03:46:25 +02:00
Fabio Manganiello d6d9d7a8e7
[CI/CD] Added extra dependencies to `update-pip-package` step. 2024-05-26 03:40:10 +02:00
Fabio Manganiello 8d26721040
Bump version: 0.99.8 → 0.99.9
continuous-integration/drone/push Build is passing Details
2024-05-26 03:32:49 +02:00
Fabio Manganiello 96f265a4a2
[CI/CD] Use a base Alpine image instead of `python:3.11-alpine`.
Weird errors seem to happen on Twine on that image:

```
Traceback (most recent call last):
  File "/usr/bin/twine", line 5, in <module>
    from twine.__main__ import main
  File "/usr/lib/python3.11/site-packages/twine/__init__.py", line 32, in <module>
    import importlib.metadata
  File "/usr/lib/python3.11/importlib/metadata/__init__.py", line 17, in <module>
    from . import _adapters, _meta
  File "/usr/lib/python3.11/importlib/metadata/_adapters.py", line 3, in <module>
    import email.message
  File "/usr/lib/python3.11/email/message.py", line 15, in <module>
    from email import utils
  File "/usr/lib/python3.11/email/utils.py", line 28, in <module>
    import random
  File "/usr/lib/python3.11/random.py", line 49, in <module>
    from math import log as _log, exp as _exp, pi as _pi, e as _e, ceil as _ceil
ImportError: Error relocating /usr/lib/python3.11/lib-dynload/math.cpython-311-x86_64-linux-musl.so: _PyModule_Add: symbol not found
```
2024-05-26 03:31:25 +02:00
Fabio Manganiello 0ffff854d3
Bump version: 0.99.7 → 0.99.8
continuous-integration/drone/push Build is passing Details
2024-05-26 03:18:16 +02:00
Fabio Manganiello 020804fd1c
Don't link wiki/Home.md to wiki/index.md 2024-05-26 03:16:32 +02:00
Fabio Manganiello 42174b31bc
Bump version: 0.99.6 → 0.99.7
continuous-integration/drone/push Build is passing Details
2024-05-26 03:12:26 +02:00
Fabio Manganiello 3642d1ffa4
Added `dns` to mocked modules. 2024-05-26 03:02:57 +02:00
Fabio Manganiello 104457a302
Bump version: 0.99.5 → 0.99.6
continuous-integration/drone/push Build is passing Details
2024-05-26 01:47:22 +02:00
Fabio Manganiello 0445087699
[CI/CD] Removed hanging dependency. 2024-05-26 01:47:05 +02:00
Fabio Manganiello 751d719b04
Bump version: 0.99.4 → 0.99.5
continuous-integration/drone/push Build encountered an error Details
2024-05-26 01:46:29 +02:00
Fabio Manganiello bef027fc07
[CI/CD] Just remove the sync-stable-branch step. 2024-05-26 01:46:04 +02:00
Fabio Manganiello cc670f9d4a
Bump version: 0.99.3 → 0.99.4
continuous-integration/drone/push Build is passing Details
2024-05-26 01:26:46 +02:00
Fabio Manganiello 86674ddc28
[CI/CD] Do a git fetch beofre checkout/rebase in sync-stable-branch. 2024-05-26 01:26:19 +02:00
Fabio Manganiello ee3933dc77
Bump version: 0.99.2 → 0.99.3
continuous-integration/drone/push Build is passing Details
2024-05-26 00:56:29 +02:00
Fabio Manganiello e23664b5e7
[CI/CD] Be explicit about the origin in sync-stable-branch. 2024-05-26 00:56:01 +02:00
Fabio Manganiello 0537815721
Bump version: 0.99.1 → 0.99.2
continuous-integration/drone/push Build is passing Details
2024-05-26 00:50:37 +02:00
Fabio Manganiello a2ec20bb3a
[CI/CD] Create `stable` branch if it doesn't exist. 2024-05-26 00:50:02 +02:00
Fabio Manganiello d3562f4d20
Bump version: 0.99.0 → 0.99.1
continuous-integration/drone/push Build is passing Details
2024-05-26 00:44:30 +02:00
Fabio Manganiello bf5aece08b
Bumped version in setup.py (for some reason bumpversion missed it) 2024-05-26 00:44:20 +02:00
Fabio Manganiello 2f20580498
[CI/CD] Remove git remote rm/add logic from sync-stable-branch.
continuous-integration/drone/push Build is passing Details
2024-05-26 00:39:51 +02:00
snyk-bot 46da373637
fix: upgrade sass from 1.75.0 to 1.76.0
Snyk has created this PR to upgrade sass from 1.75.0 to 1.76.0.

See this package in npm:
sass

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-05-22 12:34:49 +00:00
snyk-bot 092f5b607c
fix: upgrade vue from 3.4.23 to 3.4.24
Snyk has created this PR to upgrade vue from 3.4.23 to 3.4.24.

See this package in npm:
https://www.npmjs.com/package/vue

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-05-14 17:33:05 +00:00
59 changed files with 1483 additions and 473 deletions

View File

@ -29,29 +29,6 @@ steps:
commands:
- . .drone/github-mirror.sh
###
### Synchronize the `stable` branch to the new release
###
- name: sync-stable-branch
image: alpine
environment:
SSH_PUBKEY:
from_secret: ssh_pubkey
SSH_PRIVKEY:
from_secret: ssh_privkey
PGP_KEY:
from_secret: pgp_key
PGP_KEY_ID:
from_secret: pgp_key_id
commands:
- . .drone/sync-stable-branch.sh
when:
event:
- tag
###
### Rebuild the docs
###
@ -69,9 +46,6 @@ steps:
event:
- tag
depends_on:
- sync-stable-branch
###
### Run the tests
###
@ -220,6 +194,34 @@ steps:
commands:
- . .drone/update-deb-packages.sh
###
### Update the Ubuntu (latest) packages
###
- name: update-ubuntu-packages
image: ubuntu:latest
volumes:
- name: repos
path: /repos
environment:
DEB_VERSION: ubuntu
WORKDIR: /tmp/workdir
APT_ROOT: /repos/apt
PKG_NAME: platypush
when:
branch:
- master
event:
- push
depends_on:
- update-components-cache
commands:
- . .drone/update-deb-packages.sh
###
### Updates the APT repository after new packages have been pushed
###
@ -247,6 +249,7 @@ steps:
depends_on:
- update-debian-stable-packages
- update-debian-oldstable-packages
- update-ubuntu-packages
commands:
- . .drone/update-apt-repo.sh
@ -289,7 +292,7 @@ steps:
###
- name: update-pip-package
image: python:3.11-alpine
image: alpine
environment:
TWINE_USERNAME:
from_secret: pypi_user

View File

@ -1,11 +1,13 @@
#!/bin/sh
export SRCDIR="$PWD"
export WEBAPP_DIR="$SRCDIR/platypush/backend/http/webapp"
export SKIPCI="$PWD/.skipci"
rm -rf "$SKIPCI"
. .drone/macros/configure-git.sh
cd platypush/backend/http/webapp
cd "$WEBAPP_DIR"
if [ $(git log --pretty=oneline $DRONE_COMMIT_AFTER...$DRONE_COMMIT_BEFORE . | wc -l) -eq 0 ]; then
echo "No UI changes detected, skipping build"
exit 0
@ -29,12 +31,13 @@ fi
# Create a .skipci file to mark the fact that the next steps should be skipped
# (we're going to do another push anyway, so another pipeline will be triggered)
touch "$SKIPCI"
cd "$SRCDIR"
. .drone/macros/configure-ssh.sh
. .drone/macros/configure-gpg.sh
git add dist
git commit dist -S -m "[Automatic] Updated UI files" --no-verify
git add "${WEBAPP_DIR}/dist"
git commit "${WEBAPP_DIR}/dist" -S -m "[Automatic] Updated UI files" --no-verify
git remote rm origin
git remote add origin git@git.platypush.tech:platypush/platypush.git
git push -f origin master

View File

@ -14,7 +14,6 @@ git clone 'https://git.platypush.tech/platypush/platypush.wiki.git' wiki
echo "Linking the wiki to the Sphinx index"
cd wiki
ln -s Home.md index.md
cd "$APPDIR/docs"
make html
rm -f config*.yaml

View File

@ -1,18 +0,0 @@
#!/bin/sh
. .drone/macros/configure-git.sh
. .drone/macros/configure-ssh.sh
. .drone/macros/configure-gpg.sh
# Git configuration
git remote rm origin
git remote add origin git@git.platypush.tech:platypush/platypush.git
# Merge and push to the `stable` branch
git checkout stable
git rebase master
git push -u origin stable
git checkout master
# Restore the original git configuration
mv "$TMP_GIT_CONF" "$GIT_CONF"

View File

@ -1,6 +1,5 @@
#!/bin/sh
[ -f .skipci ] && exit 0
echo "-- Installing dependencies"
@ -21,7 +20,7 @@ done
echo "-- Updating Packages files"
echo "stable\noldstable" | while read distro; do
echo "stable\noldstable\nubuntu" | while read distro; do
echo "main\ndev" | while read branch; do
branch_dir="$TMP_APT_ROOT/pool/$distro/$branch"
echo "Checking pool folder: $branch_dir"
@ -58,7 +57,7 @@ add_hashes() {
done
}
echo "stable\noldstable" | while read distro; do
echo "stable\noldstable\nubuntu" | while read distro; do
dist_dir="$TMP_APT_ROOT/dists/$distro"
components=$(find "$dist_dir" -name Packages | awk -F '/' '{print $(NF-2)}' | uniq | tr '\n' ' ')
release_file="$dist_dir/Release"
@ -82,7 +81,7 @@ done
echo "-- Generating list files"
mkdir -p "$TMP_APT_ROOT/lists"
for distro in stable oldstable; do
for distro in stable oldstable ubuntu; do
for branch in main dev; do
echo "deb https://apt.platypush.tech/ $distro $branch" > "$TMP_APT_ROOT/lists/platypush-$distro-$branch.list"
done
@ -105,39 +104,43 @@ Steps:
1. Add this repository's PGP key to your apt keyring
====================================================
$ sudo wget -q -O \\\
# wget -q -O \\\
/etc/apt/trusted.gpg.d/platypush.asc \\\
https://apt.platypush.tech/pubkey.txt
2. Add the repository to your sources
=====================================
$ sudo wget -q -O \\\
# wget -q -O \\\
/etc/apt/sources.list.d/platypush.list \\\
https://apt.platypush.tech/lists/platypush-<deb_version>-<branch>.list
Where:
- deb_version can be either *stable* (for the current Debian stable version) or
*oldstable* (for the previous Debian stable version)
- branch can be either *main* (for the latest releases) or *dev* (for a package
that is always in sync with the git version)
- deb_version can be:
- *stable* - current Debian stable version
- *oldstable* - previous Debian stable version
- *ubuntu* - latest Ubuntu version
- branch can be either:
- *main* - latest stable release
- *dev* a package always in sync with the git version
For example, to install the latest stable tags on Debian stable:
$ sudo wget -q -O \\\
# wget -q -O \\\
/etc/apt/sources.list.d/platypush.list \\\
https://apt.platypush.tech/lists/platypush-stable-main.list
3. Update your repos
====================
$ sudo apt update
# apt update
4. Install Platypush
====================
$ sudo apt install platypush
# apt install platypush
EOF
echo "-- Importing and refreshing PGP key"

View File

@ -9,6 +9,7 @@ cp -r "$PWD" "$SRCDIR"
cd "$SRCDIR"
echo "-- Installing dependencies"
export DEBIAN_FRONTEND=noninteractive
apt update
apt install -y curl dpkg-dev gpg git python3 python3-pip python3-setuptools

View File

@ -1,5 +1,5 @@
#!/bin/sh
apk add --update --no-cache py3-twine
apk add --update --no-cache py3-twine py3-setuptools py3-wheel py3-pip
python setup.py sdist bdist_wheel
twine upload dist/platypush-$(python setup.py --version).tar.gz

View File

@ -235,16 +235,16 @@ Steps:
1. Add the repository to your sources
=====================================
$ sudo yum config-manager --add-repo https://rpm.platypush.tech/platypush.repo
# yum config-manager --add-repo https://rpm.platypush.tech/platypush.repo
2. Install Platypush
====================
$ sudo yum install platypush
# yum install platypush
Or, if you want to install a version always up-to-date with the git repo:
$ sudo yum install platypush-git
# yum install platypush-git
EOF
cat <<EOF > "$TMP_RPM_ROOT/pubkey.txt"

View File

@ -1,6 +1,62 @@
# Changelog
## [1.0.0 release candidate]
## [Unreleased]
- [[#281](https://git.platypush.tech/platypush/platypush/issues/281)]
replaced `warnings.warn` with `logging.warning`, as there is no easy and
reliable way of routing `warnings.warn` to `logging`.
## [1.1.0] - 2024-06-06
- [[#405](https://git.platypush.tech/platypush/platypush/issues/405)] Fixed
timezone/timestamp rendering issues for `calendar.ical` events.
- [[#403]((https://git.platypush.tech/platypush/platypush/issues/403))]
Included inherited actions in plugins docs.
## [1.0.7] - 2024-06-02
- [[#384]((https://git.platypush.tech/platypush/platypush/issues/384))] Added
`assistant.openai` and `tts.openai` plugins.
## [1.0.6] - 2024-06-01
- 🐛 Bug fix on one of the entities modules that prevented the application from
loading when `.` is part of `PYTHONPATH`.
## [1.0.5] - 2024-06-01
- A proper solution for the `utcnow()` issue.
It was a bit trickier than expected to solve, but now Platypush uses a
`utcnow()` facade that always returns a UTC datetime in a timezone-aware
representation.
The code should however also handle the case of timestamps stored on the db in
the old format.
## [1.0.4] - 2024-05-31
- Fixed regression introduced by
[c18768e61fef62924f4c1fac3089ecfb83666dab](https://git.platypush.tech/platypush/platypush/commit/c18768e61fef62924f4c1fac3089ecfb83666dab).
Python seems to have introduced a breaking change from the version 3.12 -
`datetime.utcnow()` is not deprecated, but `datetime.UTC`, the suggested
alternative, isn't available on older versions of Python. Added a workaround
that makes Platypush compatible with both the implementations.
## [1.0.3] - 2024-05-31
- [[#368](https://git.platypush.tech/platypush/platypush/issues/368)] Added
Ubuntu packages.
- Fixed bug that didn't get hooks to match events imported through the new
`platypush.events` symlinked module.
## [1.0.2] - 2024-05-26
- Fixed regression introduced by the support of custom names through the
`@procedure` decorator.
## [1.0.0] - 2024-05-26
Many, many changes for the first major release of Platypush after so many
years.

View File

@ -795,51 +795,49 @@ or tags.
#### Debian/Ubuntu
Currently the following releases are supported:
1. Add the Platypush APT key to your trusted keyring:
1. The current Debian `stable`
2. Debian `oldstable`
```
# wget -q -O \
/etc/apt/trusted.gpg.d/platypush.asc \
https://apt.platypush.tech/pubkey.txt
```
Ubuntu supported [to be added
soon](https://git.platypush.tech/platypush/platypush/issues/368).
2. Add the Platypush repository to your APT sources:
- Add the Platypush APT key to your trusted keyring:
```
# wget -q -O \
/etc/apt/sources.list.d/platypush.list \
https://apt.platypush.tech/lists/platypush-<deb_version>-<branch>.list
```
```
# wget -q -O \
/etc/apt/trusted.gpg.d/platypush.asc \
https://apt.platypush.tech/pubkey.txt
```
Where:
- Add the Platypush repository to your APT sources:
- `deb_version` can be either:
```
# wget -q -O \
/etc/apt/sources.list.d/platypush.list \
https://apt.platypush.tech/lists/platypush-<deb_version>-<branch>.list
```
- `stable`: current Debian stable
- `oldstable`: previous Debian stable
- `ubuntu`: latest Ubuntu release
Where:
- `branch` can be either:
- `deb_version` can be either *stable* (for the current Debian stable version) or
*oldstable* (for the previous Debian stable version)
- `branch` can be either *main* (for the latest releases) or *dev* (for a package
that is always in sync with the git version)
- `main`: latest stable release
- `dev`: a package always in sync with the latest git version
For example, to install the latest stable tags on Debian stable:
For example, to install the latest stable tags on Debian stable:
```
# wget -q -O \
/etc/apt/sources.list.d/platypush.list \
https://apt.platypush.tech/lists/platypush-stable-main.list
```
```
# wget -q -O \
/etc/apt/sources.list.d/platypush.list \
https://apt.platypush.tech/lists/platypush-stable-main.list
```
- Update your repos and install Platypush:
3. Update your repos and install Platypush:
```
# apt update
# apt install platypush
```
```
# apt update
# apt install platypush
```
#### Fedora

View File

@ -152,7 +152,7 @@ const generateComponentsGrid = () => {
return
}
if (window.location.pathname.endsWith('/index.html')) {
if (window.location.pathname === '/' || window.location.pathname.endsWith('/index.html')) {
if (tocWrappers.length < 2) {
return
}

View File

@ -21,7 +21,7 @@ sys.path.insert(0, os.path.abspath("./_ext"))
# -- Project information -----------------------------------------------------
project = 'Platypush'
copyright = '2017-2023, Fabio Manganiello'
copyright = '2017-2024, Fabio Manganiello'
author = 'Fabio Manganiello <fabio@manganiello.tech>'
# The short X.Y version
@ -199,6 +199,7 @@ intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
autodoc_default_options = {
'members': True,
'show-inheritance': True,
'inherited-members': True,
}
sys.path.insert(0, os.path.abspath('../..'))

View File

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

View File

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

View File

@ -11,6 +11,7 @@ Plugins
platypush/plugins/application.rst
platypush/plugins/arduino.rst
platypush/plugins/assistant.google.rst
platypush/plugins/assistant.openai.rst
platypush/plugins/assistant.picovoice.rst
platypush/plugins/autoremote.rst
platypush/plugins/bluetooth.rst
@ -134,6 +135,7 @@ Plugins
platypush/plugins/tts.rst
platypush/plugins/tts.google.rst
platypush/plugins/tts.mimic3.rst
platypush/plugins/tts.openai.rst
platypush/plugins/tts.picovoice.rst
platypush/plugins/tv.samsung.ws.rst
platypush/plugins/twilio.rst

View File

@ -23,7 +23,7 @@ when = hook
__author__ = 'Fabio Manganiello <fabio@manganiello.tech>'
__version__ = '0.99.0'
__version__ = '1.1.0'
__all__ = [
'Application',
'Variable',

View File

@ -6,6 +6,7 @@ from flask import Blueprint, request, redirect, render_template, make_response
from platypush.backend.http.app import template_folder
from platypush.backend.http.utils import HttpUtils
from platypush.user import UserManager
from platypush.utils import utcnow
login = Blueprint('login', __name__, template_folder=template_folder)
@ -17,7 +18,7 @@ __routes__ = [
@login.route('/login', methods=['GET', 'POST'])
def login():
""" Login page """
"""Login page"""
user_manager = UserManager()
session_token = request.cookies.get('session_token')
@ -37,11 +38,11 @@ def login():
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember')
expires = datetime.datetime.utcnow() + datetime.timedelta(days=365) \
if remember else None
expires = utcnow() + datetime.timedelta(days=365) if remember else None
session = user_manager.create_user_session(username=username, password=password,
expires_at=expires)
session = user_manager.create_user_session(
username=username, password=password, expires_at=expires
)
if session:
redirect_target = redirect(redirect_page, 302) # lgtm [py/url-redirection]

View File

@ -6,6 +6,7 @@ from flask import Blueprint, request, redirect, render_template, make_response,
from platypush.backend.http.app import template_folder
from platypush.backend.http.utils import HttpUtils
from platypush.user import UserManager
from platypush.utils import utcnow
register = Blueprint('register', __name__, template_folder=template_folder)
@ -17,7 +18,7 @@ __routes__ = [
@register.route('/register', methods=['GET', 'POST'])
def register():
""" Registration page """
"""Registration page"""
user_manager = UserManager()
redirect_page = request.args.get('redirect')
if not redirect_page:
@ -34,7 +35,9 @@ def register():
return redirect(redirect_page, 302) # lgtm [py/url-redirection]
if user_manager.get_user_count() > 0:
return redirect('/login?redirect=' + redirect_page, 302) # lgtm [py/url-redirection]
return redirect(
'/login?redirect=' + redirect_page, 302
) # lgtm [py/url-redirection]
if request.form:
username = request.form.get('username')
@ -44,12 +47,18 @@ def register():
if password == confirm_password:
user_manager.create_user(username=username, password=password)
session = user_manager.create_user_session(username=username, password=password,
expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=1)
if not remember else None)
session = user_manager.create_user_session(
username=username,
password=password,
expires_at=(
utcnow() + datetime.timedelta(days=1) if not remember else None
),
)
if session:
redirect_target = redirect(redirect_page, 302) # lgtm [py/url-redirection]
redirect_target = redirect(
redirect_page, 302
) # lgtm [py/url-redirection]
response = make_response(redirect_target)
response.set_cookie('session_token', session.session_token)
return response

View File

@ -10,15 +10,15 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
"axios": "^1.6.8",
"core-js": "^3.37.0",
"cronstrue": "^2.49.0",
"core-js": "^3.37.1",
"cronstrue": "^2.50.0",
"highlight.js": "^11.9.0",
"lato-font": "^3.0.0",
"mitt": "^2.1.0",
"register-service-worker": "^1.7.2",
"sass": "^1.75.0",
"sass": "^1.76.0",
"sass-loader": "^10.5.2",
"vue": "^3.4.23",
"vue": "^3.4.24",
"vue-router": "^4.3.2",
"vue-skycons": "^4.3.4",
"w3css": "^2.7.0"
@ -3080,12 +3080,12 @@
"dev": true
},
"node_modules/@vue/compiler-core": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.23.tgz",
"integrity": "sha512-HAFmuVEwNqNdmk+w4VCQ2pkLk1Vw4XYiiyxEp3z/xvl14aLTUBw2OfVH3vBcx+FtGsynQLkkhK410Nah1N2yyQ==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.24.tgz",
"integrity": "sha512-vbW/tgbwJYj62N/Ww99x0zhFTkZDTcGh3uwJEuadZ/nF9/xuFMC4693P9r+3sxGXISABpDKvffY5ApH9pmdd1A==",
"dependencies": {
"@babel/parser": "^7.24.1",
"@vue/shared": "3.4.23",
"@babel/parser": "^7.24.4",
"@vue/shared": "3.4.24",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
@ -3103,37 +3103,37 @@
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.23.tgz",
"integrity": "sha512-t0b9WSTnCRrzsBGrDd1LNR5HGzYTr7LX3z6nNBG+KGvZLqrT0mY6NsMzOqlVMBKKXKVuusbbB5aOOFgTY+senw==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.24.tgz",
"integrity": "sha512-4XgABML/4cNndVsQndG6BbGN7+EoisDwi3oXNovqL/4jdNhwvP8/rfRMTb6FxkxIxUUtg6AI1/qZvwfSjxJiWA==",
"dependencies": {
"@vue/compiler-core": "3.4.23",
"@vue/shared": "3.4.23"
"@vue/compiler-core": "3.4.24",
"@vue/shared": "3.4.24"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.23.tgz",
"integrity": "sha512-fSDTKTfzaRX1kNAUiaj8JB4AokikzStWgHooMhaxyjZerw624L+IAP/fvI4ZwMpwIh8f08PVzEnu4rg8/Npssw==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.24.tgz",
"integrity": "sha512-nRAlJUK02FTWfA2nuvNBAqsDZuERGFgxZ8sGH62XgFSvMxO2URblzulExsmj4gFZ8e+VAyDooU9oAoXfEDNxTA==",
"dependencies": {
"@babel/parser": "^7.24.1",
"@vue/compiler-core": "3.4.23",
"@vue/compiler-dom": "3.4.23",
"@vue/compiler-ssr": "3.4.23",
"@vue/shared": "3.4.23",
"@babel/parser": "^7.24.4",
"@vue/compiler-core": "3.4.24",
"@vue/compiler-dom": "3.4.24",
"@vue/compiler-ssr": "3.4.24",
"@vue/shared": "3.4.24",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.8",
"magic-string": "^0.30.10",
"postcss": "^8.4.38",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.23.tgz",
"integrity": "sha512-hb6Uj2cYs+tfqz71Wj6h3E5t6OKvb4MVcM2Nl5i/z1nv1gjEhw+zYaNOV+Xwn+SSN/VZM0DgANw5TuJfxfezPg==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.24.tgz",
"integrity": "sha512-ZsAtr4fhaUFnVcDqwW3bYCSDwq+9Gk69q2r/7dAHDrOMw41kylaMgOP4zRnn6GIEJkQznKgrMOGPMFnLB52RbQ==",
"dependencies": {
"@vue/compiler-dom": "3.4.23",
"@vue/shared": "3.4.23"
"@vue/compiler-dom": "3.4.24",
"@vue/shared": "3.4.24"
}
},
"node_modules/@vue/component-compiler-utils": {
@ -3206,48 +3206,48 @@
"integrity": "sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA=="
},
"node_modules/@vue/reactivity": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.23.tgz",
"integrity": "sha512-GlXR9PL+23fQ3IqnbSQ8OQKLodjqCyoCrmdLKZk3BP7jN6prWheAfU7a3mrltewTkoBm+N7qMEb372VHIkQRMQ==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.24.tgz",
"integrity": "sha512-nup3fSYg4i4LtNvu9slF/HF/0dkMQYfepUdORBcMSsankzRPzE7ypAFurpwyRBfU1i7Dn1kcwpYsE1wETSh91g==",
"dependencies": {
"@vue/shared": "3.4.23"
"@vue/shared": "3.4.24"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.23.tgz",
"integrity": "sha512-FeQ9MZEXoFzFkFiw9MQQ/FWs3srvrP+SjDKSeRIiQHIhtkzoj0X4rWQlRNHbGuSwLra6pMyjAttwixNMjc/xLw==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.24.tgz",
"integrity": "sha512-c7iMfj6cJMeAG3s5yOn9Rc5D9e2/wIuaozmGf/ICGCY3KV5H7mbTVdvEkd4ZshTq7RUZqj2k7LMJWVx+EBiY1g==",
"dependencies": {
"@vue/reactivity": "3.4.23",
"@vue/shared": "3.4.23"
"@vue/reactivity": "3.4.24",
"@vue/shared": "3.4.24"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.23.tgz",
"integrity": "sha512-RXJFwwykZWBkMiTPSLEWU3kgVLNAfActBfWFlZd0y79FTUxexogd0PLG4HH2LfOktjRxV47Nulygh0JFXe5f9A==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.24.tgz",
"integrity": "sha512-uXKzuh/Emfad2Y7Qm0ABsLZZV6H3mAJ5ZVqmAOlrNQRf+T5mxpPGZBfec1hkP41t6h6FwF6RSGCs/gd8WbuySQ==",
"dependencies": {
"@vue/runtime-core": "3.4.23",
"@vue/shared": "3.4.23",
"@vue/runtime-core": "3.4.24",
"@vue/shared": "3.4.24",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.23.tgz",
"integrity": "sha512-LDwGHtnIzvKFNS8dPJ1SSU5Gvm36p2ck8wCZc52fc3k/IfjKcwCyrWEf0Yag/2wTFUBXrqizfhK9c/mC367dXQ==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.24.tgz",
"integrity": "sha512-H+DLK4sQF6sRgzKyofmlEVBIV/9KrQU6HIV7nt6yIwSGGKvSwlV8pqJlebUKLpbXaNHugdSfAbP6YmXF69lxow==",
"dependencies": {
"@vue/compiler-ssr": "3.4.23",
"@vue/shared": "3.4.23"
"@vue/compiler-ssr": "3.4.24",
"@vue/shared": "3.4.24"
},
"peerDependencies": {
"vue": "3.4.23"
"vue": "3.4.24"
}
},
"node_modules/@vue/shared": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.23.tgz",
"integrity": "sha512-wBQ0gvf+SMwsCQOyusNw/GoXPV47WGd1xB5A1Pgzy0sQ3Bi5r5xm3n+92y3gCnB3MWqnRDdvfkRGxhKtbBRNgg=="
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.24.tgz",
"integrity": "sha512-BW4tajrJBM9AGAknnyEw5tO2xTmnqgup0VTnDAMcxYmqOX0RG0b9aSUGAbEKolD91tdwpA6oCwbltoJoNzpItw=="
},
"node_modules/@vue/vue-loader-v15": {
"name": "vue-loader",
@ -4655,10 +4655,11 @@
}
},
"node_modules/core-js": {
"version": "3.37.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.0.tgz",
"integrity": "sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==",
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz",
"integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@ -4700,9 +4701,10 @@
}
},
"node_modules/cronstrue": {
"version": "2.49.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.49.0.tgz",
"integrity": "sha512-FWZBqdStQaPR8ZTBQGALh1EK9Hl1HcG70dyGvD1rKLPafFO3H73o38dz/e8YkIlbLn3JxmBI/f6Doe3Nh+DcEQ==",
"version": "2.50.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.50.0.tgz",
"integrity": "sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==",
"license": "MIT",
"bin": {
"cronstrue": "bin/cli.js"
}
@ -10551,9 +10553,9 @@
"dev": true
},
"node_modules/sass": {
"version": "1.75.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.75.0.tgz",
"integrity": "sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==",
"version": "1.76.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.76.0.tgz",
"integrity": "sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw==",
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@ -11976,15 +11978,15 @@
}
},
"node_modules/vue": {
"version": "3.4.23",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.23.tgz",
"integrity": "sha512-X1y6yyGJ28LMUBJ0k/qIeKHstGd+BlWQEOT40x3auJFTmpIhpbKLgN7EFsqalnJXq1Km5ybDEsp6BhuWKciUDg==",
"version": "3.4.24",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.24.tgz",
"integrity": "sha512-NPdx7dLGyHmKHGRRU5bMRYVE+rechR+KDU5R2tSTNG36PuMwbfAJ+amEvOAw7BPfZp5sQulNELSLm5YUkau+Sg==",
"dependencies": {
"@vue/compiler-dom": "3.4.23",
"@vue/compiler-sfc": "3.4.23",
"@vue/runtime-dom": "3.4.23",
"@vue/server-renderer": "3.4.23",
"@vue/shared": "3.4.23"
"@vue/compiler-dom": "3.4.24",
"@vue/compiler-sfc": "3.4.24",
"@vue/runtime-dom": "3.4.24",
"@vue/server-renderer": "3.4.24",
"@vue/shared": "3.4.24"
},
"peerDependencies": {
"typescript": "*"

View File

@ -10,15 +10,15 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
"axios": "^1.6.8",
"core-js": "^3.37.0",
"cronstrue": "^2.49.0",
"core-js": "^3.37.1",
"cronstrue": "^2.50.0",
"highlight.js": "^11.9.0",
"lato-font": "^3.0.0",
"mitt": "^2.1.0",
"register-service-worker": "^1.7.2",
"sass": "^1.75.0",
"sass": "^1.76.0",
"sass-loader": "^10.5.2",
"vue": "^3.4.23",
"vue": "^3.4.24",
"vue-router": "^4.3.2",
"vue-skycons": "^4.3.4",
"w3css": "^2.7.0"

View File

@ -8,6 +8,9 @@
"assistant.google": {
"class": "fas fa-microphone-lines"
},
"assistant.openai": {
"class": "fas fa-microphone-lines"
},
"assistant.picovoice": {
"class": "fas fa-microphone-lines"
},

View File

@ -1,8 +1,6 @@
from collections import namedtuple
from dataclasses import dataclass, field
from logging import getLogger
from queue import Full, Queue
from threading import Event, RLock
from threading import Event
from time import time
from typing import Optional
@ -10,63 +8,7 @@ import sounddevice as sd
from platypush.utils import wait_for_either
AudioFrame = namedtuple('AudioFrame', ['data', 'timestamp'])
@dataclass
class PauseState:
"""
Data class to hold the boilerplate (state + synchronization events) for the
audio recorder pause API.
"""
_paused_event: Event = field(default_factory=Event)
_recording_event: Event = field(default_factory=Event)
_state_lock: RLock = field(default_factory=RLock)
@property
def paused(self):
with self._state_lock:
return self._paused_event.is_set()
def pause(self):
"""
Pause the audio recorder.
"""
with self._state_lock:
self._paused_event.set()
self._recording_event.clear()
def resume(self):
"""
Resume the audio recorder.
"""
with self._state_lock:
self._paused_event.clear()
self._recording_event.set()
def toggle(self):
"""
Toggle the audio recorder pause state.
"""
with self._state_lock:
if self.paused:
self.resume()
else:
self.pause()
def wait_paused(self, timeout: Optional[float] = None):
"""
Wait until the audio recorder is paused.
"""
self._paused_event.wait(timeout=timeout)
def wait_recording(self, timeout: Optional[float] = None):
"""
Wait until the audio recorder is resumed.
"""
self._recording_event.wait(timeout=timeout)
from ._state import AudioFrame, PauseState
class AudioRecorder:
@ -112,9 +54,7 @@ class AudioRecorder:
"""
Start the audio stream.
"""
self._stop_event.clear()
self.stream.start()
return self
return self.start()
def __exit__(self, *_):
"""
@ -145,6 +85,14 @@ class AudioRecorder:
self.logger.debug('Audio queue is empty')
return None
def start(self):
"""
Start the audio stream.
"""
self._stop_event.clear()
self.stream.start()
return self
def stop(self):
"""
Stop the audio stream.
@ -186,6 +134,6 @@ class AudioRecorder:
wait_for_either(
self._stop_event,
self._upstream_stop_event,
self._paused_state._recording_event,
self._paused_state._recording_event, # pylint: disable=protected-access
timeout=timeout,
)

View File

@ -0,0 +1,61 @@
from collections import namedtuple
from dataclasses import dataclass, field
from threading import Event, RLock
from typing import Optional
AudioFrame = namedtuple('AudioFrame', ['data', 'timestamp'])
@dataclass
class PauseState:
"""
Data class to hold the boilerplate (state + synchronization events) for the
audio recorder pause API.
"""
_paused_event: Event = field(default_factory=Event)
_recording_event: Event = field(default_factory=Event)
_state_lock: RLock = field(default_factory=RLock)
@property
def paused(self):
with self._state_lock:
return self._paused_event.is_set()
def pause(self):
"""
Pause the audio recorder.
"""
with self._state_lock:
self._paused_event.set()
self._recording_event.clear()
def resume(self):
"""
Resume the audio recorder.
"""
with self._state_lock:
self._paused_event.clear()
self._recording_event.set()
def toggle(self):
"""
Toggle the audio recorder pause state.
"""
with self._state_lock:
if self.paused:
self.resume()
else:
self.pause()
def wait_paused(self, timeout: Optional[float] = None):
"""
Wait until the audio recorder is paused.
"""
self._paused_event.wait(timeout=timeout)
def wait_recording(self, timeout: Optional[float] = None):
"""
Wait until the audio recorder is resumed.
"""
self._recording_event.wait(timeout=timeout)

Binary file not shown.

View File

@ -355,7 +355,7 @@ class Config:
prefix = modname + '.' if prefix is None else prefix
self.procedures.update(
**{
getattr(obj, 'procedure_name', f'{prefix}.{name}'): obj
(getattr(obj, 'procedure_name', None) or f'{prefix}{name}'): obj
for name, obj in inspect.getmembers(module)
if is_functional_procedure(obj)
}

View File

@ -1,8 +1,9 @@
from datetime import datetime, timedelta
import logging
from threading import Event
from typing import Collection, Optional
from platypush.utils import utcnow
from ._base import (
Entity,
EntityKey,
@ -45,8 +46,8 @@ def get_entities_engine(timeout: Optional[float] = None) -> EntitiesEngine:
:param timeout: Timeout in seconds (default: None).
"""
time_start = datetime.utcnow()
while not timeout or (datetime.utcnow() - time_start < timedelta(seconds=timeout)):
time_start = utcnow().timestamp()
while not timeout or (utcnow().timestamp() - time_start < timeout):
if _engine:
break

View File

@ -32,6 +32,7 @@ import platypush
from platypush.config import Config
from platypush.common.db import Base, is_defined
from platypush.message import JSONAble, Message
from platypush.utils import utcnow
EntityRegistryType = Dict[str, Type['Entity']]
entities_registry: EntityRegistryType = {}
@ -82,13 +83,11 @@ if not is_defined('entity'):
external_url = Column(String)
image_url = Column(String)
created_at = Column(
DateTime(timezone=False), default=datetime.utcnow(), nullable=False
)
created_at = Column(DateTime(timezone=False), default=utcnow(), nullable=False)
updated_at = Column(
DateTime(timezone=False),
default=datetime.utcnow(),
onupdate=datetime.utcnow(),
default=utcnow(),
onupdate=utcnow(),
)
parent = relationship(

View File

@ -1,12 +1,11 @@
import inspect
import json
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Optional, Dict, Collection, Type
from platypush.config import Config
from platypush.entities._base import Entity, EntitySavedCallback
from platypush.utils import get_plugin_name_by_class, get_redis
from platypush.utils import get_plugin_name_by_class, get_redis, utcnow
_entity_registry_varname = '_platypush/plugin_entity_registry'
@ -77,7 +76,7 @@ class EntityManager(ABC):
entity.id = None # type: ignore
entity.plugin = get_plugin_name_by_class(self.__class__) # type: ignore
entity.updated_at = datetime.utcnow() # type: ignore
entity.updated_at = utcnow() # type: ignore
entity.children = self._normalize_entities(entity.children)
return entities

View File

@ -1,5 +1,6 @@
import copy
import json
import importlib
import logging
import threading
from functools import wraps
@ -46,8 +47,7 @@ class EventCondition:
kwargs -- Fields rules as a key-value (e.g. source_button=btn_id
or recognized_phrase='Your phrase')
"""
self.type = type or Event.__class__ # type: ignore
self.type = self._get_event_type(type)
self.args = {}
self.parsed_args = {}
self.priority = priority
@ -55,6 +55,23 @@ class EventCondition:
for key, value in kwargs.items():
self.args[key] = value
@staticmethod
def _get_event_type(type: Optional[Type[Event]] = None) -> Type[Event]:
if not type:
return Event
# The package alias `platypush.events` -> `platypush.message.event` is
# supported
if type.__module__.startswith('platypush.events'):
module = importlib.import_module(
'platypush.message.event' + type.__module__[len('platypush.events') :]
)
type = getattr(module, type.__name__)
assert type, f'Invalid event type: {type}'
return type
@classmethod
def build(cls, rule):
"""

View File

@ -19,14 +19,12 @@ class AssistantEvent(Event):
"""
assistant = assistant or kwargs.get('assistant')
if assistant:
assistant = (
kwargs['plugin'] = kwargs['_assistant'] = (
assistant
if isinstance(assistant, str)
else get_plugin_name_by_class(assistant.__class__)
)
kwargs['_assistant'] = assistant
super().__init__(*args, **kwargs)
@property
@ -149,7 +147,12 @@ class SpeechRecognizedEvent(AssistantEvent):
"""
result = super().matches_condition(condition)
if result.is_match and self.assistant and 'phrase' in condition.args:
if (
result.is_match
and condition.args.get('phrase')
and self.assistant
and self.assistant.stop_conversation_on_speech_match
):
self.assistant.stop_conversation()
return result
@ -244,6 +247,21 @@ class IntentRecognizedEvent(AssistantEvent):
"""
super().__init__(*args, intent=intent, slots=slots or {}, **kwargs)
def matches_condition(self, condition):
"""
Overrides matches condition, and stops the conversation to prevent the
default assistant response if the event matched some event hook condition.
"""
result = super().matches_condition(condition)
if (
result.is_match
and self.assistant
and self.assistant.stop_conversation_on_speech_match
):
self.assistant.stop_conversation()
return result
def _matches_argument(
self, argname, condition_value, event_args, result: EventMatchResult
):

View File

@ -1,7 +1,6 @@
import asyncio
import logging
import threading
import warnings
from abc import ABC, abstractmethod
from functools import wraps
@ -168,11 +167,7 @@ class RunnablePlugin(Plugin):
self._thread: Optional[threading.Thread] = None
if kwargs.get('poll_seconds') is not None:
warnings.warn(
'poll_seconds is deprecated, use poll_interval instead',
DeprecationWarning,
stacklevel=2,
)
self.logger.warning('poll_seconds is deprecated, use poll_interval instead')
if self.poll_interval is None:
self.poll_interval = kwargs['poll_seconds']

View File

@ -1,4 +1,4 @@
from abc import ABC, abstractmethod
from abc import ABC
from dataclasses import asdict, dataclass
from enum import Enum
import os
@ -13,7 +13,6 @@ from platypush.plugins import Plugin, action
from platypush.utils import get_plugin_name_by_class
@dataclass
class AlertType(Enum):
"""
Enum representing the type of an alert.
@ -51,7 +50,7 @@ class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
tts_plugin: Optional[str] = None,
tts_plugin_args: Optional[Dict[str, Any]] = None,
conversation_start_sound: Optional[str] = None,
stop_conversation_on_speech_match: bool = False,
stop_conversation_on_speech_match: bool = True,
**kwargs,
):
"""
@ -67,17 +66,25 @@ class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
on the default audio output device. If not set, the assistant won't
play any sound when it detects a speech.
:param stop_conversation_on_speech_match: If set, the plugin will close the
conversation if the latest recognized speech matches a registered
:class:`platypush.message.event.assistant.SpeechRecognizedEvent` hook
with a phrase. This is usually set to ``True`` for
:class:`platypush.plugins.assistant.google.GoogleAssistantPlugin`,
as it overrides the default assistant response when a speech event is
actually handled on the application side.
:param stop_conversation_on_speech_match: If set, the plugin will
prevent the default assistant response when a
:class:`platypush.message.event.assistant.SpeechRecognizedEvent`
matches a user hook with a condition on a ``phrase`` field. This is
useful to prevent the assistant from responding with a default "*I'm
sorry, I can't help you with that*" when e.g. you say "*play the
music*", and you have a hook that matches the phrase "*play the
music*" and handles it with a custom action. If set, and you wish
the assistant to also provide an answer if an event matches one of
your hooks, then you should call the :meth:`render_response` method
in your hook handler. If not set, then the assistant will always try
and respond with a default message, even if a speech event matches
the phrase of one of your hooks. In this case, if you want to prevent
the default response, you should call :meth:`stop_conversation`
explicitly from your hook handler. Default: True.
"""
super().__init__(*args, **kwargs)
self.tts_plugin = tts_plugin
self.tts_plugin_args = tts_plugin_args or {}
self.tts_plugin_args = {'join': True, **(tts_plugin_args or {})}
self.stop_conversation_on_speech_match = stop_conversation_on_speech_match
self._conversation_start_sound = None
if conversation_start_sound:
@ -104,19 +111,17 @@ class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
alert_state=self._cur_alert_type.value if self._cur_alert_type else None,
)
@abstractmethod
def start_conversation(self, *_, **__):
"""
Programmatically starts a conversation.
"""
raise NotImplementedError
@abstractmethod
@action
def stop_conversation(self, *_, **__):
"""
Programmatically stops a conversation.
"""
raise NotImplementedError
self._stop_conversation()
def _stop_conversation(self, *_, **__):
tts = self._get_tts_plugin()
if tts:
tts.stop()
@action
def pause_detection(self, *_, **__):
@ -166,15 +171,38 @@ class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
return asdict(self._state)
@action
def render_response(self, text: str, *_, **__):
def render_response(
self, text: str, *_, with_follow_on_turn: Optional[bool] = None, **__
) -> bool:
"""
Render a response text as audio over the configured TTS plugin.
:param text: Text to render.
:param with_follow_on_turn: If set, the assistant will wait for a follow-up.
By default, ``with_follow_on_turn`` will be automatically set to true if
the ``text`` ends with a question mark.
:return: True if the assistant is waiting for a follow-up, False otherwise.
"""
self._on_response_render_start(text)
if not text:
self._on_no_response()
return False
follow_up = (
bool(text and text.strip().endswith('?'))
if with_follow_on_turn is None
else with_follow_on_turn
)
self._on_response_render_start(text, with_follow_on_turn=follow_up)
self._render_response(text)
self._on_response_render_end()
self._on_response_render_end(with_follow_on_turn=follow_up)
if follow_up:
self.start_conversation()
else:
self.stop_conversation()
return follow_up
def _get_tts_plugin(self):
if not self.tts_plugin:
@ -229,22 +257,37 @@ class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
self._conversation_running.clear()
self._send_event(NoResponseEvent)
def _on_response_render_start(self, text: Optional[str]):
def _on_response_render_start(
self, text: Optional[str], with_follow_on_turn: bool = False
):
from platypush.message.event.assistant import ResponseEvent
self._last_response = text
self._send_event(ResponseEvent, response_text=text)
self._send_event(
ResponseEvent, response_text=text, with_follow_on_turn=with_follow_on_turn
)
def _render_response(self, text: Optional[str]):
tts = self._get_tts_plugin()
if tts and text:
self.stop_conversation()
tts.say(text=text, **self.tts_plugin_args)
if not text:
return
def _on_response_render_end(self):
tts = self._get_tts_plugin()
if not tts:
self.logger.warning(
'Got a response to render, but no TTS plugin is configured: %s', text
)
return
tts.say(text=text, **self.tts_plugin_args)
def _on_response_render_end(self, with_follow_on_turn: bool = False):
from platypush.message.event.assistant import ResponseEndEvent
self._send_event(ResponseEndEvent, response_text=self._last_response)
self._send_event(
ResponseEndEvent,
response_text=self._last_response,
with_follow_on_turn=with_follow_on_turn,
)
def _on_hotword_detected(self, hotword: Optional[str]):
from platypush.message.event.assistant import HotwordDetectedEvent

View File

@ -76,7 +76,6 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
self,
credentials_file: Optional[str] = None,
device_model_id: str = 'Platypush',
stop_conversation_on_speech_match: bool = True,
**kwargs,
):
"""
@ -95,20 +94,8 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
:param device_model_id: The device model ID that identifies the device
where the assistant is running (default: Platypush). It can be a
custom string.
:param stop_conversation_on_speech_match: If set, the plugin will close the
conversation if the latest recognized speech matches a registered
:class:`platypush.message.event.assistant.SpeechRecognizedEvent` hook
with a phrase. This is usually set to ``True`` for
:class:`platypush.plugins.assistant.google.GoogleAssistantPlugin`,
as it overrides the default assistant response when a speech event is
actually handled on the application side.
"""
super().__init__(
stop_conversation_on_speech_match=stop_conversation_on_speech_match,
**kwargs,
)
super().__init__(**kwargs)
self._credentials_file = credentials_file
self.device_model_id = device_model_id
self.credentials = None
@ -155,7 +142,7 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
hasattr(EventType, 'ON_RENDER_RESPONSE')
and event.type == EventType.ON_RENDER_RESPONSE
):
self._on_reponse_rendered(event.args.get('text'))
self._on_response_render_start(event.args.get('text'))
elif (
hasattr(EventType, 'ON_RESPONDING_STARTED')
and event.type == EventType.ON_RESPONDING_STARTED
@ -194,8 +181,7 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
if self.assistant:
self.assistant.start_conversation()
@action
def stop_conversation(self, *_, **__):
def _stop_conversation(self, *_, **__):
"""
Programmatically stop a running conversation with the assistant
"""

View File

@ -0,0 +1,447 @@
from io import BytesIO
from threading import Event
from typing import Optional
import numpy as np
from pydub import AudioSegment
from platypush.common.assistant import AudioRecorder
from platypush.context import get_plugin
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.assistant import AssistantPlugin
from platypush.plugins.openai import OpenaiPlugin
from ._state import RecordingState
# pylint: disable=too-many-ancestors
class AssistantOpenaiPlugin(AssistantPlugin, RunnablePlugin):
"""
A voice assistant based on the OpenAI API.
It requires the :class:`platypush.plugins.openai.OpenaiPlugin` plugin to be
configured with an OpenAI API key.
Hotword detection
-----------------
This plugin doesn't have hotword detection, as OpenAI doesn't provide
an API for that. Instead, the assistant can be started and stopped
programmatically through the :meth:`.start_conversation` action.
If you want to implement hotword detection, you can use a separate plugin
such as
:class:`platypush.plugins.assistant.picovoice.AssistantPicovoicePlugin`.
The configuration in this case would be like:
.. code-block:: yaml
assistant.picovoice:
access_key: YOUR_PICOVOICE_ACCESS_KEY
# List of hotwords to listen for
keywords:
- alexa
- computer
- ok google
# Disable speech-to-text and intent recognition, only use hotword
# detection
stt_enabled: false
hotword_enabled: true
conversation_start_sound: /sound/to/play/when/the/conversation/starts.mp3
# speech_model_path: /mnt/hd/models/picovoice/cheetah/custom-en.pv
# intent_model_path: /mnt/hd/models/picovoice/rhino/custom-en-x86.rhn
openai:
api_key: YOUR_OPENAI_API_KEY
# Customize your assistant's context and knowledge base to your
# liking
context:
- role: system
content: >
You are a 16th century noble lady who talks in
Shakespearean English to her peers.
# Enable the assistant plugin
assistant.openai:
# Enable the text-to-speech plugin
tts.openai:
# Customize the voice model
voice: nova
Then you can call :meth:`.start_conversation` when the hotword is detected
:class:`platypush.message.event.assistant.HotwordDetectedEvent` is
triggered:
.. code-block:: python
from platypush import run, when
from platypush.message.event.assistant import HotwordDetectedEvent
@when(HotwordDetectedEvent)
# You can also customize it by running a different assistant logic
# depending on the hotword
# @when(HotwordDetectedEvent, hotword='computer')
def on_hotword_detected():
run("assistant.openai.start_conversation")
This configuration will:
1. Start the hotword detection when the application starts.
2. Start the OpenAI assistant when the hotword is detected.
AI responses
------------
By default (unless you set ``stop_conversation_on_speech_match`` to ``False``),
the plugin will:
1. Process the speech through the OpenAI API (the GPT model to be is
configurable in the OpenAI plugin ``model`` configuration).
2. Render the response through the configured ``tts_plugin`` (default:
``tts.openai``). If ``tts_plugin`` is not set, then the response will
be returned as a string.
Custom speech processing
------------------------
You can create custom hooks on
:class:`platypush.message.event.assistant.SpeechRecognizedEvent` with
custom ``phrase`` strings or (regex) patterns. For example:
.. code-block:: python
from platypush import run, when
from platypush.message.event.assistant import SpeechRecognizedEvent
# Matches any phrase that contains either "play music" or "play the
# music"
@when(SpeechRecognizedEvent, phrase='play (the)? music')
def play_music():
run('music.mpd.play')
If at least a custom hook with a non-empty ``phrase`` string is matched,
then the default response will be disabled. If you still want the assistant
to say something when the event is handled, you can call
``event.assistant.render_response`` on the hook:
.. code-block:: python
from datetime import datetime
from textwrap import dedent
from time import time
from platypush import run, when
from platypush.message.event.assistant import SpeechRecognizedEvent
@when(SpeechRecognizedEvent, phrase='weather today')
def weather_forecast(event: SpeechRecognizedEvent):
limit = time() + 24 * 60 * 60 # 24 hours from now
forecast = [
weather
for weather in run("weather.openweathermap.get_forecast")
if datetime.fromisoformat(weather["time"]).timestamp() < limit
]
min_temp = round(
min(weather["temperature"] for weather in forecast)
)
max_temp = round(
max(weather["temperature"] for weather in forecast)
)
max_wind_gust = round(
(max(weather["wind_gust"] for weather in forecast)) * 3.6
)
summaries = [weather["summary"] for weather in forecast]
most_common_summary = max(summaries, key=summaries.count)
avg_cloud_cover = round(
sum(weather["cloud_cover"] for weather in forecast) / len(forecast)
)
event.assistant.render_response(
dedent(
f\"\"\"
The forecast for today is: {most_common_summary}, with
a minimum of {min_temp} and a maximum of {max_temp}
degrees, wind gust of {max_wind_gust} km/h, and an
average cloud cover of {avg_cloud_cover}%.
\"\"\"
)
)
Conversation follow-up
----------------------
A conversation will have a follow-up (i.e. the assistant will listen for a
phrase after rendering a response) if the response is not empty and ends
with a question mark. If you want to force a follow-up even if the response
doesn't end with a question mark, you can call :meth:`.start_conversation`
programmatically from your hooks.
"""
def __init__(
self,
model: str = "whisper-1",
tts_plugin: Optional[str] = "tts.openai",
min_silence_secs: float = 1.0,
silence_threshold: int = -22,
sample_rate: int = 16000,
frame_size: int = 16384,
channels: int = 1,
conversation_start_timeout: float = 5.0,
conversation_end_timeout: float = 1.0,
conversation_max_duration: float = 15.0,
**kwargs,
):
"""
:param model: OpenAI model to use for audio transcription (default:
``whisper-1``).
:param tts_plugin: Name of the TTS plugin to use for rendering the responses
(default: ``tts.openai``).
:param min_silence_secs: Minimum silence duration in seconds to detect
the end of a conversation (default: 1.0 seconds).
:param silence_threshold: Silence threshold in dBFS (default: -22).
The value of 0 is the maximum amplitude, and -120 is associated to
a silent or nearly silent audio, thus the higher the value, the more
sensitive the silence detection will be (default: -22).
:param sample_rate: Recording sample rate in Hz (default: 16000).
:param frame_size: Recording frame size in samples (default: 16384).
Note that it's important to make sure that ``frame_size`` /
``sample_rate`` isn't smaller than the minimum silence duration,
otherwise the silence detection won't work properly.
:param channels: Number of recording channels (default: 1).
:param conversation_start_timeout: How long to wait for the
conversation to start (i.e. the first non-silent audio frame to be
detected) before giving up and stopping the recording (default: 5.0
seconds).
:param conversation_end_timeout: How many seconds of silence to wait
after the last non-silent audio frame before stopping the recording
(default: 1.5 seconds).
:param conversation_max_duration: Maximum conversation duration in seconds
(default: 15.0 seconds).
"""
kwargs["tts_plugin"] = tts_plugin
super().__init__(**kwargs)
self._model = model
self._min_silence_secs = min_silence_secs
self._silence_threshold = silence_threshold
self._sample_rate = sample_rate
self._frame_size = frame_size
self._channels = channels
self._conversation_start_timeout = conversation_start_timeout
self._conversation_end_timeout = conversation_end_timeout
self._conversation_max_duration = conversation_max_duration
self._start_recording_event = Event()
self._disable_default_response = False
self._recording_state = RecordingState(
sample_rate=sample_rate,
channels=channels,
min_silence_secs=min_silence_secs,
silence_threshold=silence_threshold,
)
self._recorder: Optional[AudioRecorder] = None
def _to_audio_segment(self, data: np.ndarray) -> AudioSegment:
return AudioSegment(
data.tobytes(),
frame_rate=self._sample_rate,
sample_width=data.dtype.itemsize,
channels=self._channels,
)
def _is_conversation_ended(self):
# End if the recording has been stopped
if not self._recorder or self._recorder.should_stop():
return True
# End if we reached the max conversation duration
if self._recording_state.duration >= self._conversation_max_duration:
return True
# End if the conversation hasn't started yet and we reached the
# conversation start timeout
if (
not self._recording_state.conversation_started
and self._recording_state.duration >= self._conversation_start_timeout
):
return True
# End if the conversation has started and the user has been silent for
# more than the conversation end timeout
if (
self._recording_state.conversation_started
and self._recording_state.silence_duration >= self._conversation_end_timeout
):
return True
return False
@property
def _openai(self) -> OpenaiPlugin:
openai: Optional[OpenaiPlugin] = get_plugin("openai")
assert openai, (
"OpenAI plugin not found. "
"Please configure the `openai` plugin to use `assistant.openai`"
)
return openai
def _get_prediction(self, audio: BytesIO) -> str:
return self._openai.transcribe_raw(
audio.getvalue(), extension='mp3', model=self._model
)
def _capture_audio(self, recorder: AudioRecorder):
while not self.should_stop() and not self._is_conversation_ended():
audio_data = recorder.read()
if not audio_data:
continue
self._recording_state.add_audio(audio_data)
def _audio_loop(self):
while not self.should_stop():
self._wait_recording_start()
self._recording_state.reset()
self._on_conversation_start()
try:
with AudioRecorder(
stop_event=self._should_stop,
sample_rate=self._sample_rate,
frame_size=self._frame_size,
channels=self._channels,
) as self._recorder:
self._capture_audio(self._recorder)
finally:
if self._recorder:
try:
self._recorder.stream.close()
except Exception as e:
self.logger.warning("Error closing the audio stream: %s", e)
self._recorder = None
if self._recording_state.is_silent():
self._on_conversation_timeout()
else:
audio = self._recording_state.export_audio()
text = self._get_prediction(audio)
self._on_speech_recognized(text)
def _wait_recording_start(self):
self._start_recording_event.wait()
self._start_recording_event.clear()
def _start_conversation(self, *_, **__):
self._disable_default_response = False
self._recording_state.reset()
self._start_recording_event.set()
def _stop_conversation(self, *_, **__):
self._disable_default_response = True
super()._stop_conversation()
self._recording_state.reset()
if self._recorder:
self._recorder.stop()
self._on_conversation_end()
def _on_speech_recognized(self, phrase: Optional[str]):
super()._on_speech_recognized(phrase)
# Dirty hack: wait a bit before stopping the conversation to make sure
# that there aren't event hooks triggered in other threads that are
# supposed to handle.
if self.stop_conversation_on_speech_match:
self.wait_stop(0.5)
if self.should_stop():
return
if self._disable_default_response:
self.logger.debug("Default response disabled, skipping response")
return
response = self._openai.get_response(phrase).output
if response:
self.render_response(response)
else:
self._on_no_response()
@action
def start_conversation(self, *_, **__):
"""
Start a conversation with the assistant. The conversation will be
automatically stopped after ``conversation_max_duration`` seconds of
audio, or after ``conversation_start_timeout`` seconds of silence
with no audio detected, or after ``conversation_end_timeout`` seconds
after the last non-silent audio frame has been detected, or when the
:meth:`.stop_conversation` method is called.
"""
self._start_conversation()
@action
def mute(self, *_, **__):
"""
.. note:: This plugin has no hotword detection, thus no continuous
audio detection. Speech processing is done on-demand through the
:meth:`.start_conversation` and :meth:`.stop_conversation` methods.
Therefore, the :meth:`.mute` and :meth:`.unmute` methods are not
implemented.
"""
self.logger.warning(
"assistant.openai.mute is not implemented because this plugin "
"has no hotword detection, and the only way to stop a conversation "
"is by calling stop_conversation()"
)
@action
def unmute(self, *_, **__):
"""
.. note:: This plugin has no hotword detection, thus no continuous
audio detection. Speech processing is done on-demand through the
:meth:`.start_conversation` and :meth:`.stop_conversation` methods.
Therefore, the :meth:`.mute` and :meth:`.unmute` methods are not
implemented.
"""
self.logger.warning(
"assistant.openai.unmute is not implemented because this plugin "
"has no hotword detection, and the only way to start a conversation "
"is by calling start_conversation()"
)
@action
def send_text_query(self, text: str, *_, **__):
"""
If the ``tts_plugin`` configuration is set, then the assistant will
process the given text query through
:meth:`platypush.plugins.openai.OpenaiPlugin.get_response` and render
the response through the specified TTS plugin.
:return: The response received from
:meth:`platypush.plugins.openai.OpenaiPlugin.get_response`.
"""
response = self._openai.get_response(text).output
self.render_response(response)
return response
def main(self):
while not self.should_stop():
try:
self._audio_loop()
except Exception as e:
self.logger.error("Audio loop error: %s", e, exc_info=True)
self.wait_stop(5)
finally:
self.stop_conversation()
def stop(self):
self._stop_conversation()
super().stop()

View File

@ -0,0 +1,80 @@
from io import BytesIO
from dataclasses import dataclass, field
from typing import List
import numpy as np
from pydub import AudioSegment, silence
from platypush.common.assistant import AudioFrame
@dataclass
class RecordingState:
"""
Current state of the audio recording.
"""
sample_rate: int
channels: int
min_silence_secs: float
silence_threshold: int
silence_duration: float = 0.0
audio_segments: List[AudioSegment] = field(default_factory=list)
duration: float = 0.0
conversation_started: bool = False
def _silence_duration(self, audio: AudioSegment) -> float:
silent_frames = [
(start / 1000, stop / 1000)
for start, stop in silence.detect_silence(
audio,
min_silence_len=int(self.min_silence_secs * 1000),
silence_thresh=int(self.silence_threshold),
)
]
return sum(stop - start for start, stop in silent_frames)
def _to_audio_segment(self, data: np.ndarray) -> AudioSegment:
return AudioSegment(
data.tobytes(),
frame_rate=self.sample_rate,
sample_width=data.dtype.itemsize,
channels=self.channels,
)
def _add_audio_segment(self, audio: AudioSegment):
self.audio_segments.append(audio)
self.duration += audio.duration_seconds
silence_duration = self._silence_duration(audio)
is_mostly_silent = silence_duration >= audio.duration_seconds * 0.75
if is_mostly_silent:
self.silence_duration += silence_duration
else:
self.conversation_started = True
self.silence_duration = 0.0
def is_silent(self) -> bool:
return self.silence_duration >= self.duration
def add_audio(self, audio: AudioFrame):
self._add_audio_segment(self._to_audio_segment(audio.data))
def export_audio(self) -> BytesIO:
buffer = BytesIO()
if not self.audio_segments:
return buffer
audio = self.audio_segments[0]
for segment in self.audio_segments[1:]:
audio += segment
audio.export(buffer, format="mp3", bitrate='92')
return buffer
def reset(self):
self.audio_segments.clear()
self.duration = 0.0
self.silence_duration = 0.0
self.conversation_started = False

View File

@ -0,0 +1,44 @@
{
"manifest": {
"package": "platypush.plugins.assistant.openai",
"type": "plugin",
"events": [
"platypush.message.event.assistant.ConversationEndEvent",
"platypush.message.event.assistant.ConversationStartEvent",
"platypush.message.event.assistant.ConversationTimeoutEvent",
"platypush.message.event.assistant.HotwordDetectedEvent",
"platypush.message.event.assistant.IntentRecognizedEvent",
"platypush.message.event.assistant.MicMutedEvent",
"platypush.message.event.assistant.MicUnmutedEvent",
"platypush.message.event.assistant.NoResponseEvent",
"platypush.message.event.assistant.ResponseEndEvent",
"platypush.message.event.assistant.ResponseEvent",
"platypush.message.event.assistant.SpeechRecognizedEvent"
],
"install": {
"apk": [
"ffmpeg",
"py3-numpy"
],
"apt": [
"ffmpeg",
"python3-numpy",
"python3-pydub"
],
"dnf": [
"ffmpeg",
"python-numpy"
],
"pacman": [
"ffmpeg",
"python-numpy",
"python-sounddevice"
],
"pip": [
"numpy",
"pydub",
"sounddevice"
]
}
}
}

View File

@ -530,12 +530,12 @@ class AssistantPicovoicePlugin(AssistantPlugin, RunnablePlugin):
def _get_tts_plugin(self) -> TtsPicovoicePlugin:
return self.tts
def _on_response_render_start(self, text: Optional[str]):
def _on_response_render_start(self, text: Optional[str], *_, **__):
if self._assistant:
self._assistant.set_responding(True)
return super()._on_response_render_start(text)
def _on_response_render_end(self):
def _on_response_render_end(self, *_, **__):
if self._assistant:
self._assistant.set_responding(False)
@ -562,11 +562,8 @@ class AssistantPicovoicePlugin(AssistantPlugin, RunnablePlugin):
self._assistant.override_speech_model(model_file)
self._assistant.state = AssistantState.DETECTING_SPEECH
@action
def stop_conversation(self, *_, **__):
"""
Programmatically stop a running conversation with the assistant
"""
def _stop_conversation(self, *_, **__):
super()._stop_conversation()
if not self._assistant:
self.logger.warning('Assistant not initialized')
return

View File

@ -7,6 +7,7 @@ from typing import Any, Dict, Optional, Sequence
import pvporcupine
from platypush.common.assistant import AudioRecorder
from platypush.context import get_plugin
from platypush.message.event.assistant import (
AssistantEvent,
@ -16,8 +17,6 @@ from platypush.message.event.assistant import (
SpeechRecognizedEvent,
)
from platypush.plugins.tts.picovoice import TtsPicovoicePlugin
from ._recorder import AudioRecorder
from ._speech import SpeechProcessor
from ._state import AssistantState

View File

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import timedelta, timezone
from logging import getLogger
from queue import Queue
from typing import Callable, Collection, Dict, Final, List, Optional, Type
@ -16,6 +16,7 @@ from platypush.message.event.bluetooth import (
BluetoothDeviceSignalUpdateEvent,
BluetoothDeviceEvent,
)
from platypush.utils import utcnow
from .._cache import EntityCache
from .._model import ServiceClass
@ -98,7 +99,7 @@ event_matchers: Dict[
)
and (
not (old and old.updated_at)
or datetime.utcnow() - old.updated_at
or utcnow() - old.updated_at.replace(tzinfo=timezone.utc)
>= timedelta(seconds=_rssi_update_interval)
)
),
@ -227,9 +228,11 @@ class EventHandler:
return True
mapped_uuids = [
int(str(srv.uuid).split('-', maxsplit=1)[0], 16) & 0xFFFF
if isinstance(srv.uuid, UUID)
else srv.uuid
(
int(str(srv.uuid).split('-', maxsplit=1)[0], 16) & 0xFFFF
if isinstance(srv.uuid, UUID)
else srv.uuid
)
for srv in device.services
]

View File

@ -1,9 +1,12 @@
import datetime
import requests
from typing import Optional
import requests
from dateutil.tz import gettz
from platypush.plugins import Plugin, action
from platypush.plugins.calendar import CalendarInterface
from platypush.utils import utcnow
class CalendarIcalPlugin(Plugin, CalendarInterface):
@ -20,19 +23,20 @@ class CalendarIcalPlugin(Plugin, CalendarInterface):
self.url = url
@staticmethod
def _convert_timestamp(event, attribute: str) -> Optional[str]:
def _convert_timestamp(event: dict, attribute: str) -> Optional[str]:
t = event.get(attribute)
if not t:
return
return None
if type(t.dt) == datetime.date:
if isinstance(t.dt, datetime.date) and not isinstance(t.dt, datetime.datetime):
return datetime.datetime(
t.dt.year, t.dt.month, t.dt.day, tzinfo=datetime.timezone.utc
t.dt.year, t.dt.month, t.dt.day, tzinfo=gettz()
).isoformat()
return (
datetime.datetime.utcfromtimestamp(t.dt.timestamp())
.replace(tzinfo=datetime.timezone.utc)
datetime.datetime.fromtimestamp(t.dt.timestamp())
.replace(tzinfo=t.dt.tzinfo or gettz())
.astimezone(datetime.timezone.utc)
.isoformat()
)
@ -42,21 +46,23 @@ class CalendarIcalPlugin(Plugin, CalendarInterface):
'id': str(event.get('uid')) if event.get('uid') else None,
'kind': 'calendar#event',
'summary': str(event.get('summary')) if event.get('summary') else None,
'description': str(event.get('description'))
if event.get('description')
else None,
'description': (
str(event.get('description')) if event.get('description') else None
),
'status': str(event.get('status')).lower() if event.get('status') else None,
'responseStatus': str(event.get('partstat')).lower()
if event.get('partstat')
else None,
'responseStatus': (
str(event.get('partstat')).lower() if event.get('partstat') else None
),
'location': str(event.get('location')) if event.get('location') else None,
'htmlLink': str(event.get('url')) if event.get('url') else None,
'organizer': {
'email': str(event.get('organizer')).replace('MAILTO:', ''),
'displayName': event.get('organizer').params.get('cn'),
}
if event.get('organizer')
else None,
'organizer': (
{
'email': str(event.get('organizer')).replace('MAILTO:', ''),
'displayName': event.get('organizer').params.get('cn'),
}
if event.get('organizer')
else None
),
'created': cls._convert_timestamp(event, 'created'),
'updated': cls._convert_timestamp(event, 'last-modified'),
'start': {
@ -79,10 +85,10 @@ class CalendarIcalPlugin(Plugin, CalendarInterface):
from icalendar import Calendar
events = []
response = requests.get(self.url)
assert response.ok, "HTTP error while getting events from {}: {}".format(
self.url, response.text
)
response = requests.get(self.url, timeout=20)
assert (
response.ok
), f"HTTP error while getting events from {self.url}: {response.text}"
calendar = Calendar.from_ical(response.text)
for event in calendar.walk():
@ -94,10 +100,8 @@ class CalendarIcalPlugin(Plugin, CalendarInterface):
if (
event['status'] != 'cancelled'
and event['end'].get('dateTime')
and event['end']['dateTime']
>= datetime.datetime.utcnow()
.replace(tzinfo=datetime.timezone.utc)
.isoformat()
and datetime.datetime.fromisoformat(event['end']['dateTime'])
>= utcnow()
and (
(
only_participating

View File

@ -1,8 +1,7 @@
import datetime
from platypush.plugins import action
from platypush.plugins.google import GooglePlugin
from platypush.plugins.calendar import CalendarInterface
from platypush.utils import utcnow
class GoogleCalendarPlugin(GooglePlugin, CalendarInterface):
@ -72,7 +71,7 @@ class GoogleCalendarPlugin(GooglePlugin, CalendarInterface):
:meth:`platypush.plugins.calendar.CalendarPlugin.get_upcoming_events`.
"""
now = datetime.datetime.utcnow().isoformat() + 'Z'
now = utcnow().replace(tzinfo=None).isoformat() + 'Z'
service = self.get_service('calendar', 'v3')
result = (
service.events()

View File

@ -17,8 +17,6 @@ from typing import (
Set,
Union,
)
import warnings
from platypush.config import Config
from platypush.context import get_bus
from platypush.entities import Entity, LightEntityManager
@ -86,11 +84,7 @@ class LightHuePlugin(RunnablePlugin, LightEntityManager):
poll_seconds = kwargs.pop('poll_seconds', None)
if poll_seconds is not None:
warnings.warn(
'poll_seconds is deprecated, use poll_interval instead',
DeprecationWarning,
stacklevel=2,
)
self.logger.warning('poll_seconds is deprecated, use poll_interval instead')
if poll_interval is None:
poll_interval = poll_seconds
@ -1156,12 +1150,16 @@ class LightHuePlugin(RunnablePlugin, LightEntityManager):
temperature=entity.get('state', {}).get('ct'),
colormode=entity.get('colormode'),
reachable=entity.get('state', {}).get('reachable'),
x=entity['state']['xy'][0]
if entity.get('state', {}).get('xy')
else None,
y=entity['state']['xy'][1]
if entity.get('state', {}).get('xy')
else None,
x=(
entity['state']['xy'][0]
if entity.get('state', {}).get('xy')
else None
),
y=(
entity['state']['xy'][1]
if entity.get('state', {}).get('xy')
else None
),
effect=entity.get('state', {}).get('effect'),
**(
{

View File

@ -3,7 +3,7 @@ from dataclasses import dataclass
from datetime import datetime as dt
from enum import Enum
from threading import RLock
from typing import Iterable, List, Optional
from typing import IO, Iterable, List, Optional
import requests
@ -49,7 +49,18 @@ class OpenaiPlugin(Plugin):
"""
Plugin to interact with OpenAI services.
So far only ChatGPT is supported.
Currently supported:
- :meth:`get_response`: Get a response to a prompt/question using the
GPT API. It supports custom contexts and environment settings.
- :meth:`transcribe`: Perform speech-to-text on an audio file. This API
is also leveraged by the
:class:`platypush.plugins.assistant.openai.OpenaiPlugin` to provide a
full-fledged voice assistant.
- Through the :class:`platypush.plugins.tts.openai.OpenaiPlugin` plugin,
text-to-speech is also supported.
Contexts
--------
@ -265,6 +276,84 @@ class OpenaiPlugin(Plugin):
self._update_context(msg)
return msg["content"]
def _process_transcribe_response(self, resp: requests.Response) -> str:
rs_json = None
try:
rs_json = resp.json()
except Exception:
pass
self.logger.debug("OpenAI response: %s", rs_json)
resp.raise_for_status()
return (rs_json or {}).get("text", "")
def transcribe_file(
self,
f: IO,
model: Optional[str] = 'whisper-1',
timeout: Optional[float] = None,
) -> str:
resp = requests.post(
"https://api.openai.com/v1/audio/transcriptions",
timeout=timeout or self.timeout,
headers={
"Authorization": f"Bearer {self._api_key}",
},
files={
"file": f,
},
data={
"model": model or self.model,
},
)
return self._process_transcribe_response(resp)
def transcribe_raw(
self,
audio: bytes,
extension: str,
model: Optional[str] = 'whisper-1',
timeout: Optional[float] = None,
) -> str:
resp = requests.post(
"https://api.openai.com/v1/audio/transcriptions",
timeout=timeout or self.timeout,
headers={
"Authorization": f"Bearer {self._api_key}",
},
files={
"file": (f"audio.{extension}", audio),
},
data={
"model": model or self.model,
},
)
return self._process_transcribe_response(resp)
@action
def transcribe(
self,
audio: str,
model: Optional[str] = 'whisper-1',
timeout: Optional[float] = None,
) -> str:
"""
Perform speech-to-text on an audio file.
:param audio: The audio file to transcribe.
:param model: The model to use for speech-to-text. Default:
``whisper-1``. If not set, the configured default model will be
used.
:param timeout: Timeout for the API request. If not set, the default
timeout will be used.
:return: The transcribed text.
"""
with open(os.path.expanduser(audio), "rb") as f:
return self.transcribe_file(f, model=model, timeout=timeout)
def _update_context(self, *entries: dict):
"""
Update the context with a new entry.

View File

@ -16,6 +16,7 @@ from platypush.message.event.rss import NewFeedEntryEvent
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.variable import VariablePlugin
from platypush.schemas.rss import RssFeedEntrySchema
from platypush.utils import utcnow
def _variable() -> VariablePlugin:
@ -289,7 +290,7 @@ class RssPlugin(RunnablePlugin):
title = ElementTree.Element('title')
title.text = 'Platypush feed subscriptions'
created = ElementTree.Element('dateCreated')
created.text = self._datetime_to_string(datetime.datetime.utcnow())
created.text = self._datetime_to_string(utcnow())
head.append(title)
head.append(created)

View File

@ -1,7 +1,6 @@
from collections.abc import Collection
import time
from typing import List, Optional, Union
import warnings
from platypush.context import get_bus
from platypush.entities.distance import DistanceSensor
@ -45,10 +44,8 @@ class SensorHcsr04Plugin(GpioPlugin, SensorPlugin):
measurement_interval = kwargs.pop('measurement_interval', None)
if measurement_interval is not None:
warnings.warn(
self.logger.warning(
'measurement_interval is deprecated, use poll_interval instead',
DeprecationWarning,
stacklevel=2,
)
poll_interval = measurement_interval

View File

@ -1,5 +1,4 @@
from dataclasses import asdict
import warnings
from typing import Iterable, List, Optional, Union
from platypush.plugins import RunnablePlugin, action
@ -184,10 +183,8 @@ class SoundPlugin(RunnablePlugin):
blocksize = blocksize or self.output_blocksize
if file:
warnings.warn(
self.logger.warning(
'file is deprecated, use resource instead',
DeprecationWarning,
stacklevel=1,
)
if not resource:
resource = file
@ -232,10 +229,8 @@ class SoundPlugin(RunnablePlugin):
"""
Deprecated alias for :meth:`.record`.
"""
warnings.warn(
self.logger.warning(
'sound.stream_recording is deprecated, use sound.record instead',
DeprecationWarning,
stacklevel=1,
)
return self.record(*args, **kwargs)
@ -319,10 +314,8 @@ class SoundPlugin(RunnablePlugin):
"""
Deprecated alias for :meth:`.record`.
"""
warnings.warn(
self.logger.warning(
'sound.recordplay is deprecated, use sound.record with `play_audio=True` instead',
DeprecationWarning,
stacklevel=1,
)
kwargs['play_audio'] = True
@ -398,10 +391,8 @@ class SoundPlugin(RunnablePlugin):
Deprecated alias for :meth:`.status`.
"""
warnings.warn(
self.logger.warning(
'sound.query_streams is deprecated, use sound.status instead',
DeprecationWarning,
stacklevel=1,
)
return self.status()

View File

@ -7,6 +7,7 @@ from dateutil.tz import gettz
from platypush.message.event.sun import SunriseEvent, SunsetEvent
from platypush.plugins import RunnablePlugin, action
from platypush.schemas.sun import SunEventsSchema
from platypush.utils import utcnow
class SunPlugin(RunnablePlugin):
@ -63,11 +64,11 @@ class SunPlugin(RunnablePlugin):
@staticmethod
def _convert_time(t: str) -> datetime.datetime:
now = datetime.datetime.now(datetime.UTC).replace(microsecond=0)
now = utcnow().replace(microsecond=0)
dt = datetime.datetime.strptime(
f'{now.year}-{now.month:02d}-{now.day:02d} {t}',
'%Y-%m-%d %I:%M:%S %p',
).replace(tzinfo=datetime.UTC)
).replace(tzinfo=datetime.timezone.utc)
if dt < now:
dt += datetime.timedelta(days=1)

View File

@ -55,5 +55,18 @@ class TtsPlugin(Plugin):
self._playback(url, **player_args)
@action
def stop(self):
"""
Stop the playback.
"""
try:
audio = get_plugin('sound')
except Exception:
return
if audio:
audio.stop_playback()
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,150 @@
import os
import tempfile
from contextlib import contextmanager
from multiprocessing import Process
from typing import Generator, Optional
import requests
from platypush.context import get_plugin
from platypush.plugins import action
from platypush.plugins.openai import OpenaiPlugin
from platypush.plugins.tts import TtsPlugin
class TtsOpenaiPlugin(TtsPlugin):
r"""
This plugin provides an interface to the `OpenAI text-to-speech API
<https://platform.openai.com/docs/guides/text-to-speech>`_.
It requires the :class:`platypush.plugins.openai.OpenaiPlugin` plugin to be
configured.
"""
_BUFSIZE = 1024
def __init__(
self,
model: str = 'tts-1',
voice: str = 'nova',
timeout: float = 10,
**kwargs,
):
"""
:param model: Model to be used for the text-to-speech conversion.
See the `OpenAI API models documentation
<https://platform.openai.com/docs/models/tts>`_ for the list of
available models (default: ``tts-1``).
:param voice: Default voice to be used. See the `OpenAI API
voices documentation
<https://platform.openai.com/docs/guides/text-to-speech/voice-options>`_
for the list of available voices (default: ``nova``).
:param timeout: Default timeout for the API requests (default: 10s).
"""
super().__init__(**kwargs)
openai = get_plugin('openai')
assert openai, 'openai plugin not configured'
self.openai: OpenaiPlugin = openai
self.model = model
self.voice = voice
self.timeout = timeout
self._audio_proc: Optional[Process] = None
def _process_response(
self,
response: requests.Response,
audio_file: str,
) -> Process:
def proc_fn():
try:
with open(audio_file, 'wb') as file:
for chunk in response.iter_content(chunk_size=self._BUFSIZE):
if chunk:
file.write(chunk)
file.flush()
except KeyboardInterrupt:
pass
self._audio_proc = Process(target=proc_fn, name='openai-tts-response-processor')
self._audio_proc.start()
return self._audio_proc
def _make_request(
self,
text: str,
model: Optional[str] = None,
voice: Optional[str] = None,
) -> requests.Response:
rs = requests.post(
"https://api.openai.com/v1/audio/speech",
timeout=self.timeout,
stream=True,
headers={
"Authorization": f"Bearer {self.openai._api_key}", # pylint: disable=protected-access
"Content-Type": "application/json",
},
json={
"model": model or self.model,
"voice": voice or self.voice,
"input": text,
},
)
rs.raise_for_status()
return rs
@contextmanager
def _audio_fifo(self) -> Generator[str, None, None]:
fifo_dir = tempfile.mkdtemp()
fifo_path = os.path.join(fifo_dir, 'platypush-tts-openai-fifo')
os.mkfifo(fifo_path)
yield fifo_path
os.unlink(fifo_path)
os.rmdir(fifo_dir)
@action
def say(
self,
text: str,
*_,
model: Optional[str] = None,
voice: Optional[str] = None,
**player_args,
):
"""
Say some text.
:param text: Text to say.
:param model: Default ``model`` override.
:param voice: Default ``voice`` override.
:param player_args: Extends the additional arguments to be passed to
:meth:`platypush.plugins.sound.SoundPlugin.play` (like volume,
duration, channels etc.).
"""
response_processor: Optional[Process] = None
try:
response = self._make_request(text, model=model, voice=voice)
with self._audio_fifo() as audio_file:
response_processor = self._process_response(
response=response, audio_file=audio_file
)
self._playback(audio_file, **player_args)
response_processor.join()
response_processor = None
finally:
if response_processor:
response_processor.terminate()
@action
def stop(self):
super().stop()
if self._audio_proc and self._audio_proc.is_alive():
self._audio_proc.terminate()
self._audio_proc.join()
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,34 @@
{
"manifest": {
"events": {},
"install": {
"apk": [
"ffmpeg",
"portaudio-dev",
"py3-numpy"
],
"apt": [
"ffmpeg",
"portaudio19-dev",
"python3-numpy"
],
"dnf": [
"ffmpeg",
"portaudio-devel",
"python-numpy"
],
"pacman": [
"ffmpeg",
"portaudio",
"python-numpy",
"python-sounddevice"
],
"pip": [
"numpy",
"sounddevice"
]
},
"package": "platypush.plugins.tts.openai",
"type": "plugin"
}
}

View File

@ -178,7 +178,7 @@ class TtsPicovoicePlugin(TtsPlugin):
self._play_audio(
orca=orca,
pcm=np.array(
orca.synthesize(text, speech_rate=speech_rate),
orca.synthesize(text, speech_rate=speech_rate)[0],
dtype='int16',
),
)

View File

@ -377,9 +377,11 @@ class ZwaveMqttPlugin(
'device_id': device_id.replace('0x', ''),
'name': node.get('name'),
'capabilities': capabilities,
'manufacturer_id': f'0x{node["manufacturerId"]:04x}'
if node.get('manufacturerId')
else None,
'manufacturer_id': (
f'0x{node["manufacturerId"]:04x}'
if node.get('manufacturerId')
else None
),
'manufacturer_name': node.get('manufacturer'),
'location': node.get('loc'),
'status': node.get('status'),
@ -397,12 +399,12 @@ class ZwaveMqttPlugin(
'is_security_device': node.get('supportsSecurity'),
'is_sleeping': node.get('ready') and node.get('status') == 'Asleep',
'last_update': cls._convert_timestamp(node.get('lastActive')),
'product_id': f'0x{node["productId"]:04x}'
if node.get('productId')
else None,
'product_type': f'0x{node["productType"]:04x}'
if node.get('productType')
else None,
'product_id': (
f'0x{node["productId"]:04x}' if node.get('productId') else None
),
'product_type': (
f'0x{node["productType"]:04x}' if node.get('productType') else None
),
'product_name': ' '.join(
[node.get('productLabel', ''), node.get('productDescription', '')]
),
@ -1048,7 +1050,7 @@ class ZwaveMqttPlugin(
"""
Get the current status of the Z-Wave values.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(**kwargs)
@ -1058,7 +1060,7 @@ class ZwaveMqttPlugin(
"""
Get the status of the controller.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
msg_queue: queue.Queue = queue.Queue()
@ -1105,7 +1107,7 @@ class ZwaveMqttPlugin(
:param do_security: Whether to initialize the Network Key on the device if it supports the Security CC
:param timeout: How long the inclusion process should last, in seconds (default: 30). Specify zero or null
for no timeout.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self._api_request(
@ -1128,7 +1130,7 @@ class ZwaveMqttPlugin(
Remove a node from the network (or, better, start the exclusion process).
:param timeout: How long the exclusion process should last, in seconds (default: 30).
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self._api_request('startExclusion', **kwargs)
@ -1144,7 +1146,7 @@ class ZwaveMqttPlugin(
:param node_id: Filter by node_id.
:param node_name: Filter by node name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1162,7 +1164,7 @@ class ZwaveMqttPlugin(
:param node_id: Filter by node_id.
:param node_name: Filter by node name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1180,7 +1182,7 @@ class ZwaveMqttPlugin(
:param node_id: Filter by node_id.
:param node_name: Filter by node name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1194,7 +1196,7 @@ class ZwaveMqttPlugin(
"""
Request a neighbours list update.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self._api_request('refreshNeighbors', **kwargs)
@ -1208,7 +1210,7 @@ class ZwaveMqttPlugin(
:param node_id: Filter by node_id.
:param node_name: Filter by node name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
:return: List of paired devices. Example output:
@ -1362,7 +1364,7 @@ class ZwaveMqttPlugin(
}
}
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_id or node_name:
@ -1386,7 +1388,7 @@ class ZwaveMqttPlugin(
:param new_name: New name for the node.
:param node_id: Filter by node_id.
:param node_name: Filter by current node name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1417,7 +1419,7 @@ class ZwaveMqttPlugin(
:param location: Node location.
:param node_id: Filter by node_id.
:param node_name: Filter by current node name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1442,7 +1444,7 @@ class ZwaveMqttPlugin(
Heal network by requesting nodes rediscover their neighbours.
:param timeout: Duration of the healing process in seconds (default: 60). Set to zero or null for no timeout.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self._api_request('beginHealingNetwork', **kwargs)
@ -1469,7 +1471,7 @@ class ZwaveMqttPlugin(
:param value_label: Select value by [node_id/node_name, value_label]
:param node_id: Select value by [node_id/node_name, value_label]
:param node_name: Select value by [node_id/node_name, value_label]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._get_value(
@ -1503,7 +1505,7 @@ class ZwaveMqttPlugin(
:param value_label: Select value by [node_id/node_name, value_label]
:param node_id: Select value by [node_id/node_name, value_label]
:param node_name: Select value by [node_id/node_name, value_label]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
# Compatibility layer with the .set_value format used by
@ -1558,7 +1560,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1575,7 +1577,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self.node_heal(node_id=node_id, node_name=node_name, **kwargs)
@ -1590,7 +1592,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self.node_heal(node_id=node_id, node_name=node_name, **kwargs)
@ -1604,7 +1606,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
if node_name:
@ -1620,7 +1622,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1639,7 +1641,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1663,7 +1665,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1679,7 +1681,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1695,7 +1697,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1715,7 +1717,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1735,7 +1737,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1755,7 +1757,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1771,7 +1773,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1787,7 +1789,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1803,7 +1805,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1830,7 +1832,7 @@ class ZwaveMqttPlugin(
:param node_id: Select node by node_id.
:param node_name: Select node by name.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
return self._filter_values(
@ -1842,7 +1844,7 @@ class ZwaveMqttPlugin(
"""
Get the groups on the network.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
:return: A list of the available groups. Example:
@ -1885,7 +1887,7 @@ class ZwaveMqttPlugin(
"""
Get the scenes configured on the network.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
:return: dict with the following format:
@ -1919,7 +1921,7 @@ class ZwaveMqttPlugin(
Create a new scene.
:param label: Scene label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self._api_request('_createScene', label, **kwargs)
@ -1936,7 +1938,7 @@ class ZwaveMqttPlugin(
:param scene_id: Select by scene_id.
:param scene_label: Select by scene label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
@ -1954,7 +1956,7 @@ class ZwaveMqttPlugin(
:param scene_id: Select by scene_id.
:param scene_label: Select by scene label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
@ -1984,7 +1986,7 @@ class ZwaveMqttPlugin(
:param node_name: Select value by [node_id/node_name, value_label]
:param scene_id: Select scene by scene_id.
:param scene_label: Select scene by scene label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
value = self._get_value(
@ -2032,7 +2034,7 @@ class ZwaveMqttPlugin(
:param node_name: Select value by [node_id/node_name, value_label]
:param scene_id: Select scene by scene_id.
:param scene_label: Select scene by scene label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
value = self._get_value(
@ -2058,7 +2060,7 @@ class ZwaveMqttPlugin(
:param scene_id: Select by scene_id.
:param scene_label: Select by scene label.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
@ -2078,7 +2080,7 @@ class ZwaveMqttPlugin(
:param group_id: Group ID.
:param node_id: Node ID to be added.
:param endpoint: Add a specific endpoint of the node to the group (default: add a node association).
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
group = self._get_group(group_id, **kwargs)
@ -2102,7 +2104,7 @@ class ZwaveMqttPlugin(
:param group_id: Group ID.
:param node_id: Node ID to be added.
:param endpoint: Node endpoint to remove (default: remove node association).
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
group = self._get_group(group_id, **kwargs)
@ -2128,7 +2130,7 @@ class ZwaveMqttPlugin(
Turn on a switch on a device.
:param device: ``id_on_network`` of the value to be switched on.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self.set_value(data=True, id_on_network=device, **kwargs)
@ -2139,7 +2141,7 @@ class ZwaveMqttPlugin(
Turn off a switch on a device.
:param device: ``id_on_network`` of the value to be switched off.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
self.set_value(data=False, id_on_network=device, **kwargs)
@ -2152,7 +2154,7 @@ class ZwaveMqttPlugin(
Toggle a switch on a device.
:param device: ``id_on_network`` of the value to be toggled.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`
(default: query the default configured device).
"""
value = self._get_value(id_on_network=device, use_cache=False, **kwargs)

View File

@ -558,7 +558,9 @@ class IfProcedure(Procedure):
return response
def procedure(name: Optional[str] = None):
def procedure(name_or_func: Optional[str] = None, *upper_args, **upper_kwargs):
name = name_or_func if isinstance(name_or_func, str) else None
def func_wrapper(f):
"""
Public decorator to mark a function as a procedure.
@ -569,10 +571,15 @@ def procedure(name: Optional[str] = None):
@wraps(f)
def _execute_procedure(*args, **kwargs):
args = [*upper_args, *args]
kwargs = {**upper_kwargs, **kwargs}
return exec_wrapper(f, *args, **kwargs)
return _execute_procedure
if callable(name_or_func):
return func_wrapper(name_or_func)
return func_wrapper

View File

@ -1,4 +1,4 @@
import warnings
import logging
from marshmallow import Schema, fields, pre_dump, post_dump
@ -6,6 +6,8 @@ from platypush.context import get_plugin
from . import MediaArtistSchema, MediaCollectionSchema, MediaVideoSchema
logger = logging.getLogger(__name__)
class JellyfinSchema(Schema):
def __init__(self, *args, **kwargs):
@ -20,9 +22,10 @@ class JellyfinSchema(Schema):
@post_dump
def gen_img_url(self, data: dict, **_) -> dict:
if 'image' in self.fields:
plugin = get_plugin('media.jellyfin')
assert plugin, 'The media.jellyfin plugin is not configured'
data['image'] = (
get_plugin('media.jellyfin').server
+ f'/Items/{data["id"]}' # type: ignore
plugin.server + f'/Items/{data["id"]}' # type: ignore
'/Images/Primary?fillHeight=333&fillWidth=222&quality=96'
)
@ -43,9 +46,8 @@ class JellyfinSchema(Schema):
if not video_format:
if not available_containers:
warnings.warn(
f'The media ID {data["Id"]} has no available video containers',
stacklevel=2,
logger.warning(
'The media ID %s has no available video containers', data["Id"]
)
return data

View File

@ -18,7 +18,7 @@ from platypush.exceptions.user import (
InvalidJWTTokenException,
InvalidCredentialsException,
)
from platypush.utils import get_or_generate_jwt_rsa_key_pair
from platypush.utils import get_or_generate_jwt_rsa_key_pair, utcnow
class UserManager:
@ -78,7 +78,7 @@ class UserManager:
),
password_salt=password_salt.hex(),
hmac_iterations=hmac_iterations,
created_at=datetime.datetime.utcnow(),
created_at=utcnow(),
**kwargs,
)
@ -115,10 +115,13 @@ class UserManager:
.first()
)
if not user_session or (
user_session.expires_at
and user_session.expires_at < datetime.datetime.utcnow()
):
expires_at = (
user_session.expires_at.replace(tzinfo=datetime.timezone.utc)
if user_session and user_session.expires_at
else None
)
if not user_session or (expires_at and expires_at < utcnow()):
return None, None
user = session.query(User).filter_by(user_id=user_session.user_id).first()
@ -171,7 +174,7 @@ class UserManager:
user_id=user.user_id,
session_token=self.generate_session_token(),
csrf_token=self.generate_session_token(),
created_at=datetime.datetime.utcnow(),
created_at=utcnow(),
expires_at=expires_at,
)

View File

@ -814,4 +814,12 @@ def wait_for_either(*events, timeout: Optional[float] = None, cls: Type = Event)
return OrEvent(*events, cls=cls).wait(timeout=timeout)
def utcnow():
"""
utcnow() without tears. It always returns a datetime object in UTC
timezone.
"""
return datetime.datetime.now(datetime.timezone.utc)
# vim:sw=4:ts=4:et:

View File

@ -28,6 +28,7 @@ mock_imports = [
"dbus",
"deepspeech",
"defusedxml",
"dns",
"docutils",
"envirophat",
"feedparser",
@ -92,6 +93,7 @@ mock_imports = [
"pychromecast",
"pyclip",
"pydbus",
"pydub",
"pyfirmata2",
"pyngrok",
"pyotp",

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.99.0
current_version = 1.1.0
commit = True
tag = True

View File

@ -34,8 +34,7 @@ def parse_deps(deps):
ret = []
for dep in deps:
if dep.startswith('git+'):
repo_name = dep.split('/')[-1].split('.git')[0]
dep = f'{repo_name} @ {dep}'
continue # Don't include git dependencies in the setup.py, or Twine will complain
ret.append(dep)
@ -67,7 +66,7 @@ backend = pkg_files('platypush/backend')
setup(
name="platypush",
version="0.50.3",
version="1.1.0",
author="Fabio Manganiello",
author_email="fabio@manganiello.tech",
description="Platypush service",