diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d866e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/publish_alpha.yml b/.github/workflows/publish_alpha.yml index a7fde29..1e09747 100644 --- a/.github/workflows/publish_alpha.yml +++ b/.github/workflows/publish_alpha.yml @@ -14,7 +14,7 @@ on: - 'LICENSE' - 'CHANGELOG.md' - 'MANIFEST.in' - - 'readme.md' + - 'README.md' - 'scripts/**' workflow_dispatch: @@ -34,20 +34,6 @@ jobs: runs-on: ubuntu-latest needs: update_version steps: - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: V${{ needs.update_version.outputs.version }} - release_name: Release ${{ needs.update_version.outputs.version }} - body: | - Changes in this Release - ${{ needs.update_version.outputs.changelog }} - draft: false - prerelease: true - commitish: dev - name: Checkout Repository uses: actions/checkout@v2 with: diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e56a8cd..f151381 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -11,7 +11,7 @@ on: - 'LICENSE' - 'CHANGELOG.md' - 'MANIFEST.in' - - 'readme.md' + - 'README.md' - 'scripts/**' push: branches: @@ -25,7 +25,7 @@ on: - 'LICENSE' - 'CHANGELOG.md' - 'MANIFEST.in' - - 'readme.md' + - 'README.md' - 'scripts/**' workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a1cf3..8eab4cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,134 @@ # Changelog -## [V0.0.8a2](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.8a2) (2023-12-29) +## [0.0.9](https://github.com/OpenVoiceOS/ovos-bus-client/tree/0.0.9) (2024-09-02) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.8a1...V0.0.8a2) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V...0.0.9) + +**Breaking changes:** + +- refactor/remove\_stt+tts\_prefs [\#91](https://github.com/OpenVoiceOS/ovos-bus-client/pull/91) ([JarbasAl](https://github.com/JarbasAl)) + +**Implemented enhancements:** + +- feat/blacklist\_from\_session [\#98](https://github.com/OpenVoiceOS/ovos-bus-client/pull/98) ([JarbasAl](https://github.com/JarbasAl)) +- feat/track\_speaking+recording\_per\_session [\#93](https://github.com/OpenVoiceOS/ovos-bus-client/pull/93) ([JarbasAl](https://github.com/JarbasAl)) +- feat/add\_units\_to\_session [\#90](https://github.com/OpenVoiceOS/ovos-bus-client/pull/90) ([JarbasAl](https://github.com/JarbasAl)) +- feat/targeted OCP queries [\#88](https://github.com/OpenVoiceOS/ovos-bus-client/pull/88) ([NeonJarbas](https://github.com/NeonJarbas)) + +**Fixed bugs:** + +- fix/KeyError [\#105](https://github.com/OpenVoiceOS/ovos-bus-client/pull/105) ([JarbasAl](https://github.com/JarbasAl)) +- fix/ocp\_missing\_context [\#104](https://github.com/OpenVoiceOS/ovos-bus-client/pull/104) ([JarbasAl](https://github.com/JarbasAl)) +- fix/ocp\_api\_context [\#103](https://github.com/OpenVoiceOS/ovos-bus-client/pull/103) ([JarbasAl](https://github.com/JarbasAl)) +- fix/ocp\_uris [\#102](https://github.com/OpenVoiceOS/ovos-bus-client/pull/102) ([JarbasAl](https://github.com/JarbasAl)) +- hotfix/StreamPlugin [\#100](https://github.com/OpenVoiceOS/ovos-bus-client/pull/100) ([JarbasAl](https://github.com/JarbasAl)) +- Update error handling to reduce unhandled exceptions [\#96](https://github.com/OpenVoiceOS/ovos-bus-client/pull/96) ([NeonDaniel](https://github.com/NeonDaniel)) +- Update setup.py to resolve version automation bug [\#95](https://github.com/OpenVoiceOS/ovos-bus-client/pull/95) ([NeonDaniel](https://github.com/NeonDaniel)) + +**Merged pull requests:** + +- refactor/handle\_new\_SEIs [\#97](https://github.com/OpenVoiceOS/ovos-bus-client/pull/97) ([JarbasAl](https://github.com/JarbasAl)) +- Update imports to support ovos-utils~=0.0.x with compat. warnings [\#94](https://github.com/OpenVoiceOS/ovos-bus-client/pull/94) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V) (2024-03-10) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a12...V) + +**Merged pull requests:** + +- chore\(docs\): add a long description to PyPi [\#86](https://github.com/OpenVoiceOS/ovos-bus-client/pull/86) ([mikejgray](https://github.com/mikejgray)) + +## [V0.0.9a12](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a12) (2024-02-21) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a11...V0.0.9a12) + +**Implemented enhancements:** + +- feat/session\_location [\#85](https://github.com/OpenVoiceOS/ovos-bus-client/pull/85) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.9a11](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a11) (2024-02-02) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a10...V0.0.9a11) + +**Merged pull requests:** + +- feat/remove\_all\_pages [\#84](https://github.com/OpenVoiceOS/ovos-bus-client/pull/84) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.0.9a10](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a10) (2024-01-29) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a9...V0.0.9a10) + +**Fixed bugs:** + +- Update default values [\#83](https://github.com/OpenVoiceOS/ovos-bus-client/pull/83) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.9a9](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a9) (2024-01-23) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a8...V0.0.9a9) + +**Fixed bugs:** + +- fix/info\_leak [\#81](https://github.com/OpenVoiceOS/ovos-bus-client/pull/81) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.9a8](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a8) (2024-01-13) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a7...V0.0.9a8) + +## [V0.0.9a7](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a7) (2024-01-13) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a6...V0.0.9a7) + +**Merged pull requests:** + +- refactor/orjson [\#75](https://github.com/OpenVoiceOS/ovos-bus-client/pull/75) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.9a6](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a6) (2024-01-12) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a5...V0.0.9a6) + +**Fixed bugs:** + +- typo\_and\_docstr [\#79](https://github.com/OpenVoiceOS/ovos-bus-client/pull/79) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.9a5](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a5) (2024-01-12) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a4...V0.0.9a5) + +**Implemented enhancements:** + +- feat/ovos-media [\#78](https://github.com/OpenVoiceOS/ovos-bus-client/pull/78) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.0.9a4](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a4) (2024-01-09) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a3...V0.0.9a4) + +**Fixed bugs:** + +- hotfix/avoid\_c++\_crash [\#76](https://github.com/OpenVoiceOS/ovos-bus-client/pull/76) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.9a3](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a3) (2024-01-08) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a2...V0.0.9a3) **Fixed bugs:** -- fix/get\_message\_lang [\#71](https://github.com/OpenVoiceOS/ovos-bus-client/pull/71) ([JarbasAl](https://github.com/JarbasAl)) +- fix/ocp api [\#77](https://github.com/OpenVoiceOS/ovos-bus-client/pull/77) ([NeonJarbas](https://github.com/NeonJarbas)) -## [V0.0.8a1](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.8a1) (2023-12-29) +## [V0.0.9a2](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a2) (2024-01-06) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.9a1...V0.0.9a2) + +**Implemented enhancements:** + +- fix/ocp api [\#74](https://github.com/OpenVoiceOS/ovos-bus-client/pull/74) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.0.9a1](https://github.com/OpenVoiceOS/ovos-bus-client/tree/V0.0.9a1) (2023-12-30) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.8...V0.0.9a1) + +**Fixed bugs:** -[Full Changelog](https://github.com/OpenVoiceOS/ovos-bus-client/compare/V0.0.7...V0.0.8a1) +- fix/log spam [\#73](https://github.com/OpenVoiceOS/ovos-bus-client/pull/73) ([JarbasAl](https://github.com/JarbasAl)) diff --git a/LICENSE.md b/LICENSE similarity index 96% rename from LICENSE.md rename to LICENSE index b2719d2..d33befd 100644 --- a/LICENSE.md +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024 OpenVoiceOS Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -200,12 +200,4 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - -======================================================================= -Component licenses for mycroft-core: - -The mycroft-core software references various Python Packages (via PIP), -each of which has a separate license. All are compatible with the -Apache 2.0 license. See the referenced packages listed in the -"requirements.txt" file for specific terms and conditions. diff --git a/ovos_bus_client/apis/gui.py b/ovos_bus_client/apis/gui.py index 94b3101..3286999 100644 --- a/ovos_bus_client/apis/gui.py +++ b/ovos_bus_client/apis/gui.py @@ -6,7 +6,7 @@ from ovos_utils.log import LOG, log_deprecation from ovos_bus_client.util import get_mycroft_bus from ovos_utils.gui import can_use_gui - +from ovos_config import Configuration from ovos_bus_client.message import Message @@ -91,14 +91,7 @@ def __init__(self, skill_id: str, bus=None, `all` key should reference a `gui` directory containing all specific resource subdirectories """ - if not config: - log_deprecation(f"Expected a dict config and got None.", "0.1.0") - try: - from ovos_config.config import read_mycroft_config - config = read_mycroft_config().get("gui", {}) - except ImportError: - LOG.warning("Config not provided and ovos_config not available") - config = dict() + config = config or Configuration().get("gui", {}) self.config = config if remote_server: self.config["remote-server"] = remote_server @@ -153,7 +146,9 @@ def page(self) -> Optional[str]: """ Return the active GUI page name to show """ - return self._pages[self.current_page_idx] if len(self._pages) else None + if not len(self._pages) or self.current_page_idx >= len(self._pages): + return None + return self._pages[self.current_page_idx] @property def connected(self) -> bool: @@ -364,8 +359,8 @@ def _pages2uri(self, page_names: List[str]) -> List[str]: # Prefer plugin-specific resources first, then fallback to core page = resolve_ovos_resource_file(name, extra_dirs) or \ resolve_ovos_resource_file(join('ui', name), extra_dirs) or \ - resolve_resource_file(name, self.config) or \ - resolve_resource_file(join('ui', name), self.config) + resolve_resource_file(name, config=self.config) or \ + resolve_resource_file(join('ui', name), config=self.config) if page: if self.remote_url: @@ -403,7 +398,8 @@ def _normalize_page_name(page_name: str) -> str: # base gui interactions def show_page(self, name: str, override_idle: Union[bool, int] = None, - override_animations: bool = False): + override_animations: bool = False, index: int = 0, + remove_others=False): """ Request to show a page in the GUI. @param name: page resource requested @@ -411,11 +407,12 @@ def show_page(self, name: str, override_idle: Union[bool, int] = None, if True, override display indefinitely @param override_animations: if True, disables all GUI animations """ - self.show_pages([name], 0, override_idle, override_animations) + self.show_pages([name], index, override_idle, override_animations, remove_others) def show_pages(self, page_names: List[str], index: int = 0, override_idle: Union[bool, int] = None, - override_animations: bool = False): + override_animations: bool = False, + remove_others=False): """ Request to show a list of pages in the GUI. @param page_names: list of page resources requested @@ -439,6 +436,9 @@ def show_pages(self, page_names: List[str], index: int = 0, page_urls = self._pages2uri(page_names) page_names = [self._normalize_page_name(n) for n in page_names] + if remove_others: + self.remove_all_pages(except_pages=page_names) + self._pages = page_names self.current_page_idx = index @@ -484,6 +484,17 @@ def remove_pages(self, page_names: List[str]): "page_names": page_names, "__from": self.skill_id})) + def remove_all_pages(self, except_pages=None): + """ + Request to remove all pages from the GUI. + @param except_pages: list of optional page resources to keep + """ + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") + self.bus.emit(Message("gui.page.delete.all", + {"__from": self.skill_id, + "except": except_pages or []})) + # Utils / Templates # backport - PR https://github.com/MycroftAI/mycroft-core/pull/2862 diff --git a/ovos_bus_client/apis/ocp.py b/ovos_bus_client/apis/ocp.py index 0174720..f9a49aa 100644 --- a/ovos_bus_client/apis/ocp.py +++ b/ovos_bus_client/apis/ocp.py @@ -1,5 +1,3 @@ -# Copyright 2017 Mycroft AI Inc. -# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -12,13 +10,19 @@ # See the License for the specific language governing permissions and # limitations under the License. # - +import time from datetime import timedelta - +from functools import wraps from os.path import abspath +from threading import Lock +from typing import List, Union, Optional + +from ovos_utils.gui import is_gui_connected, is_gui_running +from ovos_utils.log import LOG, deprecated +from ovos_bus_client.message import Message +from ovos_bus_client.message import dig_for_message from ovos_bus_client.util import get_mycroft_bus -from ovos_bus_client.message import Message, dig_for_message def ensure_uri(s: str): @@ -32,12 +36,12 @@ def ensure_uri(s: str): if s is uri, s is returned otherwise file:// is prepended """ if isinstance(s, str): - if '://' not in s: + if ':' not in s: return 'file://' + abspath(s) else: return s elif isinstance(s, (tuple, list)): # Handle (mime, uri) arg - if '://' not in s[0]: + if ':' not in s[0]: return 'file://' + abspath(s[0]), s[1] else: return s @@ -45,35 +49,633 @@ def ensure_uri(s: str): raise ValueError('Invalid track') +def _ensure_message_kwarg(): + """ensure message kwarg is present + NOTE: this is meant for usage only in this module, it is not a generic decorator! + """ + + def message_injector(func): + # this method ensures all skills messages are .forward from the utterance + # that triggered the skill, this ensures proper routing and metadata in message.context + @wraps(func) + def call_function(*args, **kwargs): + if not any([isinstance(a, Message) for a in args]): + m = kwargs.get("source_message") + if not m: + source_message = dig_for_message(max_records=50) + if source_message: + kwargs["source_message"] = source_message + else: + LOG.warning("source message could not be determined, message.context has been lost!") + kwargs["source_message"] = Message("") + return func(*args, **kwargs) + + return call_function + + return message_injector + + class ClassicAudioServiceInterface: - """AudioService class for interacting with the audio subsystem + """AudioService class for interacting with the classic mycroft audio subsystem + + DEPRECATED: only works in ovos-core <= 0.0.8 + + it has been removed from ovos-audio with the move to ovos-media + + "mycroft.audio.XXX" has been replaced by "ovos.audio.XXX" namespace - Audio is managed by OCP in the default implementation, - usually this class should not be directly used, see OCPInterface instead + use OCPInterface instead Args: - bus: Mycroft messagebus connection + bus: OpenVoiceOS messagebus connection """ + @deprecated("removed from ovos-audio with the adoption of ovos-media service, " + "use OCPInterface instead", "0.1.0") def __init__(self, bus=None): self.bus = bus or get_mycroft_bus() - def queue(self, tracks=None): + @_ensure_message_kwarg() + def queue(self, tracks=None, source_message: Optional[Message] = None): """Queue up a track to playing playlist. Args: tracks: track uri or list of track uri's Each track can be added as a tuple with (uri, mime) to give a hint of the mime type to the system + source_message: bus message that triggered this action + """ + tracks = tracks or [] + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(source_message.forward('mycroft.audio.service.queue', + {'tracks': tracks})) + + @_ensure_message_kwarg() + def play(self, tracks=None, utterance=None, repeat=None, source_message: Optional[Message] = None): + """Start playback. + + Args: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + utterance: forward utterance for further processing by the + audio service. + repeat: if the playback should be looped + source_message: bus message that triggered this action """ + repeat = repeat or False tracks = tracks or [] + utterance = utterance or '' if isinstance(tracks, (str, tuple)): tracks = [tracks] elif not isinstance(tracks, list): raise ValueError tracks = [ensure_uri(t) for t in tracks] - self.bus.emit(Message('mycroft.audio.service.queue', - data={'tracks': tracks})) + self.bus.emit(source_message.forward('mycroft.audio.service.play', + {'tracks': tracks, + 'utterance': utterance, + 'repeat': repeat})) + + @_ensure_message_kwarg() + def stop(self, source_message: Optional[Message] = None): + """Stop the track. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward('mycroft.audio.service.stop')) + + @_ensure_message_kwarg() + def next(self, source_message: Optional[Message] = None): + """Change to next track. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward('mycroft.audio.service.next')) + + @_ensure_message_kwarg() + def prev(self, source_message: Optional[Message] = None): + """Change to previous track. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward('mycroft.audio.service.prev')) + + @_ensure_message_kwarg() + def pause(self, source_message: Optional[Message] = None): + """Pause playback. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward('mycroft.audio.service.pause')) + + @_ensure_message_kwarg() + def resume(self, source_message: Optional[Message] = None): + """Resume paused playback. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward('mycroft.audio.service.resume')) + + @_ensure_message_kwarg() + def get_track_length(self, source_message: Optional[Message] = None): + """ + getting the duration of the audio in seconds + Args: + source_message: bus message that triggered this action + """ + length = 0 + info = self.bus.wait_for_response( + source_message.forward('mycroft.audio.service.get_track_length'), + timeout=1) + if info: + length = info.data.get("length") or 0 + return length / 1000 # convert to seconds + + @_ensure_message_kwarg() + def get_track_position(self, source_message: Optional[Message] = None): + """ + get current position in seconds + Args: + source_message: bus message that triggered this action + """ + pos = 0 + info = self.bus.wait_for_response( + source_message.forward('mycroft.audio.service.get_track_position'), + timeout=1) + if info: + pos = info.data.get("position") or 0 + return pos / 1000 # convert to seconds + + @_ensure_message_kwarg() + def set_track_position(self, seconds, source_message: Optional[Message] = None): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + source_message: bus message that triggered this action + """ + self.bus.emit(source_message.forward('mycroft.audio.service.set_track_position', + {"position": seconds * 1000})) # convert to ms + + @_ensure_message_kwarg() + def seek(self, seconds: Union[int, float, timedelta] = 1, + source_message: Optional[Message] = None): + """Seek X seconds. + + Args: + seconds (int): number of seconds to seek, if negative rewind + source_message: bus message that triggered this action + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + if seconds < 0: + self.seek_backward(abs(seconds), source_message=source_message) + else: + self.seek_forward(seconds, source_message=source_message) + + @_ensure_message_kwarg() + def seek_forward(self, seconds: Union[int, float, timedelta] = 1, + source_message: Optional[Message] = None): + """Skip ahead X seconds. + + Args: + seconds (int): number of seconds to skip + source_message: bus message that triggered this action + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(source_message.forward('mycroft.audio.service.seek_forward', + {"seconds": seconds})) + + @_ensure_message_kwarg() + def seek_backward(self, seconds: Union[int, float, timedelta] = 1, source_message: Optional[Message] = None): + """Rewind X seconds + + Args: + seconds (int): number of seconds to rewind + source_message: bus message that triggered this action + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(source_message.forward('mycroft.audio.service.seek_backward', + {"seconds": seconds})) + + @_ensure_message_kwarg() + def track_info(self, source_message: Optional[Message] = None): + """Request information of current playing track. + Args: + source_message: bus message that triggered this action + + Returns: + Dict with track info. + """ + info = self.bus.wait_for_response( + source_message.forward('mycroft.audio.service.track_info'), + reply_type='mycroft.audio.service.track_info_reply', + timeout=1) + return info.data if info else {} + + @_ensure_message_kwarg() + def available_backends(self, source_message: Optional[Message] = None): + """Return available audio backends. + Args: + source_message: bus message that triggered this action + + Returns: + dict with backend names as keys + """ + m = source_message.forward('mycroft.audio.service.list_backends') + response = self.bus.wait_for_response(m) + return response.data if response else {} + + @property + def is_playing(self): + """True if the audioservice is playing, else False.""" + return self.track_info() != {} + + +class OCPInterface: + """bus api interface for OCP subsystem + Args: + bus: OpenVoiceOS messagebus connection + """ + + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() + + # OCP bus api + @staticmethod + def norm_tracks(tracks: list): + try: + from ovos_utils.ocp import Playlist, MediaEntry, PluginStream, dict2entry + except ImportError as e: + raise RuntimeError("This method requires ovos-utils ~=0.1") from e + + """ensures a list of tracks contains only MediaEntry or Playlist items""" + assert isinstance(tracks, list) + # support Playlist and MediaEntry objects in tracks + for idx, track in enumerate(tracks): + if isinstance(track, dict): + tracks[idx] = dict2entry(track) + if isinstance(track, PluginStream): + # TODO - this method will be deprecated + # once all SEI parsers can handle the new objects + # this module can serialize them just fine, + # but we dont know who is listening + tracks[idx] = track.as_media_entry + elif isinstance(track, list) and not isinstance(track, Playlist): + tracks[idx] = OCPInterface.norm_tracks(track) + elif not isinstance(track, (Playlist, MediaEntry)): + # TODO - support string uris + # let it fail in next assert + # log all bad entries before failing + LOG.error(f"Bad track, invalid type: {track}") + assert all(isinstance(t, (MediaEntry, Playlist, PluginStream)) for t in tracks) + return tracks + + @_ensure_message_kwarg() + def queue(self, tracks: list, source_message: Optional[Message] = None): + """Queue up a track to OCP playing playlist. + + Args: + tracks: track dict or list of track dicts (OCP result style) + source_message: bus message that triggered this action + """ + tracks = self.norm_tracks(tracks) + self.bus.emit(source_message.forward('ovos.common_play.playlist.queue', + {'tracks': tracks})) + + @_ensure_message_kwarg() + def play(self, tracks: list, utterance=None, source_message: Optional[Message] = None): + """Start playback. + Args: + tracks: track dict or list of track dicts (OCP result style) + utterance: forward utterance for further processing by OCP + source_message: bus message that triggered this action + """ + tracks = self.norm_tracks(tracks) + + utterance = utterance or '' + tracks = [t.as_dict for t in tracks] + self.bus.emit(source_message.forward('ovos.common_play.play', + {"media": tracks[0], + "playlist": tracks, + "utterance": utterance})) + + @_ensure_message_kwarg() + def stop(self, source_message: Optional[Message] = None): + """Stop the track. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward("ovos.common_play.stop")) + + @_ensure_message_kwarg() + def next(self, source_message: Optional[Message] = None): + """Change to next track. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward("ovos.common_play.next")) + + @_ensure_message_kwarg() + def prev(self, source_message: Optional[Message] = None): + """Change to previous track. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward("ovos.common_play.previous")) + + @_ensure_message_kwarg() + def pause(self, source_message: Optional[Message] = None): + """Pause playback. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward("ovos.common_play.pause")) + + @_ensure_message_kwarg() + def resume(self, source_message: Optional[Message] = None): + """Resume paused playback. + Args: + source_message: bus message that triggered this action""" + self.bus.emit(source_message.forward("ovos.common_play.resume")) + + @_ensure_message_kwarg() + def seek_forward(self, seconds=1, source_message: Optional[Message] = None): + """Skip ahead X seconds. + Args: + seconds (int): number of seconds to skip + source_message: bus message that triggered this action + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(source_message.forward('ovos.common_play.seek', + {"seconds": seconds})) + + @_ensure_message_kwarg() + def seek_backward(self, seconds=1, source_message: Optional[Message] = None): + """Rewind X seconds + Args: + seconds (int): number of seconds to rewind + source_message: bus message that triggered this action + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(source_message.forward('ovos.common_play.seek', + {"seconds": seconds * -1})) + + @_ensure_message_kwarg() + def get_track_length(self, source_message: Optional[Message] = None): + """ + getting the duration of the audio in miliseconds + Args: + source_message: bus message that triggered this action + """ + length = 0 + msg = source_message.forward('ovos.common_play.get_track_length') + info = self.bus.wait_for_response(msg, timeout=1) + if info: + length = info.data.get("length", 0) + return length + + @_ensure_message_kwarg() + def get_track_position(self, source_message: Optional[Message] = None): + """ + get current position in miliseconds + Args: + source_message: bus message that triggered this action + """ + pos = 0 + msg = source_message.forward('ovos.common_play.get_track_position') + info = self.bus.wait_for_response(msg, timeout=1) + if info: + pos = info.data.get("position", 0) + return pos + + @_ensure_message_kwarg() + def set_track_position(self, miliseconds, source_message: Optional[Message] = None): + """Go to X position. + Arguments: + miliseconds (int): position to go to in miliseconds + source_message: bus message that triggered this action + """ + self.bus.emit(source_message.forward('ovos.common_play.set_track_position', + {"position": miliseconds})) + + @_ensure_message_kwarg() + def track_info(self, source_message: Optional[Message] = None): + """Request information of current playing track. + Args: + source_message: bus message that triggered this action + Returns: + Dict with track info. + """ + msg = source_message.forward('ovos.common_play.track_info') + response = self.bus.wait_for_response(msg) + return response.data if response else {} + + @_ensure_message_kwarg() + def available_backends(self, source_message: Optional[Message] = None): + """Return available audio backends. + Args: + source_message: bus message that triggered this action + Returns: + dict with backend names as keys + """ + msg = source_message.forward('ovos.common_play.list_backends') + response = self.bus.wait_for_response(msg) + return response.data if response else {} + + +class OCPQuery: + try: + from ovos_utils.ocp import MediaType + cast2audio = [ + MediaType.MUSIC, + MediaType.PODCAST, + MediaType.AUDIOBOOK, + MediaType.RADIO, + MediaType.RADIO_THEATRE, + MediaType.VISUAL_STORY, + MediaType.NEWS + ] + except ImportError as e: + from enum import IntEnum + + class MediaType(IntEnum): + GENERIC = 0 # nothing else matches + + cast2audio = None + + def __init__(self, query, bus, media_type=MediaType.GENERIC, config=None): + if self.cast2audio is None: + raise RuntimeError("This class requires ovos-utils ~=0.1") + LOG.debug(f"Created {media_type.name} query: {query}") + self.query = query + self.media_type = media_type + self.bus = bus + self.config = config or {} + self.reset() + + def reset(self): + try: + from ovos_utils.ocp import PlaybackMode + except ImportError as e: + raise RuntimeError("This method requires ovos-utils ~=0.1") from e + self.active_skills = {} + self.active_skills_lock = Lock() + self.query_replies = [] + self.searching = False + self.search_start = 0 + self.query_timeouts = self.config.get("min_timeout", 5) + if self.config.get("playback_mode") in [PlaybackMode.AUDIO_ONLY]: + self.has_gui = False + else: + self.has_gui = is_gui_running() or is_gui_connected(self.bus) + + @_ensure_message_kwarg() + def send(self, skill_id: str = None, source_message: Optional[Message] = None): + self.query_replies = [] + self.query_timeouts = self.config.get("min_timeout", 5) + self.search_start = time.time() + self.searching = True + self.register_events() + if skill_id: + self.bus.emit(source_message.forward(f'ovos.common_play.query.{skill_id}', + {"phrase": self.query, + "question_type": self.media_type})) + else: + self.bus.emit(source_message.forward('ovos.common_play.query', + {"phrase": self.query, + "question_type": self.media_type})) + + def wait(self): + try: + from ovos_utils.ocp import MediaType + except ImportError as e: + raise RuntimeError("This method requires ovos-utils ~=0.1") from e + # if there is no match type defined, lets increase timeout a bit + # since all skills need to search + if self.media_type == MediaType.GENERIC: + timeout = self.config.get("max_timeout", 15) + 3 # timeout bonus + else: + timeout = self.config.get("max_timeout", 15) + while self.searching and time.time() - self.search_start <= timeout: + time.sleep(0.1) + self.searching = False + self.remove_events() + + @property + def results(self) -> List[dict]: + return [s for s in self.query_replies if s.get("results")] + + def register_events(self): + LOG.debug("Registering Search Bus Events") + self.bus.on("ovos.common_play.skill.search_start", self.handle_skill_search_start) + self.bus.on("ovos.common_play.skill.search_end", self.handle_skill_search_end) + self.bus.on("ovos.common_play.query.response", self.handle_skill_response) + + def remove_events(self): + LOG.debug("Removing Search Bus Events") + self.bus.remove_all_listeners("ovos.common_play.skill.search_start") + self.bus.remove_all_listeners("ovos.common_play.skill.search_end") + self.bus.remove_all_listeners("ovos.common_play.query.response") + + def handle_skill_search_start(self, message): + skill_id = message.data["skill_id"] + LOG.debug(f"{message.data['skill_id']} is searching") + with self.active_skills_lock: + if skill_id not in self.active_skills: + self.active_skills[skill_id] = Lock() + + def handle_skill_response(self, message): + search_phrase = message.data["phrase"] + if search_phrase != self.query: + # not an answer for this search query + return + timeout = message.data.get("timeout") + skill_id = message.data['skill_id'] + # LOG.debug(f"OVOSCommonPlay result: {skill_id}") + + # in case this handler fires before the search start handler + with self.active_skills_lock: + if skill_id not in self.active_skills: + self.active_skills[skill_id] = Lock() + with self.active_skills[skill_id]: + if message.data.get("searching"): + # extend the timeout by N seconds + if timeout and self.config.get("allow_extensions", True): + self.query_timeouts += timeout + # else -> expired search + + else: + # Collect replies until the timeout + if not self.searching and not len(self.query_replies): + LOG.debug(" too late!! ignored in track selection process") + LOG.warning(f"{skill_id} is not answering fast enough!") + return + + # populate search playlist + res = message.data.get("results", []) + LOG.debug(f'got {len(res)} results from {skill_id}') + if res: + self.query_replies.append(message.data) + + # abort searching if we gathered enough results + # TODO ensure we have a decent confidence match, if all matches + # are < 50% conf extend timeout instead + if time.time() - self.search_start > self.query_timeouts: + if self.searching: + self.searching = False + LOG.debug("common play query timeout, parsing results") + + elif self.searching: + for res in message.data.get("results", []): + if res.get("match_confidence", 0) >= \ + self.config.get("early_stop_thresh", 85): + # got a really good match, dont search further + LOG.info( + "Receiving very high confidence match, stopping " + "search early") + + # allow other skills to "just miss" + early_stop_grace = \ + self.config.get("early_stop_grace_period", 0.5) + if early_stop_grace: + LOG.debug( + f" - grace period: {early_stop_grace} seconds") + time.sleep(early_stop_grace) + self.searching = False + return + + def handle_skill_search_end(self, message): + skill_id = message.data["skill_id"] + LOG.debug(f"{message.data['skill_id']} finished search") + with self.active_skills_lock: + if skill_id in self.active_skills: + with self.active_skills[skill_id]: + del self.active_skills[skill_id] + + # if this was the last skill end searching period + time.sleep(0.5) + # TODO this sleep is hacky, but avoids a race condition in + # case some skill just decides to respond before the others even + # acknowledge search is starting, this gives more than enough time + # for self.active_skills to be populated, a better approach should + # be employed but this works fine for now + if not self.active_skills and self.searching: + LOG.info("Received search responses from all skills!") + self.searching = False + + +########################################################## +# WIP ZONE - APIs below used for ovos-media + + +class OCPAudioServiceInterface: + """Internal OCP audio subsystem + most likely you should use OCPInterface instead + NOTE: this class operates with uris not with MediaEntry/Playlist/dict entries + """ + + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() def play(self, tracks=None, utterance=None, repeat=None): """Start playback. @@ -94,30 +696,30 @@ def play(self, tracks=None, utterance=None, repeat=None): elif not isinstance(tracks, list): raise ValueError tracks = [ensure_uri(t) for t in tracks] - self.bus.emit(Message('mycroft.audio.service.play', + self.bus.emit(Message('ovos.audio.service.play', data={'tracks': tracks, 'utterance': utterance, 'repeat': repeat})) def stop(self): """Stop the track.""" - self.bus.emit(Message('mycroft.audio.service.stop')) + self.bus.emit(Message('ovos.audio.service.stop')) def next(self): """Change to next track.""" - self.bus.emit(Message('mycroft.audio.service.next')) + self.bus.emit(Message('ovos.audio.service.next')) def prev(self): """Change to previous track.""" - self.bus.emit(Message('mycroft.audio.service.prev')) + self.bus.emit(Message('ovos.audio.service.prev')) def pause(self): """Pause playback.""" - self.bus.emit(Message('mycroft.audio.service.pause')) + self.bus.emit(Message('ovos.audio.service.pause')) def resume(self): """Resume paused playback.""" - self.bus.emit(Message('mycroft.audio.service.resume')) + self.bus.emit(Message('ovos.audio.service.resume')) def get_track_length(self): """ @@ -125,7 +727,7 @@ def get_track_length(self): """ length = 0 info = self.bus.wait_for_response( - Message('mycroft.audio.service.get_track_length'), + Message('ovos.audio.service.get_track_length'), timeout=1) if info: length = info.data.get("length") or 0 @@ -137,7 +739,7 @@ def get_track_position(self): """ pos = 0 info = self.bus.wait_for_response( - Message('mycroft.audio.service.get_track_position'), + Message('ovos.audio.service.get_track_position'), timeout=1) if info: pos = info.data.get("position") or 0 @@ -149,10 +751,10 @@ def set_track_position(self, seconds): Arguments: seconds (int): number of seconds to seek, if negative rewind """ - self.bus.emit(Message('mycroft.audio.service.set_track_position', + self.bus.emit(Message('ovos.audio.service.set_track_position', {"position": seconds * 1000})) # convert to ms - def seek(self, seconds=1): + def seek(self, seconds: Union[int, float, timedelta] = 1): """Seek X seconds. Args: @@ -165,7 +767,7 @@ def seek(self, seconds=1): else: self.seek_forward(seconds) - def seek_forward(self, seconds=1): + def seek_forward(self, seconds: Union[int, float, timedelta] = 1): """Skip ahead X seconds. Args: @@ -173,10 +775,10 @@ def seek_forward(self, seconds=1): """ if isinstance(seconds, timedelta): seconds = seconds.total_seconds() - self.bus.emit(Message('mycroft.audio.service.seek_forward', + self.bus.emit(Message('ovos.audio.service.seek_forward', {"seconds": seconds})) - def seek_backward(self, seconds=1): + def seek_backward(self, seconds: Union[int, float, timedelta] = 1): """Rewind X seconds Args: @@ -184,7 +786,7 @@ def seek_backward(self, seconds=1): """ if isinstance(seconds, timedelta): seconds = seconds.total_seconds() - self.bus.emit(Message('mycroft.audio.service.seek_backward', + self.bus.emit(Message('ovos.audio.service.seek_backward', {"seconds": seconds})) def track_info(self): @@ -194,8 +796,8 @@ def track_info(self): Dict with track info. """ info = self.bus.wait_for_response( - Message('mycroft.audio.service.track_info'), - reply_type='mycroft.audio.service.track_info_reply', + Message('ovos.audio.service.track_info'), + reply_type='ovos.audio.service.track_info_reply', timeout=1) return info.data if info else {} @@ -205,7 +807,7 @@ def available_backends(self): Returns: dict with backend names as keys """ - msg = Message('mycroft.audio.service.list_backends') + msg = Message('ovos.audio.service.list_backends') response = self.bus.wait_for_response(msg) return response.data if response else {} @@ -215,58 +817,299 @@ def is_playing(self): return self.track_info() != {} -class OCPInterface: - """bus api interface for OCP subsystem - Args: - bus: Mycroft messagebus connection +class OCPVideoServiceInterface: + """Internal OCP video subsystem + most likely you should use OCPInterface instead + NOTE: this class operates with uris not with MediaEntry/Playlist/dict entries """ def __init__(self, bus=None): self.bus = bus or get_mycroft_bus() - def _format_msg(self, msg_type, msg_data=None): - # this method ensures all skills are .forward from the utterance - # that triggered the skill, this ensures proper routing and metadata - msg_data = msg_data or {} - msg = dig_for_message() - if msg: - msg = msg.forward(msg_type, msg_data) + def play(self, tracks=None, utterance=None, repeat=None): + """Start playback. + + Args: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + utterance: forward utterance for further processing by the + video service. + repeat: if the playback should be looped + """ + repeat = repeat or False + tracks = tracks or [] + utterance = utterance or '' + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(Message('ovos.video.service.play', + data={'tracks': tracks, + 'utterance': utterance, + 'repeat': repeat})) + + def stop(self): + """Stop the track.""" + self.bus.emit(Message('ovos.video.service.stop')) + + def next(self): + """Change to next track.""" + self.bus.emit(Message('ovos.video.service.next')) + + def prev(self): + """Change to previous track.""" + self.bus.emit(Message('ovos.video.service.prev')) + + def pause(self): + """Pause playback.""" + self.bus.emit(Message('ovos.video.service.pause')) + + def resume(self): + """Resume paused playback.""" + self.bus.emit(Message('ovos.video.service.resume')) + + def get_track_length(self): + """ + getting the duration of the video in seconds + """ + length = 0 + info = self.bus.wait_for_response( + Message('ovos.video.service.get_track_length'), + timeout=1) + if info: + length = info.data.get("length") or 0 + return length / 1000 # convert to seconds + + def get_track_position(self): + """ + get current position in seconds + """ + pos = 0 + info = self.bus.wait_for_response( + Message('ovos.video.service.get_track_position'), + timeout=1) + if info: + pos = info.data.get("position") or 0 + return pos / 1000 # convert to seconds + + def set_track_position(self, seconds): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + """ + self.bus.emit(Message('ovos.video.service.set_track_position', + {"position": seconds * 1000})) # convert to ms + + def seek(self, seconds=1): + """Seek X seconds. + + Args: + seconds (int): number of seconds to seek, if negative rewind + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + if seconds < 0: + self.seek_backward(abs(seconds)) else: - msg = Message(msg_type, msg_data) - # at this stage source == skills, lets indicate audio service took over - sauce = msg.context.get("source") - if sauce == "skills": - msg.context["source"] = "audio_service" - return msg + self.seek_forward(seconds) - # OCP bus api - def queue(self, tracks): - """Queue up a track to OCP playing playlist. + def seek_forward(self, seconds: Union[int, float, timedelta] = 1): + """Skip ahead X seconds. Args: - tracks: track dict or list of track dicts (OCP result style) + seconds (int): number of seconds to skip """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(Message('ovos.video.service.seek_forward', + {"seconds": seconds})) - assert isinstance(tracks, list) - assert all(isinstance(t, dict) for t in tracks) + def seek_backward(self, seconds: Union[int, float, timedelta] = 1): + """Rewind X seconds + + Args: + seconds (int): number of seconds to rewind + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(Message('ovos.video.service.seek_backward', + {"seconds": seconds})) + + def track_info(self): + """Request information of current playing track. + + Returns: + Dict with track info. + """ + info = self.bus.wait_for_response( + Message('ovos.video.service.track_info'), + reply_type='ovos.video.service.track_info_reply', + timeout=1) + return info.data if info else {} + + def available_backends(self): + """Return available video backends. + + Returns: + dict with backend names as keys + """ + msg = Message('ovos.video.service.list_backends') + response = self.bus.wait_for_response(msg) + return response.data if response else {} + + @property + def is_playing(self): + """True if the videoservice is playing, else False.""" + return self.track_info() != {} + + +class OCPWebServiceInterface: + """Internal OCP web view subsystem + most likely you should use OCPInterface instead + NOTE: this class operates with uris not with MediaEntry/Playlist/dict entries + """ - msg = self._format_msg('ovos.common_play.playlist.queue', - {'tracks': tracks}) - self.bus.emit(msg) + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() - def play(self, tracks, utterance=None): + def play(self, tracks=None, utterance=None, repeat=None): """Start playback. + Args: - tracks: track dict or list of track dicts (OCP result style) - utterance: forward utterance for further processing by OCP + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + utterance: forward utterance for further processing by the + web service. + repeat: if the playback should be looped """ - assert isinstance(tracks, list) - assert all(isinstance(t, dict) for t in tracks) - + repeat = repeat or False + tracks = tracks or [] utterance = utterance or '' + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(Message('ovos.web.service.play', + data={'tracks': tracks, + 'utterance': utterance, + 'repeat': repeat})) + + def stop(self): + """Stop the track.""" + self.bus.emit(Message('ovos.web.service.stop')) + + def next(self): + """Change to next track.""" + self.bus.emit(Message('ovos.web.service.next')) + + def prev(self): + """Change to previous track.""" + self.bus.emit(Message('ovos.web.service.prev')) + + def pause(self): + """Pause playback.""" + self.bus.emit(Message('ovos.web.service.pause')) + + def resume(self): + """Resume paused playback.""" + self.bus.emit(Message('ovos.web.service.resume')) + + def get_track_length(self): + """ + getting the duration of the web in seconds + """ + length = 0 + info = self.bus.wait_for_response( + Message('ovos.web.service.get_track_length'), + timeout=1) + if info: + length = info.data.get("length") or 0 + return length / 1000 # convert to seconds + + def get_track_position(self): + """ + get current position in seconds + """ + pos = 0 + info = self.bus.wait_for_response( + Message('ovos.web.service.get_track_position'), + timeout=1) + if info: + pos = info.data.get("position") or 0 + return pos / 1000 # convert to seconds - msg = self._format_msg('ovos.common_play.play', - {"media": tracks[0], - "playlist": tracks, - "utterance": utterance}) - self.bus.emit(msg) + def set_track_position(self, seconds): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + """ + self.bus.emit(Message('ovos.web.service.set_track_position', + {"position": seconds * 1000})) # convert to ms + + def seek(self, seconds: Union[int, float, timedelta] = 1): + """Seek X seconds. + + Args: + seconds (int): number of seconds to seek, if negative rewind + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + if seconds < 0: + self.seek_backward(abs(seconds)) + else: + self.seek_forward(seconds) + + def seek_forward(self, seconds: Union[int, float, timedelta] = 1): + """Skip ahead X seconds. + + Args: + seconds (int): number of seconds to skip + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(Message('ovos.web.service.seek_forward', + {"seconds": seconds})) + + def seek_backward(self, seconds: Union[int, float, timedelta] = 1): + """Rewind X seconds + + Args: + seconds (int): number of seconds to rewind + """ + if isinstance(seconds, timedelta): + seconds = seconds.total_seconds() + self.bus.emit(Message('ovos.web.service.seek_backward', + {"seconds": seconds})) + + def track_info(self): + """Request information of current playing track. + + Returns: + Dict with track info. + """ + info = self.bus.wait_for_response( + Message('ovos.web.service.track_info'), + reply_type='ovos.web.service.track_info_reply', + timeout=1) + return info.data if info else {} + + def available_backends(self): + """Return available web backends. + + Returns: + dict with backend names as keys + """ + msg = Message('ovos.web.service.list_backends') + response = self.bus.wait_for_response(msg) + return response.data if response else {} + + @property + def is_playing(self): + """True if the webservice is playing, else False.""" + return self.track_info() != {} diff --git a/ovos_bus_client/client/client.py b/ovos_bus_client/client/client.py index 9747fa1..eea4ca1 100644 --- a/ovos_bus_client/client/client.py +++ b/ovos_bus_client/client/client.py @@ -1,4 +1,4 @@ -import json +import orjson import time import traceback from os import getpid @@ -116,11 +116,16 @@ def on_error(self, *args): LOG.warning('Could not send message because connection has closed') elif isinstance(error, ConnectionRefusedError): LOG.warning('Connection Refused. Is Messagebus Service running?') + elif isinstance(error, ConnectionResetError): + LOG.warning('Connection Reset. Did the Messagebus Service stop?') else: LOG.exception('=== %s ===', repr(error)) + try: + self.emitter.emit('error', error) + except Exception as e: + LOG.exception(f'Failed to emit error event: {e}') try: - self.emitter.emit('error', error) if self.client.keep_running: self.client.close() except Exception as e: @@ -184,7 +189,7 @@ def emit(self, message: Message): if hasattr(message, 'serialize'): msg = message.serialize() else: - msg = json.dumps(message.__dict__) + msg = orjson.dumps(message.__dict__).decode("utf-8") try: self.client.send(msg) except WebSocketConnectionClosedException: @@ -335,7 +340,7 @@ def _remove_normal(self, event_name, func): if event_name not in self.emitter._events: LOG.debug("Not able to find '%s'", event_name) self.emitter.remove_listener(event_name, func) - except ValueError: + except (ValueError, KeyError): LOG.warning('Failed to remove event %s: %s', event_name, str(func)) for line in traceback.format_stack(): @@ -417,7 +422,7 @@ def emit(self, message: GUIMessage): if hasattr(message, 'serialize'): self.client.send(message.serialize()) else: - self.client.send(json.dumps(message.__dict__)) + self.client.send(orjson.dumps(message.__dict__).decode("utf-8")) except WebSocketConnectionClosedException: LOG.warning('Could not send %s message because connection ' 'has been closed', message.msg_type) diff --git a/ovos_bus_client/message.py b/ovos_bus_client/message.py index ef3e824..6afa7fa 100644 --- a/ovos_bus_client/message.py +++ b/ovos_bus_client/message.py @@ -21,7 +21,7 @@ """ import inspect -import json +import orjson import re from copy import deepcopy from typing import Optional @@ -118,15 +118,15 @@ def serialize(self) -> str: data = self._json_dump(self.data) ctxt = self._json_dump(self.context) - msg = json.dumps({'type': self.msg_type, 'data': data, 'context': ctxt}) + msg = orjson.dumps({'type': self.msg_type, 'data': data, 'context': ctxt}).decode("utf-8") if self._secret_key: payload = encrypt_as_dict(self._secret_key, msg) - return json.dumps(payload) + return orjson.dumps(payload).decode("utf-8") return msg @property def as_dict(self) -> dict: - return json.loads(self.serialize()) + return orjson.loads(self.serialize()) @staticmethod def _json_dump(value): @@ -153,7 +153,7 @@ def serialize_item(x): @staticmethod def _json_load(value): if isinstance(value, str): - obj = json.loads(value) + obj = orjson.loads(value) else: obj = value assert isinstance(obj, dict) @@ -446,10 +446,10 @@ def serialize(self) -> str: str: a json string representation of the message. """ data = self._json_dump(self.data) - msg = json.dumps({'type': self.msg_type, **data}) + msg = orjson.dumps({'type': self.msg_type, **data}).decode("utf-8") if self._secret_key: payload = encrypt_as_dict(self._secret_key, msg) - return json.dumps(payload) + return orjson.dumps(payload).decode("utf-8") return msg @staticmethod diff --git a/ovos_bus_client/scripts.py b/ovos_bus_client/scripts.py index 997d489..6a12937 100644 --- a/ovos_bus_client/scripts.py +++ b/ovos_bus_client/scripts.py @@ -4,6 +4,7 @@ from ovos_bus_client import MessageBusClient, Message from ovos_config import Configuration import sys +import time def ovos_speak(): @@ -22,6 +23,7 @@ def ovos_speak(): if not client.connected_event.is_set(): client.connected_event.wait() client.emit(Message("speak", {"utterance": utt, "lang": lang})) + time.sleep(0.5) # avoids crash in c++ bus server client.close() @@ -41,6 +43,7 @@ def ovos_say_to(): if not client.connected_event.is_set(): client.connected_event.wait() client.emit(Message("recognizer_loop:utterance", {"utterances": [utt], "lang": lang})) + time.sleep(0.5) # avoids crash in c++ bus server client.close() @@ -50,6 +53,7 @@ def ovos_listen(): if not client.connected_event.is_set(): client.connected_event.wait() client.emit(Message("mycroft.mic.listen")) + time.sleep(0.5) # avoids crash in c++ bus server client.close() @@ -80,6 +84,7 @@ def simple_cli(): client.emit(Message("recognizer_loop:utterance", {"utterances": [utt], "lang": lang}, {"session": sess.serialize()})) + time.sleep(0.5) # avoids crash in c++ bus server except KeyboardInterrupt: break @@ -87,4 +92,4 @@ def simple_cli(): if __name__ == "__main__": - simple_cli() \ No newline at end of file + simple_cli() diff --git a/ovos_bus_client/session.py b/ovos_bus_client/session.py index beca8d6..163d0f3 100644 --- a/ovos_bus_client/session.py +++ b/ovos_bus_client/session.py @@ -1,12 +1,12 @@ import enum import time -from threading import Lock +from threading import Lock, Event from typing import Optional, List, Tuple, Union, Iterable, Dict from uuid import uuid4 from ovos_config.config import Configuration from ovos_config.locale import get_default_lang -from ovos_utils.log import LOG +from ovos_utils.log import LOG, log_deprecation from ovos_bus_client.message import dig_for_message, Message @@ -261,14 +261,24 @@ def get_context(self, max_frames: int = None, class Session: - def __init__(self, session_id: str = None, expiration_seconds: int = None, + def __init__(self, session_id: str = None, + expiration_seconds: int = None, active_skills: List[List[Union[str, float]]] = None, - utterance_states: Dict = None, lang: str = None, + utterance_states: Dict = None, + lang: str = None, context: IntentContextManager = None, site_id: str = "unknown", pipeline: List[str] = None, stt_prefs: Dict = None, - tts_prefs: Dict = None): + tts_prefs: Dict = None, + location_prefs: Dict = None, + system_unit: str = None, + time_format: str = None, + date_format: str = None, + is_speaking: bool = False, + is_recording: bool = False, + blacklisted_intents: Optional[List[str]] = None, + blacklisted_skills: Optional[List[str]] = None): """ Construct a session identifier @param session_id: string UUID for the session @@ -278,11 +288,23 @@ def __init__(self, session_id: str = None, expiration_seconds: int = None, @param lang: language associated with this Session @param context: IntentContextManager for this Session """ + if tts_prefs: + log_deprecation("'tts_prefs' kwarg has been deprecated! value will be ignored", "0.1.0") + if stt_prefs: + log_deprecation("'stt_prefs' kwarg has been deprecated! value will be ignored", "0.1.0") self.session_id = session_id or str(uuid4()) - + self.blacklisted_skills = (blacklisted_skills or + Configuration().get("skills", {}).get("blacklisted_skills", [])) + self.blacklisted_intents = (blacklisted_intents or + Configuration().get("intents", {}).get("blacklisted_intents", [])) self.lang = lang or get_default_lang() + self.system_unit = system_unit or Configuration().get("system_unit", "metric") + self.date_format = date_format or Configuration().get("date_format", "DMY") + self.time_format = time_format or Configuration().get("time_format", "full") - self.site_id = site_id or "unknown" # indoors placement info + self.is_recording = is_recording + self.is_speaking = is_speaking + self.site_id = site_id or Configuration().get("site_id") or "unknown" # indoors placement info self.active_skills = active_skills or [] # [skill_id , timestamp]# (Message , timestamp) self.utterance_states = utterance_states or {} # {skill_id: UtteranceState} @@ -291,31 +313,22 @@ def __init__(self, session_id: str = None, expiration_seconds: int = None, self.expiration_seconds = expiration_seconds or \ Configuration().get('session', {}).get("ttl", -1) self.pipeline = pipeline or Configuration().get('intents', {}).get("pipeline") or [ + "stop_high", "converse", "padatious_high", - "adapt", - "common_qa", + "adapt_high", "fallback_high", + "stop_medium", "padatious_medium", + "adapt_medium", + "adapt_low", + "common_qa", "fallback_medium", - "padatious_low", "fallback_low" ] self.context = context or IntentContextManager() - if not stt_prefs: - stt = Configuration().get("stt", {}) - sttm = stt.get("module", "ovos-stt-plugin-server") - stt_prefs = {"plugin_id": sttm, - "config": stt.get(sttm) or {}} - self.stt_preferences = stt_prefs - - if not tts_prefs: - tts = Configuration().get("tts", {}) - ttsm = tts.get("module", "ovos-tts-plugin-server") - tts_prefs = {"plugin_id": ttsm, - "config": tts.get(ttsm) or {}} - self.tts_preferences = tts_prefs + self.location_preferences = location_prefs or Configuration().get("location", {}) @property def active(self) -> bool: @@ -411,8 +424,14 @@ def serialize(self) -> dict: "context": self.context.serialize(), "site_id": self.site_id, "pipeline": self.pipeline, - "stt": self.stt_preferences, - "tts": self.tts_preferences + "location": self.location_preferences, + "system_unit": self.system_unit, + "time_format": self.time_format, + "date_format": self.date_format, + "is_speaking": self.is_speaking, + "is_recording": self.is_recording, + "blacklisted_skills": self.blacklisted_skills, + "blacklisted_intents": self.blacklisted_intents } def update_history(self, message: Message = None): @@ -437,8 +456,14 @@ def deserialize(data: Dict): context = IntentContextManager.deserialize(data.get("context", {})) site_id = data.get("site_id", "unknown") pipeline = data.get("pipeline", []) - tts = data.get("tts_preferences", {}) - stt = data.get("stt_preferences", {}) + location = data.get("location", {}) + system_unit = data.get("system_unit") + date_format = data.get("date_format") + time_format = data.get("time_format") + is_recording = data.get("is_recording", False) + is_speaking = data.get("is_speaking", False) + blacklisted_skills = data.get("blacklisted_skills", []) + blacklisted_intents = data.get("blacklisted_intents", []) return Session(uid, active_skills=active, utterance_states=states, @@ -446,8 +471,14 @@ def deserialize(data: Dict): context=context, pipeline=pipeline, site_id=site_id, - tts_prefs=tts, - stt_prefs=stt) + location_prefs=location, + system_unit=system_unit, + date_format=date_format, + time_format=time_format, + is_recording=is_recording, + is_speaking=is_speaking, + blacklisted_intents=blacklisted_intents, + blacklisted_skills=blacklisted_skills) @staticmethod def from_message(message: Message = None): @@ -498,14 +529,13 @@ def sync(cls, message=None): @classmethod def connect_to_bus(cls, bus): cls.bus = bus - cls.bus.on("ovos.session.sync", - cls.handle_default_session_request) + cls.bus.on("recognizer_loop:record_begin", cls.handle_recording_start) + cls.bus.on("recognizer_loop:record_end", cls.handle_recording_end) + cls.bus.on("recognizer_loop:audio_output_start", cls.handle_audio_output_start) + cls.bus.on("recognizer_loop:audio_output_end", cls.handle_audio_output_end) + cls.bus.on("ovos.session.sync", cls.handle_default_session_request) cls.sync() - @classmethod - def handle_default_session_request(cls, message=None): - cls.sync(message) - @staticmethod def prune_sessions(): """ @@ -543,7 +573,9 @@ def update(sess: Session, make_default: bool = False): if make_default: sess.session_id = "default" - LOG.debug(f"replacing default session with: {sess.serialize()}") + # this log is dangerous, session may contain things like passwords and access keys + # this comment is here to avoid reintroducing it by accident + # LOG.debug(f"replacing default session with: {sess.serialize()}") # DO NOT re-enable in production if sess.session_id == "default": SessionManager.default_session = sess @@ -583,3 +615,99 @@ def touch(message: Message = None): """ sess = SessionManager.get(message) sess.touch() + + ############################## + # util methods for skill consumption + @classmethod + def is_speaking(cls, session: Session = None): + session = session or SessionManager.get() + return session.is_speaking + + @classmethod + def wait_while_speaking(cls, timeout=15, session: Session = None): + """ wait until audio service reports end of audio output """ + if not cls.bus: + LOG.error("SessionManager not connected to bus, can not monitor speech state") + return + + session = session or SessionManager.get() + if not cls.is_speaking(session): + return + + # wait until end of speech + event = Event() + sessid = session.session_id + + def handle_output_end(msg): + nonlocal sessid, event + sess = SessionManager.get(msg) + if sessid == sess.session_id: + event.set() + + cls.bus.on("recognizer_loop:audio_output_end", handle_output_end) + event.wait(timeout=timeout) + cls.bus.remove("recognizer_loop:audio_output_end", handle_output_end) + + @classmethod + def is_recording(cls, session: Session = None): + session = session or SessionManager.get() + return session.is_recording + + @classmethod + def wait_while_recording(cls, timeout=45, session: Session = None): + """ wait until listener service reports end of recording""" + if not cls.bus: + LOG.error("SessionManager not connected to bus, can not monitor recording state") + return + + session = session or SessionManager.get() + if not cls.is_recording(session): + return + + # wait until end of recording + event = Event() + sessid = session.session_id + + def handle_rec_end(msg): + nonlocal sessid, event + sess = SessionManager.get(msg) + if sessid == sess.session_id: + event.set() + + cls.bus.on("recognizer_loop:record_end", handle_rec_end) + event.wait(timeout=timeout) + cls.bus.remove("recognizer_loop:record_end", handle_rec_end) + + ############################### + # State tracking events + @classmethod + def handle_recording_start(cls, message): + """track when a session is recording audio""" + sess = cls.get(message) + sess.is_recording = True + cls.update(sess) + + @classmethod + def handle_recording_end(cls, message): + """track when a session stops recording audio""" + sess = cls.get(message) + sess.is_recording = False + cls.update(sess) + + @classmethod + def handle_audio_output_start(cls, message): + """track when a session is outputting audio""" + sess = cls.get(message) + sess.is_speaking = True + cls.update(sess) + + @classmethod + def handle_audio_output_end(cls, message): + """track when a session stops outputting audio""" + sess = cls.get(message) + sess.is_speaking = False + cls.update(sess) + + @classmethod + def handle_default_session_request(cls, message=None): + cls.sync(message) diff --git a/ovos_bus_client/util/__init__.py b/ovos_bus_client/util/__init__.py index 9f67b26..5316f83 100644 --- a/ovos_bus_client/util/__init__.py +++ b/ovos_bus_client/util/__init__.py @@ -15,7 +15,7 @@ """ Tools and constructs that are useful together with the messagebus. """ -import json +import orjson from ovos_config.config import read_mycroft_config from ovos_config.locale import get_default_lang @@ -117,7 +117,7 @@ def wait_for_reply(message, reply_type=None, timeout=3.0, bus=None): bus = bus or get_mycroft_bus() if isinstance(message, str): try: - message = json.loads(message) + message = orjson.loads(message) except: pass if isinstance(message, str): @@ -142,7 +142,7 @@ def send_message(message, data=None, context=None, bus=None): message = Message(message, data, context) else: try: - message = json.loads(message) + message = orjson.loads(message) except: message = Message(message) if isinstance(message, dict): @@ -179,7 +179,7 @@ def send_binary_file_message(filepath, msg_type="mycroft.binary.file", def decode_binary_message(message): if isinstance(message, str): try: # json string - message = json.loads(message) + message = orjson.loads(message) binary_data = message.get("binary") or message["data"]["binary"] except: # hex string binary_data = message diff --git a/ovos_bus_client/version.py b/ovos_bus_client/version.py index e157dbf..5bb8a4e 100644 --- a/ovos_bus_client/version.py +++ b/ovos_bus_client/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 0 -VERSION_BUILD = 8 +VERSION_BUILD = 9 VERSION_ALPHA = 0 # END_VERSION_BLOCK diff --git a/requirements.txt b/requirements.txt index 17c6bbc..3f71313 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ ovos-config >= 0.0.12, < 0.2.0 -ovos-utils >= 0.0.37, < 0.2.0 +ovos-utils >= 0.0.38, < 0.2.0 websocket-client>=0.54.0 pyee>=8.1.0, < 9.0.0 +orjson diff --git a/setup.py b/setup.py index 7eb8b4d..24e9718 100644 --- a/setup.py +++ b/setup.py @@ -8,8 +8,7 @@ def required(requirements_file): """ Read requirements file and remove comments and empty lines. """ - base_dir = os.path.abspath(os.path.dirname(__file__)) - with open(os.path.join(base_dir, requirements_file), 'r') as f: + with open(os.path.join(BASEDIR, requirements_file), 'r') as f: requirements = f.read().splitlines() if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ: print('USING LOOSE REQUIREMENTS!') @@ -43,6 +42,9 @@ def get_version(): return version +with open(os.path.join(BASEDIR, "README.md"), "r") as f: + long_description = f.read() + setup( name='ovos-bus-client', version=get_version(), @@ -58,17 +60,16 @@ def get_version(): url='https://github.com/OpenVoiceOS/ovos-bus-client', license='Apache-2.0', author='JarbasAI', - author_email='jarbasai@mailfence.com', + author_email='jarbas@openvoiceos.com', description='OVOS Messagebus Client', + long_description=long_description, + long_description_content_type="text/markdown", classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3', ], entry_points={ 'console_scripts': [