diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..26e59a20 --- /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: "/requirements" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index fc2630fb..85599026 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,7 +23,6 @@ jobs: python -m pip install build wheel - name: Install repo run: | - pip install -r requirements/test.txt pip install -e .[extras] - name: Generate coverage report run: | diff --git a/.github/workflows/publish_alpha.yml b/.github/workflows/publish_alpha.yml index 3c0ef9d1..d17dc6e4 100644 --- a/.github/workflows/publish_alpha.yml +++ b/.github/workflows/publish_alpha.yml @@ -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 53c1a11b..a24f52ef 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -37,7 +37,7 @@ jobs: strategy: max-parallel: 2 matrix: - python-version: [ 3.7, 3.8, 3.9, "3.10" ] + python-version: [ 3.8, 3.9, "3.10" ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -52,7 +52,6 @@ jobs: python -m pip install build wheel - name: Install core repo run: | - pip install -r requirements/test.txt pip install -e .[extras] - name: Install test dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e64070d5..8291a017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,41 +1,142 @@ # Changelog -## [V0.0.37a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37a4) (2023-12-28) +## [Unreleased](https://github.com/OpenVoiceOS/ovos-utils/tree/HEAD) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37a3...V0.0.37a4) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V...HEAD) -**Merged pull requests:** +**Implemented enhancements:** -- deprecate bus utils [\#207](https://github.com/OpenVoiceOS/ovos-utils/pull/207) ([JarbasAl](https://github.com/JarbasAl)) +- feat/ocp\_stream\_utils [\#257](https://github.com/OpenVoiceOS/ovos-utils/pull/257) ([JarbasAl](https://github.com/JarbasAl)) +- new\_util/get\_sound\_duration [\#254](https://github.com/OpenVoiceOS/ovos-utils/pull/254) ([JarbasAl](https://github.com/JarbasAl)) +- Add Damerau-Levenshtein similarity matching [\#248](https://github.com/OpenVoiceOS/ovos-utils/pull/248) ([femelo](https://github.com/femelo)) +- fix/dont\_lie\_about\_being\_a\_uri [\#246](https://github.com/OpenVoiceOS/ovos-utils/pull/246) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.37a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37a3) (2023-12-28) +**Fixed bugs:** -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37a2...V0.0.37a3) +- fix/plugin\_stream\_extraction [\#263](https://github.com/OpenVoiceOS/ovos-utils/pull/263) ([JarbasAl](https://github.com/JarbasAl)) +- fix/playlist\_deserialization [\#262](https://github.com/OpenVoiceOS/ovos-utils/pull/262) ([JarbasAl](https://github.com/JarbasAl)) +- fix/detect\_ovos\_gui\_app [\#258](https://github.com/OpenVoiceOS/ovos-utils/pull/258) ([JarbasAl](https://github.com/JarbasAl)) +- fix/ocp\_playlist [\#256](https://github.com/OpenVoiceOS/ovos-utils/pull/256) ([JarbasAl](https://github.com/JarbasAl)) +- fix/sound\_duration [\#255](https://github.com/OpenVoiceOS/ovos-utils/pull/255) ([JarbasAl](https://github.com/JarbasAl)) +- fix/log\_level\_cfg [\#252](https://github.com/OpenVoiceOS/ovos-utils/pull/252) ([JarbasAl](https://github.com/JarbasAl)) +- fix/log\_spam [\#251](https://github.com/OpenVoiceOS/ovos-utils/pull/251) ([JarbasAl](https://github.com/JarbasAl)) +- feat: mac support for ram cache [\#231](https://github.com/OpenVoiceOS/ovos-utils/pull/231) ([mikejgray](https://github.com/mikejgray)) **Closed issues:** -- decouple concerns from bus/workshop [\#205](https://github.com/OpenVoiceOS/ovos-utils/issues/205) -- ROADMAP - ovos-utils 0.1.0 [\#117](https://github.com/OpenVoiceOS/ovos-utils/issues/117) +- "systemctl restart" not working when using with ovos-phal-plugin-system [\#259](https://github.com/OpenVoiceOS/ovos-utils/issues/259) +- add some trivial unit test coverage that log config changes are reacted to [\#253](https://github.com/OpenVoiceOS/ovos-utils/issues/253) +- log.py - New functions are missing test coverage [\#239](https://github.com/OpenVoiceOS/ovos-utils/issues/239) + +**Merged pull requests:** + +- log module unit test coverage [\#260](https://github.com/OpenVoiceOS/ovos-utils/pull/260) ([NeonDaniel](https://github.com/NeonDaniel)) +- deprecate/signal utils [\#249](https://github.com/OpenVoiceOS/ovos-utils/pull/249) ([JarbasAl](https://github.com/JarbasAl)) + +## [V](https://github.com/OpenVoiceOS/ovos-utils/tree/V) (2024-03-10) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a16...V) **Merged pull requests:** -- LAST ALPHA [\#206](https://github.com/OpenVoiceOS/ovos-utils/pull/206) ([JarbasAl](https://github.com/JarbasAl)) +- chore\(docs\): add a long description to PyPi [\#229](https://github.com/OpenVoiceOS/ovos-utils/pull/229) ([mikejgray](https://github.com/mikejgray)) -## [V0.0.37a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37a2) (2023-12-18) +## [V0.1.0a16](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a16) (2024-03-10) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37a1...V0.0.37a2) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a15...V0.1.0a16) + +**Merged pull requests:** + +- chore\(docs\): rename readme.md to README.md [\#230](https://github.com/OpenVoiceOS/ovos-utils/pull/230) ([mikejgray](https://github.com/mikejgray)) + +## [V0.1.0a15](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a15) (2024-02-18) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a13...V0.1.0a15) + +**Merged pull requests:** + +- Option for systemd-timesyncd [\#200](https://github.com/OpenVoiceOS/ovos-utils/pull/200) ([builderjer](https://github.com/builderjer)) + +## [V0.1.0a13](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a13) (2024-01-30) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a12...V0.1.0a13) + +**Merged pull requests:** + +- Update rapidfuzz requirement from ~=2.0 to ~=3.6 in /requirements [\#224](https://github.com/OpenVoiceOS/ovos-utils/pull/224) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Update pexpect requirement from ~=4.6 to ~=4.9 in /requirements [\#223](https://github.com/OpenVoiceOS/ovos-utils/pull/223) ([dependabot[bot]](https://github.com/apps/dependabot)) + +## [V0.1.0a12](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a12) (2024-01-25) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a11...V0.1.0a12) **Fixed bugs:** -- update imports for py 3.10 compat [\#202](https://github.com/OpenVoiceOS/ovos-utils/pull/202) ([JarbasAl](https://github.com/JarbasAl)) +- fix/ocp\_playlist [\#221](https://github.com/OpenVoiceOS/ovos-utils/pull/221) ([NeonJarbas](https://github.com/NeonJarbas)) -## [V0.0.37a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37a1) (2023-11-08) +## [V0.1.0a11](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a11) (2024-01-25) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36...V0.0.37a1) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a10...V0.1.0a11) **Fixed bugs:** -- elevate sound media role [\#201](https://github.com/OpenVoiceOS/ovos-utils/pull/201) ([emphasize](https://github.com/emphasize)) +- fix/restore deprecated OCP enums compat [\#220](https://github.com/OpenVoiceOS/ovos-utils/pull/220) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.1.0a10](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a10) (2024-01-15) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a9...V0.1.0a10) + +**Merged pull requests:** + +- clarify datetime arg [\#219](https://github.com/OpenVoiceOS/ovos-utils/pull/219) ([emphasize](https://github.com/emphasize)) + +## [V0.1.0a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a9) (2024-01-12) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a8...V0.1.0a9) + +**Implemented enhancements:** + +- OCP serialization [\#218](https://github.com/OpenVoiceOS/ovos-utils/pull/218) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.1.0a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a8) (2024-01-08) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a7...V0.1.0a8) + +**Implemented enhancements:** + +- refactor/ocp\_utils [\#216](https://github.com/OpenVoiceOS/ovos-utils/pull/216) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.1.0a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a7) (2024-01-06) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a6...V0.1.0a7) + +**Merged pull requests:** + +- refactor/ocp\_utils [\#215](https://github.com/OpenVoiceOS/ovos-utils/pull/215) ([NeonJarbas](https://github.com/NeonJarbas)) + +## [V0.1.0a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a6) (2023-12-30) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a5...V0.1.0a6) + +**Implemented enhancements:** + +- Feat/ovos logs script [\#203](https://github.com/OpenVoiceOS/ovos-utils/pull/203) ([emphasize](https://github.com/emphasize)) + +## [V0.1.0a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a5) (2023-12-30) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a4...V0.1.0a5) + +**Merged pull requests:** + +- fix/log\_spam [\#213](https://github.com/OpenVoiceOS/ovos-utils/pull/213) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.1.0a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a4) (2023-12-29) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0a3...V0.1.0a4) + +## [V0.1.0a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0a3) (2023-12-29) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.38...V0.1.0a3) diff --git a/README.md b/README.md new file mode 100644 index 00000000..7dd896e6 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# OVOS-utils + +collection of simple utilities for use across the mycroft ecosystem + +## Install + +```bash +pip install ovos_utils +``` + +## Commandline scripts +### ovos-logs + Small helper tool to quickly navigate the logs, create slices and quickview errors + +--------------- +- **ovos-logs slice [options]** + + **Slice logs of a given time period. Defaults on the last service start (`-s`) until now (`-u`)** + + _Different logs can be picked using the `-l` option. All logs will be included if not specified._ + _Optionally the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) can be specified._ + + + _[ex: `ovos-logs slice`]_ + _Slice all logs from service start up until now._ + + _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25`]_ + _Slice all logs from 17:05:20 until 17:05:25._ + _**no logs in that timeframe in other present logs_ + Screenshot 2023-12-25 185004 + + _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25 -l skills`]_ + _Slice skills.log from 17:05:20 until 17:05:25._ + + _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25 -f ~/testslice.log`]_ + _Slice the logs from 17:05:20 until 17:05:25 on all log files and dump the slices in the file ~/testslice.log (default: `~/slice_.log`)._ + Screenshot 2023-12-25 190732 +-------------- + +- **ovos-logs list [-e|-w|-d|-x] [options]** + + **List logs by severity (error/warning/debug/exception). A log level has to be specified - more than one can be listed** + + _A start and end date can be specified using the `-s` and `-u` options. Defaults to the last service start until now._ + _Different logs can be picked using the `-l` option. All logs will be included if not specified._ + _Optionally, the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) can be passed as arguments._ + + _[ex: `ovos-logs list -x`]_ + _List the logs with level EXCEPTION (plus tracebacks) from the last service start until now._ + Screenshot 2023-12-25 184321 + + _[ex: `ovos-logs list -w -e -s 20-12-2023 -l bus -l skills`]_ + _List the logs with level WARNING and ERROR from the 20th of December 2023 until now from the logs bus.log and skills.log._ + Screenshot 2023-12-25 173739 +--------------------- + +- **ovos-logs reduce [options]** + + **Downsize logs to a given size (in bytes) or remove entries before a given date.** + + _Different logs can be included using the `-l` option. If not specified, all logs will be included._ + _Optionally the directory where the logs are stored (`-p`) can be specified._ + + _[ex: `ovos-logs reduce`]_ + _Downsize all logs to 0 bytes_ + + _[ex: `ovos-logs reduce -s 1000000`]_ + _Downsize all logs to ~1MB (latest logs)_ + + _[ex: `ovos-logs reduce -d "1-12-2023 17:00"`]_ + _Downsize all logs to entries after the specified date/time_ + + _[ex: `ovos-logs reduce -s 1000000 -l skills -l bus`]_ + _Downsize skills.log and bus.log to ~1MB (latest logs)_ + +--------------------- + +- **ovos-logs show -l [servicelog]** + + **Show logs** + + _[ex: `ovos-logs show -l bus`]_ + _Show the logs from bus.log._ + + _[ex: wrong servicelog]_ + _**logs shown depending on the logs present in the folder_ + diff --git a/examples/change_wakeword.py b/examples/change_wakeword.py deleted file mode 100644 index c8e1d113..00000000 --- a/examples/change_wakeword.py +++ /dev/null @@ -1,26 +0,0 @@ -from ovos_utils.configuration import update_mycroft_config -from ovos_utils.lang.phonemes import get_phonemes - - -def create_wakeword(word, sensitivity): - # sensitivity is a bitch to do automatically - # IDEA make some web ui or whatever to tweak it experimentally - phonemes = get_phonemes(word) - config = { - "listener": { - "wake_word": word - }, - word: { - "andromeda": { - "module": "pocketsphinx", - "phonemes": phonemes, - "sample_rate": 16000, - "threshold": sensitivity, - "lang": "en-us" - } - } - } - update_mycroft_config(config) - - -create_wakeword("andromeda", "1e-25") \ No newline at end of file diff --git a/examples/core_status.py b/examples/core_status.py deleted file mode 100644 index 00423e31..00000000 --- a/examples/core_status.py +++ /dev/null @@ -1,10 +0,0 @@ -from ovos_utils.skills import skills_loaded -from time import sleep - -loaded = False -while not loaded: - loaded = skills_loaded() - sleep(0.5) - -print("Skills all loaded!") - diff --git a/examples/count_utterances.py b/examples/count_utterances.py deleted file mode 100644 index 8275326f..00000000 --- a/examples/count_utterances.py +++ /dev/null @@ -1,29 +0,0 @@ -from ovos_utils.messagebus import listen_for_message -from ovos_utils.log import LOG -from ovos_utils import wait_for_exit_signal - -heard = 0 -spoken = 0 - - -def handle_speak(message): - global spoken - spoken += 1 - LOG.info("Mycroft spoke {n} sentences since start".format(n=spoken)) - - -def handle_hear(message): - global heard - heard += 1 - LOG.info("Mycroft responded to {n} sentences since start".format(n=heard)) - - -bus = listen_for_message("speak", handle_speak) -listen_for_message("recognize_loop:utterance", handle_hear, bus=bus) # re utilize bus - -wait_for_exit_signal() # wait for ctrl+c - -# cleanup is a good practice! -bus.remove_all_listeners("speak") -bus.remove_all_listeners("recognize_loop:utterance") -bus.close() diff --git a/examples/fuzzywuzzy_intent_engine.py b/examples/fuzzywuzzy_intent_engine.py deleted file mode 100644 index dd0d9d11..00000000 --- a/examples/fuzzywuzzy_intent_engine.py +++ /dev/null @@ -1,83 +0,0 @@ -from ovos_utils.intents.engines import BaseIntentEngine -from ovos_utils.skills.intent_provider import IntentEngineSkill - -from mycroft.configuration.config import Configuration -from mycroft.skills.core import MycroftSkill, Message - -from fuzzywuzzy import process - - -class FuzzyEngine(BaseIntentEngine): - def __init__(self): - self.name = "fuzzy" - BaseIntentEngine.__init__(self, self.name) - self.config = Configuration.get().get(self.name, {}) - - def calc_intent(self, query): - """ return best intent for this query """ - data = {"conf": 0, - "utterance": query, - "name": None} - - best_match = None - best_score = 0 - for intent in self.intent_samples: - samples = self.intent_samples[intent] - match, score = process.extractOne(query, samples) - if score > best_score: - best_score = score - best_match = (intent, match) - - if best_match is not None: - intent, match = best_match - data["name"] = intent - data["match"] = match - data["conf"] = best_score - - entities = [] - for entity in self.entity_samples: - samples = self.entity_samples - for s in samples: - if s in query: - entities.append((entity, s)) - - data["entities"] = [{entity: s} for entity, s in entities] - return data - - -# engine skill for mycroft -class FuzzyEngineSkill(IntentEngineSkill): - def initialize(self): - priority = 5 - engine = FuzzyEngine() - self.bind_engine(engine, priority) - - -class FuzzySkill(MycroftSkill): - def register_fuzzy_intent(self, name, samples, handler): - message = "fuzzy:register_intent" - name = str(self.skill_id) + ':' + name - data = {"name": name, "samples": samples} - - self.bus.emit(Message(message, data)) - self.add_event(name, handler, 'mycroft.skill.handler') - - def register_fuzzy_entity(self, name, samples): - message = "fuzzy:register_entity" - name = str(self.skill_id) + ':' + name - data = {"name": name, "samples": samples} - self.bus.emit(Message(message, data)) - - -def create_skill(): - return FuzzySkill() - -# install this file as a regular skill (fuzzy_engine/__init__.py) - -## to import and create fuzzy skills - -# from os.path import dirname -# skills_dir = dirname(dirname(__file__)) -# import sys -# sys.path.append(skills_dir) -# from fuzzy_engine import PadaosSkill \ No newline at end of file diff --git a/examples/gui_tracking.py b/examples/gui_tracking.py deleted file mode 100644 index 58f27a63..00000000 --- a/examples/gui_tracking.py +++ /dev/null @@ -1,48 +0,0 @@ -from ovos_utils.gui import GUITracker -from ovos_utils import wait_for_exit_signal - - -class MyGUIEventTracker(GUITracker): - # GUI event handlers - # user can/should subclass this - def on_idle(self, namespace): - print("IDLE", namespace) - timestamp = self.idle_ts - - def on_active(self, namespace): - # NOTE: page has not been loaded yet - # event will fire right after this one - print("ACTIVE", namespace) - # check namespace values, they should all be set before this event - values = self.gui_values[namespace] - - def on_new_page(self, page, namespace, index): - print("NEW PAGE", namespace, index, namespace) - # check all loaded pages - for n in self.gui_pages: # list of named tuples - nspace = n.name # namespace / skill_id - pages = n.pages # ordered list of page uris - - def on_gui_value(self, namespace, key, value): - # WARNING this will pollute logs quite a lot, and you will get - # duplicates, better to check values on a different event, - # demonstrated in on_active - print("VALUE", namespace, key, value) - - -g = MyGUIEventTracker() - -print("device has screen:", g.can_display()) -print("mycroft-gui installed:", g.is_gui_installed()) -print("gui connected:", g.is_gui_connected()) - - -# check registered idle screens -print("Registered idle screens:") -for name in g.idle_screens: - namespace = g.idle_screens[name] - print(" - ", name, ":", namespace) - - -# just block listening for events until ctrl + C -wait_for_exit_signal() diff --git a/examples/intent_api.py b/examples/intent_api.py deleted file mode 100644 index 733dac9f..00000000 --- a/examples/intent_api.py +++ /dev/null @@ -1,32 +0,0 @@ -from ovos_utils.intents import IntentQueryApi -from pprint import pprint - - -intents = IntentQueryApi() - -pprint(intents.get_skill("who are you")) -pprint(intents.get_skill("set volume to 100%")) - -exit() -# loaded skills -pprint(intents.get_skills_manifest()) -pprint(intents.get_active_skills()) - -# intent parsing -pprint(intents.get_adapt_intent("who are you")) -pprint(intents.get_padatious_intent("who are you")) -pprint(intents.get_intent("who are you")) # intent that will trigger - -# skill from utterance -pprint(intents.get_skill("who are you")) - -# registered intents -pprint(intents.get_adapt_manifest()) -pprint(intents.get_padatious_manifest()) -pprint(intents.get_intent_manifest()) # all of the above - -# registered vocab -pprint(intents.get_entities_manifest()) # padatious entities / .entity files -pprint(intents.get_vocab_manifest()) # adapt vocab / .voc files -pprint(intents.get_regex_manifest()) # adapt regex / .rx files -pprint(intents.get_keywords_manifest()) # all of the above diff --git a/examples/live_translate_satellite.py b/examples/live_translate_satellite.py deleted file mode 100644 index 40a7b88d..00000000 --- a/examples/live_translate_satellite.py +++ /dev/null @@ -1,29 +0,0 @@ -from ovos_utils.messagebus import get_mycroft_bus, listen_for_message -from ovos_utils import wait_for_exit_signal -from ovos_utils.lang.translate import say_in_language - -# from ovos_utils.lang.translate import translate_to_mp3 -# from ovos_utils.sound import play_mp3 - -bus_ip = "0.0.0.0" # enter a remote ip here, remember bus is unencrypted! careful with opening firewalls -bus = get_mycroft_bus(host=bus_ip) - -TARGET_LANG = "pt" - - -def translate(message): - utterance = message.data["utterance"] - say_in_language(utterance, lang=TARGET_LANG) # will play .mp3 directly - - # if you need more control - # path = translate_to_mp3(utterance, lang=TARGET_LANG) - # play_mp3(path, cmd="play %1") # using sox - - -listen_for_message("speak", translate, bus=bus) - - -wait_for_exit_signal() # wait for ctrl+c - -bus.remove_all_listeners("speak") -bus.close() diff --git a/examples/location.py b/examples/location.py deleted file mode 100644 index 6e8dfda5..00000000 --- a/examples/location.py +++ /dev/null @@ -1,18 +0,0 @@ -from ovos_utils.location import geolocate, reverse_geolocate -from pprint import pprint - - -full_addr = "798 N 1415 Rd, Lawrence, KS 66049, United States" -vague_addr = "Lawrence Kansas" -country = "Portugal" - -data = geolocate(country) -pprint(data) - - -lat = 38.9730682373638 -lon = -95.2361831665156 - -data = reverse_geolocate(lat, lon) - -pprint(data) \ No newline at end of file diff --git a/examples/mark1_animations.py b/examples/mark1_animations.py deleted file mode 100644 index 688c2056..00000000 --- a/examples/mark1_animations.py +++ /dev/null @@ -1,33 +0,0 @@ -from ovos_utils.enclosure.mark1.faceplate.icons import HollowHeartIcon, \ - HeartIcon, SkullIcon, Boat -from ovos_utils.enclosure.mark1.faceplate.animations import LeftRight, \ - HorizontalScroll - - -class SailingBoat(Boat, HorizontalScroll): - pass - - -class MovingHeart(HeartIcon, LeftRight): - pass - - -class MovingHeart2(HollowHeartIcon, LeftRight): - pass - - -class MovingSkull(SkullIcon, LeftRight): - pass - - -from time import sleep -from ovos_utils.messagebus import get_mycroft_bus - - -bus = get_mycroft_bus("192.168.1.70") - -boat = MovingHeart(bus=bus) - -for faceplate in boat: - faceplate.display() - sleep(0.5) \ No newline at end of file diff --git a/examples/mark1_game_of_life.py b/examples/mark1_game_of_life.py deleted file mode 100644 index 12f48cdc..00000000 --- a/examples/mark1_game_of_life.py +++ /dev/null @@ -1,14 +0,0 @@ -from ovos_utils.enclosure.mark1.faceplate.cellular_automaton import GoL -from ovos_utils.messagebus import get_mycroft_bus - -bus = get_mycroft_bus("192.168.1.70") - - -game_of_life = GoL(bus=bus) - - -def handle_new_frame(grid): - grid.display(invert=False) - - -game_of_life.run(0.5, handle_new_frame) \ No newline at end of file diff --git a/examples/mark1_image_rotate.py b/examples/mark1_image_rotate.py deleted file mode 100644 index 4c43c105..00000000 --- a/examples/mark1_image_rotate.py +++ /dev/null @@ -1,36 +0,0 @@ -from ovos_utils.enclosure.mark1.faceplate.icons import Boat, MusicIcon, StormIcon, \ - SnowIcon, SunnyIcon, PartlyCloudyIcon, PlusIcon, SkullIcon, CrossIcon, \ - HollowHeartIcon, HeartIcon, DeadFishIcon, InfoIcon, \ - ArrowLeftIcon, JarbasAI, WarningIcon -from ovos_utils.messagebus import get_mycroft_bus -from time import sleep - -bus = get_mycroft_bus("192.168.1.70") - -images = [Boat(bus=bus), - MusicIcon(bus=bus), - StormIcon(bus=bus), - SunnyIcon(bus=bus), - PartlyCloudyIcon(bus=bus), - PlusIcon(bus=bus), - CrossIcon(bus=bus), - SnowIcon(bus=bus), - SkullIcon(bus=bus), - HeartIcon(bus=bus), - HollowHeartIcon(bus=bus), - ArrowLeftIcon(bus=bus), - JarbasAI(bus=bus), - WarningIcon(bus=bus), - InfoIcon(bus=bus), - DeadFishIcon(bus=bus)] - -from ovos_utils.enclosure.mark1.faceplate.icons import SpaceInvader1, \ - SpaceInvader2, SpaceInvader3, SpaceInvader4 -images = [SpaceInvader1(bus=bus), - SpaceInvader2(bus=bus), - SpaceInvader3(bus=bus), - SpaceInvader4(bus=bus)] -for faceplate in images: - faceplate.print() - faceplate.display() - sleep(3) diff --git a/examples/mark1_pixel_wise.py b/examples/mark1_pixel_wise.py deleted file mode 100644 index c4cc4eb9..00000000 --- a/examples/mark1_pixel_wise.py +++ /dev/null @@ -1,50 +0,0 @@ -from ovos_utils.enclosure.mark1.faceplate.icons import FaceplateGrid -from ovos_utils.messagebus import get_mycroft_bus -from time import sleep - - -bus = get_mycroft_bus("192.168.1.70") - - -grid = FaceplateGrid(bus=bus) - -# grid is white -grid.display() -sleep(2) - -# grid is black -grid.invert() -grid.display() -sleep(2) - -# read pixels -n_pixels = len(grid) -assert grid.height == 8 -assert grid.width == 32 -assert grid[1][1] == 1 # 1 == black / pixel off - -try: - grid[grid.height] -except IndexError: - pass # 0 <= i < grid.height -try: - grid[grid.height-1][grid.width] -except IndexError: - pass # 0 <= j < grid.width - -# create a dotted line -grid[4][0] = 0 # white -grid[4][5] = 0 # white -grid[4][10] = 0 # white -grid[4][15] = 0 # white -grid[4][20] = 0 # white -grid[4][25] = 0 # white -grid[4][30] = 0 # white -grid.display() - -# optionally disable invert -# if 1 == white / pixel on -# makes more sense to you -sleep(2) -grid.display(invert=False) - diff --git a/examples/mark1_space_invader.py b/examples/mark1_space_invader.py deleted file mode 100644 index 906f990e..00000000 --- a/examples/mark1_space_invader.py +++ /dev/null @@ -1,12 +0,0 @@ -from ovos_utils.enclosure.mark1.faceplate.cellular_automaton import \ - SpaceInvader -from ovos_utils.messagebus import get_mycroft_bus -from time import sleep - -bus = get_mycroft_bus("192.168.1.70") - -game_of_life = SpaceInvader(bus=bus) - -for grid in game_of_life: - grid.display(invert=False) - sleep(2) diff --git a/examples/music.txt b/examples/music.txt deleted file mode 100644 index b375a00c..00000000 --- a/examples/music.txt +++ /dev/null @@ -1,8 +0,0 @@ -XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -XXXXXXXXXXXXXX XXXXXXXXXXXXX -XXXXXXXXXXXXXX XXXXXXXXXXXXX -XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX -XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX -XXXXXXXXXXXXX XX XXXXXXXXXXXXX -XXXXXXXXXXXX X XXXXXXXXXXXXX -XXXXXXXXXXXXX XXX XXXXXXXXXXXXXX \ No newline at end of file diff --git a/examples/padaos_intent_engine.py b/examples/padaos_intent_engine.py deleted file mode 100644 index 15eb223a..00000000 --- a/examples/padaos_intent_engine.py +++ /dev/null @@ -1,79 +0,0 @@ -from ovos_utils.intents.engines import BaseIntentEngine -from ovos_utils.skills.intent_provider import IntentEngineSkill - -from mycroft.configuration.config import Configuration -from mycroft.skills.core import MycroftSkill, Message - -from padaos import IntentContainer - - -class PadaosEngine(BaseIntentEngine): - def __init__(self): - self.name = "padaos" - BaseIntentEngine.__init__(self, self.name) - self.config = Configuration.get().get(self.name, {}) - self.container = IntentContainer() - - def add_intent(self, name, samples): - self.container.add_intent(name, samples) - - def remove_intent(self, name): - self.container.remove_intent(name) - - def add_entity(self, name, samples): - self.container.add_entity(name, samples) - - def remove_entity(self, name): - self.container.remove_entity(name) - - def train(self, single_thread=False): - """ train all registered intents and entities""" - # Padaos is simply regex, it handles this when registering - pass - - def calc_intent(self, query): - """ return best intent for this query """ - data = {"conf": 0, - "utterance": query, - "name": None} - data.update(self.container.calc_intent(query)) - return data - - -# engine skill for mycroft -class PadaosEngineSkill(IntentEngineSkill): - def initialize(self): - priority = 4 - engine = PadaosEngine() - self.bind_engine(engine, priority) - - -class PadaosSkill(MycroftSkill): - def register_padaos_intent(self, name, samples, handler): - message = "padaos:register_intent" - name = str(self.skill_id) + ':' + name - data = {"name": name, "samples": samples} - - self.bus.emit(Message(message, data)) - self.add_event(name, handler, 'mycroft.skill.handler') - - def register_padaos_entity(self, name, samples): - message = "padaos:register_entity" - name = str(self.skill_id) + ':' + name - data = {"name": name, "samples": samples} - self.bus.emit(Message(message, data)) - - -def create_skill(): - return PadaosSkill() - - -# install this file as a regular skill (padaos_engine/__init__.py) - -## to import and create padaos skills - -# from os.path import dirname -# skills_dir = dirname(dirname(__file__)) -# import sys -# sys.path.append(skills_dir) -# from padaos_engine import PadaosSkill \ No newline at end of file diff --git a/examples/private_settings.py b/examples/private_settings.py deleted file mode 100644 index 959ea93b..00000000 --- a/examples/private_settings.py +++ /dev/null @@ -1,19 +0,0 @@ -from ovos_utils.skills.settings import PrivateSettings - - -with PrivateSettings("testskill.jarbasai") as settings: - print(settings.path) # ~/.cache/json_database/testskill.jarbasai.json - settings["key"] = "value" - - meta = settings.settingsmeta - # can be used for displaying in GUI - """ - {'skillMetadata': {'sections': [{'fields': [{'label': 'Key', - 'name': 'key', - 'type': 'text', - 'value': 'value'}], - 'name': 'testskill.jarbasai'}]}} - """ - - # auto saved when leaving "with" context - # you can also manually call settings.store() if not using "with" context \ No newline at end of file diff --git a/examples/remote_skill_settings.py b/examples/remote_skill_settings.py deleted file mode 100644 index 293b8039..00000000 --- a/examples/remote_skill_settings.py +++ /dev/null @@ -1,51 +0,0 @@ -from ovos_utils.skills.settings import get_all_remote_settings, get_remote_settings - -""" -WARNING: selene backend does not use a proper skill_id, if you have -skills with same name but different author settings will overwrite each -other on the backend - -2 way sync with Selene is also not implemented and causes a lot of issues, -these problems have been reported over 9 months ago (and counting) - -I strongly recommend you validate returned data as much as possible, -maybe ask the user to confirm any action, DO NOT automate settings sync - -skill matching is currently done by checking "if {skill} in string" -once mycroft fixes it on their side this will start using a proper -unique identifier - -THIS METHOD IS NOT ALWAYS SAFE -""" - -# get raw skill settings payload -all_remote_settings = get_all_remote_settings() -""" -{'@0e813c09-8dbc-40ed-974c-5705274a7b55|mycroft-npr-news|20.08': {'custom_url': '', - 'station': 'not_set', - 'use_curl': True}, - '@0e813c09-8dbc-40ed-974c-5705274a7b55|mycroft-pairing|20.08': None, - '@0e813c09-8dbc-40ed-974c-5705274a7b55|mycroft-volume|20.08': {'ducking': True}, - (...) - 'mycroft-weather|20.08': {'units': 'default'}, - 'mycroft-wiki|20.08': None} -""" - -# search remote settings for a specific skill -voip_remote_settings = get_remote_settings("skill-voip") -""" -{'add_contact': False, - 'auto_answer': True, - 'auto_reject': False, - 'auto_speech': 'I am busy, call again later', - 'contact_address': 'user@sipxcom.com', - 'contact_name': 'name here', - 'delete_contact': False, - 'gateway': 'sipxcom.com', - 'password': 'SECRET', - 'sipxcom_gateway': 'https://sipx.mattkeys.net', - 'sipxcom_password': 'secret', - 'sipxcom_sync': False, - 'sipxcom_user': 'MattKeys', - 'user': 'user'} -""" \ No newline at end of file diff --git a/examples/send_file_over_bus.py b/examples/send_file_over_bus.py deleted file mode 100644 index c7a602ea..00000000 --- a/examples/send_file_over_bus.py +++ /dev/null @@ -1,46 +0,0 @@ -from ovos_utils.messagebus import send_binary_data_message, \ - send_binary_file_message, decode_binary_message, listen_for_message - -from time import sleep -import json -from os.path import dirname, join - -# random file -my_file_path = join(dirname(__file__), "music.txt") -with open(my_file_path, "rb") as f: - original_binary = f.read() - - -def receive_file(message): - print("Receiving file") - path = message.data["path"] - print(path) - hex_data = message.data["binary"] - - # all accepted decode formats - binary_data = decode_binary_message(message) - print(binary_data == original_binary) - binary_data = decode_binary_message(hex_data) - print(binary_data == original_binary) - binary_data = decode_binary_message(message.data) - print(binary_data == original_binary) - binary_data = decode_binary_message(json.dumps(message.data)) - print(binary_data == original_binary) - binary_data = decode_binary_message(message.serialize()) - print(binary_data == original_binary) - - -listen_for_message("mycroft.binary.file", receive_file) -sleep(1) -send_binary_file_message(my_file_path) - - -def receive_binary(message): - print("Receiving binary data") - binary_data = decode_binary_message(message) - print(binary_data == original_binary) - - -listen_for_message("mycroft.binary.data", receive_binary) -sleep(1) -send_binary_data_message(original_binary) diff --git a/examples/translation_utils.py b/examples/translation_utils.py deleted file mode 100644 index 7abe1534..00000000 --- a/examples/translation_utils.py +++ /dev/null @@ -1,20 +0,0 @@ -from ovos_utils.lang import detect_lang, translate_text - -# detecting language -detect_lang("olá eu chamo-me joaquim") # "pt" -detect_lang("olá eu chamo-me joaquim", return_dict=True) -"""{'confidence': 0.9999939001351439, 'language': 'pt'}""" - -detect_lang("hello world") # "en" -detect_lang("This piece of text is in English. Този текст е на Български.", return_dict=True) -"""{'confidence': 0.28571342657428966, 'language': 'en'}""" - -# translating text -# - source lang will be auto detected using utils above -# - default target language is english -translate_text("olá eu chamo-me joaquim") -"""Hello I call myself joaquim""" - -# - you should specify source lang whenever possible to save 1 api call -translate_text("olá eu chamo-me joaquim", source_lang="pt", lang="es") -"""Hola, me llamo Joaquim""" diff --git a/examples/universal_chat.py b/examples/universal_chat.py deleted file mode 100644 index c764bb1d..00000000 --- a/examples/universal_chat.py +++ /dev/null @@ -1,38 +0,0 @@ -from ovos_utils.messagebus import send_message, listen_for_message -from ovos_utils.lang.translate import translate_text -from ovos_utils.lang.detect import detect_lang -from ovos_utils.log import LOG - - -OUTPUT_LANG = "pt" # received messages will be in this language -MYCROFT_LANG = "en" # mycroft is configured in this language - - -def handle_speak(message): - utt = message.data["utterance"] - utt = translate_text(utt, "pt") # source lang is auto detected - print("MYCROFT:", utt) - - -bus = listen_for_message("speak", handle_speak) - -print("Write in any language and mycroft will answer in {lang}".format(lang=OUTPUT_LANG)) - - -while True: - try: - utt = input("YOU:") - lang = detect_lang("utt") # source lang is auto detected, this is optional - if lang != MYCROFT_LANG: - utt = translate_text(utt) - send_message("recognizer_loop:utterance", - {"utterances": [utt]}, - bus=bus # re-utilize the bus connection - ) - except KeyboardInterrupt: - break - except Exception as e: - LOG.exception(e) - -bus.remove_all_listeners("speak") -bus.close() diff --git a/examples/watchdog.py b/examples/watchdog.py deleted file mode 100644 index 5e20fe25..00000000 --- a/examples/watchdog.py +++ /dev/null @@ -1,21 +0,0 @@ -from ovos_utils.messagebus import send_message -from ovos_utils.log import LOG -from ovos_utils import create_daemon, wait_for_exit_signal -import random -from time import sleep - - -def alert(): - LOG.info("Alerting user of some event using Mycroft") - send_message("speak", {"utterance": "Alert! something happened"}) - - -def did_something_happen(): - while True: - if random.choice([True, False]): - alert() - sleep(10) - - -create_daemon(did_something_happen) # check for something in background -wait_for_exit_signal() # wait for ctrl+c \ No newline at end of file diff --git a/ovos_utils/__init__.py b/ovos_utils/__init__.py index 1fa3e1f7..98912ab8 100644 --- a/ovos_utils/__init__.py +++ b/ovos_utils/__init__.py @@ -13,17 +13,12 @@ import datetime import re from functools import lru_cache, wraps -from os.path import isdir, join from threading import Thread, Event -from time import monotonic_ns -from time import sleep +from time import monotonic_ns, sleep import kthread -# TODO: Deprecate below imports -from ovos_utils.file_utils import resolve_ovos_resource_file, resolve_resource_file -from ovos_utils.network_utils import get_ip, get_external_ip, is_connected_dns, is_connected_http, is_connected -from ovos_utils.log import LOG, deprecated +from ovos_utils.log import LOG def threaded_timeout(timeout=5): @@ -33,7 +28,7 @@ def threaded_timeout(timeout=5): Adapted from https://github.com/OpenJarbas/InGeo @param timeout: Timeout in seconds to wait before terminating the process """ - + def deco(func): @wraps(func) def wrapper(*args, **kwargs): @@ -66,41 +61,11 @@ def func_wrapped(): class classproperty(property): """Decorator for a Class-level property. Credit to Denis Rhyzhkov on Stackoverflow: https://stackoverflow.com/a/13624858/1280629""" + def __get__(self, owner_self, owner_cls): return self.fget(owner_cls) -@deprecated("Anything depending on `mycroft`" - "should install `ovos-core` as a dependency", "0.1.0") -def ensure_mycroft_import(): - # TODO: Deprecate in 0.1.0 - try: - import mycroft - except ImportError: - import sys - from ovos_utils import get_mycroft_root - MYCROFT_ROOT_PATH = get_mycroft_root() - if MYCROFT_ROOT_PATH is not None: - sys.path.append(MYCROFT_ROOT_PATH) - else: - raise - - -@deprecated("Code should import from the current" - "namespace; other system paths are irrelevant.", "0.1.0") -def get_mycroft_root(): - # TODO: Deprecate in 0.1.0 - paths = [ - "/opt/venvs/mycroft-core/lib/python3.7/site-packages/", # mark1/2 - "/opt/venvs/mycroft-core/lib/python3.4/site-packages/ ", # old mark1 installs - "/home/pi/mycroft-core" # picroft - ] - for p in paths: - if isdir(join(p, "mycroft")): - return p - return None - - def timed_lru_cache( _func=None, *, seconds: int = 7000, maxsize: int = 128, typed: bool = False ): diff --git a/ovos_utils/configuration.py b/ovos_utils/configuration.py deleted file mode 100644 index 01d95093..00000000 --- a/ovos_utils/configuration.py +++ /dev/null @@ -1,377 +0,0 @@ -import json -from os import makedirs -from os.path import join, expanduser, exists, isfile - -import ovos_utils.xdg_utils as xdg -from ovos_utils.log import LOG, deprecated - - -# TODO - deprecate this submodule in 0.1.0 -# note that a couple of these are also used inside ovos-utils -# perhaps those usages should also move into workshop ? - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_default_lang(): - try: - from ovos_config.locale import get_default_lang as _get - return _get() - except ImportError: - return read_mycroft_config().get("lang", "en-us") - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def find_user_config(): - try: - from ovos_config.locations import find_user_config as _get - return _get() - except ImportError: - - return join(get_xdg_config_save_path(), get_config_filename()) - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_webcache_location(): - return join(get_xdg_config_save_path(), 'web_cache.json') - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_config_locations(default=True, web_cache=True, system=True, - old_user=True, user=True): - try: - from ovos_config.locations import get_config_locations as _get - return _get(default, web_cache, system, old_user, user) - except ImportError: - locs = [] - ovos_cfg = get_ovos_config() - if default: - locs.append(ovos_cfg["default_config_path"]) - if system: - locs.append(f"/etc/{ovos_cfg['base_folder']}/{ovos_cfg['config_filename']}") - if web_cache: - locs.append(get_webcache_location()) - if old_user: - locs.append(f"~/.{ovos_cfg['base_folder']}/{ovos_cfg['config_filename']}") - if user: - locs.append(f"{get_xdg_config_save_path()}/{ovos_cfg['config_filename']}") - return locs - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_ovos_config(): - try: - from ovos_config.meta import get_ovos_config as _get - return _get() - except ImportError: - return {"xdg": True, - "base_folder": "mycroft", - "config_filename": "mycroft.conf"} - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_base(): - try: - from ovos_config.meta import get_xdg_base as _get - return _get() - except ImportError: - return "mycroft" - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_config_locations(): - # This includes both the user config and - # /etc/xdg/mycroft/mycroft.conf - xdg_paths = list(reversed( - [join(p, get_config_filename()) - for p in get_xdg_config_dirs()] - )) - return xdg_paths - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_data_dirs(): - try: - from ovos_config.locations import get_xdg_data_dirs as _get - return _get() - except ImportError: - return [expanduser("~/.local/share/mycroft")] - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_config_dirs(folder=None): - try: - from ovos_config.locations import get_xdg_config_dirs as _get - return _get() - except ImportError: - folder = folder or get_xdg_base() - xdg_dirs = xdg.xdg_config_dirs() + [xdg.xdg_config_home()] - return [join(path, folder) for path in xdg_dirs] - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_cache_save_path(folder=None): - try: - from ovos_config.locations import get_xdg_cache_save_path as _get - return _get() - except ImportError: - folder = folder or get_xdg_base() - return join(xdg.xdg_cache_home(), folder) - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_data_save_path(): - try: - from ovos_config.locations import get_xdg_data_save_path as _get - return _get() - except ImportError: - return expanduser("~/.local/share/mycroft") - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_xdg_config_save_path(): - try: - from ovos_config.locations import get_xdg_config_save_path as _get - return _get() - except ImportError: - return expanduser("~/.config/mycroft") - - -@deprecated("XDG is always used.", "0.1.0") -def is_using_xdg(): - """ DEPRECATED """ - return True - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def set_xdg_base(*args, **kwargs): - try: - from ovos_config.meta import set_xdg_base as _set - _set(*args, **kwargs) - except: - pass - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def set_config_filename(*args, **kwargs): - try: - from ovos_config.meta import config_filename as _set - _set(*args, **kwargs) - except: - pass - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_config_filename(): - try: - from ovos_config.locale import get_config_filename as _get - return _get() - except ImportError: - return "mycroft.conf" - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def get_ovos_default_config_paths(): - try: - from ovos_config.meta import get_ovos_default_config_paths as _get - return _get() - except: - return ["/etc/OpenVoiceOS/ovos.conf"] - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def read_mycroft_config(): - try: - from ovos_config import Configuration - return Configuration() - except ImportError: - pass - path = expanduser(f"~/.config/mycroft/mycroft.conf") - if isfile(path): - with open(path) as f: - return json.load(f) - return { - # TODO - default cfg - "lang": "en-us" - } - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def update_mycroft_config(config, path=None, bus=None): - try: - from ovos_config.config import update_mycroft_config as _update - _update(config, path, bus) - except ImportError: - pass - # save in default user location - path = expanduser(f"~/.config/mycroft") - makedirs(path, exist_ok=True) - with open(f"{path}/mycroft.conf", "w") as f: - json.dump(config, f, indent=2) - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def set_default_config(*args, **kwargs): - try: - from ovos_config.meta import set_default_config as _set - _set(*args, **kwargs) - except: - pass - - -@deprecated("configuration moved to the `ovos_config` package.", "0.1.0") -def save_ovos_core_config(*args, **kwargs): - try: - from ovos_config.meta import save_ovos_config as _set - _set(*args, **kwargs) - except: - pass - - -try: - from ovos_config.models import ( - LocalConf, - ReadOnlyConfig, - MycroftUserConfig, - MycroftDefaultConfig, - MycroftSystemConfig, - MycroftXDGConfig - ) -except ImportError: - LOG.warning("configuration classes moved to the `ovos_config.models` package. " - "This submodule will be removed in ovos_utils 0.1.0") - from combo_lock import NamedLock - import yaml - from ovos_utils.json_helper import load_commented_json, merge_dict - - - class LocalConf(dict): - """Config dictionary from file.""" - allow_overwrite = True - # lock is shared among all subclasses, - # regardless of what file is being edited only one file should change at a time - # this ensure orderly behaviour in anything monitoring changes, - # eg FileWatcher util, configuration.patch bus handlers - __lock = NamedLock("ovos_config") - - def __init__(self, path): - super().__init__(self) - self.path = path - if path: - self.load_local(path) - - def _get_file_format(self, path=None): - """The config file format - supported file extensions: - - json (.json) - - commented json (.conf) - - yaml (.yaml/.yml) - - returns "yaml" or "json" - """ - path = path or self.path - if not path: - return "dict" - if path.endswith(".yml") or path.endswith(".yaml"): - return "yaml" - else: - return "json" - - def load_local(self, path=None): - """Load local json file into self. - - Args: - path (str): file to load - """ - path = path or self.path - if not path: - LOG.error(f"in memory configuration, nothing to load") - return - if exists(path) and isfile(path): - with self.__lock: - try: - if self._get_file_format(path) == "yaml": - with open(path) as f: - config = yaml.safe_load(f) - else: - config = load_commented_json(path) - if config: - for key in config: - self.__setitem__(key, config[key]) - LOG.debug(f"Configuration {path} loaded") - else: - LOG.debug(f"Empty config found at: {path}") - except Exception as e: - LOG.exception(f"Error loading configuration '{path}'") - else: - LOG.debug(f"Configuration '{path}' not defined, skipping") - - def reload(self): - self.load_local(self.path) - - def store(self, path=None): - path = path or self.path - if not path: - LOG.error(f"in memory configuration, no save location") - return - with self.__lock: - if self._get_file_format(path) == "yaml": - with open(path, 'w+') as f: - yaml.dump(dict(self), f, allow_unicode=True, - default_flow_style=False, sort_keys=False) - else: - with open(path, 'w+') as f: - json.dump(self, f, indent=2) - - def merge(self, conf): - merge_dict(self, conf) - - - class ReadOnlyConfig(LocalConf): - """ read only """ - - def __init__(self, path, allow_overwrite=False): - super().__init__(path) - self.allow_overwrite = allow_overwrite - - def reload(self): - old = self.allow_overwrite - self.allow_overwrite = True - super().reload() - self.allow_overwrite = old - - def __setitem__(self, key, value): - if not self.allow_overwrite: - raise PermissionError(f"{self.path} is read only! it can not be modified at runtime") - super().__setitem__(key, value) - - def merge(self, *args, **kwargs): - if not self.allow_overwrite: - raise PermissionError(f"{self.path} is read only! it can not be modified at runtime") - super().merge(*args, **kwargs) - - def store(self, path=None): - if not self.allow_overwrite: - raise PermissionError(f"{self.path} is read only! it can not be modified at runtime") - super().store(path) - - - class MycroftDefaultConfig(ReadOnlyConfig): - def __init__(self): - super().__init__(join(get_xdg_config_save_path(), get_config_filename())) - - def set_root_config_path(self, root_config): - # in case we got it wrong / non standard - self.path = root_config - self.reload() - - - class MycroftSystemConfig(ReadOnlyConfig): - def __init__(self, allow_overwrite=False): - super().__init__("/etc/mycroft/mycroft.conf", allow_overwrite) - - - class RemoteConf(LocalConf): - def __init__(self, cache=get_webcache_location()): - super(RemoteConf, self).__init__(cache) - - - MycroftXDGConfig = MycroftUserConfig = MycroftDefaultConfig diff --git a/ovos_utils/device_input.py b/ovos_utils/device_input.py index 17e9b21b..6ab4bae5 100644 --- a/ovos_utils/device_input.py +++ b/ovos_utils/device_input.py @@ -1,5 +1,6 @@ import subprocess from distutils.spawn import find_executable + from ovos_utils.gui import is_gui_installed from ovos_utils.log import LOG diff --git a/ovos_utils/enclosure/__init__.py b/ovos_utils/enclosure/__init__.py deleted file mode 100644 index e9135711..00000000 --- a/ovos_utils/enclosure/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -from ovos_utils.system import MycroftRootLocations -from ovos_utils.fingerprinting import detect_platform, MycroftPlatform -from enum import Enum -from os.path import exists -from typing import Optional -from ovos_utils.log import deprecated, log_deprecation - -log_deprecation("ovos_utils.enclosure has been deprecated!", "0.1.0") - - -class MycroftEnclosures(str, Enum): - # TODO: Deprecate in 0.1.0 - PICROFT = "picroft" - BIGSCREEN = "kde" - OVOS = "OpenVoiceOS" - OLD_MARK1 = "mycroft_mark_1(old)" - MARK1 = "mycroft_mark_1" - MARK2 = "mycroft_mark_2" - HOLMESV = "HolmesV" - OLD_HOLMES = "mycroft-lib" - GENERIC = "generic" - OTHER = "unknown" - - -@deprecated("This method is deprecated. Code should import from the current" - "namespace; other system paths are irrelevant.", "0.1.0") -def enclosure2rootdir(enclosure: MycroftEnclosures = None) -> Optional[str]: - """ - Find the default installed core location for a specific platform. - @param enclosure: MycroftEnclosures object to get root path for - @return: string default root path - """ - # TODO: Deprecate in 0.1.0 - enclosure = enclosure or detect_enclosure() - if enclosure == MycroftEnclosures.OLD_MARK1: - return MycroftRootLocations.OLD_MARK1 - elif enclosure == MycroftEnclosures.MARK1: - return MycroftRootLocations.MARK1 - elif enclosure == MycroftEnclosures.MARK2: - return MycroftRootLocations.MARK2 - elif enclosure == MycroftEnclosures.PICROFT: - return MycroftPlatform.PICROFT - elif enclosure == MycroftEnclosures.OVOS: - return MycroftPlatform.OVOS - elif enclosure == MycroftEnclosures.BIGSCREEN: - return MycroftPlatform.BIGSCREEN - return None - - -@deprecated("This method is deprecated. Platform-specific code should" - "use ovos_utils.fingerprinting.detect_platform directly", "0.1.0") -def detect_enclosure() -> MycroftEnclosures: - """ - Determine which enclosure is present on this file system. - @return: MycroftEnclosures object detected - """ - # TODO: Deprecate in 0.1.0 - platform = detect_platform() - if platform == MycroftPlatform.MARK1: - if exists(MycroftRootLocations.OLD_MARK1): - return MycroftEnclosures.OLD_MARK1 - return MycroftEnclosures.MARK1 - elif platform == MycroftPlatform.MARK2: - return MycroftEnclosures.MARK2 - elif platform == MycroftPlatform.PICROFT: - return MycroftEnclosures.PICROFT - elif platform == MycroftPlatform.OVOS: - return MycroftEnclosures.OVOS - elif platform == MycroftPlatform.BIGSCREEN: - return MycroftEnclosures.BIGSCREEN - elif platform == MycroftPlatform.HOLMESV: - return MycroftEnclosures.HOLMESV - elif platform == MycroftPlatform.OLD_HOLMES: - return MycroftEnclosures.OLD_HOLMES - - return MycroftEnclosures.OTHER diff --git a/ovos_utils/enclosure/api.py b/ovos_utils/enclosure/api.py deleted file mode 100644 index 3427bdcb..00000000 --- a/ovos_utils/enclosure/api.py +++ /dev/null @@ -1,345 +0,0 @@ -from ovos_utils.log import log_deprecation - -log_deprecation("EnclosureApi has moved to ovos_bus_client.apis.enclosure", "0.1.0") - - -try: - from ovos_bus_client.apis.enclosure import EnclosureApi - -except ImportError: - from ovos_utils.fakebus import Message, dig_for_message - - class EnclosureAPI: - """ - This API is intended to be used to interface with the hardware - that is running Mycroft. It exposes all possible commands which - can be sent to a Mycroft enclosure implementation. - - Different enclosure implementations may implement this differently - and/or may ignore certain API calls completely. For example, - the eyes_color() API might be ignore on a Mycroft that uses simple - LEDs which only turn on/off, or not at all on an implementation - where there is no face at all. - """ - - def __init__(self, bus=None, skill_id=""): - self.bus = bus - self.skill_id = skill_id - - def set_bus(self, bus): - self.bus = bus - - def set_id(self, skill_id): - self.skill_id = skill_id - - def _get_source_message(self): - return dig_for_message() or \ - Message("", context={"destination": ["enclosure"], - "skill_id": self.skill_id}) - - def register(self, skill_id=""): - """Registers a skill as active. Used for speak() and speak_dialog() - to 'patch' a previous implementation. Somewhat hacky. - DEPRECATED - unused - """ - source_message = self._get_source_message() - skill_id = skill_id or self.skill_id - self.bus.emit(source_message.forward("enclosure.active_skill", - {"skill_id": skill_id})) - - def reset(self): - """The enclosure should restore itself to a started state. - Typically this would be represented by the eyes being 'open' - and the mouth reset to its default (smile or blank). - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.reset")) - - def system_reset(self): - """The enclosure hardware should reset any CPUs, etc.""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.system.reset")) - - def system_mute(self): - """Mute (turn off) the system speaker.""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.system.mute")) - - def system_unmute(self): - """Unmute (turn on) the system speaker.""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.system.unmute")) - - def system_blink(self, times): - """The 'eyes' should blink the given number of times. - Args: - times (int): number of times to blink - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.system.blink", - {'times': times})) - - def eyes_on(self): - """Illuminate or show the eyes.""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.on")) - - def eyes_off(self): - """Turn off or hide the eyes.""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.off")) - - def eyes_blink(self, side): - """Make the eyes blink - Args: - side (str): 'r', 'l', or 'b' for 'right', 'left' or 'both' - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.blink", - {'side': side})) - - def eyes_narrow(self): - """Make the eyes look narrow, like a squint""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.narrow")) - - def eyes_look(self, side): - """Make the eyes look to the given side - Args: - side (str): 'r' for right - 'l' for left - 'u' for up - 'd' for down - 'c' for crossed - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.look", - {'side': side})) - - def eyes_color(self, r=255, g=255, b=255): - """Change the eye color to the given RGB color - Args: - r (int): 0-255, red value - g (int): 0-255, green value - b (int): 0-255, blue value - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.color", - {'r': r, 'g': g, 'b': b})) - - def eyes_setpixel(self, idx, r=255, g=255, b=255): - """Set individual pixels of the Mark 1 neopixel eyes - Args: - idx (int): 0-11 for the right eye, 12-23 for the left - r (int): The red value to apply - g (int): The green value to apply - b (int): The blue value to apply - """ - source_message = self._get_source_message() - if idx < 0 or idx > 23: - raise ValueError(f'idx ({idx}) must be between 0-23') - self.bus.emit(source_message.forward("enclosure.eyes.setpixel", - {'idx': idx, - 'r': r, 'g': g, 'b': b})) - - def eyes_fill(self, percentage): - """Use the eyes as a type of progress meter - Args: - percentage (int): 0-49 fills the right eye, 50-100 also covers left - """ - source_message = self._get_source_message() - if percentage < 0 or percentage > 100: - raise ValueError(f'percentage ({percentage}) must be between 0-100') - self.bus.emit(source_message.forward("enclosure.eyes.fill", - {'percentage': percentage})) - - def eyes_brightness(self, level=30): - """Set the brightness of the eyes in the display. - Args: - level (int): 1-30, bigger numbers being brighter - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.level", - {'level': level})) - - def eyes_reset(self): - """Restore the eyes to their default (ready) state.""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.reset")) - - def eyes_spin(self): - """Make the eyes 'roll' - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.spin")) - - def eyes_timed_spin(self, length): - """Make the eyes 'roll' for the given time. - Args: - length (int): duration in milliseconds of roll, None = forever - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.eyes.timedspin", - {'length': length})) - - def eyes_volume(self, volume): - """Indicate the volume using the eyes - Args: - volume (int): 0 to 11 - """ - source_message = self._get_source_message() - if volume < 0 or volume > 11: - raise ValueError('volume ({}) must be between 0-11'. - format(str(volume))) - self.bus.emit(source_message.forward("enclosure.eyes.volume", - {'volume': volume})) - - def mouth_reset(self): - """Restore the mouth display to normal (blank)""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.reset")) - - def mouth_talk(self): - """Show a generic 'talking' animation for non-synched speech""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.talk")) - - def mouth_think(self): - """Show a 'thinking' image or animation""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.think")) - - def mouth_listen(self): - """Show a 'thinking' image or animation""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.listen")) - - def mouth_smile(self): - """Show a 'smile' image or animation""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.smile")) - - def mouth_viseme(self, start, viseme_pairs): - """ Send mouth visemes as a list in a single message. - - Args: - start (int): Timestamp for start of speech - viseme_pairs: Pairs of viseme id and cumulative end times - (code, end time) - - codes: - 0 = shape for sounds like 'y' or 'aa' - 1 = shape for sounds like 'aw' - 2 = shape for sounds like 'uh' or 'r' - 3 = shape for sounds like 'th' or 'sh' - 4 = neutral shape for no sound - 5 = shape for sounds like 'f' or 'v' - 6 = shape for sounds like 'oy' or 'ao' - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.viseme_list", - {"start": start, - "visemes": viseme_pairs})) - - def mouth_text(self, text=""): - """Display text (scrolling as needed) - Args: - text (str): text string to display - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.text", - {'text': text})) - - def mouth_display(self, img_code="", x=0, y=0, refresh=True): - """Display images on faceplate. Currently supports images up to 16x8, - or half the face. You can use the 'x' parameter to cover the other - half of the faceplate. - Args: - img_code (str): text string that encodes a black and white image - x (int): x offset for image - y (int): y offset for image - refresh (bool): specify whether to clear the faceplate before - displaying the new image or not. - Useful if you'd like to display multiple images - on the faceplate at once. - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward('enclosure.mouth.display', - {'img_code': img_code, - 'xOffset': x, - 'yOffset': y, - 'clearPrev': refresh})) - - def mouth_display_png(self, image_absolute_path, - invert=False, x=0, y=0, refresh=True): - """ Send an image to the enclosure. - - Args: - image_absolute_path (string): The absolute path of the image - invert (bool): inverts the image being drawn. - x (int): x offset for image - y (int): y offset for image - refresh (bool): specify whether to clear the faceplate before - displaying the new image or not. - Useful if you'd like to display muliple images - on the faceplate at once. - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.mouth.display_image", - {'img_path': image_absolute_path, - 'xOffset': x, - 'yOffset': y, - 'invert': invert, - 'clearPrev': refresh})) - - def weather_display(self, img_code, temp): - """Show a the temperature and a weather icon - - Args: - img_code (char): one of the following icon codes - 0 = sunny - 1 = partly cloudy - 2 = cloudy - 3 = light rain - 4 = raining - 5 = stormy - 6 = snowing - 7 = wind/mist - temp (int): the temperature (either C or F, not indicated) - """ - source_message = self._get_source_message() - self.bus.emit(source_message.forward("enclosure.weather.display", - {'img_code': img_code, - 'temp': temp})) - - def activate_mouth_events(self): - """Enable movement of the mouth with speech""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward('enclosure.mouth.events.activate')) - - def deactivate_mouth_events(self): - """Disable movement of the mouth with speech""" - source_message = self._get_source_message() - self.bus.emit(source_message.forward( - 'enclosure.mouth.events.deactivate')) - - def get_eyes_color(self): - """Get the eye RGB color for all pixels - Returns: - (list) pixels - list of (r,g,b) tuples for each eye pixel - """ - source_message = self._get_source_message() - message = source_message.forward("enclosure.eyes.rgb.get") - response = self.bus.wait_for_response(message, "enclosure.eyes.rgb") - if response: - return response.data["pixels"] - raise TimeoutError("Enclosure took too long to respond") - - def get_eyes_pixel_color(self, idx): - """Get the RGB color for a specific eye pixel - Returns: - (r,g,b) tuples for selected pixel - """ - if idx < 0 or idx > 23: - raise ValueError(f'idx ({idx}) must be between 0-23') - return self.get_eyes_color()[idx] diff --git a/ovos_utils/enclosure/mark1/__init__.py b/ovos_utils/enclosure/mark1/__init__.py deleted file mode 100644 index 6f51bbdd..00000000 --- a/ovos_utils/enclosure/mark1/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from ovos_utils.enclosure.api import EnclosureAPI -from ovos_utils.log import log_deprecation - -log_deprecation("ovos_utils.enclosure.mark1 moved to https://github.com/OpenVoiceOS/ovos-mark1-utils", "0.1.0") - - -class Mark1EnclosureAPI(EnclosureAPI): - """ Mark1 enclosure, messagebus API""" diff --git a/ovos_utils/enclosure/mark1/eyes/__init__.py b/ovos_utils/enclosure/mark1/eyes/__init__.py deleted file mode 100644 index 334161eb..00000000 --- a/ovos_utils/enclosure/mark1/eyes/__init__.py +++ /dev/null @@ -1,513 +0,0 @@ -from ovos_utils.log import log_deprecation - -log_deprecation("ovos_utils.enclosure.mark1.eyes moved to https://github.com/OpenVoiceOS/ovos-mark1-utils", "0.1.0") - - -try: - from ovos_mark1.eyes import * -except: - from ovos_utils.enclosure.mark1 import Mark1EnclosureAPI - from ovos_utils.messagebus import get_mycroft_bus - from ovos_utils.colors import Color - from ovos_utils import rotate_list - from time import sleep - - - class EyePixel: - def __init__(self, index, api, color=None): - self.index = index - self.api = api - self.color = color or Color() - - @property - def rgb(self): - return self.color.rgb255 - - def sync_color(self): - r, g, b = self.api.get_eyes_pixel_color(self.index) - color = Color.from_rgb(r, g, b) - self.change_color(color) - - def update_color(self): - self.change_color(self.color) - - def change_color(self, name): - if isinstance(name, str): - self.color = Color.from_name(name) - elif isinstance(name, Color): - self.color = name - else: - raise ValueError("not a Color object") - r, g, b = self.rgb - self.api.eyes_setpixel(self.index, r, g, b) - - def set_saturation(self, value): - self.color.set_saturation(value) - self.update_color() - - def set_luminance(self, value): - self.color.set_luminance(value) - self.update_color() - - def set_hue(self, value): - self.color.set_hue(value) - self.update_color() - - def __repr__(self): - return "Pixel_" + str(self.index) + ":" + self.color.color_description - - - class Eye(list): - def __init__(self, pixel_range, bus=None, color=None): - super().__init__() - self.bus = bus or get_mycroft_bus() - self.api = Mark1EnclosureAPI(self.bus) - for idx in range(pixel_range[0], pixel_range[1]): - pixel = EyePixel(idx, self.api) - self.append(pixel) - self.color = Color() - if color: - self.change_color(color) - else: - self.sync_color() - - def sync_color(self): - for p in self: - p.sync_color() - sleep(0.05) - - def update_color(self): - for p in self: - p.update_color() - sleep(0.05) - - def change_color(self, name): - if isinstance(name, str): - self.color = Color.from_name(name) - elif isinstance(name, Color): - self.color = name - else: - raise ValueError("not a Color object") - for led in self: - led.change_color(self.color) - # writer bugs out if messages sent too fast - sleep(0.05) - - def saturation_spin(self, speed=0.05): - values = [] - for idx, pixel in enumerate(self): - sat = 0.09 * idx - pixel.set_saturation(sat) - values.append(sat) - sleep(speed) - - while True: - values = rotate_list(values, -1) - for idx, value in enumerate(values): - self[idx].set_saturation(value) - sleep(speed) - - def luminance_spin(self, speed=0.05): - values = [] - for idx, pixel in enumerate(self): - sat = 0.05 * idx - pixel.set_luminance(sat) - values.append(sat) - sleep(speed) - - while True: - values = rotate_list(values, -1) - for idx, value in enumerate(values): - self[idx].set_luminance(value) - sleep(speed) - - def hue_spin(self, speed=0.05): - values = [] - for idx, pixel in enumerate(self): - sat = 0.083 * idx - pixel.set_hue(sat) - values.append(sat) - sleep(speed) - - while True: - values = rotate_list(values, -1) - for idx, value in enumerate(values): - self[idx].set_hue(value) - sleep(speed) - - def set_hue(self, hue): - for pixel in self: - pixel.color.set_hue(hue) - pixel.update_color() - - def set_luminance(self, value): - for pixel in self: - pixel.color.set_luminance(value) - self.update_color() - - def set_saturation(self, value): - for pixel in self: - pixel.color.set_saturation(value) - self.update_color() - - def on(self): - self.set_luminance(1) - - def off(self): - self.set_luminance(0) - - def blink_once(self): - """ - Make the eye blink - """ - raise NotImplementedError - - def blink(self, speed=0.5): - """ - Make the right eye blink in a loop - """ - while True: - self.blink_once() - sleep(speed) - - - class RightEye(Eye): - def __init__(self, bus, color=None): - super().__init__(bus=bus, pixel_range=(12, 24), color=color) - - def sync_color(self): - pixels = self.api.get_eyes_color()[12:] - for idx, (r, g, b) in enumerate(pixels): - self[idx].color = Color.from_rgb(r, g, b) - self.update_color() - - def blink_once(self): - """ - Make the right eye blink - """ - self.api.eyes_blink("r") - - - class LeftEye(Eye): - def __init__(self, bus, color=None): - super().__init__(bus=bus, pixel_range=(0, 12), color=color) - - def sync_color(self): - pixels = self.api.get_eyes_color()[:12] - for idx, (r, g, b) in enumerate(pixels): - self[idx].color = Color.from_rgb(r, g, b) - self.update_color() - - def blink_once(self): - """ - Make the left eye blink - """ - self.api.eyes_blink("l") - - - class Eyes(list): - def __init__(self, bus=None, color=None): - super().__init__() - self.bus = bus or get_mycroft_bus() - self.api = Mark1EnclosureAPI(self.bus) - self.right = RightEye(self.bus) - self.left = LeftEye(self.bus) - self.color = Color() - if color: - self.change_color(color) - else: - self.sync_color() - - def __getitem__(self, item): - assert isinstance(item, int) - assert 0 <= item <= 23 - if item < 12: - return self.left[item] - return self.right[item - 12] - - def __setitem__(self, key, value): - assert isinstance(key, int) - assert 0 <= key <= 23 - if key < 12: - self.left[key] = value - self.right[key] = value - - def __iter__(self): - for i in range(len(self)): - yield self[i] - - def __len__(self): - return len(self.left) + len(self.right) - - def sync_color(self): - """ updates internal color value to current color """ - pixels = self.api.get_eyes_color() - for idx, (r, g, b) in enumerate(pixels): - self[idx].color = Color.from_rgb(r, g, b) - - def update_color(self): - """ updates arduino color to current pixels """ - for i in range(len(self) // 2): - self.left[i].update_color() - sleep(0.05) - self.right[i].update_color() - sleep(0.05) - - def change_color(self, name): - """ changes color of both eyes """ - if isinstance(name, str): - self.color = Color.from_name(name) - elif isinstance(name, Color): - self.color = name - else: - raise ValueError("not a Color object") - r, g, b = self.color.rgb255 - self.api.eyes_color(r, g, b) - for idx in range(len(self)): - self[idx].color = self.color - - # animations - def saturation_spin(self, speed=0.05): - values = [] - for idx in range(len(self) // 2): - sat = 0.09 * idx - values.append(sat) - self.left[idx].set_saturation(sat) - sleep(0.03) - self.right[idx].set_saturation(sat) - - while True: - values = rotate_list(values, -1) - for idx, value in enumerate(values): - self.left[idx].set_saturation(value) - sleep(0.03) - self.right[idx].set_saturation(value) - sleep(speed) - - def luminance_spin(self, speed=0.05): - values = [] - for idx in range(len(self) // 2): - sat = 0.05 * idx - values.append(sat) - self.left[idx].set_luminance(sat) - sleep(0.03) - self.right[idx].set_luminance(sat) - - while True: - values = rotate_list(values, -1) - for idx, value in enumerate(values): - self.left[idx].set_luminance(value) - sleep(0.03) - self.right[idx].set_luminance(value) - sleep(speed) - - def hue_spin(self, speed=0.05): - values = [] - for idx in range(len(self) // 2): - sat = 0.083 * idx - values.append(sat) - self.left[idx].set_hue(sat) - sleep(0.03) - self.right[idx].set_hue(sat) - - while True: - values = rotate_list(values, -1) - for idx, value in enumerate(values): - for pixel in self: - print(pixel) - self.left[idx].set_hue(value) - sleep(0.03) - self.right[idx].set_hue(value) - sleep(speed) - - def flash(self, speed=0.2): - while True: - sleep(speed) - self.on() - sleep(speed) - self.off() - - def rainbow_flash(self, speed=0.2): - colors = ["red", "orange", "yellow", "green", "cyan", "blue", - "violet", "purple"] - while True: - for color in colors: - sleep(speed) - self.off() - self.change_color(color) - sleep(speed) - self.on() - - def beacon(self, speed=0.1): - values = [i + i for i in range(30)] - while True: - for value in values: - self.set_brightness(value) - sleep(speed) - values.reverse() - - def rainbow_beacon(self, speed=0.1): - values = [i + i for i in range(30)] - values += reversed(values) - colors = ["red", "orange", "yellow", "green", "cyan", "blue", - "violet", "purple"] - self.set_brightness(0) - while True: - for color in colors: - for value in values: - sleep(speed) - self.set_brightness(value) - self.change_color(color) - - # Arduino API - def set_hue(self, hue): - self.right.set_hue(hue) - sleep(0.05) - self.left.set_hue(hue) - - def set_brightness(self, level): - """ - Set the brightness of the eyes in the display. - Args: - level (int): 1-30, bigger numbers being brighter - """ - self.api.eyes_brightness(level) - - def spin(self): - self.api.eyes_spin() - - def timed_spin(self, length): - self.api.eyes_timed_spin(length) - - def reset(self): - self.api.eyes_reset() - - def fill_once(self, percent): - """ - Use the eyes as a type of progress meter - Args: - percent (int): 0-49 fills the right eye, 50-100 also covers left - """ - self.api.eyes_fill(percent) - - def look(self, side): - """Make the eyes look to the given side - Args: - side (str): 'r' for right - 'l' for left - 'u' for up - 'd' for down - 'c' for crossed - """ - self.api.eyes_look(side) - - def look_right(self): - self.look("r") - - def look_left(self): - self.look("l") - - def look_up(self): - self.look("u") - - def look_down(self): - self.look("d") - - def cross(self): - self.look("c") - - def narrow(self): - """Make the eyes look narrow, like a squint""" - self.api.eyes_narrow() - - def on(self): - """Illuminate or show the eyes.""" - self.api.eyes_on() - - def off(self): - """Turn off or hide the eyes.""" - self.api.eyes_off() - - def blink_once(self, side="b"): - """Make the eyes blink - Args: - side (str): 'r', 'l', or 'b' for 'right', 'left' or 'both' - """ - self.api.eyes_blink(side) - - def blink_right_once(self): - self.right.blink_once() - - def blink_left_once(self): - self.left.blink_once() - - def blink(self, speed=0.5): - """ - Make the eyes blink in a loop - """ - while True: - self.blink_once() - sleep(speed) - - def blink_right(self, speed=0.5): - """ - Make the right eye blink in a loop - """ - self.right.blink(speed) - - def blink_left(self, speed=0.5): - """ - Make the left eyes blink in a loop - """ - self.left.blink(speed) - - def blink_alternate(self, speed=0.5): - """ - Make the eyes blink in a loop - """ - while True: - self.blink_right_once() - sleep(speed) - self.blink_left_once() - sleep(speed) - - def up_down(self, speed=0.8): - """ - Make the eyes blink in a loop - """ - while True: - self.look_up() - sleep(speed) - self.look_down() - sleep(speed) - - def left_right(self, speed=0.8): - """ - Make the eyes blink in a loop - """ - while True: - self.look_left() - sleep(speed) - self.look_right() - sleep(speed) - - def fill(self, speed=0.1): - values = [i for i in range(101)] - values += reversed(values) - while True: - for percent in values: - self.fill_once(percent) - sleep(speed) - - def rainbow_fill(self, speed=0.1): - values = [i for i in range(101)] - values += reversed(values) - colors = ["red", "orange", "yellow", "green", "cyan", "blue", - "violet", "purple"] - while True: - for color in colors: - for percent in values: - self.fill_once(percent) - sleep(speed) - if percent == 100: - self.change_color(color) diff --git a/ovos_utils/enclosure/mark1/faceplate/__init__.py b/ovos_utils/enclosure/mark1/faceplate/__init__.py deleted file mode 100644 index f6fedfbd..00000000 --- a/ovos_utils/enclosure/mark1/faceplate/__init__.py +++ /dev/null @@ -1,410 +0,0 @@ -from ovos_utils.enclosure.mark1 import Mark1EnclosureAPI -from ovos_utils import create_loop -from ovos_utils.log import LOG, log_deprecation -from ovos_utils.messagebus import get_mycroft_bus -import random -from time import sleep -from collections.abc import MutableSequence -import copy - -log_deprecation("ovos_utils.enclosure.mark1.faceplate moved to https://github.com/OpenVoiceOS/ovos-mark1-utils", "0.1.0") - - -try: - from ovos_mark1.faceplate import * -except ImportError: - - class FaceplateGrid(MutableSequence): - encoded = None - str_grid = None - pad_char = "." - - def __init__(self, grid=None, bus=None): - self.bus = bus or get_mycroft_bus() - self._api = Mark1EnclosureAPI(self.bus) - self.grid = [] - for x in range(8): - self.grid.append([]) - for y in range(32): - self.grid[x].append(0) - if self.encoded: - self.grid = self.decode(self.encoded).grid - elif self.str_grid is not None: - self.grid = FaceplateGrid(bus=self.bus)\ - .from_string(self.str_grid).grid - elif grid is not None: - self.grid = grid - - @property - def height(self): - return len(self.grid) - - @property - def width(self): - return max([len(r) for r in self.grid]) - - def display(self, invert=True, clear=True, x_offset=0, y_offset=0): - self._api.mouth_display(self.encode(invert), - x_offset, y_offset, clear) - - def print(self, draw_padding=True, invert=False): - print(self.to_string(draw_padding=draw_padding, invert=invert)) - - def encode(self, invert=False): - # to understand how this function works you need to understand how the - # Mark I arduino proprietary encoding works to display to the faceplate - - # https://mycroft-ai.gitbook.io/docs/skill-development/displaying-information/mark-1-display - - # Each char value str_gridesents a width number starting with B=1 - # then increment 1 for the next. ie C=2 - width_codes = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', - 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', - 'X', 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a'] - - height_codes = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'] - - encode = width_codes[self.width - 1] - encode += height_codes[self.height - 1] - - # Turn the image pixels into binary values 1's and 0's - # the Mark I face plate encoding uses binary values to - # binary_values returns a list of 1's and 0s'. ie ['1', '1', '0', ...] - binary_values = [] - for i in range(self.width): # pixels - for j in range(self.height): # lines - pixels = self.grid[j] - - if pixels[i] is None: # padding - pixels[i] = 0 - - if pixels[i] != 0: - if invert is False: - binary_values.append('1') - else: - binary_values.append('0') - else: - if invert is False: - binary_values.append('0') - else: - binary_values.append('1') - # these values are used to determine how binary values - # needs to be grouped together - number_of_bottom_pixel = 0 - - if self.height > 4: - number_of_top_pixel = 4 - number_of_bottom_pixel = self.height - 4 - else: - number_of_top_pixel = self.height - - # this loop will group together the individual binary values - # ie. binary_list = ['1111', '001', '0101', '100'] - binary_list = [] - binary_code = '' - increment = 0 - alternate = False - for val in binary_values: - binary_code += val - increment += 1 - if increment == number_of_top_pixel and alternate is False: - # binary code is reversed for encoding - binary_list.append(binary_code[::-1]) - increment = 0 - binary_code = '' - alternate = True - elif increment == number_of_bottom_pixel and alternate is True: - binary_list.append(binary_code[::-1]) - increment = 0 - binary_code = '' - alternate = False - # Code to let the Mark I arduino know where to place the - # pixels on the faceplate - pixel_codes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', - 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'] - for binary_values in binary_list: - number = int(binary_values, 2) - pixel_code = pixel_codes[number] - encode += pixel_code - return encode - - def decode(self, encoded, invert=False, pad=True): - codes = list(encoded) - - # Each char value str_gridesents a width number starting with B=1 - # then increment 1 for the next. ie C=2 - width_codes = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', - 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', - 'X', 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a'] - - height_codes = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'] - - height = height_codes.index(codes[1]) + 1 - width = width_codes.index(codes[0]) + 1 - - # Code to let the Mark I arduino know where to place the - # pixels on the faceplate - pixel_codes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', - 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'] - codes.reverse() - binary_list = [] - for pixel_code in codes[:-2]: - number = pixel_codes.index(pixel_code.upper()) - bin_str = str(bin(number))[2:] - while not len(bin_str) == 4: - bin_str = "0" + bin_str - binary_list += [bin_str] - - binary_list.reverse() - - for idx, binary_code in enumerate(binary_list): - # binary code is reversed for encoding - binary_list[idx] = binary_code[::-1] - - binary_code = "".join(binary_list) - - # Turn the image pixels into binary values 1's and 0's - # the Mark I face plate encoding uses binary values to - # binary_values returns a list of 1's and 0s'. ie ['1', '1', '0', ...] - grid = [] - # binary_code is a sequence of column by column - cols = [list(binary_code)[x:x + height] for x in - range(0, len(list(binary_code)), height)] - - for x in range(height): - row = [] - for y in range(width): - bit = int(cols[y][x]) - if invert: - if bit: - bit = 0 - else: - bit = 1 - row.append(bit) - grid.append(row) - - # handle padding - if pad: - if width < self.width: - n = int((self.width - width) / 2) - if invert: - padding = [1] * n - else: - padding = [0] * n - for idx, row in enumerate(grid): - grid[idx] = padding + row + padding - if height < self.height: - pass # TODO vertical padding - self.grid = grid - return self - - def from_string(self, str_grid): - rows = [r for r in str_grid.split("\n") if len(r)] - grid = [] - for r in rows: - row = [] - for char in list(r): - if char == " ": - row.append(1) - elif char == FaceplateGrid.pad_char: - row.append(None) - else: - row.append(0) - while len(row) < self.width: - row.append(None) - grid.append(row) - self.grid = grid - return self - - def to_string(self, draw_padding=False, invert=False): - str_grid = "" - for row in self.grid: - line = "" - for col in row: - if col is None and draw_padding: - line += self.pad_char - elif col == 1: - if invert: - line += "X" - else: - line += " " - elif col == 0: - if invert: - line += " " - else: - line += "X" - str_grid += line + "\n" - return str_grid - - def invert(self): - for x in range(self.height): - for y in range(self.width): - if self.grid[x][y] == 0: - self.grid[x][y] = 1 - elif self.grid[x][y] == 1: - self.grid[x][y] = 0 - return self - - def clear(self): - for x in range(self.height): - for y in range(self.width): - self.grid[x][y] = 0 - return self - - @property - def is_empty(self): - for x in range(self.height): - for y in range(self.width): - if self.grid[x][y] == 1: - return False - return True - - def randomize(self, n=200): - for i in range(n): - x = random.randint(0, self.height-1) - y = random.randint(0, self.width-1) - self.grid[x][y] = int(random.randint(0, 1)) - return self - - def __len__(self): - # number of pixels - return self.width * self.height - - def __delitem__(self, index): - self.grid.__delitem__(index) - - def insert(self, index, value): - self.grid.insert(index - 1, value) - - def __setitem__(self, index, value): - self.grid.__setitem__(index, value) - - def __getitem__(self, index): - return self.grid.__getitem__(index) - - - class FacePlateAnimation(FaceplateGrid): - - def __init__(self, grid=None, bus=None): - super().__init__(grid, bus) - self.finished = False - - def animate(self): - pass - - def __iter__(self): - while not self.finished: - self.animate() - yield self - - def start(self): - self.finished = False - - def stop(self): - self.finished = True - - def run(self, delay=0.5, callback=None, daemonic=False): - self.start() - - if delay < 0.4: - # writer bugs out if sending messages too rapidly - delay = 0.4 - - def step(callback=callback): - try: - if not self.finished: - self.animate() - if callback: - callback(self) - except Exception as e: - LOG.error(e) - - if daemonic: - create_loop(step, delay) - else: - while not self.finished: - step() - sleep(delay) - self.stop() - - def scroll_down(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - self.grid[y][x] = old[y - 1][x] - - def scroll_up(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - if y == self.height - 1: - self.grid[y][x] = old[0][x] - else: - self.grid[y][x] = old[y + 1][x] - - def scroll_right(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - self.grid[y][x] = old[y][x - 1] - - def scroll_left(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - if x == self.width -1: - self.grid[y][x] = old[y][0] - else: - self.grid[y][x] = old[y][x + 1] - - def move_down(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - if y - 1 < 0: - self.grid[y][x] = 0 - else: - self.grid[y][x] = old[y - 1][x] - - def move_up(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - if y == self.height - 1: - self.grid[y][x] = 0 - else: - self.grid[y][x] = old[y + 1][x] - - def move_right(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - self.grid[y][x] = old[y][x - 1] - - def move_left(self): - old = copy.deepcopy(self.grid) - for x in range(self.width): - for y in range(self.height): - if x == self.width - 1: - self.grid[y][x] = 0 - else: - self.grid[y][x] = old[y][x + 1] - - - class BlackScreen(FaceplateGrid): - # Basically a util class to handle - # inverting on __init__ - str_grid = """ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.invert() diff --git a/ovos_utils/enclosure/mark1/faceplate/animations.py b/ovos_utils/enclosure/mark1/faceplate/animations.py deleted file mode 100644 index cc50e40f..00000000 --- a/ovos_utils/enclosure/mark1/faceplate/animations.py +++ /dev/null @@ -1,570 +0,0 @@ -import copy -import random - -from ovos_utils.log import log_deprecation - -log_deprecation("ovos_utils.enclosure.mark1.faceplate moved to https://github.com/OpenVoiceOS/ovos-mark1-utils", "0.1.0") - - -try: - from ovos_mark1.faceplate.animations import * -except ImportError: - from ovos_utils.enclosure.mark1.faceplate import FacePlateAnimation, BlackScreen - - # Base animations - # These are mostly meant to be subclassed (empty animations) - class HorizontalScroll(FacePlateAnimation): - def __init__(self, direction="right", grid=None, bus=None): - super().__init__(grid, bus) - assert direction.startswith("r") or direction.startswith("l") - self.direction = direction[0] - - def animate(self): - if self.direction == "r": - self.scroll_right() - else: - self.scroll_left() - if self.is_empty: - self.stop() - - - class VerticalScroll(FacePlateAnimation): - def __init__(self, direction="up", - grid=None, bus=None): - super().__init__(grid, bus) - assert direction.startswith("u") or direction.startswith("d") - self.direction = direction[0] - - def animate(self): - if self.direction == "u": - self.scroll_up() - else: - self.scroll_down() - if self.is_empty: - self.stop() - - - class LeftRight(FacePlateAnimation): - def __init__(self, direction="right", start="left", grid=None, bus=None): - super().__init__(grid, bus) - assert direction.startswith("r") or direction.startswith("l") - self.direction = direction[0] - - # start at right/left side/center - inverted = not isinstance(self, BlackScreen) - if start[0] == "l": - # left side - for y in range(self.height): - for x in range(self.width): - if not inverted and self.grid[y][x] == 1: - pass - elif inverted and self.grid[y][x] == 0: - pass - elif start[0] == "r": - pass # right side - else: - pass # center - print(self.grid[1]) - - def animate(self): - left_collision = False - right_collision = False - inverted = not isinstance(self, BlackScreen) - for y in range(self.height): - if inverted: - if self.grid[y][self.width - 1] == 0: - right_collision = True - if self.grid[y][0] == 0: - left_collision = True - else: - if self.grid[y][self.width - 1] == 1: - right_collision = True - if self.grid[y][0] == 1: - left_collision = True - if left_collision and right_collision: - return # No space left to animate - elif right_collision: - self.direction = "l" - elif left_collision: - self.direction = "r" - if self.direction == "r": - self.scroll_right() - else: - self.scroll_left() - if self.is_empty: - self.stop() - - - class UpDown(FacePlateAnimation): - def __init__(self, direction="up", grid=None, bus=None): - super().__init__(grid, bus) - assert direction.startswith("u") or direction.startswith("d") - self.direction = direction[0] - - def animate(self): - top_collision = False - bottom_collision = False - for x in range(self.width): - if self.grid[0][x] == 1: - top_collision = True - if self.grid[self.height - 1][x] == 1: - bottom_collision = True - - if top_collision and bottom_collision: - return # No space left to animate - elif top_collision: - self.direction = "d" - elif bottom_collision: - self.direction = "u" - if self.direction == "u": - self.scroll_up() - else: - self.scroll_down() - if self.is_empty: - self.stop() - - - class CollisionBox(FacePlateAnimation): - def __init__(self, - horizontal_direction=None, - vertical_direction=None, - grid=None, bus=None): - super().__init__(grid, bus) - assert horizontal_direction is None or \ - horizontal_direction.startswith("r") or \ - horizontal_direction.startswith("l") - assert vertical_direction is None or \ - vertical_direction.startswith("u") or \ - vertical_direction.startswith("d") - self.vertical_direction = vertical_direction[0] if \ - vertical_direction else None - self.horizontal_direction = horizontal_direction[0] if \ - horizontal_direction else None - - def animate(self): - left_collision = False - right_collision = False - top_collision = False - bottom_collision = False - for y in range(self.height): - if self.grid[y][self.width - 1] == 1: - right_collision = True - if self.grid[y][0] == 1: - left_collision = True - for x in range(self.width): - if self.grid[0][x] == 1: - top_collision = True - if self.grid[self.height - 1][x] == 1: - bottom_collision = True - - if top_collision and bottom_collision: - self.vertical_direction = None - elif top_collision: - self.vertical_direction = "d" - elif bottom_collision: - self.vertical_direction = "u" - - if left_collision and right_collision: - self.horizontal_direction = None - elif right_collision: - self.horizontal_direction = "l" - elif left_collision: - self.horizontal_direction = "r" - - if self.vertical_direction is None: - pass - elif self.vertical_direction == "u": - self.scroll_up() - elif self.vertical_direction == "d": - self.scroll_down() - - if self.horizontal_direction is None: - pass - elif self.horizontal_direction == "r": - self.scroll_right() - elif self.horizontal_direction == "l": - self.scroll_left() - - if self.is_empty: - self.stop() - - - # Ready to use animations - class SquareWave(HorizontalScroll): - def __init__(self, direction="r", frequency=3, - amplitude=4, grid=None, bus=None): - super().__init__(direction, grid, bus) - # frequency must be > 1 - # frequency is in number of pixels - assert 0 < frequency - # amplitude must be 2, 4 or 6. else it renders badly - # amplitude is in number of pixels - assert 0 < amplitude < self.height - assert divmod(amplitude, 2)[1] == 0 - - self.freq = frequency - self.amplitude = self.height - amplitude - - self._initial_grid() - self.invert() - - def _initial_grid(self): - # draws the initial state - count = 0 - top = True - a = self.amplitude // 2 - 1 - for x in range(self.width): - if top: - self.grid[a + 1][x] = 1 - else: - self.grid[-a - 1][x] = 1 - - for y in range(self.height): - if count == 0: - self.grid[y][x] = 1 - if y <= a: - self.grid[y][x] = 0 - elif y >= self.height - a: - self.grid[y][x] = 0 - count += 1 - if count == self.freq + 1: - count = 0 - top = not top - - - class StrayDot(CollisionBox): - def __init__(self, - start_x=None, - start_y=None, - horizontal_direction=None, - vertical_direction=None, - grid=None, bus=None): - horizontal_direction = horizontal_direction or \ - random.choice(["l", "r"]) - vertical_direction = vertical_direction or \ - random.choice(["u", "d"]) - super().__init__(horizontal_direction, vertical_direction, - grid, bus) - start_x = start_x or random.randint(0, self.width - 1) - start_y = start_y or random.randint(0, self.height - 1) - self.grid[start_y][start_x] = 1 - - - class ParticleBox(FacePlateAnimation): - def __init__(self, n_particles=5, bus=None): - super().__init__(bus=bus) - assert 0 < n_particles < 11 - self.n_particles = n_particles - self.particles = [] - - class Dot: - def __init__(self, idx, x, y, vx, vy): - self.x = x - self.y = y - self.vx = vx - self.vy = vy - self.idx = idx - - for i in range(n_particles): - vx = random.choice(["l", "r"]) - vy = random.choice(["u", "d"]) - x = random.randint(0, self.width - 1) - y = random.randint(0, self.height - 1) - while self.grid[y][x] == 1: - # 2 particles can't occupy same space - x = random.randint(0, self.width - 1) - y = random.randint(0, self.height - 1) - self.grid[y][x] = 1 - self.particles.append(Dot(i, x, y, vx, vy)) - - def render_particles(self): - self.clear() - for p in self.particles: - self.grid[p.y][p.x] = 1 - - def get_particle(self, x, y): - for p in self.particles: - if p.x == x and p.y == y: - return p - - def process_collisions(self): - # new particles after this turn - new_particles = copy.deepcopy(self.particles) - - # NOTE this is not a physics simulation! - # while it is behaving like an elastic collision - # if there is a 3+ particle collision results will be incorrect - # as long as only 2 particles collide it looks accurate - # max number of particles limited to 10 to minimize chance of this - # happening - for p in self.particles: - idx = p.idx - - # horizontal movement - if p.vx is None: - # not moving horizontally - if p.x != 0: - # check for collisions from left p2 -> p1 - p2 = self.get_particle(p.x - 1, p.y) - if p2 and p2.vx == "r": - # collision p2 -> p1 - if p.x == self.width - 1: - new_particles[idx].vx = None - else: - new_particles[idx].vx = "r" - new_particles[idx].x += 1 - if p.x != self.width - 1: - # check for collisions from right p1 <- p2 - p2 = self.get_particle(p.x + 1, p.y) - if p2 and p2.vx == "l": - # collision p1 <- p2 - if p.x == 0: - new_particles[idx].vx = None - else: - new_particles[idx].vx = "l" - new_particles[idx].x -= 1 - # moving right - elif p.vx == "r": - p2 = self.get_particle(p.x + 1, p.y) - if p.x == self.width - 1: - # border collision p1 -> | - new_particles[idx].vx = "l" - new_particles[idx].x -= 1 - elif p2: - # particle collision p1 -> p2 - if p2.vx is None: - # p2 moves, p1 stops - new_particles[idx].vx = None - elif p2.vx == "r": - # moving together - new_particles[idx].x += 1 - elif p2 and p2.vx == "l": - if p.x == 0: - new_particles[idx].vx = None - else: - # both change direction - new_particles[idx].vx = "l" - new_particles[idx].x -= 1 - else: - # move right - new_particles[idx].x += 1 - # moving left - elif p.vx == "l": - p2 = self.get_particle(p.x - 1, p.y) - if p.x == 0: - # border collision | <- p1 - new_particles[idx].vx = "r" - new_particles[idx].x += 1 - elif p2: - # particle collision p2 <- p1 - - if p2.vx is None: - # p2 moves, p1 stops - new_particles[idx].vx = None - elif p2.vx == "l": - # moving together, no collision - new_particles[idx].x -= 1 - elif p2.vx == "r": - if p.x == self.width - 1: - new_particles[idx].vx = None - else: - # both change direction - new_particles[idx].vx = "r" - new_particles[idx].x += 1 - else: - # move left - new_particles[idx].x -= 1 - - # vertical movement - if p.vy is None: - # not moving vertically - if p.y != 0: - # check for collisions from top p2 -> p1 - p2 = self.get_particle(p.x, p.y - 1) - if p2 and p2.vy == "d": - if p.y == self.height - 1: - new_particles[idx].vy = None - else: - # collision p2 -> p1 - new_particles[idx].vy = "d" - new_particles[idx].y += 1 - if p.y != self.height - 1: - # check for collisions from bottom p1 <- p2 - p2 = self.get_particle(p.x, p.y + 1) - if p2 and p2.vy == "u": - # collision p1 <- p2 - if p.y == 0: # on top - new_particles[idx].vy = None - else: - new_particles[idx].vy = "u" - new_particles[idx].y -= 1 - # moving down - elif p.vy == "d": - p2 = self.get_particle(p.x, p.y + 1) - if p.y == self.height - 1: - # border collision p1 -> | - new_particles[idx].vy = "u" - new_particles[idx].y -= 1 - elif p2: - # particle collision p1 -> p2 - - if p2.vy is None: - # p2 moves, p1 stops - new_particles[idx].vy = None - elif p2.vy == "d": - # moving together - new_particles[idx].y += 1 - elif p2.vy == "u": - if p.y == 0: - new_particles[ - idx].vy = None # wall absorbed momentum - else: - # both change direction - new_particles[idx].vy = "u" - new_particles[idx].y -= 1 - else: - # move down - new_particles[idx].y += 1 - # moving up - elif p.vy == "u": - - p2 = self.get_particle(p.x, p.y - 1) - if p.y == 0: - # border collision | <- p1 - new_particles[idx].vy = "d" - new_particles[idx].y += 1 - elif p2: - # particle collision p2 <- p1 - if p2.vy is None: - # p2 moves, p1 stops - new_particles[idx].vy = None - elif p2.vy == "u": - # moving together, no collision - new_particles[idx].y -= 1 - elif p2.vy == "d": - # both change direction - if p.y == self.height - 1: - new_particles[idx].vy = None - else: - new_particles[idx].vy = "d" - new_particles[idx].y += 1 - else: - # move left - new_particles[idx].y -= 1 - - # update processed particles - self.particles = new_particles - - def animate(self): - self.process_collisions() - self.render_particles() - - - class FallingDots(FacePlateAnimation): - def __init__(self, n=10, bus=None): - super().__init__(bus=bus) - self._create = True - assert 0 < n < 32 - self.n = n - - @property - def n_dots(self): - n = 0 - for y in range(self.height): - for x in range(self.width): - if self.grid[y][x]: - n += 1 - return n - - def animate(self): - self.move_down() - if self._create: - if random.choice([True, False]): - self._create = False - x = random.randint(0, self.width - 1) - self.grid[0][x] = 1 - if self.n_dots < self.n: - self._create = True - - - class StraightParticleShooter(FacePlateAnimation): - def __init__(self, period=None, bus=None): - super().__init__(bus=bus) - self.direction = "d" - self.period = period - self.counter = 0 - # draw shooter - self.grid[0][0] = 1 - self.grid[1][0] = 1 - self.grid[1][1] = 1 - self.grid[2][0] = 1 - - def line_down(self): - old = copy.deepcopy(self.grid) - for y in range(self.height): - self.grid[y][0] = old[y - 1][0] - self.grid[y][1] = old[y - 1][1] - - def line_up(self): - old = copy.deepcopy(self.grid) - for y in range(self.height): - if y == self.height - 1: - self.grid[y][0] = old[0][0] - self.grid[y][1] = old[0][1] - else: - self.grid[y][0] = old[y + 1][0] - self.grid[y][1] = old[y + 1][1] - - def scroll_particles(self): - old = copy.deepcopy(self.grid) - for x in range(2, self.width): - for y in range(self.height): - if old[y][x] == 1: - self.grid[y][x] = 0 - if x < self.width - 1: - self.grid[y][x + 1] = 1 - - @property - def num_particles(self): - n = 0 - for x in range(2, self.width): - for y in range(self.height): - if self.grid[y][x] == 1: - n += 1 - return n - - @property - def line(self): - for y in range(self.height): - if self.grid[y][0] == 1: - return y - return 0 - - def animate(self): - # collision detection - top_collision = False - bottom_collision = False - if self.grid[0][0] == 1: - top_collision = True - elif self.grid[self.height - 1][0] == 1: - bottom_collision = True - if top_collision: - self.direction = "d" - elif bottom_collision: - self.direction = "u" - - # bounce the "emitter" up and down - if self.direction == "u": - self.line_up() - else: - self.line_down() - - # create particles - period = self.period or random.randint(0, 20) - if self.num_particles < 1 or self.counter >= period: - self.grid[self.line + 1][2] = 1 - self.counter = 0 - - # animate particles - self.scroll_particles() - self.counter += 1 diff --git a/ovos_utils/enclosure/mark1/faceplate/cellular_automaton.py b/ovos_utils/enclosure/mark1/faceplate/cellular_automaton.py deleted file mode 100644 index 23198546..00000000 --- a/ovos_utils/enclosure/mark1/faceplate/cellular_automaton.py +++ /dev/null @@ -1,485 +0,0 @@ -import copy -import random - -from ovos_utils.log import log_deprecation - -log_deprecation("ovos_utils.enclosure.mark1.faceplate moved to https://github.com/OpenVoiceOS/ovos-mark1-utils", "0.1.0") - - -try: - from ovos_mark1.faceplate.cellular_automaton import * -except: - from ovos_utils.enclosure.mark1.faceplate import FacePlateAnimation - - - # Game of Life Base - class GoL(FacePlateAnimation): - - def __init__(self, entropy=0, grid=None, bus=None): - super().__init__(grid, bus) - self.entropy = entropy - if self.is_empty: - self.randomize() - - def _live_neighbours(self, y, x): - """Returns the number of live neighbours.""" - count = 0 - if y > 0: - if self.grid[y - 1][x]: - count = count + 1 - if x > 0: - if self.grid[y - 1][x - 1]: - count = count + 1 - if self.width > (x + 1): - if self.grid[y - 1][x + 1]: - count = count + 1 - - if x > 0: - if self.grid[y][x - 1]: - count = count + 1 - if self.width > (x + 1): - if self.grid[y][x + 1]: - count = count + 1 - - if self.height > (y + 1): - if self.grid[y + 1][x]: - count = count + 1 - if x > 0: - if self.grid[y + 1][x - 1]: - count = count + 1 - if self.width > (x + 1): - if self.grid[y + 1][x + 1]: - count = count + 1 - - return count - - def animate(self): - """Game of Life turn""" - nt = copy.deepcopy(self.grid) - for y in range(0, self.height): - for x in range(0, self.width): - neighbours = self._live_neighbours(y, x) - if self.grid[y][x] == 0: - if neighbours == 3: - nt[y][x] = 1 - else: - if (neighbours < 2) or (neighbours > 3): - nt[y][x] = 0 - if nt == self.grid and self.entropy <= 0: - self.stop() - self.grid = nt - self.randomize(self.entropy) - if self.is_empty: - self.stop() - - - # Langtons Ant base - class _Ant: - def __init__(self, x, y, direction, height=8, width=32): - self.x = x - self.y = y - assert direction[0] in ["r", "l", "u", "d"] - self.direction = direction[0] - self.grid_height = height - self.grid_width = width - self.dead = False - - def move_forward(self): - if self.direction == "r": - self.x += 1 - if self.x == self.grid_width: - self.dead = True - elif self.direction == "d": - self.y += 1 - if self.y == self.grid_height: - self.dead = True - elif self.direction == "l": - self.x -= 1 - if self.x == -1: - self.dead = True - elif self.direction == "u": - self.y -= 1 - if self.y == -1: - self.dead = True - - def turn_right(self): - if self.direction == "r": - self.direction = "d" - elif self.direction == "d": - self.direction = "l" - elif self.direction == "l": - self.direction = "u" - elif self.direction == "u": - self.direction = "r" - - def turn_left(self): - if self.direction == "r": - self.direction = "u" - elif self.direction == "d": - self.direction = "r" - elif self.direction == "l": - self.direction = "d" - elif self.direction == "u": - self.direction = "l" - - - class _InfiniteAnt(_Ant): - def move_forward(self): - if self.direction == "r": - self.x += 1 - if self.x == self.grid_width: - self.x = 0 - elif self.direction == "d": - self.y += 1 - if self.y == self.grid_height: - self.y = 0 - elif self.direction == "l": - self.x -= 1 - if self.x == -1: - self.x = self.grid_width - 1 - elif self.direction == "u": - self.y -= 1 - if self.y == -1: - self.y = self.grid_height - 1 - - - class _ReverseAnt(_Ant): - def turn_left(self): - super().turn_right() - - def turn_right(self): - super().turn_left() - - - class _ReverseInfiniteAnt(_InfiniteAnt): - def turn_left(self): - super().turn_right() - - def turn_right(self): - super().turn_left() - - - class LangtonsAnt(FacePlateAnimation): - def __init__(self, ants=1, continuous=True, gen_reverse=False, - grid=None, bus=None): - super().__init__(grid=grid, bus=bus) - self.ants = [] - # if continuous loops around the board - # height + 1 -> 0 - # width + 1 -> 0 - # else ant is removed - self.continuous = continuous - if isinstance(ants, int): - # spawn N ants - assert 0 <= ants < 256 - for i in range(ants): - x = random.randint(0, self.width - 1) - y = random.randint(0, self.height - 1) - direction = random.choice(["u", "d", "l", "r"]) - reverse = False - if gen_reverse: - reverse = random.choice([True, False]) - ant = self.ant_factory(x, y, direction, reverse) - self.ants.append(ant) - elif isinstance(ants, list): - # Ant objects - self.ants = ants - for ant in self.ants: - assert isinstance(ant, _Ant) - else: - raise ValueError - - def ant_factory(self, x, y, direction, reverse=False): - # reverse ants exit black squares to the opposite direction - if self.continuous: - # loops around the board instead of dying - if reverse: - return _ReverseInfiniteAnt(x, y, direction) - return _InfiniteAnt(x, y, direction) - if reverse: - return _ReverseAnt(x, y, direction) - return _Ant(x, y, direction) - - def move_ants(self): - # copy grid, multiple ants might want to flip same square - # end result is the same so this is not a problem as long as it does - # not change during iteration - old_grid = copy.deepcopy(self.grid) - - for idx, ant in enumerate(self.ants): - if self.ants[idx].dead: - continue - if old_grid[ant.y][ant.x] == 1: - # black - self.ants[idx].turn_left() - else: - # white - self.ants[idx].turn_right() - # flip color - self.grid[ant.y][ant.x] = not old_grid[ant.y][ant.x] - # update ant position - self.ants[idx].move_forward() - - def animate(self): - self.move_ants() - # Stop condition -> all ants moved out of the board - dead_ants = [ant for ant in self.ants if ant.dead] - if len(dead_ants) == len(self.ants): - self.stop() - - - # Game of Life Animations - class SpaceInvader(GoL): - # This basically half "pulsar" - str_grid = """ - XXXXXXXXXXXX XXX XXXXXXXXXXXXX - XXXXXXXXXX X X X X X XXXXXXXXXXX - XXXXXXXX XX X XX XXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXX XXX XXXXXXXXXXXXX - XXXXXXXXXXXX XXXXX XXXXXXXXXXXXX - XXXXXXXXXXXX XXXXX XXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - """ - - - # Langton's Ant animations - - # Single Ant - class LangtonsLineDisplacer(LangtonsAnt): - # see pattern here - # https://youtu.be/w6XQQhCgq5c?t=84 - - def __init__(self, x=None, y=None, continuous=True, bus=None): - super().__init__(0, continuous, bus=bus) - x = x if x is not None else random.randint(0, self.width - 1) - y = y if y is not None else random.randint(0, self.height - 1) - ant = self.ant_factory(x, y - 1, "u") - self.ants.append(ant) - # create initial line - for i in range(0, self.width): - self.grid[y][i] = 1 - - - # 2 Ants - class LangtonsAntsOscillator(LangtonsAnt): - # see pattern here - # https://youtu.be/w6XQQhCgq5c?t=103 - - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x1 = x2 = x if x is not None else self.width // 2 - y1 = y if y is not None else self.height // 2 - y2 = y1 - 1 - dir1 = "d" - dir2 = "u" - ant1 = self.ant_factory(x1, y1, dir1) - ant2 = self.ant_factory(x2, y2, dir2) - self.ants += [ant1, ant2] - - - class LangtonsAntsOscillator2(LangtonsAnt): - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x1 = x2 = x if x is not None else self.width // 2 - y1 = y if y is not None else self.height // 2 - y2 = y1 - 1 - dir1 = dir2 = "u" - ant1 = self.ant_factory(x1, y1, dir1) - ant2 = self.ant_factory(x2, y2, dir2) - self.ants += [ant1, ant2] - - - class LangtonsAntsOscillator3(LangtonsAnt): - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x1 = x2 = x if x is not None else self.width // 2 - y1 = y if y is not None else self.height // 2 - y2 = y1 - 1 - dir1 = "l" - dir2 = "r" - ant1 = self.ant_factory(x1, y1, dir1) - ant2 = self.ant_factory(x2, y2, dir2) - self.ants += [ant1, ant2] - - - class LangtonsAntsOscillator4(LangtonsAnt): - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x1 = x2 = x if x is not None else self.width // 2 - y1 = y if y is not None else self.height // 2 - y2 = y1 - 1 - dir1 = dir2 = "l" - ant1 = self.ant_factory(x1, y1, dir1) - ant2 = self.ant_factory(x2, y2, dir2) - self.ants += [ant1, ant2] - - - class LangtonsAntsOscillator5(LangtonsAnt): - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x1 = x2 = x if x is not None else self.width // 2 - y1 = y if y is not None else self.height // 2 - y2 = y1 - 1 - dir1 = "u" - dir2 = "d" - ant1 = self.ant_factory(x1, y1, dir1) - ant2 = self.ant_factory(x2, y2, dir2) - self.ants += [ant1, ant2] - - - class LangtonsAntTrail(LangtonsAnt): - # see pattern here - # https://youtu.be/w6XQQhCgq5c?t=159 - - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x = x if x is not None else random.randint(0, self.width - 1) - y = y if y is not None else random.randint(0, self.height - 1) - dir1 = "u" - dir2 = "d" - ant1 = self.ant_factory(x, y - 1, dir1) - ant2 = self.ant_factory(x, y, dir2, reverse=True) - self.ants += [ant1, ant2] - - - class LangtonsAntDotTransporter(LangtonsAnt): - # https://youtu.be/w6XQQhCgq5c?t=171 - - def __init__(self, x=None, y=None, bus=None): - super().__init__(0, bus=bus) - x = x if x is not None else random.randint(0, self.width - 1) - y = y if y is not None else random.randint(0, self.height - 1) - dir1 = dir2 = "u" - ant1 = self.ant_factory(x, y, dir1) - ant2 = self.ant_factory(x + 1, y, dir2, reverse=True) - self.ants += [ant1, ant2] - # initial grid - if y + 1 == self.height: - self.grid[0][x + 1] = 1 # bellow anti-ant - else: - self.grid[y + 1][x + 1] = 1 # bellow anti-ant - self.grid[y - 1][x] = 1 # above ant - - - # 1D / elementar automata - - class ElementarAutomata(FacePlateAnimation): - def __init__(self, direction="u", idx=0, seed=None, grid=None, bus=None): - super().__init__(grid, bus) - assert direction[0] in ["u", "d", "l", "r"] - self.direction = direction[0] - self.row = idx - self.initial_state(seed) - - def initial_state(self, seed=None): - if seed is not None: - line = seed - else: - line = [0 for i in range(self.width)] - self.grid[self.row] = line - - def rule(self): - # process the line - raise NotImplementedError - - def animate(self): - old = copy.deepcopy(self.grid) - new_line = self.rule() - if self.direction == "u": - self.move_up() - elif self.direction == "d": - self.move_down() - elif self.direction == "l": - self.move_left() - elif self.direction == "r": - self.move_right() - self.grid[self.row] = new_line - if old == self.grid: - self.stop() - - - class SierpinskiTriangle(ElementarAutomata): - def __init__(self, direction="u", seed=None, bus=None): - assert direction[0] in ["u", "d"] - if direction[0] == "u": - idx = -1 - else: - idx = 0 - super().__init__(direction, idx, seed=seed, bus=bus) - - def initial_state(self, seed=None): - if seed is not None: - line = seed - else: - line = [0 for i in range(self.width)] - idx = self.width // 2 - line[idx] = 1 - self.grid[self.row] = line - - def rule(self): - new_line = copy.deepcopy(self.grid[self.row]) - # handle middle - for i in range(1, self.width - 1): - left = self.grid[self.row][i - 1] - right = self.grid[self.row][i + 1] - if left and right: - new_line[i] = 0 - elif left or right: - new_line[i] = 1 - else: - new_line[i] = 0 - - # handle edges - # if self.grid[0][1] == 1: - # new_line[0] = 1 - # else: - # new_line[0] = 0 - # if self.grid[0][-2] == 1: - # new_line[-1] = 1 - # else: - # new_line[-1] = 0 - return new_line - - - class Rule110(ElementarAutomata): - def __init__(self, direction="u", seed=None, bus=None): - assert direction[0] in ["u", "d"] - if direction[0] == "u": - idx = -1 - else: - idx = 0 - super().__init__(direction, idx, seed=seed, bus=bus) - - def initial_state(self, seed=None): - if seed is not None: - line = seed - else: - line = [0 for i in range(self.width)] - idx = self.width - 1 - line[idx] = 1 - self.grid[self.row] = line - - def rule(self): - new_line = copy.deepcopy(self.grid[self.row]) - # handle middle - for i in range(1, self.width - 1): - left = self.grid[self.row][i - 1] - mid = self.grid[self.row][i] - right = self.grid[self.row][i + 1] - if str(left) + str(mid) + str(right) in \ - ["110", "101", "011", "010", "001"]: - new_line[i] = 1 - else: - new_line[i] = 0 - - # handle edges - if self.grid[0][1] == 1: - new_line[0] = 1 - else: - new_line[0] = 0 - if self.grid[0][-2] == 1: - new_line[-1] = 1 - else: - new_line[-1] = 0 - return new_line diff --git a/ovos_utils/enclosure/mark1/faceplate/icons.py b/ovos_utils/enclosure/mark1/faceplate/icons.py deleted file mode 100644 index f49c1e02..00000000 --- a/ovos_utils/enclosure/mark1/faceplate/icons.py +++ /dev/null @@ -1,239 +0,0 @@ -from ovos_utils.log import log_deprecation - -log_deprecation("ovos_utils.enclosure.mark1.faceplate moved to https://github.com/OpenVoiceOS/ovos-mark1-utils", "0.1.0") - - -try: - from ovos_mark1.faceplate.icons import * -except: - from ovos_utils.enclosure.mark1.faceplate import FaceplateGrid, BlackScreen - - class MusicIcon(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXX XXXXXXXXXXXXX - XXXXXXXXXXXXXX XXXXXXXXXXXXX - XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX - XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX - XXXXXXXXXXXXX XX XXXXXXXXXXXXX - XXXXXXXXXXXX X XXXXXXXXXXXXX - XXXXXXXXXXXXX XXX XXXXXXXXXXXXXX - """ - - - class PlusIcon(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - """ - - - class HeartIcon(FaceplateGrid): - str_grid = """ - - xx xx - xxxx xxxx - xxxxxxxxx - xxxxxxx - xxxxx - xxx - x - """ - - - class HollowHeartIcon(FaceplateGrid): - str_grid = """ - - xx xx - x x x x - x x x - x x - x x - x x - x - """ - - - class SkullIcon(FaceplateGrid): - str_grid = """ - - xxxxxxx - x xxx x - xxxxxxxxx - xxx xxx - xxxxx - x x x - x x x - """ - - - class DeadFishIcon(FaceplateGrid): - str_grid = """ - - x xxxx - x x x x xx xxx - xxxxxxxxxxxxxxx - x x x x xxxxxx - x xxxx - """ - - - class InfoIcon(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXX - """ - - - class ArrowLeftIcon(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXXXXXX - XXXXXXXXXXXX XXXXXXXXXXXXXXXXX - XXXXXXXXXXX X XXXXXXXXXX - XXXXXXXXXX X XXXXXXXXXX - XXXXXXXXXXX X XXXXXXXXXX - XXXXXXXXXXXX XXXXXXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXXXXXX - """ - - - class WarningIcon(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXXX XXXXXXXXXXXXXXXX - XXXXXXXXXXXXXX XXXXXXXXXXXXXXX - XXXXXXXXXXXXX X XXXXXXXXXXXXXX - XXXXXXXXXXXX XXX XXXXXXXXXXXXX - XXXXXXXXXXX XXX XXXXXXXXXXXX - XXXXXXXXXX XXXXXXXXXXX - XXXXXXXXX XXX XXXXXXXXXX - XXXXXXXX X XXXXXXXXX - """ - - - class CrossIcon(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXX XXXXXXXXXXXX - XXXXXXXXXXXX XXX XXXXXXXXXXX - XXXXXXXXXXXXX X XXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXX X XXXXXXXXXXXX - XXXXXXXXXXXX XXX XXXXXXXXXXX - XXXXXXXXXXXXX XXXXX XXXXXXXXXXXX - """ - - - class JarbasAI(BlackScreen): - str_grid = """ - X XXXXXXXXXXXXXXXXXXXXXXX X - XX XXXXXXXXXXXXXXXXXXXXXXXX X XX - XX XXXXXXXXXX XXXXXXXXXXXXX X X - XX XXXXXXXXXX XXXXXXXXX X X - XX XX X X X XX X XXX X X - X X XX X XX XX X XX X X X X - X X XX X XXX XX X XX XXX X X X - X X XXX X X X X X - """ - - - class SpaceInvader1(BlackScreen): - str_grid = """ - XXXXXXXXXXXXXX XXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXX x x XXXXXXXXX - XXXXXXXXXX XX XX XXXXXXXXX - XXXXXXXXXXXXXX XXXXXXXXXXXXX - XXXXXXXXXX XXXXXXXXX - XXXXXXXXXX XXXXXXXXXXX XXXXXXXXX - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - """ - - - class SpaceInvader2(BlackScreen): - str_grid = """ - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXXXX x x XXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXX X X XXXXXXXXX - XXXXXXXXXX XXXXXXXXX - XXXXXXXXXX XXX XXX XXXXXXXXX - XXXXXXXXXXXXXXX X XXXXXXXXXXXXXX - XXXXXXXXXXXXXXX X XXXXXXXXXXXXXX - """ - - - class SpaceInvader3(BlackScreen): - str_grid = """ - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXXXXXXXX XXXXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXX - XXXXXXXXXXXX X X XXXXXXXXXXX - XXXXXXXXXX XXXXXXXXX - XXXXXXXXXX XX XXX XX XXXXXXXXX - XXXXXXXXXXXXXX XXXXXXXXXXXXX - XXXXXXXXXXXXX XXX XXXXXXXXXXXX - """ - - - class SpaceInvader4(BlackScreen): - str_grid = """ - XXXXXXXXXXXXX XXXXXXXXXXXX - XXXXXXXXXXXXXXX XXXXXXXXXXXXXX - XXXXXXXXXXXXX XXXXXXXXXXX - XXXXXXXXXXXX X XXXXXXXXXXX - XXXXXXXXXX XXXXXXXXX - XXXXXXXXXX XX XX XXXXXXXXX - XXXXXXXXXXXXXX X XXXXXXXXXXXXX - XXXXXXXXXXXXX XXX XXXXXXXXXXXX - """ - - - # Encoded icons - class Boat(BlackScreen): - encoded = "QIAAABACAGIEMEOEPHAEAGACABABAAAAAA" - - - # Default weather icons for mark1 - class SunnyIcon(FaceplateGrid): - encoded = "IICEIBMDNLMDIBCEAA" - - - class PartlyCloudyIcon(FaceplateGrid): - encoded = "IIEEGBGDHLHDHBGEEA" - - - class CloudyIcon(FaceplateGrid): - encoded = "IIIBMDMDODODODMDIB" - - - class LightRainIcon(FaceplateGrid): - encoded = "IIMAOJOFPBPJPFOBMA" - - - class RainIcon(FaceplateGrid): - encoded = "IIMIOFOBPFPDPJOFMA" - - - class StormIcon(FaceplateGrid): - encoded = "IIAAIIMEODLBJAAAAA" - - - class SnowIcon(FaceplateGrid): - encoded = "IIJEKCMBPHMBKCJEAA" - - - class WindIcon(FaceplateGrid): - encoded = "IIABIBIBIJIJJGJAGA" diff --git a/ovos_utils/events.py b/ovos_utils/events.py index 8c603bf7..cef7c169 100644 --- a/ovos_utils/events.py +++ b/ovos_utils/events.py @@ -5,7 +5,7 @@ from ovos_utils.fakebus import Message, FakeBus, dig_for_message from ovos_utils.file_utils import to_alnum -from ovos_utils.log import LOG, log_deprecation, deprecated +from ovos_utils.log import LOG def unmunge_message(message, skill_id: str): @@ -206,17 +206,8 @@ def clear(self): class EventSchedulerInterface: """Interface for accessing the event scheduler over the message bus.""" - def __init__(self, name=None, sched_id=None, bus=None, skill_id=None): - # NOTE: can not rename or move sched_id/name arguments to keep api - # compatibility - if name: - log_deprecation("name argument has been deprecated! " - "use skill_id instead", "0.1.0") - if sched_id: - log_deprecation("sched_id argument has been deprecated! " - "use skill_id instead", "0.1.0") - - self.skill_id = skill_id or sched_id or name or self.__class__.__name__ + def __init__(self, bus=None, skill_id=None): + self.skill_id = skill_id or self.__class__.__name__.lower() self.bus = bus self.events = EventContainer(bus) self.scheduled_repeats = [] @@ -230,15 +221,14 @@ def set_bus(self, bus): self.bus = bus self.events.set_bus(bus) - def set_id(self, sched_id: str): + def set_id(self, skill_id: str): """ Attach the skill_id of the parent skill Args: - sched_id (str): skill_id of the parent skill + skill_id (str): skill_id of the parent skill """ - # NOTE: can not rename sched_id kwarg to keep api compatibility - self.skill_id = sched_id + self.skill_id = skill_id def _get_source_message(self): message = dig_for_message() or Message("") @@ -427,39 +417,3 @@ def shutdown(self): """ self.cancel_all_repeating_events() self.events.clear() - - @property - @deprecated("self.sched_id has been deprecated! use self.skill_id instead", - "0.1.0") - def sched_id(self): - """DEPRECATED: do not use, method only for api backwards compatibility - Logs a warning and returns self.skill_id - """ - return self.skill_id - - @sched_id.setter - @deprecated("self.sched_id has been deprecated! use self.skill_id instead", - "0.1.0") - def sched_id(self, skill_id): - """DEPRECATED: do not use, method only for api backwards compatibility - Logs a warning and sets self.skill_id - """ - self.skill_id = skill_id - - @property - @deprecated("self.name has been deprecated! use self.skill_id instead", - "0.1.0") - def name(self): - """DEPRECATED: do not use, method only for api backwards compatibility - Logs a warning and returns self.skill_id - """ - return self.skill_id - - @name.setter - @deprecated("self.name has been deprecated! use self.skill_id instead", - "0.1.0") - def name(self, skill_id): - """DEPRECATED: do not use, method only for api backwards compatibility - Logs a warning and sets self.skill_id - """ - self.skill_id = skill_id diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index 1ce41efd..0b02f6b1 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -3,19 +3,47 @@ import os import re import tempfile +from os import walk +from os.path import dirname, splitext, join +from sys import platform from threading import RLock from typing import Optional, List -from os import walk -from os.path import dirname -from os.path import splitext, join - from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from ovos_utils.bracket_expansion import expand_options from ovos_utils.log import LOG, log_deprecation -from ovos_utils.system import search_mycroft_core_location + + +def ensure_directory_exists(directory, domain=None): + """ Create a directory and give access rights to all + + Args: + domain (str): The IPC domain. Basically a subdirectory to prevent + overlapping signal filenames. + + Returns: + str: a path to the directory + """ + if domain: + directory = os.path.join(directory, domain) + + # Expand and normalize the path + directory = os.path.normpath(directory) + directory = os.path.expanduser(directory) + + if not os.path.isdir(directory): + try: + save = os.umask(0) + os.makedirs(directory, 0o777) # give everyone rights to r/w here + except OSError: + LOG.warning("Failed to create: " + directory) + pass + finally: + os.umask(save) + + return directory def to_alnum(skill_id: str) -> str: @@ -59,12 +87,12 @@ def get_temp_path(*args) -> str: def get_cache_directory(folder: str) -> str: """ Get a temporary cache directory, preferably in RAM. - Note that Windows will not use RAM. + Note that only Linux use RAM. @param folder: base path to use for cache @return: valid cache path """ path = get_temp_path(folder) - if os.name != 'nt': + if platform == 'linux': try: from memory_tempfile import MemoryTempfile path = join(MemoryTempfile(fallback=True).gettempdir(), folder) diff --git a/ovos_utils/fingerprinting.py b/ovos_utils/fingerprinting.py deleted file mode 100644 index 073e81b3..00000000 --- a/ovos_utils/fingerprinting.py +++ /dev/null @@ -1,471 +0,0 @@ -import platform -import socket -from enum import Enum -from os.path import join, isfile -from ovos_utils.log import LOG, deprecated -from ovos_utils.system import is_installed, is_running_from_module, has_screen, \ - get_desktop_environment, search_mycroft_core_location, is_process_running - - -class MycroftPlatform(str, Enum): - PICROFT = "picroft" - BIGSCREEN = "kde" - OVOS = "OpenVoiceOS" - HIVEMIND = "HiveMind" - MARK1 = "mycroft_mark_1" - MARK2 = "mycroft_mark_2" - HOLMESV = "HolmesV" - OLD_HOLMES = "mycroft-lib" - NEON = "neon_core" - CHATTERBOX = "chatterbox" - OTHER = "unknown" - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def detect_platform(): - return max(((k, v) for k, v in classify_fingerprint().items()), - key=lambda k: k[1])[0] - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def get_config_fingerprint(config=None): - if not config: - try: - from ovos_config.config import read_mycroft_config - config = read_mycroft_config() - except ImportError: - LOG.warning("Config not provided and ovos_config not available") - config = dict() - conf = config - listener_conf = conf.get("listener", {}) - skills_conf = conf.get("skills", {}) - return { - "enclosure": conf.get("enclosure", {}).get("platform"), - "data_dir": conf.get("data_dir"), - "msm_skills_dir": skills_conf.get("msm", {}).get("directory"), - "ipc_path": conf.get("ipc_path"), - "input_device_name": listener_conf.get("device_name"), - "input_device_index": listener_conf.get("device_index"), - "default_audio_backend": conf.get("Audio", {}).get("default-backend"), - "priority_skills": skills_conf.get("priority_skills"), - "backend_url": conf.get("server", {}).get("url") - } - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def get_platform_fingerprint(): - return { - "hostname": socket.gethostname(), - "platform": platform.platform(), - "python_version": platform.python_version(), - "system": platform.system(), - "version": platform.version(), - "arch": platform.machine(), - "release": platform.release(), - "desktop_env": get_desktop_environment(), - "mycroft_core_location": search_mycroft_core_location(), - "can_display": has_screen(), - "is_gui_installed": is_installed("mycroft-gui-app"), - "is_vlc_installed": is_installed("vlc"), - "pulseaudio_running": is_process_running("pulseaudio"), - "core_supports_xdg": core_supports_xdg(), - "core_version": { - "version_str": get_mycroft_version(), - "is_chatterbox_core": is_chatterbox_core(), - "is_neon_core": is_neon_core(), - "is_holmes": is_holmes(), - "is_ovos": is_ovos(), - "is_mycroft_core": is_mycroft_core() - } - } - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def get_fingerprint(): - finger = get_platform_fingerprint() - finger["configuration"] = get_config_fingerprint() - return finger - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def core_supports_xdg(): - return True # no longer optional - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def get_mycroft_version(): - try: # ovos - from mycroft.version import OVOS_VERSION_STR - return OVOS_VERSION_STR - except ImportError: - pass - try: # mycroft - from mycroft.version import CORE_VERSION_STR - return CORE_VERSION_STR - except ImportError: - pass - - root = search_mycroft_core_location() - if root: - version_file = join(root, "version", "__init__.py") - if not isfile(version_file): - version_file = join(root, "mycroft", "version", "__init__.py") - if isfile(version_file): - version = [] - with open(version_file) as f: - text = f.read() - version.append( - text.split("CORE_VERSION_MAJOR =")[-1].split("\n")[ - 0].strip()) - version.append( - text.split("CORE_VERSION_MINOR =")[-1].split("\n")[ - 0].strip()) - version.append( - text.split("CORE_VERSION_BUILD =")[-1].split("\n")[ - 0].strip()) - version = ".".join(version) - if "CORE_VERSION_STR = '.'.join(map(str, " \ - "CORE_VERSION_TUPLE)) + " in text: - version += text.split( - "CORE_VERSION_STR = '.'.join(map(str, " - "CORE_VERSION_TUPLE)) + ")[-1].split("\n")[0][1:-1] - return version - return None - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_chatterbox_core(): - try: - import chatterbox - return True - except ImportError: - return False - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_neon_core(): - try: - import neon_core - return True - except ImportError: - return False - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_mycroft_core(): - try: - import mycroft - return True - except ImportError: - return False - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_vanilla_mycroft_core(): - return is_mycroft_core() and not is_ovos() - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_holmes(): - return "HolmesV" in (get_mycroft_version() or "") or is_mycroft_lib() - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_mycroft_lib(): - return "mycroft-lib" in (get_mycroft_version() or "") - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def is_ovos(): - return is_running_from_module("ovos-core") - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def classify_platform_print(fingerprint=None): - fingerprint = fingerprint or get_platform_fingerprint() - # key, val pairs that indicate a certain platform - fingerprints = { - MycroftPlatform.PICROFT: { - "core_supports_xdg": False, - "core_version": {'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': False, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.BIGSCREEN: { - "core_supports_xdg": False, - "core_version": {'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': False, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.OVOS: { - "core_supports_xdg": True, - "core_version": {'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': False, - 'is_ovos': True, - 'is_mycroft_core': True} - }, - MycroftPlatform.MARK1: { - "core_supports_xdg": False, - "core_version": {'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': False, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.MARK2: { - "core_supports_xdg": False, - "core_version": {'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': False, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.HOLMESV: { - "core_supports_xdg": True, - "core_version": {'version_str': '20.8.1(HolmesV)', - 'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': True, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.OLD_HOLMES: { - "core_supports_xdg": False, - "core_version": {'version_str': '20.8.1(mycroft-lib)', - 'is_chatterbox_core': False, - 'is_neon_core': False, - 'is_holmes': True, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.CHATTERBOX: { - "core_supports_xdg": True, - "core_version": {'is_chatterbox_core': True, - 'is_neon_core': False, - 'is_holmes': True, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.NEON: { - "core_supports_xdg": True, - "core_version": {'is_chatterbox_core': False, - 'is_neon_core': True, - 'is_holmes': True, - 'is_ovos': False, - 'is_mycroft_core': True} - }, - MycroftPlatform.OTHER: { - "core_supports_xdg": True - } - } - - # score += score * weight (if key matches) - weights = { - MycroftPlatform.PICROFT: {}, - MycroftPlatform.BIGSCREEN: {}, - MycroftPlatform.OVOS: { - "core_supports_xdg": 1.0, - "core_version": 3.0 - }, - MycroftPlatform.CHATTERBOX: { - "core_supports_xdg": 1.0, - "core_version": 3.0 - }, - MycroftPlatform.NEON: { - "core_supports_xdg": 1.0, - "core_version": 3.0 - }, - MycroftPlatform.MARK1: {}, - MycroftPlatform.MARK2: {}, - MycroftPlatform.HOLMESV: { - "core_supports_xdg": 1.0, - "core_version": 3.0 - }, - MycroftPlatform.OLD_HOLMES: { - "core_supports_xdg": 1.0, - "core_version": 3.0 - }, - MycroftPlatform.OTHER: {"core_supports_xdg": 0.01} - } - - # score -= score * weight (if key does not match) - negative_weights = { - MycroftPlatform.PICROFT: { - "core_supports_xdg": 0.1 # likely to change soon once - # mycroft-core merges the open PR - }, - MycroftPlatform.BIGSCREEN: {}, - MycroftPlatform.OVOS: {}, - MycroftPlatform.MARK1: { - "core_supports_xdg": 0.1 # likely to change soon once - # mycroft-core merges the open PR - }, - MycroftPlatform.MARK2: { - "core_supports_xdg": 0.1 # likely to change soon once - # mycroft-core merges the open PR - }, - MycroftPlatform.HOLMESV: {}, - MycroftPlatform.OLD_HOLMES: {}, - MycroftPlatform.OTHER: {} - } - - key_counts = {e: 0 for e in MycroftPlatform} - - for k, v in fingerprint.items(): - # compare this fingerprint value to known platform features - for enclosure in MycroftPlatform: - if enclosure not in fingerprints: - continue - count = key_counts[enclosure] or 1 - # key is in fingerprint - if fingerprints[enclosure].get(k): - # key matches - if fingerprints[enclosure].get(k) == v: - if k in weights.get(enclosure, []): - key_counts[enclosure] += count * weights[enclosure][k] - # key does not match - elif k in negative_weights.get(enclosure, []): - key_counts[enclosure] -= abs(count) * \ - negative_weights[enclosure][k] - - # score platforms - m = max(v for v in key_counts.values()) - if not m: - return {k: 0 for k in key_counts} - return {k: v / m for k, v in key_counts.items()} - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def classify_config_print(fingerprint=None): - fingerprint = fingerprint or get_config_fingerprint() - - # key, val pairs that indicate a certain platform - fingerprints = { - MycroftPlatform.PICROFT: { - 'backend_url': 'https://api.mycroft.ai', - 'enclosure': 'picroft' - }, - MycroftPlatform.BIGSCREEN: { - 'backend_url': 'https://api.mycroft.ai' - }, - MycroftPlatform.OVOS: { - 'enclosure': 'OpenVoiceOS', - 'data_dir': '/opt/ovos' - }, - MycroftPlatform.MARK1: { - 'backend_url': 'https://api.mycroft.ai', - 'enclosure': 'mycroft_mark_1', - "data_dir": "/opt/mycroft" - }, - MycroftPlatform.MARK2: { - 'backend_url': 'https://api.mycroft.ai', - 'enclosure': 'mycroft_mark_2', - "data_dir": "/opt/mycroft" - }, - MycroftPlatform.HOLMESV: { - "enclosure": "HolmesV" - }, - MycroftPlatform.OLD_HOLMES: { - "enclosure": "mycroft-lib" - }, - MycroftPlatform.CHATTERBOX: { - 'enclosure': 'chatterhat', # TODO list comparison - "data_dir": "~/chatterbox", - }, - MycroftPlatform.NEON: {}, - MycroftPlatform.OTHER: {} - } - - # score += score * weight (if key matches) - weights = { - MycroftPlatform.PICROFT: { - "enclosure": 1.0, - 'backend_url': 0.5 - }, - MycroftPlatform.BIGSCREEN: {}, - MycroftPlatform.OVOS: { - "enclosure": 1.0 - }, - MycroftPlatform.CHATTERBOX: { - "enclosure": 1.0, - 'backend_url': 1.0, - "data_dir": 1.0 - }, - MycroftPlatform.NEON: { - "enclosure": 1.0 - }, - MycroftPlatform.MARK1: { - "enclosure": 1.0, - 'backend_url': 1.0, - "data_dir": 1.0 - }, - MycroftPlatform.MARK2: { - "enclosure": 1.0, - 'backend_url': 1.0, - "data_dir": 1.0 - }, - MycroftPlatform.HOLMESV: {}, - MycroftPlatform.OTHER: {} - } - - # score -= score * weight (if key does not match) - negative_weights = { - MycroftPlatform.PICROFT: { - "enclosure": 0.2, - 'backend_url': 0.2 - }, - MycroftPlatform.BIGSCREEN: {}, - MycroftPlatform.OVOS: {}, - MycroftPlatform.MARK1: { - "enclosure": 3.0, - 'backend_url': 0.5, - 'data_dir': 1.0 - }, - MycroftPlatform.MARK2: { - "enclosure": 3.0, - 'backend_url': 0.5, - 'data_dir': 1.5 # pantacor really needs /opt, users cant change it - }, - MycroftPlatform.HOLMESV: {}, - MycroftPlatform.OLD_HOLMES: {"enclosure": 0.5}, - MycroftPlatform.OTHER: {} - } - - key_counts = {e: 0 for e in MycroftPlatform} - - for k, v in fingerprint.items(): - # compare this fingerprint value to known platform features - for enclosure in MycroftPlatform: - count = key_counts[enclosure] or 1 - # key is in fingerprint - if fingerprints[enclosure].get(k): - - # key matches - if fingerprints[enclosure][k] == v: - if k in weights.get(enclosure, []): - key_counts[enclosure] += count * weights[enclosure][k] - # key does not match - elif k in negative_weights.get(enclosure, []): - key_counts[enclosure] -= abs(count) * \ - negative_weights[enclosure][k] - - # score platforms - m = max(v for v in key_counts.values()) - if not m: - return {k: 0 for k in key_counts} - return {k: v / m for k, v in key_counts.items()} - - -@deprecated("fingerprinting utils are deprecated.", "0.1.0") -def classify_fingerprint(): - plat = classify_platform_print() - conf = classify_config_print() - for k, v in conf.items(): - # high bias for platform fingerprint - plat[k] = (v * 0.5 + plat[k] * 1.5) / 2 - return plat diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py index 766780d7..f6a9d2ae 100644 --- a/ovos_utils/gui.py +++ b/ovos_utils/gui.py @@ -1,20 +1,15 @@ -import time -from collections import namedtuple -from enum import IntEnum -from os import walk -from os.path import join, splitext, isfile, isdir -from typing import List, Union, Optional, Callable +from os.path import join, isdir +from typing import List -from ovos_utils import resolve_ovos_resource_file, resolve_resource_file -from ovos_utils.fakebus import Message -from ovos_utils.log import LOG, log_deprecation, deprecated +from ovos_utils.log import LOG +from ovos_bus_client.util import wait_for_reply from ovos_utils.system import is_installed, has_screen, is_process_running _default_gui_apps = ( - "mycroft-gui-app", + "ovos-gui-app", "ovos-shell", - "mycroft-embedded-shell", - "plasmashell" + "mycroft-gui-app", + "mycroft-embedded-shell" ) @@ -38,7 +33,13 @@ def is_gui_running(applications: List[str] = _default_gui_apps) -> bool: Return true if a GUI application is running @param applications: list of applications to check for """ - return any((is_process_running(app) for app in applications)) + deprecated = any((is_process_running(app) for app in applications + if app.startswith("mycroft-"))) + if deprecated: + LOG.warning("you are running a deprecated mycroft-gui version, " + "please move to a OVOS maintained version") + return True + return deprecated or any((is_process_running(app) for app in applications)) def is_gui_connected(bus=None) -> bool: @@ -48,10 +49,6 @@ def is_gui_connected(bus=None) -> bool: @param bus: MessageBusClient to use for query @return: True if GUI is connected """ - try: - from ovos_bus_client.util import wait_for_reply - except: - from ovos_utils.messagebus import wait_for_reply response = wait_for_reply("gui.status.request", "gui.status.request.response", bus=bus) if response: @@ -94,1141 +91,8 @@ def get_ui_directories(root_dir: str) -> dict: LOG.debug("Skill implements resources in `gui` directory") ui_directories["all"] = join(base_directory, "gui") return ui_directories - LOG.info("Checking for legacy UI directories") + if isdir(join(base_directory, "ui")): - LOG.debug("Handling `ui` directory as `qt5`") + LOG.debug("legacy UI directory found - Handling `ui` directory as `qt5`") ui_directories["qt5"] = join(base_directory, "ui") return ui_directories - - -class GUIPlaybackStatus(IntEnum): - STOPPED = 0 - PLAYING = 1 - PAUSED = 2 - UNDEFINED = 3 - - -class GUITracker: - """ Replicates GUI API from mycroft-core, - does not interact with GUI but exactly mimics status""" - Namespace = namedtuple('Namespace', ['name', 'pages']) - RESERVED_KEYS = ['__from', '__idle'] - IDLE_MESSAGE = "mycroft.mark2.collect_idle" # TODO this will change - - @deprecated("GUITracker has been deprecated without replacement", "0.1.0") - def __init__(self, bus=None, - host='0.0.0.0', port=8181, route='/core', ssl=False): - if not bus: - from ovos_bus_client.util import get_mycroft_bus - bus = get_mycroft_bus(host, port, route, ssl) - self.bus = bus - self._active_skill = None - self._is_idle = False - self.idle_ts = 0 - # This datastore holds the data associated with the GUI provider. Data - # is stored in Namespaces, so you can have: - # self.datastore["namespace"]["name"] = value - # Typically the namespace is a meaningless identifier, but there is a - # special "SYSTEM" namespace. - self._datastore = {} - - # self.loaded is a list, each element consists of a namespace named - # tuple. - # The namespace namedtuple has the properties "name" and "pages" - # The name contains the namespace name as a string and pages is a - # mutable list of loaded pages. - # - # [Namespace name, [List of loaded qml pages]] - # [ - # ["SKILL_NAME", ["page1.qml, "page2.qml", ... , "pageN.qml"] - # [...] - # ] - self._loaded = [] # list of lists in order. - - # Listen for new GUI clients to announce themselves on the main bus - self._active_namespaces = [] - - # GUI handlers - self.bus.on("gui.value.set", self._on_gui_set_value) - self.bus.on("gui.page.show", self._on_gui_show_page) - self.bus.on("gui.page.delete", self._on_gui_delete_page) - self.bus.on("gui.clear.namespace", self._on_gui_delete_namespace) - - # Idle screen handlers TODO message cleanup... - self._idle_screens = {} - self.bus.on("mycroft.device.show.idle", self._on_show_idle) # legacy - self.bus.on(self.IDLE_MESSAGE, self._on_show_idle) - self.bus.on("mycroft.mark2.register_idle", self._on_register_idle) - - self.bus.emit(Message("mycroft.mark2.collect_idle")) - - @staticmethod - def is_gui_installed(): - return is_gui_installed() - - @staticmethod - def is_gui_running(): - return is_gui_running() - - def is_gui_connected(self): - return is_gui_connected(self.bus) - - @staticmethod - def can_display(): - return can_display() - - def is_displaying(self): - return self.active_skill is not None - - def is_idle(self): - return self._is_idle - - @property - def active_skill(self): - return self._active_skill - - @property - def gui_values(self): - return self._datastore - - @property - def idle_screens(self): - return self._idle_screens - - @property - def active_namespaces(self): - return self._active_namespaces - - @property - def gui_pages(self): - return self._loaded - - # GUI event handlers - # user can/should subclass this - def on_idle(self, namespace): - pass - - def on_active(self, namespace): - pass - - def on_new_page(self, namespace, page, index): - pass - - def on_delete_page(self, namespace, index): - pass - - def on_gui_value(self, namespace, key, value): - pass - - def on_new_namespace(self, namespace): - pass - - def on_move_namespace(self, namespace, from_index, to_index): - pass - - def on_remove_namespace(self, namespace, index): - pass - - ###################################################################### - # GUI client API - # TODO see how much of this can be removed - @staticmethod - def _get_page_data(message): - """ Extract page related data from a message. - - Args: - message: messagebus message object - Returns: - tuple (page, namespace, index) - Raises: - ValueError if value is missing. - """ - data = message.data - # Note: 'page' can be either a string or a list of strings - if 'page' not in data: - raise ValueError("Page missing in data") - if 'index' in data: - index = data['index'] - else: - index = 0 - page = data.get("page", "") - namespace = data.get("__from", "") - return page, namespace, index - - def _set(self, namespace, name, value): - """ Perform the send of the values to the connected GUIs. """ - if namespace not in self._datastore: - self._datastore[namespace] = {} - if self._datastore[namespace].get(name) != value: - self._datastore[namespace][name] = value - - def __find_namespace(self, namespace): - for i, skill in enumerate(self._loaded): - if skill[0] == namespace: - return i - return None - - def __insert_pages(self, namespace: str, pages: List[str]): - """ Insert pages into the namespace - - Args: - namespace (str): Namespace to add to - pages (list): Pages (str) to insert - """ - LOG.debug("Inserting new pages") - # Insert the pages into local reprensentation as well. - updated = self.Namespace(self._loaded[0].name, - self._loaded[0].pages + pages) - self._loaded[0] = updated - - def __remove_page(self, namespace, pos): - """ Delete page. - - Args: - namespace (str): Namespace to remove from - pos (int): Page position to remove - """ - LOG.debug("Deleting {} from {}".format(pos, namespace)) - self.on_delete_page(namespace, pos) - # Remove the page from the local reprensentation as well. - self._loaded[0].pages.pop(pos) - - def __insert_new_namespace(self, namespace: str, pages: List[str]): - """ Insert new namespace and pages. - - This first sends a message adding a new namespace at the - highest priority (position 0 in the namespace stack) - - Args: - namespace (str): The skill namespace to create - pages (str): Pages to insert (name matches QML) - """ - LOG.debug("Inserting new namespace") - self.on_new_namespace(namespace) - # Make sure the local copy is updated - self._loaded.insert(0, self.Namespace(namespace, pages)) - if time.time() - self.idle_ts > 1: - # we cant know if this page is idle or not, but when it is we - # received a idle event within the same second - self._is_idle = False - self.on_active(namespace) - else: - self.on_idle(namespace) - - def __move_namespace(self, from_pos, to_pos): - """ Move an existing namespace to a new position in the stack. - - Args: - from_pos (int): Position in the stack to move from - to_pos (int): Position to move to - """ - LOG.debug("Activating existing namespace") - # Move the local representation of the skill from current - # position to position 0. - namespace = self._loaded[from_pos].name - self.on_move_namespace(namespace, from_pos, to_pos) - self._loaded.insert(to_pos, self._loaded.pop(from_pos)) - - def _show(self, namespace, page, index): - """ Show a page and load it as needed. - - Args: - page (str or list): page(s) to show - namespace (str): skill namespace - index (int): ??? TODO: Unused in code ??? - - TODO: - Update sync to match. - - Separate into multiple functions/methods - """ - - LOG.debug("GUIConnection activating: " + namespace) - self._active_skill = namespace - pages = page if isinstance(page, list) else [page] - - # find namespace among loaded namespaces - try: - index = self.__find_namespace(namespace) - if index is None: - # This namespace doesn't exist, insert them first so they're - # shown. - self.__insert_new_namespace(namespace, pages) - return - else: # Namespace exists - if index > 0: - # Namespace is inactive, activate it by moving it to - # position 0 - self.__move_namespace(index, 0) - - # Find if any new pages needs to be inserted - new_pages = [p for p in pages if - p not in self._loaded[0].pages] - if new_pages: - self.__insert_pages(namespace, new_pages) - except Exception as e: - LOG.exception(repr(e)) - - ###################################################################### - # Internal GUI events - def _on_gui_set_value(self, message): - data = message.data - namespace = data.get("__from", "") - - # Pass these values on to the GUI renderers - for key in data: - if key not in self.RESERVED_KEYS: - try: - self._set(namespace, key, data[key]) - self.on_gui_value(namespace, key, data[key]) - except Exception as e: - LOG.exception(repr(e)) - - def _on_gui_delete_page(self, message): - """ Bus handler for removing pages. """ - page, namespace, _ = self._get_page_data(message) - try: - self._remove_pages(namespace, page) - except Exception as e: - LOG.exception(repr(e)) - - def _on_gui_delete_namespace(self, message): - """ Bus handler for removing namespace. """ - try: - namespace = message.data['__from'] - self._remove_namespace(namespace) - except Exception as e: - LOG.exception(repr(e)) - - def _on_gui_show_page(self, message): - try: - page, namespace, index = self._get_page_data(message) - # Pass the request to the GUI(s) to pull up a page template - self._show(namespace, page, index) - self.on_new_page(namespace, page, index) - except Exception as e: - LOG.exception(repr(e)) - - def _remove_namespace(self, namespace): - """ Remove namespace. - - Args: - namespace (str): namespace to remove - """ - index = self.__find_namespace(namespace) - if index is None: - return - else: - LOG.debug("Removing namespace {} at {}".format(namespace, index)) - self.on_remove_namespace(namespace, index) - # Remove namespace from loaded namespaces - self._loaded.pop(index) - - def _remove_pages(self, namespace, pages): - """ Remove the listed pages from the provided namespace. - - Args: - namespace (str): The namespace to modify - pages (list): List of page names (str) to delete - """ - try: - index = self.__find_namespace(namespace) - if index is None: - return - else: - # Remove any pages that doesn't exist in the namespace - pages = [p for p in pages if p in self._loaded[index].pages] - # Make sure to remove pages from the back - indexes = [self._loaded[index].pages.index(p) for p in pages] - indexes = sorted(indexes) - indexes.reverse() - for page_index in indexes: - self.__remove_page(namespace, page_index) - except Exception as e: - LOG.exception(repr(e)) - - def _on_register_idle(self, message): - """Handler for catching incoming idle screens.""" - if "name" in message.data and "id" in message.data: - screen = message.data["name"] - if screen not in self._idle_screens: - self.bus.on("{}.idle".format(message.data["id"]), - self._on_show_idle) - self._idle_screens[screen] = message.data["id"] - LOG.info("Registered {}".format(message.data["name"])) - else: - LOG.error("Malformed idle screen registration received") - - def _on_show_idle(self, message): - self.idle_ts = time.time() - self._is_idle = True - - -try: - from ovos_bus_client.apis.gui import extend_about_data as _ead, GUIInterface as _GI, GUIWidgets as _GW - - - def extend_about_data(about_data: Union[list, dict], - bus=None): - return _ead(about_data, bus) - - - class GUIWidgets(_GW): - def __new__(cls, *args, **kwargs): - log_deprecation("GUIWidgets moved to ovos_bus_client.apis.gui", "0.1.0") - return _GW(*args, **kwargs) - - - class GUIInterface(_GI): - def __new__(cls, *args, **kwargs): - log_deprecation("GUIInterface moved to ovos_bus_client.apis.gui", "0.1.0") - return _GI(*args, **kwargs) - -except ImportError: - - @deprecated("extend_about_data moved to ovos_bus_client.apis.gui", "0.1.0") - def extend_about_data(about_data: Union[list, dict], - bus=None): - """ - Add more information to the "About" section in the GUI. - @param about_data: list of dict key, val information to add to the GUI - @param bus: MessageBusClient object to emit update on - """ - if not bus: - from ovos_bus_client.util import get_mycroft_bus - bus = get_mycroft_bus() - if isinstance(about_data, list): - bus.emit(Message("smartspeaker.extension.extend.about", - {"display_list": about_data})) - elif isinstance(about_data, dict): - display_list = [about_data] - bus.emit(Message("smartspeaker.extension.extend.about", - {"display_list": display_list})) - else: - LOG.error("about_data is not a list or dictionary") - - - class GUIWidgets: - @deprecated("GUIWidgets moved to ovos_bus_client.apis.gui", "0.1.0") - def __init__(self, bus=None): - if not bus: - from ovos_bus_client.util import get_mycroft_bus - bus = get_mycroft_bus() - self.bus = bus - - def show_widget(self, widget_type, widget_data): - LOG.debug("Showing widget: " + widget_type) - self.bus.emit(Message("ovos.widgets.display", {"type": widget_type, "data": widget_data})) - - def remove_widget(self, widget_type, widget_data): - LOG.debug("Removing widget: " + widget_type) - self.bus.emit(Message("ovos.widgets.remove", {"type": widget_type, "data": widget_data})) - - def update_widget(self, widget_type, widget_data): - LOG.debug("Updating widget: " + widget_type) - self.bus.emit(Message("ovos.widgets.update", {"type": widget_type, "data": widget_data})) - - - class _GUIDict(dict): - """ - This is a helper dictionary subclass. It ensures that values changed - in it are propagated to the GUI service in real time. - """ - - def __init__(self, gui, **kwargs): - self.gui = gui - super().__init__(**kwargs) - - def __setitem__(self, key, value): - old = self.get(key) - if old != value: - super(_GUIDict, self).__setitem__(key, value) - self.gui._sync_data() - - - class GUIInterface: - """ - Interface to the Graphical User Interface, allows interaction with - the mycroft-gui from anywhere - - Values set in this class are synced to the GUI, accessible within QML - via the built-in sessionData mechanism. For example, in Python you can - write in a skill: - self.gui['temp'] = 33 - self.gui.show_page('Weather.qml') - Then in the Weather.qml you'd access the temp via code such as: - text: sessionData.time - """ - - @deprecated("GUIInterface moved to ovos_bus_client.apis.gui", "0.1.0") - def __init__(self, skill_id: str, bus=None, - remote_server: str = None, config: dict = None, - ui_directories: dict = None): - """ - Create an interface to the GUI module. Values set here are exposed to - the GUI client as sessionData - @param skill_id: ID of this interface - @param bus: MessagebusClient object to connect to - @param remote_server: Optional URL of a remote GUI server - @param config: dict gui Configuration - @param ui_directories: dict framework to directory containing resources - `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() - self.config = config - if remote_server: - self.config["remote-server"] = remote_server - self._bus = bus - self.__session_data = {} # synced to GUI for use by this skill's pages - self._pages = [] - self.current_page_idx = -1 - self._skill_id = skill_id - self.on_gui_changed_callback = None - self._events = [] - self.ui_directories = ui_directories or dict() - if bus: - self.set_bus(bus) - - @property - def remote_url(self) -> Optional[str]: - """Returns configuration value for url of remote-server.""" - return self.config.get('remote-server') - - @remote_url.setter - def remote_url(self, val: str): - self.config["remote-server"] = val - - def set_bus(self, bus=None): - if not bus: - from ovos_bus_client.util import get_mycroft_bus - bus = get_mycroft_bus() - self._bus = bus - self.setup_default_handlers() - - @property - def bus(self): - """ - Return the attached MessageBusClient - """ - return self._bus - - @bus.setter - def bus(self, val): - self.set_bus(val) - - @property - def skill_id(self) -> str: - """ - Return the ID of the module implementing this interface - """ - return self._skill_id - - @skill_id.setter - def skill_id(self, val: str): - self._skill_id = val - - @property - 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 - - @property - def connected(self) -> bool: - """ - Returns True if at least 1 remote gui is connected or if gui is - installed and running locally, else False - """ - if not self.bus: - return False - return can_use_gui(self.bus) - - @property - def pages(self) -> List[str]: - """ - Get a list of the active page ID's managed by this interface - """ - return self._pages - - def build_message_type(self, event: str) -> str: - """ - Ensure the specified event prepends this interface's `skill_id` - """ - if not event.startswith(f'{self.skill_id}.'): - event = f'{self.skill_id}.' + event - return event - - # events - def setup_default_handlers(self): - """ - Sets the handlers for the default messages. - """ - msg_type = self.build_message_type('set') - self.bus.on(msg_type, self.gui_set) - self._events.append((msg_type, self.gui_set)) - self.bus.on("gui.request_page_upload", self.upload_gui_pages) - if self.ui_directories: - LOG.debug("Volunteering gui page upload") - self.bus.emit(Message("gui.volunteer_page_upload", - {'skill_id': self.skill_id}, - {'source': self.skill_id, "destination": ["gui"]})) - - def upload_gui_pages(self, message: Message): - """ - Emit a response Message with all known GUI files managed by - this interface for the requested infrastructure - @param message: `gui.request_page_upload` Message requesting pages - """ - if not self.ui_directories: - LOG.debug("No UI resources to upload") - return - - requested_skill = message.data.get("skill_id") or self._skill_id - if requested_skill != self._skill_id: - # GUI requesting a specific skill to upload other than this one - return - - request_res_type = message.data.get("framework") or "all" if "all" in \ - self.ui_directories else "qt5" - # Note that ui_directory "all" is a special case that will upload all - # gui files, including all framework subdirectories - if request_res_type not in self.ui_directories: - LOG.warning(f"Requested UI files not available: {request_res_type}") - return - LOG.debug(f"Requested upload resources for: {request_res_type}") - pages = dict() - # `pages` keys are unique identifiers in the scope of this interface; - # if ui_directory is "all", then pages are prefixed with `/` - res_dir = self.ui_directories[request_res_type] - for path, _, files in walk(res_dir): - for file in files: - try: - full_path: str = join(path, file) - page_name = full_path.replace(f"{res_dir}/", "", 1) - with open(full_path, 'rb') as f: - file_bytes = f.read() - pages[page_name] = file_bytes.hex() - except Exception as e: - LOG.exception(f"{file} not uploaded: {e}") - # Note that `pages` in this context include file extensions - self.bus.emit(message.forward("gui.page.upload", - {"__from": self.skill_id, - "framework": request_res_type, - "pages": pages})) - - def register_handler(self, event: str, handler: Callable): - """ - Register a handler for GUI events. - - will be prepended with self.skill_id.XXX if missing in event - - When using the triggerEvent method from Qt - triggerEvent("event", {"data": "cool"}) - - Args: - event (str): event to catch - handler: function to handle the event - """ - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - event = self.build_message_type(event) - self._events.append((event, handler)) - self.bus.on(event, handler) - - def set_on_gui_changed(self, callback: Callable): - """ - Registers a callback function to run when a value is - changed from the GUI. - - Arguments: - callback: Function to call when a value is changed - """ - self.on_gui_changed_callback = callback - - # internals - def gui_set(self, message: Message): - """ - Handler catching variable changes from the GUI. - - Arguments: - message: Messagebus message - """ - for key in message.data: - self[key] = message.data[key] - if self.on_gui_changed_callback: - self.on_gui_changed_callback() - - def _sync_data(self): - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - data = self.__session_data.copy() - data.update({'__from': self.skill_id}) - self.bus.emit(Message("gui.value.set", data)) - - def __setitem__(self, key, value): - """Implements set part of dict-like behaviour with named keys.""" - old = self.__session_data.get(key) - if old == value: # no need to sync - return - - # cast to helper dict subclass that syncs data - if isinstance(value, dict) and not isinstance(value, _GUIDict): - value = _GUIDict(self, **value) - - self.__session_data[key] = value - - # emit notification (but not needed if page has not been shown yet) - if self.page: - self._sync_data() - - def __getitem__(self, key): - """Implements get part of dict-like behaviour with named keys.""" - return self.__session_data[key] - - def get(self, *args, **kwargs): - """Implements the get method for accessing dict keys.""" - return self.__session_data.get(*args, **kwargs) - - def __contains__(self, key): - """ - Implements the "in" operation. - """ - return self.__session_data.__contains__(key) - - def clear(self): - """ - Reset the value dictionary, and remove namespace from GUI. - - This method does not close the GUI for a Skill. For this purpose see - the `release` method. - """ - self.__session_data = {} - self._pages = [] - self.current_page_idx = -1 - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - self.bus.emit(Message("gui.clear.namespace", - {"__from": self.skill_id})) - - def send_event(self, event_name: str, - params: Union[dict, list, str, int, float, bool] = None): - """ - Trigger a gui event. - - Arguments: - event_name (str): name of event to be triggered - params: json serializable object containing any parameters that - should be sent along with the request. - """ - params = params or {} - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - self.bus.emit(Message("gui.event.send", - {"__from": self.skill_id, - "event_name": event_name, - "params": params})) - - def _pages2uri(self, page_names: List[str]) -> List[str]: - """ - Get a list of resolved URIs from a list of string page names. - @param page_names: List of GUI resource names (file basenames) to locate - @return: List of resolved paths to the requested pages - """ - # TODO: This method resolves absolute file paths. These will no longer - # be used with the implementation of `ovos-gui` - page_urls = [] - extra_dirs = list(self.ui_directories.values()) or list() - for name in page_names: - # 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) - - if page: - if self.remote_url: - page_urls.append(self.remote_url + "/" + page) - elif page.startswith("file://"): - page_urls.append(page) - else: - page_urls.append("file://" + page) - else: - # This is expected; with `ovos-gui`, pages are referenced by ID - # rather than filename in order to support multiple frameworks - LOG.debug(f"Requested page not resolved to a file: {page}") - LOG.debug(f"Resolved pages: {page_urls}") - return page_urls - - @staticmethod - def _normalize_page_name(page_name: str) -> str: - """ - Normalize a requested GUI resource - @param page_name: string name of a GUI resource - @return: normalized string name (`.qml` removed for other GUI support) - """ - if isfile(page_name): - log_deprecation("GUI resources should specify a resource name and " - "not a file path.", "0.1.0") - return page_name - file, ext = splitext(page_name) - if ext == ".qml": - log_deprecation("GUI resources should exclude gui-specific file " - f"extensions. This call should probably pass " - f"`{file}`, instead of `{page_name}`", "0.1.0") - return file - - return page_name - - # base gui interactions - def show_page(self, name: str, override_idle: Union[bool, int] = None, - override_animations: bool = False): - """ - Request to show a page in the GUI. - @param name: page resource requested - @param override_idle: number of seconds to override display for; - if True, override display indefinitely - @param override_animations: if True, disables all GUI animations - """ - self.show_pages([name], 0, override_idle, override_animations) - - def show_pages(self, page_names: List[str], index: int = 0, - override_idle: Union[bool, int] = None, - override_animations: bool = False): - """ - Request to show a list of pages in the GUI. - @param page_names: list of page resources requested - @param index: position to insert pages at (default 0) - @param override_idle: number of seconds to override display for; - if True, override display indefinitely - @param override_animations: if True, disables all GUI animations - """ - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - if isinstance(page_names, str): - page_names = [page_names] - if not isinstance(page_names, list): - raise ValueError('page_names must be a list') - - if index > len(page_names): - LOG.error('Default index is larger than page list length') - index = len(page_names) - 1 - - # TODO: deprecate sending page_urls after ovos_gui implementation - page_urls = self._pages2uri(page_names) - page_names = [self._normalize_page_name(n) for n in page_names] - - self._pages = page_names - self.current_page_idx = index - - # First sync any data... - data = self.__session_data.copy() - data.update({'__from': self.skill_id}) - LOG.debug(f"Updating gui data: {data}") - self.bus.emit(Message("gui.value.set", data)) - - # finally tell gui what to show - self.bus.emit(Message("gui.page.show", - {"page": page_urls, - "page_names": page_names, - "ui_directories": self.ui_directories, - "index": index, - "__from": self.skill_id, - "__idle": override_idle, - "__animations": override_animations})) - - def remove_page(self, page: str): - """ - Remove a single page from the GUI. - @param page: Name of page to remove - """ - self.remove_pages([page]) - - def remove_pages(self, page_names: List[str]): - """ - Request to remove a list of pages from the GUI. - @param page_names: list of page resources requested - """ - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - if isinstance(page_names, str): - page_names = [page_names] - if not isinstance(page_names, list): - raise ValueError('page_names must be a list') - # TODO: deprecate sending page_urls after ovos_gui implementation - page_urls = self._pages2uri(page_names) - page_names = [self._normalize_page_name(n) for n in page_names] - self.bus.emit(Message("gui.page.delete", - {"page": page_urls, - "page_names": page_names, - "__from": self.skill_id})) - - # Utils / Templates - - # backport - PR https://github.com/MycroftAI/mycroft-core/pull/2862 - def show_notification(self, content: str, duration: int = 10, - action: str = None, noticetype: str = "transient", - style: str = "info", - callback_data: Optional[dict] = None): - """Display a Notification on homepage in the GUI. - Arguments: - content (str): Main text content of a notification, Limited - to two visual lines. - duration (int): seconds to display notification for - action (str): Callback to any event registered by the skill - to perform a certain action when notification is clicked. - noticetype (str): - transient: 'Default' displays a notification with a timeout. - sticky: displays a notification that sticks to the screen. - style (str): - info: 'Default' displays a notification with information styling - warning: displays a notification with warning styling - success: displays a notification with success styling - error: displays a notification with error styling - callback_data (dict): data dictionary available to use with action - """ - # TODO: Define enums for style and noticetype - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - # GUI does not accept NONE type, send an empty dict - # Sending NONE will corrupt entries in the model - callback_data = callback_data or dict() - self.bus.emit(Message("ovos.notification.api.set", - data={ - "duration": duration, - "sender": self.skill_id, - "text": content, - "action": action, - "type": noticetype, - "style": style, - "callback_data": callback_data - })) - - def show_controlled_notification(self, content: str, style: str = "info"): - """ - Display a controlled Notification in the GUI. - Arguments: - content (str): Main text content of a notification, Limited - to two visual lines. - style (str): - info: 'Default' displays a notification with information styling - warning: displays a notification with warning styling - success: displays a notification with success styling - error: displays a notification with error styling - """ - # TODO: Define enum for style - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - self.bus.emit(Message("ovos.notification.api.set.controlled", - data={ - "sender": self.skill_id, - "text": content, - "style": style - })) - - def remove_controlled_notification(self): - """ - Remove a controlled Notification in the GUI. - """ - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - self.bus.emit(Message("ovos.notification.api.remove.controlled")) - - def show_text(self, text: str, title: Optional[str] = None, - override_idle: Union[int, bool] = None, - override_animations: bool = False): - """ - Display a GUI page for viewing simple text. - - Arguments: - text (str): Main text content. It will auto-paginate - title (str): A title to display above the text content. - override_idle (boolean, int): - True: Takes over the resting page indefinitely - (int): Delays resting page for the specified number of - seconds. - override_animations (boolean): - True: Disables showing all platform skill animations. - False: 'Default' always show animations. - """ - self["text"] = text - self["title"] = title - self.show_page("SYSTEM_TextFrame.qml", override_idle, - override_animations) - - def show_image(self, url: str, caption: Optional[str] = None, - title: Optional[str] = None, - fill: str = None, background_color: str = None, - override_idle: Union[int, bool] = None, - override_animations: bool = False): - """ - Display a GUI page for viewing an image. - - Arguments: - url (str): Pointer to the image - caption (str): A caption to show under the image - title (str): A title to display above the image content - fill (str): Fill type supports 'PreserveAspectFit', - 'PreserveAspectCrop', 'Stretch' - background_color (str): A background color for - the page in hex i.e. #000000 - override_idle (boolean, int): - True: Takes over the resting page indefinitely - (int): Delays resting page for the specified number of - seconds. - override_animations (boolean): - True: Disables showing all platform skill animations. - False: 'Default' always show animations. - """ - self["image"] = url - self["title"] = title - self["caption"] = caption - self["fill"] = fill - self["background_color"] = background_color - self.show_page("SYSTEM_ImageFrame.qml", override_idle, - override_animations) - - def show_animated_image(self, url: str, caption: Optional[str] = None, - title: Optional[str] = None, - fill: str = None, background_color: str = None, - override_idle: Union[int, bool] = None, - override_animations: bool = False): - """ - Display a GUI page for viewing an image. - - Args: - url (str): Pointer to the .gif image - caption (str): A caption to show under the image - title (str): A title to display above the image content - fill (str): Fill type supports 'PreserveAspectFit', - 'PreserveAspectCrop', 'Stretch' - background_color (str): A background color for - the page in hex i.e. #000000 - override_idle (boolean, int): - True: Takes over the resting page indefinitely - (int): Delays resting page for the specified number of - seconds. - override_animations (boolean): - True: Disables showing all platform skill animations. - False: 'Default' always show animations. - """ - self["image"] = url - self["title"] = title - self["caption"] = caption - self["fill"] = fill - self["background_color"] = background_color - self.show_page("SYSTEM_AnimatedImageFrame.qml", override_idle, - override_animations) - - def show_html(self, html: str, resource_url: Optional[str] = None, - override_idle: Union[int, bool] = None, - override_animations: bool = False): - """ - Display an HTML page in the GUI. - - Args: - html (str): HTML text to display - resource_url (str): Pointer to HTML resources - override_idle (boolean, int): - True: Takes over the resting page indefinitely - (int): Delays resting page for the specified number of - seconds. - override_animations (boolean): - True: Disables showing all platform skill animations. - False: 'Default' always show animations. - """ - self["html"] = html - self["resourceLocation"] = resource_url - self.show_page("SYSTEM_HtmlFrame.qml", override_idle, - override_animations) - - def show_url(self, url: str, override_idle: Union[int, bool] = None, - override_animations: bool = False): - """ - Display an HTML page in the GUI. - - Args: - url (str): URL to render - override_idle (boolean, int): - True: Takes over the resting page indefinitely - (int): Delays resting page for the specified number of - seconds. - override_animations (boolean): - True: Disables showing all platform skill animations. - False: 'Default' always show animations. - """ - self["url"] = url - self.show_page("SYSTEM_UrlFrame.qml", override_idle, - override_animations) - - def show_input_box(self, title: Optional[str] = None, - placeholder: Optional[str] = None, - confirm_text: Optional[str] = None, - exit_text: Optional[str] = None, - override_idle: Union[int, bool] = None, - override_animations: bool = False): - """ - Display a fullscreen UI for a user to enter text and confirm or cancel - @param title: title of input UI should describe what the input is - @param placeholder: default text hint to show in an empty entry box - @param confirm_text: text to display on the submit/confirm button - @param exit_text: text to display on the cancel/exit button - @param override_idle: if True, takes over the resting page indefinitely - else Delays resting page for the specified number of seconds. - @param override_animations: disable showing all platform animations - """ - self["title"] = title - self["placeholder"] = placeholder - self["skill_id_handler"] = self.skill_id - if not confirm_text: - self["confirm_text"] = "Confirm" - else: - self["confirm_text"] = confirm_text - - if not exit_text: - self["exit_text"] = "Exit" - else: - self["exit_text"] = exit_text - - self.show_page("SYSTEM_InputBox.qml", override_idle, - override_animations) - - def remove_input_box(self): - """ - Remove an input box shown by `show_input_box` - """ - LOG.info(f"GUI pages length {len(self._pages)}") - if len(self._pages) > 1: - self.remove_page("SYSTEM_InputBox.qml") - else: - self.release() - - def release(self): - """ - Signal that this skill is no longer using the GUI, - allow different platforms to properly handle this event. - Also calls self.clear() to reset the state variables - Platforms can close the window or go back to previous page - """ - if not self.bus: - raise RuntimeError("bus not set, did you call self.bind() ?") - self.clear() - self.bus.emit(Message("mycroft.gui.screen.close", - {"skill_id": self.skill_id})) - - def shutdown(self): - """ - Shutdown gui interface. - - Clear pages loaded through this interface and remove the bus events - """ - if self.bus: - self.release() - for event, handler in self._events: - self.bus.remove(event, handler) diff --git a/ovos_utils/intents/__init__.py b/ovos_utils/intents/__init__.py deleted file mode 100644 index 2d329971..00000000 --- a/ovos_utils/intents/__init__.py +++ /dev/null @@ -1,143 +0,0 @@ -from ovos_utils.intents.intent_service_interface import IntentQueryApi, \ - IntentServiceInterface -from ovos_utils.intents.converse import ConverseTracker -from ovos_utils.intents.layers import IntentLayers -from ovos_utils.log import log_deprecation - -log_deprecation("ovos_utils.intents moved to ovos_workshop.intents", "0.1.0") - -try: - from ovos_workshop.intents import * - -except ImportError: - - class Intent: - def __init__(self, name, requires, at_least_one, optional): - """Create Intent object - Args: - name(str): Name for Intent - requires(list): Entities that are required - at_least_one(list): One of these Entities are required - optional(list): Optional Entities used by the intent - """ - self.name = name - self.requires = requires - self.at_least_one = at_least_one - self.optional = optional - - def validate(self, tags, confidence): - """Using this method removes tags from the result of validate_with_tags - Returns: - intent(intent): Results from validate_with_tags - """ - raise NotImplementedError("please install adapt-parser") - - def validate_with_tags(self, tags, confidence): - """Validate whether tags has required entites for this intent to fire - Args: - tags(list): Tags and Entities used for validation - confidence(float): The weight associate to the parse result, - as indicated by the parser. This is influenced by a parser - that uses edit distance or context. - Returns: - intent, tags: Returns intent and tags used by the intent on - failure to meat required entities then returns intent with - confidence - of 0.0 and an empty list for tags. - """ - raise NotImplementedError("please install adapt-parser") - - - class IntentBuilder: - """ - IntentBuilder, used to construct intent parsers. - Attributes: - at_least_one(list): A list of Entities where one is required. - These are separated into lists so you can have one of (A or B) and - then require one of (D or F). - requires(list): A list of Required Entities - optional(list): A list of optional Entities - name(str): Name of intent - Notes: - This is designed to allow construction of intents in one line. - Example: - IntentBuilder("Intent")\ - .requires("A")\ - .one_of("C","D")\ - .optional("G").build() - """ - - def __init__(self, intent_name): - """ - Constructor - Args: - intent_name(str): the name of the intents that this parser - parses/validates - """ - self.at_least_one = [] - self.requires = [] - self.optional = [] - self.name = intent_name - - def one_of(self, *args): - """ - The intent parser should require one of the provided entity types to - validate this clause. - Args: - args(args): *args notation list of entity names - Returns: - self: to continue modifications. - """ - self.at_least_one.append(args) - return self - - def require(self, entity_type, attribute_name=None): - """ - The intent parser should require an entity of the provided type. - Args: - entity_type(str): an entity type - attribute_name(str): the name of the attribute on the parsed intent. - Defaults to match entity_type. - Returns: - self: to continue modifications. - """ - if not attribute_name: - attribute_name = entity_type - self.requires += [(entity_type, attribute_name)] - return self - - def optionally(self, entity_type, attribute_name=None): - """ - Parsed intents from this parser can optionally include an entity of the - provided type. - Args: - entity_type(str): an entity type - attribute_name(str): the name of the attribute on the parsed intent. - Defaults to match entity_type. - Returns: - self: to continue modifications. - """ - if not attribute_name: - attribute_name = entity_type - self.optional += [(entity_type, attribute_name)] - return self - - def build(self): - """ - Constructs an intent from the builder's specifications. - :return: an Intent instance. - """ - return Intent(self.name, self.requires, - self.at_least_one, self.optional) - - -class AdaptIntent(IntentBuilder): - """Wrapper for IntentBuilder setting a blank name. - - Args: - name (str): Optional name of intent - """ - - def __init__(self, name=''): - super().__init__(name) - diff --git a/ovos_utils/intents/converse.py b/ovos_utils/intents/converse.py deleted file mode 100644 index 8f8c6e6e..00000000 --- a/ovos_utils/intents/converse.py +++ /dev/null @@ -1,205 +0,0 @@ -import time - -import ovos_utils.messagebus -from ovos_utils.intents.intent_service_interface import IntentQueryApi -from ovos_utils.log import LOG, deprecated - - -class ConverseTracker: - """ Using the messagebus this class recreates/keeps track of the state - of the converse system, it uses both passive listening and active - queries to sync it's state, it also emits 2 new bus events - - Implements https://github.com/MycroftAI/mycroft-core/pull/1468 - """ - bus = None - active_skills = [] - converse_timeout = 5 # MAGIC NUMBER hard coded in mycroft-core - last_conversed = None - intent_api = None - - @deprecated("ConverseTracker has been deprecated without replacement", "0.1.0") - def __init__(self): - pass - - @classmethod - def connect_bus(cls, mycroft_bus): - """Registers the bus object to use.""" - # PATCH - in mycroft-core this would be handled in intent_service - # in here it is done in MycroftSkill.bind so i added this - # conditional check - if cls.bus is None and mycroft_bus is not None: - cls.bus = mycroft_bus - cls.intent_api = IntentQueryApi(cls.bus) - cls.register_bus_events() - - @classmethod - def register_bus_events(cls): - cls.bus.on('active_skill_request', cls.handle_activate_request) - cls.bus.on('skill.converse.response', cls.handle_converse_response) - cls.bus.on("mycroft.skill.handler.start", cls.handle_intent_start) - cls.bus.on("recognizer_loop:utterance", cls.handle_utterance) - - # public methods - @classmethod - def check_skill(cls, skill_id): - """ Check if a skill is active """ - cls.filter_active_skills() - for skill in list(cls.active_skills): - if skill[0] == skill_id: - return True - return False - - @classmethod - def filter_active_skills(cls): - """ Removes expired skills from active skill list """ - # filter timestamps - for skill in list(cls.active_skills): - if time.time() - skill[1] <= cls.converse_timeout * 60: - cls.remove_active_skill(skill[0]) - - @classmethod - def sync_with_intent_service(cls): - """sync active skill list using intent api - - WARNING - we don't have the timestamps so order might be messed up!! - avoid calling this until - """ - skill_ids = cls.intent_api.get_active_skills(include_timestamps=True) - if skill_ids: - if len(skill_ids[0]) == 2: - # PR was merged! hurray! - cls.active_skills = skill_ids - else: - # hoping they come sorted by timestamp.... - # older to newer (most recently used) - for skill_id in reversed(skill_ids): - # are we tracking this skill ? - if not cls.check_skill(skill_id): - # we missed adding this skill in our tracking - cls.add_active_skill(skill_id) - for skill in cls.active_skills: - if skill[0] not in skill_ids: - # we missed removing this skill in our tracking - cls.remove_active_skill(skill[0]) - - # https://github.com/MycroftAI/mycroft-core/pull/1468 - @classmethod - def remove_active_skill(cls, skill_id, silent=False): - """ - Emits "converse.skill.deactivated" event, improvement of #1468 - """ - for skill in list(cls.active_skills): - if skill[0] == skill_id: - cls.active_skills.remove(skill) - if not silent: - cls.bus.emit(ovos_utils.messagebus.Message("converse.skill.deactivated", - {"skill_id": skill[0]})) - - @classmethod - def add_active_skill(cls, skill_id): - """ - Emits "converse.skill.activated" event, improvement of #1468 - """ - # search the list for an existing entry that already contains it - # and remove that reference - if skill_id != '': - cls.remove_active_skill(skill_id, silent=True) - # add skill with timestamp to start of skill_list - cls.active_skills.insert(0, [skill_id, time.time()]) - # this might be sent more than once and it's perfectly fine - # it's just a new info message not consumed anywhere by default - cls.bus.emit(ovos_utils.messagebus.Message("converse.skill.activated", - {"skill_id": skill_id})) - else: - LOG.warning('Skill ID was empty, won\'t add to list of ' - 'active skills.') - - # status tracking - @classmethod - def handle_activate_request(cls, message): - """ - a skill bumped itself to the top of active skills list - duplicate functionality from mycroft-core, keeping list in sync - """ - skill_id = message.data["skill_id"] - cls.add_active_skill(skill_id) - - @classmethod - def handle_converse_error(cls, message): - """ - a skill was removed from active skill list due to converse error - duplicate functionality from mycroft-core, keeping list in sync - """ - skill_id = message.data["skill_id"] - if message.data["error"] == "skill id does not exist": - cls.remove_active_skill(skill_id) - - @classmethod - def handle_intent_start(cls, message): - """ - duplicate functionality from mycroft-core, keeping list in sync - - TODO skill_id from message, core is not passing it along... it used - to be possible to retrieve it from munged message but that changed. - send a PR (if those got merged this code wouldn't exist) - - handle_utterance will take over this functionality for now - handle_converse_response will take corrective action - """ - # skill_id = message.data["skill_id"] - # bump skill to top of active list - # cls.add_active_skill(skill_id) - - @classmethod - def handle_utterance(cls, message): - """ - duplicate functionality from mycroft-core, keeping list in sync - - WORKAROUND - skill_id missing in handle_intent_start, will keep list - in sync by using the IntentAPI to check what skill the utterance - should trigger - - handle_converse_response will take corrective action - """ - # NOTE borked in mycroft-core - # needs https://github.com/MycroftAI/mycroft-core/pull/2786 - skill_id = cls.intent_api.get_skill(message.data["utterances"][0]) - if skill_id: - # this skill will trigger and therefore is the last active skill - cls.add_active_skill(skill_id) - # will remove expired intents from list - cls.filter_active_skills() - - @classmethod - def handle_converse_response(cls, message): - """ - tracks last_conversed skill - - FAILSAFE - additional checks to correct active skills list, - but that should never happen, accounts for mistakes in - handle_utterance / intent_api - - accounts for https://github.com/MycroftAI/mycroft-core/pull/2786 - not yet being merged - """ - skill_id = message.data["skill_id"] - if 'error' in message.data: - cls.handle_converse_error(message) - elif message.data.get('result') is True: - cls.last_conversed = skill_id - if not cls.check_skill(skill_id): - # seems like we missed adding this skill to active list - # NOTE this is a failsafe and should never trigger - # since this answered True we have the real timestamp - cls.add_active_skill(skill_id) - elif not cls.check_skill(skill_id): - # seems like we missed adding this skill to active list - # NOTE this is a failsafe and should never trigger - # since this answered false and we don't have the real timestamp - # let's add it to the end of the active_skills list - ts = time.time() - if len(cls.active_skills): - ts = cls.active_skills[-1][1] - cls.active_skills.append([skill_id, ts]) diff --git a/ovos_utils/intents/intent_service_interface.py b/ovos_utils/intents/intent_service_interface.py deleted file mode 100644 index cddadff5..00000000 --- a/ovos_utils/intents/intent_service_interface.py +++ /dev/null @@ -1,585 +0,0 @@ -from os.path import exists, isfile -from threading import RLock -from typing import List, Tuple, Optional - -import ovos_utils.messagebus -from ovos_utils.log import LOG, log_deprecation, deprecated - -from ovos_utils.file_utils import to_alnum # backwards compat import - -log_deprecation("ovos_utils.intents moved to ovos_workshop.intents", "0.1.0") - - -try: - from ovos_workshop.intents import * -except: - - def munge_regex(regex: str, skill_id: str) -> str: - """ - Insert skill id as letters into match groups. - - Args: - regex (str): regex string - skill_id (str): skill identifier - Returns: - (str) munged regex - """ - base = '(?P<' + to_alnum(skill_id) - return base.join(regex.split('(?P<')) - - - def munge_intent_parser(intent_parser, name, skill_id): - """ - Rename intent keywords to make them skill exclusive - This gives the intent parser an exclusive name in the - format :. The keywords are given unique - names in the format . - - The function will not munge instances that's already been - munged - - Args: - intent_parser: (IntentParser) object to update - name: (str) Skill name - skill_id: (int) skill identifier - """ - # Munge parser name - if not name.startswith(str(skill_id) + ':'): - intent_parser.name = str(skill_id) + ':' + name - else: - intent_parser.name = name - - # Munge keywords - skill_id = to_alnum(skill_id) - # Munge required keyword - reqs = [] - for i in intent_parser.requires: - if not i[0].startswith(skill_id): - kw = (skill_id + i[0], skill_id + i[0]) - reqs.append(kw) - else: - reqs.append(i) - intent_parser.requires = reqs - - # Munge optional keywords - opts = [] - for i in intent_parser.optional: - if not i[0].startswith(skill_id): - kw = (skill_id + i[0], skill_id + i[0]) - opts.append(kw) - else: - opts.append(i) - intent_parser.optional = opts - - # Munge at_least_one keywords - at_least_one = [] - for i in intent_parser.at_least_one: - element = [skill_id + e.replace(skill_id, '') for e in i] - at_least_one.append(tuple(element)) - intent_parser.at_least_one = at_least_one - - - class IntentServiceInterface: - """ - Interface to communicate with the Mycroft intent service. - - This class wraps the messagebus interface of the intent service allowing - for easier interaction with the service. It wraps both the Adapt and - Padatious parts of the intent services. - """ - - def __init__(self, bus=None): - self._bus = bus - self.skill_id = self.__class__.__name__ - # TODO: Consider using properties with setters to prevent duplicates - self.registered_intents: List[Tuple[str, object]] = [] - self.detached_intents: List[Tuple[str, object]] = [] - self._iterator_lock = RLock() - - @property - def intent_names(self) -> List[str]: - """ - Get a list of intent names (both registered and disabled). - """ - return [a[0] for a in self.registered_intents + self.detached_intents] - - @property - def bus(self): - if not self._bus: - raise RuntimeError("bus not set. call `set_bus()` before trying to" - "interact with the Messagebus") - return self._bus - - @bus.setter - def bus(self, val): - self.set_bus(val) - - def set_bus(self, bus=None): - self._bus = bus or ovos_utils.messagebus.get_mycroft_bus() - - def set_id(self, skill_id: str): - self.skill_id = skill_id - - def register_adapt_keyword(self, vocab_type: str, entity: str, - aliases: Optional[List[str]] = None, - lang: str = None): - """ - Send a message to the intent service to add an Adapt keyword. - @param vocab_type: Keyword reference (file basename) - @param entity: Primary keyword value - @param aliases: List of alternative keyword values - @param lang: BCP-47 language code of entity and aliases - """ - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - - # TODO 22.02: Remove compatibility data - aliases = aliases or [] - entity_data = {'entity_value': entity, - 'entity_type': vocab_type, - 'lang': lang} - compatibility_data = {'start': entity, 'end': vocab_type} - - self.bus.emit(msg.forward("register_vocab", - {**entity_data, **compatibility_data})) - for alias in aliases: - alias_data = { - 'entity_value': alias, - 'entity_type': vocab_type, - 'alias_of': entity, - 'lang': lang} - compatibility_data = {'start': alias, 'end': vocab_type} - self.bus.emit(msg.forward("register_vocab", - {**alias_data, **compatibility_data})) - - def register_adapt_regex(self, regex: str, lang: str = None): - """ - Register a regex string with the intent service. - @param regex: Regex to be registered; Adapt extracts keyword references - from named match group. - @param lang: BCP-47 language code of regex - """ - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward("register_vocab", - {'regex': regex, 'lang': lang})) - - def register_adapt_intent(self, name: str, intent_parser: object): - """ - Register an Adapt intent parser object. Serializes the intent_parser - and sends it over the messagebus to registered. - @param name: string intent name (without skill_id prefix) - @param intent_parser: Adapt Intent object - """ - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward("register_intent", intent_parser.__dict__)) - self.registered_intents.append((name, intent_parser)) - self.detached_intents = [detached for detached in self.detached_intents - if detached[0] != name] - - def detach_intent(self, intent_name: str): - """ - DEPRECATED: Use `remove_intent` instead, all other methods from this - class expect intent_name; this was the weird one expecting the internal - munged intent_name with skill_id. - """ - name = intent_name.split(':')[1] - log_deprecation(f"Update to `self.remove_intent({name})", - "0.1.0") - self.remove_intent(name) - - def remove_intent(self, intent_name: str): - """ - Remove an intent from the intent service. The intent is saved in the - list of detached intents for use when re-enabling an intent. A - `detach_intent` Message is emitted for the intent service to handle. - @param intent_name: Registered intent to remove/detach (no skill_id) - """ - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - if intent_name in self.intent_names: - # TODO: This will create duplicates of already detached intents - LOG.info(f"Detaching intent: {intent_name}") - self.detached_intents.append((intent_name, - self.get_intent(intent_name))) - self.registered_intents = [pair for pair in self.registered_intents - if pair[0] != intent_name] - self.bus.emit(msg.forward("detach_intent", - {"intent_name": - f"{self.skill_id}:{intent_name}"})) - - def intent_is_detached(self, intent_name: str) -> bool: - """ - Determine if an intent is detached. - @param intent_name: String intent reference to check (without skill_id) - @return: True if intent is in detached_intents, else False. - """ - is_detached = False - with self._iterator_lock: - for (name, _) in self.detached_intents: - if name == intent_name: - is_detached = True - break - return is_detached - - def set_adapt_context(self, context: str, word: str, origin: str): - """ - Set an Adapt context. - @param context: context keyword name to add/update - @param word: word to register (context keyword value) - @param origin: original origin of the context (for cross context) - """ - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward('add_context', - {'context': context, 'word': word, - 'origin': origin})) - - def remove_adapt_context(self, context: str): - """ - Remove an Adapt context. - @param context: context keyword name to remove - """ - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward('remove_context', {'context': context})) - - def register_padatious_intent(self, intent_name: str, filename: str, - lang: str): - """ - Register a Padatious intent file with the intent service. - @param intent_name: Unique intent identifier - (usually `skill_id`:`filename`) - @param filename: Absolute file path to entity file - @param lang: BCP-47 language code of registered intent - """ - if not isinstance(filename, str): - raise ValueError('Filename path must be a string') - if not exists(filename): - raise FileNotFoundError(f'Unable to find "{filename}"') - with open(filename) as f: - samples = [_ for _ in f.read().split("\n") if _ - and not _.startswith("#")] - data = {'file_name': filename, - "samples": samples, - 'name': intent_name, - 'lang': lang} - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward("padatious:register_intent", data)) - self.registered_intents.append((intent_name.split(':')[-1], data)) - - def register_padatious_entity(self, entity_name: str, filename: str, - lang: str): - """ - Register a Padatious entity file with the intent service. - @param entity_name: Unique entity identifier - (usually `skill_id`:`filename`) - @param filename: Absolute file path to entity file - @param lang: BCP-47 language code of registered intent - """ - if not isinstance(filename, str): - raise ValueError('Filename path must be a string') - if not exists(filename): - raise FileNotFoundError('Unable to find "{}"'.format(filename)) - with open(filename) as f: - samples = [_ for _ in f.read().split("\n") if _ - and not _.startswith("#")] - msg = ovos_utils.messagebus.dig_for_message() or ovos_utils.messagebus.Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward('padatious:register_entity', - {'file_name': filename, - "samples": samples, - 'name': entity_name, - 'lang': lang})) - - def get_intent_names(self): - log_deprecation("Reference `intent_names` directly", "0.1.0") - return self.intent_names - - def detach_all(self): - """ - Detach all intents associated with this interface and remove all - internal references to intents and handlers. - """ - for name in self.intent_names: - self.remove_intent(name) - if self.registered_intents: - LOG.error(f"Expected an empty list; got: {self.registered_intents}") - self.registered_intents = [] - self.detached_intents = [] # Explicitly remove all intent references - - def get_intent(self, intent_name: str) -> Optional[object]: - """ - Get an intent object by name. This will find both enabled and disabled - intents. - @param intent_name: name of intent to find (without skill_id) - @return: intent object if found, else None - """ - to_return = None - with self._iterator_lock: - for name, intent in self.registered_intents: - if name == intent_name: - to_return = intent - break - if to_return is None: - with self._iterator_lock: - for name, intent in self.detached_intents: - if name == intent_name: - to_return = intent - break - return to_return - - def __iter__(self): - """Iterator over the registered intents. - - Returns an iterator returning name-handler pairs of the registered - intent handlers. - """ - return iter(self.registered_intents) - - def __contains__(self, val): - """ - Checks if an intent name has been registered. - """ - return val in [i[0] for i in self.registered_intents] - - - def open_intent_envelope(message): - """ - Convert dictionary received over messagebus to Intent. - """ - # TODO can this method be fully removed from ovos_utils ? - from adapt.intent import Intent - - intent_dict = message.data - return Intent(intent_dict.get('name'), - intent_dict.get('requires'), - intent_dict.get('at_least_one'), - intent_dict.get('optional')) - - -class IntentQueryApi: - """ - Query Intent Service at runtime - """ - - @deprecated("IntentQueryApi has been deprecated without replacement", "0.1.0") - def __init__(self, bus=None, timeout=5): - if bus is None: - bus = ovos_utils.messagebus.get_mycroft_bus() - self.bus = bus - self.timeout = timeout - - def get_adapt_intent(self, utterance, lang="en-us"): - """ get best adapt intent for utterance """ - msg = ovos_utils.messagebus.Message("intent.service.adapt.get", - {"utterance": utterance, "lang": lang}, - context={"destination": "intent_service", - "source": "intent_api"}) - - resp = self.bus.wait_for_response(msg, - 'intent.service.adapt.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intent"] - - def get_padatious_intent(self, utterance, lang="en-us"): - """ get best padatious intent for utterance """ - msg = ovos_utils.messagebus.Message("intent.service.padatious.get", - {"utterance": utterance, "lang": lang}, - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.padatious.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intent"] - - def get_intent(self, utterance, lang="en-us"): - """ get best intent for utterance """ - msg = ovos_utils.messagebus.Message("intent.service.intent.get", - {"utterance": utterance, "lang": lang}, - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.intent.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intent"] - - def get_skill(self, utterance, lang="en-us"): - """ get skill that utterance will trigger """ - intent = self.get_intent(utterance, lang) - if not intent: - return None - # theoretically skill_id might be missing - if intent.get("skill_id"): - return intent["skill_id"] - # retrieve skill from munged intent name - if intent.get("intent_name"): # padatious + adapt - return intent["name"].split(":")[0] - if intent.get("intent_type"): # adapt - return intent["intent_type"].split(":")[0] - return None # raise some error here maybe? this should never happen - - def get_skills_manifest(self): - msg = ovos_utils.messagebus.Message("intent.service.skills.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.skills.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["skills"] - - def get_active_skills(self, include_timestamps=False): - msg = ovos_utils.messagebus.Message("intent.service.active_skills.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.active_skills.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - if include_timestamps: - return data["skills"] - return [s[0] for s in data["skills"]] - - def get_adapt_manifest(self): - msg = ovos_utils.messagebus.Message("intent.service.adapt.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.adapt.manifest', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intents"] - - def get_padatious_manifest(self): - msg = ovos_utils.messagebus.Message("intent.service.padatious.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.padatious.manifest', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intents"] - - def get_intent_manifest(self): - padatious = self.get_padatious_manifest() - adapt = self.get_adapt_manifest() - return {"adapt": adapt, - "padatious": padatious} - - def get_vocab_manifest(self): - msg = ovos_utils.messagebus.Message("intent.service.adapt.vocab.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - reply_msg_type = 'intent.service.adapt.vocab.manifest' - resp = self.bus.wait_for_response(msg, - reply_msg_type, - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - - vocab = {} - for voc in data["vocab"]: - if voc.get("regex"): - continue - if voc["end"] not in vocab: - vocab[voc["end"]] = {"samples": []} - vocab[voc["end"]]["samples"].append(voc["start"]) - return [{"name": voc, "samples": vocab[voc]["samples"]} - for voc in vocab] - - def get_regex_manifest(self): - msg = ovos_utils.messagebus.Message("intent.service.adapt.vocab.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - reply_msg_type = 'intent.service.adapt.vocab.manifest' - resp = self.bus.wait_for_response(msg, - reply_msg_type, - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - - vocab = {} - for voc in data["vocab"]: - if not voc.get("regex"): - continue - name = voc["regex"].split("(?P<")[-1].split(">")[0] - if name not in vocab: - vocab[name] = {"samples": []} - vocab[name]["samples"].append(voc["regex"]) - return [{"name": voc, "regexes": vocab[voc]["samples"]} - for voc in vocab] - - def get_entities_manifest(self): - msg = ovos_utils.messagebus.Message("intent.service.padatious.entities.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - reply_msg_type = 'intent.service.padatious.entities.manifest' - resp = self.bus.wait_for_response(msg, - reply_msg_type, - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - - entities = [] - # read files - for ent in data["entities"]: - if isfile(ent["file_name"]): - with open(ent["file_name"]) as f: - lines = f.read().replace("(", "").replace(")", "").split( - "\n") - samples = [] - for l in lines: - samples += [a.strip() for a in l.split("|") if a.strip()] - entities.append({"name": ent["name"], "samples": samples}) - return entities - - def get_keywords_manifest(self): - padatious = self.get_entities_manifest() - adapt = self.get_vocab_manifest() - regex = self.get_regex_manifest() - return {"adapt": adapt, - "padatious": padatious, - "regex": regex} - - diff --git a/ovos_utils/intents/layers.py b/ovos_utils/intents/layers.py deleted file mode 100644 index e1c62ede..00000000 --- a/ovos_utils/intents/layers.py +++ /dev/null @@ -1,161 +0,0 @@ -from ovos_utils.log import LOG, deprecated - - -try: - from ovos_workshop.decorators.layers import IntentLayers -except ImportError: - import ovos_utils.messagebus - - from time import sleep - - class IntentLayers: - - @deprecated("IntentLayers moved to ovos_workshop.decorators.layers", "0.1.0") - def __init__(self, bus=None, layers=None): - layers = layers or [] - self.bus = bus or ovos_utils.messagebus.get_mycroft_bus() - # make intent levels for N layers - self.layers = layers - self.current_layer = 0 - self.activate_layer(0) - self.named_layers = {} - - def disable_intent(self, intent_name): - """Disable a registered intent""" - self.bus.emit(ovos_utils.messagebus.Message("mycroft.skill.disable_intent", - {"intent_name": intent_name})) - - def enable_intent(self, intent_name): - """Reenable a registered self intent""" - self.bus.emit(ovos_utils.messagebus.Message("mycroft.skill.enable_intent", - {"intent_name": intent_name})) - - def reset(self): - LOG.info("Reseting Intent Layers") - self.activate_layer(0) - - def next(self): - LOG.info("Going to next Intent Layer") - self.current_layer += 1 - if self.current_layer > len(self.layers): - LOG.info("Already in last layer, going to layer 0") - self.current_layer = 0 - self.activate_layer(self.current_layer) - - def previous(self): - LOG.info("Going to previous Intent Layer") - self.current_layer -= 1 - if self.current_layer < 0: - self.current_layer = 0 - LOG.error("Already in layer 0") - else: - self.activate_layer(self.current_layer) - - def add_layer(self, intent_list=None): - intent_list = intent_list or [] - self.layers.append(intent_list) - LOG.info("Adding intent layer: " + str(intent_list)) - - def add_named_layer(self, name, intent_list=None): - intent_list = intent_list or [] - self.named_layers[name] = len(self.layers) - self.layers.append(intent_list) - LOG.info("Setting layer " + name + " to: " + str(intent_list)) - - def activate_named_layer(self, name): - if name in self.named_layers: - i = self.named_layers[name] - LOG.info("activating layer named: " + name) - self.activate_layer(i) - else: - LOG.error("no layer named: " + name) - - def deactivate_named_layer(self, name): - if name in self.named_layers: - i = self.named_layers[name] - LOG.info("deactivating layer named: " + name) - self.deactivate_layer(i) - else: - LOG.error("no layer named: " + name) - - def remove_named_layer(self, name): - if name in self.named_layers: - i = self.named_layers[name] - LOG.info("removing layer named: " + name) - self.remove_layer(i) - else: - LOG.error("no layer named: " + name) - - def replace_named_layer(self, name, intent_list=None): - if name in self.named_layers: - i = self.named_layers[name] - LOG.info("replacing layer named: " + name) - self.replace_layer(i, intent_list) - else: - LOG.error("no layer named: " + name) - - def replace_layer(self, layer_num, intent_list=None): - intent_list = intent_list or [] - if self.current_layer == layer_num: - self.deactivate_layer(layer_num) - LOG.info("Adding layer" + str(intent_list) + " in position " + str( - layer_num)) - self.layers[layer_num] = intent_list - if self.current_layer == layer_num: - self.activate_layer(layer_num) - - def remove_layer(self, layer_num): - if layer_num >= len(self.layers): - return False - if self.current_layer == layer_num: - self.deactivate_layer(layer_num) - self.layers.pop(layer_num) - LOG.info("Removing layer number " + str(layer_num)) - return True - - def find_layer(self, intent_name): - layer_list = [] - for i in range(0, len(self.layers)): - if intent_name in self.layers[i]: - layer_list.append(i) - return layer_list - - def disable(self): - LOG.info("Disabling layers") - # disable all layers - for i in range(0, len(self.layers)): - self.deactivate_layer(i) - - def activate_layer(self, layer_num): - # error check - if layer_num < 0 or layer_num > len(self.layers): - LOG.error("invalid layer number") - return False - - self.current_layer = layer_num - - # disable other layers - self.disable() - - # TODO in here we should wait for all intents to be detached - # sometimes detach intent from this step comes after register from next - # is there some bus signal we can track? - sleep(0.3) - - # enable layer - LOG.info("Activating Layer " + str(layer_num)) - if layer_num < len(self.layers) and len(self.layers): - for intent_name in self.layers[layer_num]: - self.enable_intent(intent_name) - return True - return False - - def deactivate_layer(self, layer_num): - # error check - if layer_num < 0 or layer_num > len(self.layers): - LOG.error("invalid layer number") - return False - LOG.info("Deactivating Layer " + str(layer_num)) - for intent_name in self.layers[layer_num]: - self.disable_intent(intent_name) - return True diff --git a/ovos_utils/json_helper.py b/ovos_utils/json_helper.py index c54ba9b5..ed356450 100644 --- a/ovos_utils/json_helper.py +++ b/ovos_utils/json_helper.py @@ -1,9 +1,5 @@ import json from copy import copy -# TODO: Deprecate unused imports -from json_database.utils import is_jsonifiable, get_key_recursively, \ - get_key_recursively_fuzzy, get_value_recursively_fuzzy, \ - get_value_recursively, jsonify_recursively def nested_get(base, key_list): diff --git a/ovos_utils/log.py b/ovos_utils/log.py index d054053b..6f4c69a0 100644 --- a/ovos_utils/log.py +++ b/ovos_utils/log.py @@ -12,12 +12,15 @@ # import functools import inspect +import json import logging import os import sys from logging.handlers import RotatingFileHandler from os.path import join -from typing import List +from pathlib import Path +from typing import Optional, List, Set + class LOG: """ @@ -77,21 +80,21 @@ def __init__(cls, name='OVOS'): @classmethod def init(cls, config=None): - + from ovos_utils.xdg_utils import xdg_state_home try: from ovos_config.meta import get_xdg_base - default_base = get_xdg_base() + xdg_base = get_xdg_base() except ImportError: - default_base = os.environ.get("OVOS_CONFIG_BASE_FOLDER") or \ - "mycroft" - from ovos_utils.xdg_utils import xdg_state_home + xdg_base = os.environ.get("OVOS_CONFIG_BASE_FOLDER") or "mycroft" + + xdg_path = os.path.join(xdg_state_home(), xdg_base) config = config or {} - cls.base_path = config.get("path") or \ - f"{xdg_state_home()}/{default_base}" + cls.base_path = config.get("path") or xdg_path cls.max_bytes = config.get("max_bytes", 50000000) cls.backup_count = config.get("backup_count", 3) - cls.level = config.get("level") or LOG.level + level = config.get("level") or LOG.level + cls.set_level(level) cls.diagnostic_mode = config.get("diagnostic", False) @classmethod @@ -179,38 +182,73 @@ def exception(cls, *args, **kwargs): cls._get_real_logger().exception(*args, **kwargs) -def init_service_logger(service_name): - # this is makes all logs from this service be configured to write to service_name.log file - # if this is not called in every __main__.py entrypoint logs will be written - # to a generic OVOS.log file shared across all services +def _monitor_log_level(): + _logs_conf = get_logs_config(LOG.name) + hax = hash(json.dumps(_logs_conf, sort_keys=True, indent=2)) + if hax != _monitor_log_level.config_hash: + _monitor_log_level.config_hash = hax + LOG.init(_logs_conf) + LOG.info("updated LOG level") + + +_monitor_log_level.config_hash = None + + +def init_service_logger(service_name: str): + """ + Initialize `LOG` for the specified service + @param service_name: Name of service to configure `LOG` for + """ + _logs_conf = get_logs_config(service_name) + _monitor_log_level.config_hash = hash(json.dumps(_logs_conf, sort_keys=True, + indent=2)) + LOG.name = service_name + LOG.init(_logs_conf) # set up the LOG instance try: - from ovos_config.config import read_mycroft_config - _cfg = read_mycroft_config() + from ovos_config import Configuration + Configuration.set_config_watcher(_monitor_log_level) except ImportError: - LOG.warning("ovos_config not available. Falling back to defaults") - _cfg = dict() + LOG.warning("Can not monitor config LOG level changes") +def get_logs_config(service_name: Optional[str] = None, + _cfg: Optional[dict] = None) -> dict: + """ + Get logging configuration for the specified service + @param service_name: Name of service to get logging configuration for + @param _cfg: Configuration to parse + @return: dict logging configuration for the specified service + """ + if _cfg is None: + try: + from ovos_config import Configuration + _cfg = Configuration() + except ImportError: + LOG.warning("ovos_config not available. Falling back to defaults") + _cfg = {} + # First try and get the "logging" section - log_config = _cfg.get("logging") + logging_conf = _cfg.get("logging") # For compatibility we try to get the "logs" from the root level # and default to empty which is used in case there is no logging # section _logs_conf = _cfg.get("logs") or {} - if log_config: # We found a logging section + if logging_conf: # We found a logging section # if "logs" is defined in "logging" use that as the default # where per-service "logs" are not defined - _logs_conf = log_config.get("logs") or _logs_conf + _logs_conf = logging_conf.get("logs") or _logs_conf # Now get our config by service name - _cfg = log_config.get(service_name) or log_config + if service_name: + _cfg = logging_conf.get(service_name) or logging_conf + else: + # No service name specified, use `logging` configuration + _cfg = logging_conf # and if "logs" is redefined in "logging." use that _logs_conf = _cfg.get("logs") or _logs_conf # Grab the log level from whatever section we found, defaulting to INFO _log_level = _cfg.get("log_level", "INFO") - # and write it into the "logs" config _logs_conf["level"] = _log_level - LOG.name = service_name - LOG.init(_logs_conf) # setup the LOG instance + return _logs_conf def log_deprecation(log_message: str = "DEPRECATED", @@ -229,7 +267,6 @@ def log_deprecation(log_message: str = "DEPRECATED", determination. i.e. an internal exception handling method should log the first call external to that package """ - import inspect stack = inspect.stack()[1:] # [0] is this method call_info = "Unknown Origin" origin_module = func_module @@ -265,6 +302,7 @@ def deprecated(log_message: str, deprecation_version: str): @param log_message: Deprecation log message @param deprecation_version: package version in which deprecation will occur """ + def wrapped(func): @functools.wraps(func) def log_wrapper(*args, **kwargs): @@ -273,6 +311,88 @@ def log_wrapper(*args, **kwargs): func_module=func.__module__, deprecation_version=deprecation_version) return func(*args, **kwargs) + return log_wrapper return wrapped + + +def get_log_path(service: str, directories: Optional[List[str]] = None) \ + -> Optional[str]: + """ + Get the path to the log directory for a given service. + Default behaviour is to check the configured paths for the service. + If a list of directories is provided, check that list for the service log + + Args: + service: service name + directories: (optional) list of directories to check for service + + Returns: + path to log directory for service + (returned path may be `None` if `directories` is specified) + """ + if directories: + for directory in directories: + file = os.path.join(directory, f"{service}.log") + if os.path.exists(file): + return directory + return None + + from ovos_utils.xdg_utils import xdg_state_home + try: + from ovos_config import Configuration + from ovos_config.meta import get_xdg_base + except ImportError: + xdg_base = os.environ.get("OVOS_CONFIG_BASE_FOLDER", "mycroft") + return os.path.join(xdg_state_home(), xdg_base) + + config = get_logs_config(service_name=service) + # service specific config or default config location + path = config.get("path") + # default xdg location + if not path: + path = os.path.join(xdg_state_home(), get_xdg_base()) + + return path + + +def get_log_paths(config: Optional[dict] = None) -> Set[str]: + """ + Get all log paths for all service logs + Different services may have different log paths + + Returns: + set of paths to log directories + """ + paths = set() + if not config: + try: + from ovos_config import Configuration + config = Configuration() + except ImportError: + LOG.warning("ovos_config not available. Falling back to defaults") + config = dict() + + for name, service_config in config.get("logging", {}).items(): + if not isinstance(service_config, dict) or name == "logs": + continue + if service_config.get("path"): + paths.add(service_config.get("path")) + paths.add(get_log_path("")) + + return paths + + +def get_available_logs(directories: Optional[List[str]] = None) -> List[str]: + """ + Get a list of all available log files + Args: + directories: (optional) list of directories to check for service + + Returns: + list of log file basenames (i.e. "audio", "skills") + """ + directories = directories or get_log_paths() + return [Path(f).stem for path in directories + for f in os.listdir(path) if Path(f).suffix == ".log"] diff --git a/ovos_utils/log_parser.py b/ovos_utils/log_parser.py new file mode 100644 index 00000000..f7de2f5d --- /dev/null +++ b/ovos_utils/log_parser.py @@ -0,0 +1,669 @@ +import re +import os +from datetime import datetime +from traceback import FrameSummary +from dataclasses import dataclass +from typing import Any, Tuple, List, Generator, Dict, Union, Optional + +from dateutil.parser import parse +import rich_click as click +from rich.console import Console +from rich.style import Style +from rich.table import Table +import pydoc +from combo_lock import ComboLock + +try: + from ovos_config import Configuration + use24h = Configuration().get("time_format", "full") == "full" + date_format = Configuration().get("date_format", "DMY") +except ImportError: + use24h = True + date_format = "DMY" + +from ovos_utils.log import get_log_path, get_log_paths, get_available_logs + + +TIME_FORMAT = '%Y-%m-%d %H:%M:%S.%f' +LOGLOCK = ComboLock("ovos_logs_console_script") + + +@dataclass +class LogLine: + timestamp: datetime = None + source: str = "" + location: str = "" + level: str = "" + message: str = "" + + def __str__(self): + # sytsem messages etc. + if not all([self.source, self.location, self.level]): + return self.message + return f"{self.format_timestamp()} - {self.source} - {self.location} - {self.level} - {self.message}" + + def format_timestamp(self): + if self.timestamp: + return self.timestamp.strftime(TIME_FORMAT)[:-3] + return "" + + +# Traceback frame +class Frame(FrameSummary): + def __init__(self, filename, lineno, name, line): + super().__init__(filename, lineno, name, line=line) + + def as_dict(self): + return { + "location": self.format_location(), + "level": "TRACEBACK", + "message": self.line + } + + def as_logline(self): + return LogLine(**self.as_dict()) + + def format_location(self): + if "/bin/" in self.filename: + package = self.filename.split("/bin/")[-1].replace(".py", "")\ + .replace("-", "_").replace("/", ".") + elif "site-packages" not in self.filename and \ + (pyver := re.search(r"python\d\.\d+[\\/]", self.filename)): + package = self.filename.split(pyver.group())[-1].replace(".py", "")\ + .replace("-", "_").replace("/", ".") + else: + package = self.filename.split("site-packages/")[-1].replace(".py", "")\ + .replace("-", "_").replace("/", ".") + method = self.name.replace(".py", "").replace("-", "_") + return f"{package}:{method}:{self.lineno}" + + def __str__(self): + return f' File "{self.filename}", line {self.lineno}, in {self.name}\n {self.line}\n' + + +class Traceback: + PATTERN = r'File "(?P[^"]+)", line (?P\d+), in (?P\S+)\n\s*(?P.+)' + + def __init__(self, frames: List[Frame], exception: str, timestamp: datetime = None): + self.frames = frames + self.exception = exception + self._timestamp = timestamp + + @property + def exception_location(self): + return self.frames[-1].format_location() + + @property + def timestamp(self): + return self._timestamp + + @timestamp.setter + def timestamp(self, value): + self._timestamp = value + + def to_loglines(self) -> List[LogLine]: + + lines = [LogLine(timestamp=self.timestamp, + location=self.exception_location, + level="EXCEPTION", + message=self.exception)] + + for frame in self.frames: + lines.append(frame.as_logline()) + + return lines + + @classmethod + def from_list(cls, lines): + lines = [line if line.endswith("\n") else line + "\n" for line in lines] + multiline = "".join(lines) + return cls.from_string(multiline) + + @classmethod + def from_string(cls, s): + matches = re.findall(cls.PATTERN, s, re.MULTILINE) + frames = [] + for match in matches: + data = dict(zip(["filename", "lineno", "name", "line"], match)) + frames.append(Frame(**data)) + exception = next(line for line in s.split("\n")[::-1] if line) + return cls(frames, exception) + + def __str__(self): + multiline = "Traceback (most recent call last):\n" + for frame in self.frames: + multiline += str(frame) + multiline += f"{self.exception}\n" + return multiline + + +class OVOSLogParser: + LOG_PATTERN = r'(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{1,6}) - (?P.+?) - (?P.+?) - (?P\w+) - (?P.*)' + + @classmethod + def parse(self, log_line, last_timestamp=None) -> LogLine: + log_line.rstrip("\n") + match = re.match(self.LOG_PATTERN, log_line) + data = {} + if match: + data = match.groupdict() + data['timestamp'] = datetime.strptime(data['timestamp'], TIME_FORMAT) + return LogLine(**data) + + data["timestamp"] = last_timestamp or "" + data["message"] = log_line + return LogLine(**data) + + @classmethod + def parse_file(self, source) -> Generator[Union[LogLine, Traceback], None, None]: + if not os.path.exists(source): + raise FileNotFoundError(f"File {source} does not exist") + + with open(source, 'r') as file: + trace = None + last_timestamp = None + for line in file: + # gather all lines of the traceback + if line == "Traceback (most recent call last):\n": + trace = [line] + continue + # TODO do tracebacks always end on a empty line? + elif trace and line == "\n": + trace.append(line) + traceback = Traceback.from_list(trace) + traceback.timestamp = last_timestamp + yield traceback + trace = None + elif trace: + trace.append(line) + else: + log = self.parse(line, last_timestamp) + if log.message == "\n": + continue + timestamp = log.timestamp + if timestamp: + last_timestamp = timestamp + yield log + + +console = Console() + +EXPECTED_DATE_FORMAT = "YYYY-MM-DD" if date_format == "YMD" else "DD-MM-YYYY" +EXPECTED_DATE = "2023-12-01" if date_format == "YMD" else "01-12-2023" +EXPECTED_DATETIME_FORMAT = f'"[{EXPECTED_DATE_FORMAT}] HH:MM[:SS]{" AM/PM" if not use24h else ""}"' +EXPECTED_TIME = f'"09:00:05{" PM" if not use24h else ""}"' + +LOGSOPTHELP = """logs to be sliced +\n\nmultiple: -l bus -l audio""" +STARTTIMEHELP = f"""start time of the log slice (default: since service restart, +input format: {EXPECTED_DATETIME_FORMAT}) +\n\nExample: -s \"{EXPECTED_DATE} 12:00{' AM/PM' if not use24h else ''}\" / -s + {'"' if not use24h else ''}12:00:05{' AM/PM"' if not use24h else ''}""" + +click.rich_click.STYLE_ARGUMENT = "dark_red" +click.rich_click.STYLE_OPTION = "dark_red" +click.rich_click.STYLE_SWITCH = "indian_red" +click.rich_click.USE_MARKDOWN = True +click.rich_click.COMMAND_GROUPS = { + "ovos-logs": [ + { + "name": "Slice logs by time", + "commands": ["slice"], + "table_styles": { + "row_styles": ["white"], + "padding": (0, 2), + "title_justify": "left" + }, + }, + { + "name": "List logs by severity", + "commands": ["list"], + "table_styles": { + "row_styles": ["white"], + "padding": (0, 2), + }, + }, + { + "name": "Downsize logs", + "commands": ["reduce"], + "table_styles": { + "row_styles": ["white"], + "padding": (0, 1), + }, + }, + { + "name": "Show logs (using less)", + "commands": ["show"], + "table_styles": { + "row_styles": ["white"], + "padding": (0, 2), + }, + } + ] +} + + +def get_last_load_time(directories: Optional[List[str]] = None) -> Optional[datetime]: + # if nothing's found return the beginning of unix time + last_timestamp = datetime.fromtimestamp(0) + if directories is None: + directory = get_log_path("skills") + else: + directory = get_log_path("skills", directories) + + if directory: + with open(os.path.join(directory,"skills.log"), "r") as f: + for line in f.readlines()[::-1]: + logline = OVOSLogParser.parse(line) + if logline.timestamp: + last_timestamp = logline.timestamp + if logline.message == "Loading message bus configs": + break + return last_timestamp + + +def valid_log(logs, paths): + for log in logs: + if log.lower() not in get_available_logs(paths): + return False + return True + + +def parse_time(time_str): + try: + time = parse(time_str) + except ValueError: + return None + return time + + +def get_timestamped_filename(basename: str, ext: str, basedir = "~", timeformat = '%Y%m%d_%H%M%S'): + if basedir == "~": + basedir = os.path.expanduser("~") + + t = datetime.now().strftime(timeformat) + return os.path.join(basedir, f"{basename}_{t}.{ext}") + + +def parse_timeframe(start, end, directories: Optional[List[str]] = None) -> Tuple[Any, Any]: + """ + Parses the start and end time given a string input. + If the start is None, parse the skill log to determine the last service load time and + if that fails return the beginning starting datetime of the log. + If the end is None, return the current datetime. + + :param start: start time of the log slice (default: since service restart) + :param end: end time of the log slice (default: now) + :param directories: the directory logs reside in + :return: start and end time + """ + if start is None: + start = get_last_load_time(directories) + else: + start = parse_time(start) + + if end is None: + end = datetime.now() + else: + end = parse_time(end) + return start, end + + +@click.group() +def ovos_logs(): + """\b + Small helper tool to quickly navigate the logs, create slices and quickview errors + + `ovos-logs [COMMAND] --help` for further information about the specific command ARGUMENTS + \b + """ + pass + + +@ovos_logs.command() +@click.option("--start", "-s", help=STARTTIMEHELP) +@click.option("--until", "-u", help=f"end time of the log slice [default: now]") +@click.option("--logs", "-l", multiple=True, default=get_available_logs(), help=LOGSOPTHELP, show_default=True) +@click.option("--paths", "-p", multiple=True, default=get_log_paths(), help=f"the directory logs reside in", show_default=True) +@click.option("--file", "-f", is_flag=False, flag_value=get_timestamped_filename("slice", "log"), + default=None, help=f"output as file (if flagged, but not specified: {get_timestamped_filename('slice', 'log')})") +def slice(start, until, logs, paths, file): + """\b + Optionally define start (`-s`) and the time until (`-u`) the slice should be limited to. + \b + Different logs can be included using the `-l` option. If not specified, all logs will be included. + Optionally the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) + can be specified. + \b + > Examples: + > ovos-logs slice # Slice all logs from service start up until now + > ovos-logs slice -s 01-12-2023 -u '01-12-2023 17:00:20' # Slice all logs from the start of december the first until 17:00:20 + > ovos-logs slice -l bus -l skills -f ~/myslice.log # Slice skills.log and bus.log from service start up until now and dump it to the file ~/myslice.log + """ + logs_present = [] + + if not all(os.path.exists(path) for path in paths): + return console.print(f"Directory [{[p for p in paths if not os.path.exists(p)]}] does not exist") + else: + logs_present = get_available_logs(paths) + + start, end = parse_timeframe(start, until, paths) + if start is None: + return console.print(f"Need a valid start time in the format {EXPECTED_DATETIME_FORMAT}") + elif end is None: + return console.print(f"Need a valid end time in the format {EXPECTED_DATETIME_FORMAT}") + elif start > end: + return console.print(f"Start time [{start}] is after end time [{end}]") + + if not logs: + logs = logs_present + elif not valid_log(logs, paths): + return console.print(f"Invalid log name, valid logs are {logs_present}") + + _templog: Dict[str, List[LogLine]] = dict() + + for service in logs: + path = get_log_path(service, paths) + logfile = os.path.join(path, f"{service}.log") + if not os.path.exists(logfile): + continue + _templog[service] = [] + for log in OVOSLogParser.parse_file(logfile): + if start <= log.timestamp < end: + if isinstance(log, Traceback): + _templog[service].extend(log.to_loglines()) + else: + _templog[service].append(log) + if not _templog[service]: + del _templog[service] + + if not _templog: + return console.print("No logs found in the specified time frame") + + if file is not None: + # test if file is writable + try: + with open(file, 'w') as f: + pass + except: + return console.print(f"File [{file}] is not writable. Aborted") + else: + console.print(f"Log slice saved to [bold]{file}[/bold]") + + for service in _templog: + table = Table(title=service) + table.add_column("Time", style="cyan", no_wrap=True) + table.add_column() + table.add_column("Message", style="magenta") + table.add_column("Origin", style="green") + lineno = 0 + for logline in _templog[service]: + lineno += 1 + style = None + timestamp = logline.timestamp or "" + if isinstance(timestamp, datetime): + timestamp = timestamp.strftime("%H:%M:%S.%f" if use24h else "%I:%M:%S.%f")[:-3] + if not use24h: + timestamp += logline.timestamp.strftime(" %p") + + level = logline.level or "" + message = logline.message or "" + if level == "ERROR": + level = "[bold red]" + level[:1] + elif level == "EXCEPTION": + level = "[bold red]" + level[:3] + elif level == "WARNING": + level = "[bold yellow]" + level[:1] + elif level == "DEBUG": + level = "[bold blue]" + level[:1] + elif level == "TRACEBACK": + level = "[white]" + level[:5] + message = "[grey42]" + message + elif level == "INFO": + level = "" + message = "[navajo_white1]" + message + if lineno % 2 == 0: + style = Style(bgcolor="grey7") + table.add_row( + timestamp, + level, + message, + logline.location or "", + style=style + ) + if len(logline.message) > 200: + table.add_row() + + console.print(table) + if file: + Console(file=open(file, 'a')).print(table) + + +@ovos_logs.command() +@click.option("--error", "-e", is_flag=True, help="display error messages") +@click.option("--warning", "-w", is_flag=True, help="display warning messages") +@click.option("--exception", "-x", is_flag=True, help="display exceptions") +@click.option("--debug", "-d", is_flag=True, help="display debug messages") +@click.option("--start", "-s", help=STARTTIMEHELP) +@click.option("--until", "-u", help=f"end time of the log slice [default: now]") +@click.option("--logs", "-l", multiple=True, default=get_available_logs(), help=LOGSOPTHELP, show_default=True) +@click.option("--paths", "-p", multiple=True, type=click.Path(), default=get_log_paths(), help=f"the directory logs reside in", show_default=True) +@click.option("--file", "-f", is_flag=False, type=click.Path(), flag_value=get_timestamped_filename("list", "log"), default=None, + help=f"output as file (if flagged, but not specified: {get_timestamped_filename('list', 'log')})") +def list(error, warning, exception, debug, start, until, logs, paths, file): + """\b + Log level has to be specified. + \b + Optionally define start (`-s`) and the time until (`-u`) the slice should be limited to. + \b + Different logs can be included using the `-l` option. If not specified, all logs will be included. + \b + Optionally the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) + can be specified. + \b + > Examples: + > ovos-logs list -x # List all exceptions from service start up until now + > ovos-logs list -e -w -s 01-12-2023 -u '01-12-2023 17:00:20' # List all errors and warnings from the start of december the first until 17:00:20 + > ovos-logs list -x -l bus -l skills -f # List all exceptions from skills.log and bus.log and dump it to the file ~/list_xxx_xxx.log + """ + if not any([error, warning, debug, exception]): + return console.print("Need at least one of --error, --warning, --exception or --debug") + else: + log_levels = [lv_str for lv, lv_str in [(error, "ERROR"), (warning, "WARNING"), + (debug, "DEBUG"), (exception, "EXCEPTION")] if lv] + + if not all(os.path.exists(path) for path in paths): + return console.print(f"Directory [{[p for p in paths if not os.path.exists(p)]}] does not exist") + else: + logs_present = get_available_logs(paths) + + start, end = parse_timeframe(start, until, paths) + if start is None: + return console.print(f"Need a valid start time in the format {EXPECTED_DATETIME_FORMAT}") + elif end is None: + return console.print(f"Need a valid end time in the format {EXPECTED_DATETIME_FORMAT}") + elif start > end: + return console.print(f"Start time [{start}] is after end time [{end}]") + + if not logs: + logs = logs_present + elif not valid_log(logs, paths): + return console.print(f"Invalid log name, valid logs are {logs_present}") + + _templog: Dict[str, List[LogLine]] = dict() + + for service in logs: + path = get_log_path(service, paths) + if path is None: + continue + logfile = os.path.join(path, f"{service}.log") + _templog[service] = [] + for log in OVOSLogParser.parse_file(logfile): + if isinstance(log, Traceback): + if exception: + _templog[service].extend(log.to_loglines()) + continue + # LOG.exception + if exception and log.level == "EXCEPTION": + _templog[service].append(log) + if error and log.level == "ERROR": + _templog[service].append(log) + if warning and log.level == "WARNING": + _templog[service].append(log) + if debug and log.level == "DEBUG": + _templog[service].append(log) + if not _templog[service]: + del _templog[service] + + if not _templog: + return console.print("No logs found for the specified log level") + + if file is not None: + # test if file is writable + try: + with open(file, 'w') as f: + pass + except: + return console.print(f"File [{file}] is not writable. Aborted") + else: + console.print(f"Log list saved to [bold]{file}[/bold]") + + for service in _templog: + table = Table(title=f"{service} ({','.join(log_levels)})") + # for traceback indication + table.add_column("Time", style="cyan", no_wrap=True) + if exception or len(log_levels) > 1: + table.add_column() + table.add_column("Message", style="magenta") + table.add_column("Origin", style="green") + lineno = 0 + for log in _templog[service]: + style = None + lineno += 1 + timestamp = log.timestamp or "" + if timestamp: + timestamp = timestamp.strftime("%H:%M:%S.%f" if use24h else "%I:%M:%S.%f")[:-3] + if not use24h and timestamp: + timestamp += log.timestamp.strftime(" %p") + level = log.level.upper() + message = log.message.rstrip("\n") + if level == "ERROR": + level = "[bold red]" + level[:1] + elif level == "EXCEPTION": + level = "[bold red]" + level[:3] + elif level == "WARNING": + level = "[bold yellow]" + level[:1] + elif level == "DEBUG": + level = "[bold blue]" + level[:1] + elif level == "TRACEBACK": + level = "[white]" + level[:5] + message = "[grey42]" + message + elif level == "INFO": + level = "" + message = "[navajo_white1]" + message + + if lineno % 2 == 0: + style = Style(bgcolor="grey7") + row = [timestamp, level, message, log.location] + if not exception and len(log_levels) < 2: + row.pop(1) + table.add_row(*row, style=style) + if len(log.message) > 200: + table.add_row() + + console.print(table) + if file: + Console(file=open(file, 'a')).print(table) + + +@ovos_logs.command() +@click.option("--log", "-l", required=True, type=click.Choice(get_available_logs(), case_sensitive=False), help=f"log to show; available: {get_available_logs()}") +@click.option("--paths", "-p", multiple=True, type=click.Path(), default=get_log_paths(), help=f"the directory logs reside in", show_default=True) +def show(log, paths): + """\b + A service log has to be specified (`-l`). + \b + Optionally the directory where the logs are stored (`-p`) can be specified. + \b + > Examples: + > ovos-logs show -l skills # Display skills.log + > ovos-logs show -l debug -p ~/custom_path/ # Display debug.log from a custom path + """ + if not any(os.path.exists(os.path.join(path, f"{log}.log")) for path in paths): + return console.print(f"File does not exist") + else: + log = os.path.join(get_log_path(log, paths), f"{log}.log") + + pydoc.pager(open(log).read()) + + +@ovos_logs.command() +@click.option("--size", "-s", is_flag=False, flag_value=None, default=0, help="truncate logs to a given size (in bytes)") +@click.option("--date", "-d", help="truncate logs to a given date") +@click.option("--logs", "-l", multiple=True, default=get_available_logs(), help=LOGSOPTHELP, show_default=True) +@click.option("--paths", "-p", multiple=True, type=click.Path(), default=get_log_paths(), help=f"the directory logs reside in", show_default=True) +def reduce(size, date, logs, paths): + """\b + Reduce logs to a given size (in bytes) or remove entries before a given date. + \b + Different logs can be included using the `-l` option. If not specified, all logs will be included. + Optionally the directory where the logs are stored (`-p`) can be specified. + \b + > Examples: + > ovos-logs reduce # Reduce all logs to 0 bytes + > ovos-logs reduce -s 1000000 # Reduce all logs to ~1MB (latest logs) + > ovos-logs reduce -d "1-12-2023 17:00" # Reduce all logs to entries after the specified date/time + > ovos-logs reduce -s 1000000 -l skills -l bus # Reduce skills.log and bus.log to ~1MB (latest logs) + """ + + if date: + size = None + date = parse_time(date) + if date is None: + return console.print(f"The date/time provided couldn't be parsed. Expected format: {EXPECTED_DATETIME_FORMAT}") + + if not all(os.path.exists(path) for path in paths): + return console.print(f"Directory [{[p for p in paths if not os.path.exists(p)]}] does not exist") + else: + logs_present = get_available_logs(paths) + + if not logs: + logs = logs_present + elif not valid_log(logs, paths): + return console.print(f"Invalid log name, valid logs are {logs_present}") + + for service in logs: + path = get_log_path(service, paths) + logfile = os.path.join(path, f"{service}.log") + reduced = False + with LOGLOCK: + if size: + with open(logfile, 'r') as f: + f.seek(0, os.SEEK_END) + fullsize = f.tell() + f.seek(max(fullsize - size, 0)) + # skip cutoff line + f.readline() + remaining_lines = f.readlines() + if fullsize > size and remaining_lines: + reduced = True + with open(logfile, 'w') as f: + f.writelines(remaining_lines) + elif date: + loglines = [] + for log in OVOSLogParser.parse_file(logfile): + if log.timestamp and log.timestamp < date: + reduced = True + continue + loglines.append(log) + if reduced: + with open(logfile, 'w') as f: + for log in loglines: + f.write(str(log) + "\n") + else: + reduced = True + with open(logfile, 'w') as f: + f.write("") + + if reduced: + console.print(f"{service} log reduced") diff --git a/ovos_utils/messagebus.py b/ovos_utils/messagebus.py index 924ab3c4..698685fa 100644 --- a/ovos_utils/messagebus.py +++ b/ovos_utils/messagebus.py @@ -1,529 +1 @@ -import json -import time - -from ovos_utils import create_loop from ovos_utils.fakebus import dig_for_message, FakeMessage, Message, FakeBus -from ovos_utils.log import LOG, log_deprecation, deprecated -from ovos_utils.events import EventContainer as _EC - -log_deprecation("decode_binary_message, send_binary_file_message, send_binary_data_message, \ - send_message, wait_for_reply, listen_once_for_message, get_message_lang, get_websocket, get_mycroft_bus, \ - listen_for_message have moved to ovos_bus_client.util", "0.1.0") -log_deprecation("dig_for_message, FakeMessage, FakeBus moved to ovos_utils.fakebus", "0.1.0") - - -class EventContainer(_EC): - def __init__(self, bus=None): - log_deprecation("Import from `ovos_utils.events`", "0.1.0") - _EC.__init__(self, bus=bus) - - -def create_wrapper(*args, **kwargs): - log_deprecation("Import from `ovos_utils.events`", "0.1.0") - from ovos_utils.events import create_wrapper - return create_wrapper(*args, **kwargs) - - -def get_handler_name(*args, **kwargs): - log_deprecation("Import from `ovos_utils.events`", "0.1.0") - from ovos_utils.events import get_handler_name - return get_handler_name(*args, **kwargs) - - -def merge_dict(*args, **kwargs): - log_deprecation("Import from `ovos_utils.json_helper`", "0.1.0") - from ovos_utils.json_helper import merge_dict - return merge_dict(*args, **kwargs) - - -try: - from ovos_bus_client.util import decode_binary_message, send_binary_file_message, send_binary_data_message, \ - send_message, wait_for_reply, listen_once_for_message, get_message_lang, get_websocket, get_mycroft_bus, \ - listen_for_message - -except: - _DEFAULT_WS_CONFIG = {"host": "0.0.0.0", - "port": 8181, - "route": "/core", - "ssl": False} - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def get_message_lang(message=None): - """Get the language from the message or the default language. - Args: - message: message to check for language code. - Returns: - The language code from the message or the default language. - """ - try: - from ovos_config.locale import get_default_lang - default_lang = get_default_lang() - except ImportError: - LOG.warning("ovos_config not available. Using default lang en-us") - default_lang = "en-us" - message = message or dig_for_message() - if not message: - return default_lang - lang = message.data.get("lang") or message.context.get("lang") or default_lang - return lang.lower() - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def get_websocket(host, port, route='/', ssl=False, threaded=True): - """ - Returns a connection to a websocket - """ - from ovos_bus_client import MessageBusClient - - client = MessageBusClient(host, port, route, ssl) - if threaded: - client.run_in_thread() - return client - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def get_mycroft_bus(host: str = None, port: int = None, route: str = None, - ssl: bool = None): - """ - Returns a connection to the mycroft messagebus - """ - try: - from ovos_config.config import read_mycroft_config - config = read_mycroft_config().get('websocket') or dict() - except ImportError: - LOG.warning("ovos_config not available. Falling back to default WS") - config = dict() - host = host or config.get('host') or _DEFAULT_WS_CONFIG['host'] - port = port or config.get('port') or _DEFAULT_WS_CONFIG['port'] - route = route or config.get('route') or _DEFAULT_WS_CONFIG['route'] - if ssl is None: - ssl = config.get('ssl') if 'ssl' in config else \ - _DEFAULT_WS_CONFIG['ssl'] - return get_websocket(host, port, route, ssl) - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def listen_for_message(msg_type, handler, bus=None): - """ - Continuously listens and reacts to a specific messagetype on the mycroft messagebus - - NOTE: when finished you should call bus.remove(msg_type, handler) - """ - bus = bus or get_mycroft_bus() - bus.on(msg_type, handler) - return bus - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def listen_once_for_message(msg_type, handler, bus=None): - """ - listens and reacts once to a specific messagetype on the mycroft messagebus - """ - auto_close = bus is None - bus = bus or get_mycroft_bus() - - def _handler(message): - handler(message) - if auto_close: - bus.close() - - bus.once(msg_type, _handler) - return bus - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def wait_for_reply(message: Message, reply_type=None, timeout=3.0, bus=None): - """Send a message and wait for a response. - - Args: - message (Message or str or dict): message object or type to send - reply_type (str): the message type of the expected reply. - Defaults to ".response". - timeout: seconds to wait before timeout, defaults to 3 - Returns: - The received message or None if the response timed out - """ - auto_close = bus is None - bus = bus or get_mycroft_bus() - if isinstance(message, str): - try: - message = json.loads(message) - except: - pass - if isinstance(message, str): - message = Message(message) - elif isinstance(message, dict): - message = Message(message["type"], - message.get("data"), - message.get("context")) - elif not isinstance(message, Message): - raise ValueError - response = bus.wait_for_response(message, reply_type, timeout) - if auto_close: - bus.close() - return response - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def send_message(message, data=None, context=None, bus=None): - auto_close = bus is None - bus = bus or get_mycroft_bus() - if isinstance(message, str): - if isinstance(data, dict) or isinstance(context, dict): - message = Message(message, data, context) - else: - try: - message = json.loads(message) - except: - message = Message(message) - if isinstance(message, dict): - message = Message(message["type"], - message.get("data"), - message.get("context")) - if not isinstance(message, Message): - raise ValueError - bus.emit(message) - if auto_close: - bus.close() - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def send_binary_data_message(binary_data, msg_type="mycroft.binary.data", - msg_data=None, msg_context=None, bus=None): - msg_data = msg_data or {} - msg = { - "type": msg_type, - "data": merge_dict(msg_data, {"binary": binary_data.hex()}), - "context": msg_context or None - } - send_message(msg, bus=bus) - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def send_binary_file_message(filepath, msg_type="mycroft.binary.file", - msg_context=None, bus=None): - with open(filepath, 'rb') as f: - binary_data = f.read() - msg_data = {"path": filepath} - send_binary_data_message(binary_data, msg_type=msg_type, msg_data=msg_data, - msg_context=msg_context, bus=bus) - - - @deprecated("moved to ovos_bus_client.util", "0.1.0") - def decode_binary_message(message): - if isinstance(message, str): - try: # json string - message = json.loads(message) - binary_data = message.get("binary") or message["data"]["binary"] - except: # hex string - binary_data = message - elif isinstance(message, dict): - # data field or serialized message - binary_data = message.get("binary") or message["data"]["binary"] - else: - # message object - binary_data = message.data["binary"] - # decode hex string - return bytearray.fromhex(binary_data) - - -class BusService: - """ - Provide some service over the messagebus for other components - - response = Message("face.recognition.reply") - service = BusService(response) - service.listen("face.recognition") - - while True: - data = do_computation() - service.update_response(data) # replaces response.data - - """ - - @deprecated("deprecated without replacement", "0.1.0") - def __init__(self, message, trigger_messages=None, bus=None): - self.bus = bus or get_mycroft_bus() - self.response = message - trigger_messages = trigger_messages or [] - self.events = [] - for message_type in trigger_messages: - self.listen(message_type) - - def listen(self, message_type, callback=None): - if callback is None: - callback = self._respond - self.bus.on(message_type, callback) - self.events.append((message_type, callback)) - - def update_response(self, data=None): - if data is not None: - self.response.data = data - - def _respond(self, message): - self.bus.emit(message.reply(self.response.type, self.response.data)) - - def shutdown(self): - """ remove all listeners """ - for event, callback in self.events: - self.bus.remove(event, callback) - - -class BusFeedProvider: - """ - - Meant to be subclassed - - - class ClockService(BusFeedProvider): - def __init__(self, name="clock_transmitter", bus=None): - trigger_message = Message("time.request") - super().__init__(trigger_message, name, bus) - self.set_data_gatherer(self.handle_get_time) - - def handle_get_time(self, message): - self.update({"date": datetime.now()}) - - - clock_service = ClockService() - - """ - - @deprecated("deprecated without replacement", "0.1.0") - def __init__(self, trigger_message, name=None, bus=None, config=None): - """ - initialize responder - - args: - name(str): name identifier for .conf settings - bus (WebsocketClient): mycroft messagebus websocket - """ - 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() - except ImportError: - LOG.warning("ovos_config not available. Falling back to " - "default configuration") - config = dict() - self.trigger_message = trigger_message - self.name = name or self.__class__.__name__ - self.bus = bus or get_mycroft_bus() - self.callback = None - self.service = None - self._daemon = None - self.config = config.get(self.name, {}) - - def update(self, data): - """ - change the data of the response to be sent when queried - """ - if self.service is not None: - self.service.update_response(data) - - def set_data_gatherer(self, callback, default_data=None, daemonic=False, interval=90): - """ - prepare responder for sending, register answers - """ - - self.bus.remove_all_listeners(self.trigger_message) - if ".request" in self.trigger_message: - response_type = self.trigger_message.replace(".request", ".reply") - else: - response_type = self.trigger_message + ".reply" - - response = Message(response_type, default_data) - self.service = BusService(response, bus=self.bus) - self.callback = callback - self.bus.on(self.trigger_message, self._respond) - if daemonic: - self._daemon = create_loop(self._data_daemon, interval) - - def _data_daemon(self): - if self.callback is not None: - self.callback(self.trigger_message) - - def _respond(self, message): - """ - gather data and emit to bus - """ - try: - if self.callback: - self.callback(message) - except Exception as e: - LOG.error(e) - self.service.respond(message) - - def shutdown(self): - self.bus.remove_all_listeners(self.trigger_message) - if self._daemon: - self._daemon.join(0) - self._daemon = None - if self.service: - self.service.shutdown() - self.service = None - - -class BusQuery: - """ - retrieve data from some other component over the messagebus at any time - - message = Message("request.msg", {...}, {...}) - query = BusQuery(message) - response = query.send() - # do some more stuff - response = query.send() # reutilize the object - - """ - - @deprecated("deprecated without replacement", "0.1.0") - def __init__(self, message, bus=None): - self.bus = bus or get_mycroft_bus() - self._waiting = False - self.response = Message(None, None, None) - self.query = message - self.valid_response_types = [] - - def add_response_type(self, response_type): - """ listen to a new response_type """ - if response_type not in self.valid_response_types: - self.valid_response_types.append(response_type) - self.bus.on(response_type, self._end_wait) - - def _end_wait(self, message): - self.response = message - self._waiting = False - - def _wait_response(self, timeout): - start = time.time() - elapsed = 0 - self._waiting = True - while self._waiting and elapsed < timeout: - elapsed = time.time() - start - time.sleep(0.1) - self._waiting = False - - def send(self, response_type=None, timeout=10): - self.response = Message(None, None, None) - if response_type is None: - response_type = self.query.type + ".reply" - self.add_response_type(response_type) - self.bus.emit(self.query) - self._wait_response(timeout) - return self.response - - def remove_listeners(self): - for event in self.valid_response_types: - self.bus.remove(event, self._end_wait) - - def shutdown(self): - """ remove all listeners """ - self.remove_listeners() - - -class BusFeedConsumer: - """ - this is meant to be subclassed - - class Clock(BusFeedConsumer): - def __init__(self, name="clock_receiver", timeout=3, bus=None): - request_message = Message("time.request") - super().__init__(request_message, name, timeout, bus) - - # blocking - clock = Clock() - date = clock.request()["date"] - - # async - clock = Clock(timeout=0) # non - blocking - clock.request(daemonic=True, # loop on background - frequency=1) # update result every second - - date = clock.result["date"] - - """ - - @deprecated("deprecated without replacement", "0.1.0") - def __init__(self, query_message, name=None, timeout=5, bus=None, - config=None): - self.query_message = query_message - self.query_message.context["source"] = self.name - self.name = name or self.__class__.__name__ - self.bus = bus or get_mycroft_bus() - 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() - except ImportError: - LOG.warning("Config not provided and ovos_config not available") - config = dict() - self.config = config.get(self.name, {}) - self.timeout = timeout - self.query = None - self.valid_responses = [] - self._daemon = None - - def request(self, response_messages=None, daemonic=False, interval=90): - """ - prepare query for sending, add several possible kinds of - response message automatically - "message_type.reply" , - "message_type.response", - "message_type.result" - """ - response_messages = response_messages or [] - - # generate valid reply message types - self.valid_responses = response_messages - if ".request" in self.query_message.type: - response = self.query_message.type.replace(".request", ".reply") - if response not in self.valid_responses: - self.valid_responses.append(response) - response = self.query_message.type.replace(".request", ".response") - if response not in self.valid_responses: - self.valid_responses.append(response) - response = self.query_message.type.replace(".request", ".result") - if response not in self.valid_responses: - self.valid_responses.append(response) - else: - response = self.query_message.type + ".reply" - if response not in self.valid_responses: - self.valid_responses.append(response) - response = self.query_message.type + ".response" - if response not in self.valid_responses: - self.valid_responses.append(response) - response = self.query_message.type + ".result" - if response not in self.valid_responses: - self.valid_responses.append(response) - - # update message context - self.query_message.context["valid_responses"] = self.valid_responses - - self._query() - if daemonic: - self._daemon = create_loop(self._request_daemon, interval) - return self.result - - def _request_daemon(self): - self.query.send(self.valid_responses[0], self.timeout) - - def _query(self): - self.query = BusQuery(self.query_message) - for message in self.valid_responses[1:]: - self.query.add_response_type(message) - self.query.send(self.valid_responses[0], self.timeout) - - @property - def result(self): - return self.query.response.data - - def shutdown(self): - """ remove all listeners """ - if self._daemon: - self._daemon.join(0) - self._daemon = None - if self.query: - self.query.shutdown() diff --git a/ovos_utils/metrics.py b/ovos_utils/metrics.py index 60af232f..28a32f74 100644 --- a/ovos_utils/metrics.py +++ b/ovos_utils/metrics.py @@ -5,6 +5,7 @@ class Stopwatch: """ Simple time measuring class. """ + def __init__(self): self.timestamp = None self.time = None @@ -57,4 +58,3 @@ def __str__(self): return str(self.time or cur_time - self.timestamp) else: return 'Not started' - diff --git a/ovos_utils/network_utils.py b/ovos_utils/network_utils.py index 316b2581..40d5008b 100644 --- a/ovos_utils/network_utils.py +++ b/ovos_utils/network_utils.py @@ -3,10 +3,8 @@ import requests - from ovos_utils.log import LOG - _DEFAULT_TEST_CONFIG = { "ip_url": 'https://api.ipify.org', "dns_primary": "1.1.1.1", @@ -15,7 +13,7 @@ "web_url_secondary": "https://checkonline.home-assistant.io/online.txt", "captive_portal_url": "http://nmcheck.gnome.org/check_network_status.txt", "captive_portal_text": "NetworkManager is online" - } +} def get_network_tests_config(): @@ -140,4 +138,3 @@ def check_captive_portal(host: Optional[str] = None, LOG.exception("Error checking for captive portal") return captive_portal - diff --git a/ovos_utils/ocp.py b/ovos_utils/ocp.py new file mode 100644 index 00000000..27a70385 --- /dev/null +++ b/ovos_utils/ocp.py @@ -0,0 +1,638 @@ +import inspect +import mimetypes +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional, Tuple, List, Union + +import orjson + +from ovos_utils.log import LOG, deprecated + +OCP_ID = "ovos.common_play" + + +class MatchConfidence(IntEnum): + EXACT = 95 + VERY_HIGH = 90 + HIGH = 80 + AVERAGE_HIGH = 70 + AVERAGE = 50 + AVERAGE_LOW = 30 + LOW = 15 + VERY_LOW = 1 + + +class TrackState(IntEnum): + DISAMBIGUATION = 1 # media result, not queued for playback + PLAYING_SKILL = 20 # Skill is handling playback internally + PLAYING_AUDIOSERVICE = 21 ## DEPRECATED - used in ovos 0.0.7 + PLAYING_VIDEO = 22 # Skill forwarded playback to video service + PLAYING_AUDIO = 23 # Skill forwarded playback to audio service + PLAYING_MPRIS = 24 # External media player is handling playback + PLAYING_WEBVIEW = 25 # Media playback handled in browser (eg. javascript) + + QUEUED_SKILL = 30 # Waiting playback to be handled inside skill + QUEUED_AUDIOSERVICE = 31 ## DEPRECATED - used in ovos 0.0.7 + QUEUED_VIDEO = 32 # Waiting playback in video service + QUEUED_AUDIO = 33 # Waiting playback in audio service + QUEUED_WEBVIEW = 34 # Waiting playback in browser service + + +class MediaState(IntEnum): + # https://doc.qt.io/qt-5/qmediaplayer.html#MediaStatus-enum + # The status of the media cannot be determined. + UNKNOWN = 0 + # There is no current media. PlayerState == STOPPED + NO_MEDIA = 1 + # The current media is being loaded. The player may be in any state. + LOADING_MEDIA = 2 + # The current media has been loaded. PlayerState== STOPPED + LOADED_MEDIA = 3 + # Playback of the current media has stalled due to + # insufficient buffering or some other temporary interruption. + # PlayerState != STOPPED + STALLED_MEDIA = 4 + # The player is buffering data but has enough data buffered + # for playback to continue for the immediate future. + # PlayerState != STOPPED + BUFFERING_MEDIA = 5 + # The player has fully buffered the current media. PlayerState != STOPPED + BUFFERED_MEDIA = 6 + # Playback has reached the end of the current media. PlayerState == STOPPED + END_OF_MEDIA = 7 + # The current media cannot be played. PlayerState == STOPPED + INVALID_MEDIA = 8 + + +class PlayerState(IntEnum): + # https://doc.qt.io/qt-5/qmediaplayer.html#State-enum + STOPPED = 0 + PLAYING = 1 + PAUSED = 2 + + +class LoopState(IntEnum): + NONE = 0 + REPEAT = 1 + REPEAT_TRACK = 2 + + +class PlaybackType(IntEnum): + SKILL = 0 # skills handle playback whatever way they see fit, + # eg spotify / mycroft common play + VIDEO = 1 # Video results + AUDIO = 2 # Results should be played audio only + AUDIO_SERVICE = 3 ## DEPRECATED - used in ovos 0.0.7 + MPRIS = 4 # External MPRIS compliant player + WEBVIEW = 5 # webview, render a url instead of media player + UNDEFINED = 100 # data not available, hopefully status will be updated soon.. + + +class PlaybackMode(IntEnum): + AUTO = 0 # play each entry as considered appropriate, + # ie, make it happen the best way possible + AUDIO_ONLY = 10 # only consider audio entries + VIDEO_ONLY = 20 # only consider video entries + FORCE_AUDIO = 30 # cast video to audio unconditionally + FORCE_AUDIOSERVICE = 40 ## DEPRECATED - used in ovos 0.0.7 + EVENTS_ONLY = 50 # only emit ocp events, do not display or play anything. + # allows integration with external interfaces + + +class MediaType(IntEnum): + GENERIC = 0 # nothing else matches + AUDIO = 1 # things like ambient noises + MUSIC = 2 + VIDEO = 3 # eg, youtube videos + AUDIOBOOK = 4 + GAME = 5 # because it shares the verb "play", mostly for disambguation + PODCAST = 6 + RADIO = 7 # live radio + NEWS = 8 # news reports + TV = 9 # live tv stream + MOVIE = 10 + TRAILER = 11 + AUDIO_DESCRIPTION = 12 # narrated movie for the blind + VISUAL_STORY = 13 # things like animated comic books + BEHIND_THE_SCENES = 14 + DOCUMENTARY = 15 + RADIO_THEATRE = 16 + SHORT_FILM = 17 # typically movies under 45 min + SILENT_MOVIE = 18 + VIDEO_EPISODES = 19 # tv series etc + BLACK_WHITE_MOVIE = 20 + CARTOON = 21 + ANIME = 22 + ASMR = 23 + + ADULT = 69 # for content filtering + HENTAI = 70 # for content filtering + ADULT_AUDIO = 71 # for content filtering + + +@deprecated("import ovos_utils.available_extractors from ovos_plugin_manager.ocp instead", "0.1.0") +def available_extractors(): + # TODO - delete me, still imported in ovos-bus-client, but never made it into a non-alpha + try: + from ovos_plugin_manager.ocp import available_extractors as _ax + except ImportError: + try: + # before move to OPM + from ovos_plugin_common_play.ocp.utils import available_extractors as _ax + except ImportError: + LOG.error("please install/update ovos_plugin_manager") + raise + return _ax() + + +def find_mime(uri): + """ Determine mime type. """ + mime = mimetypes.guess_type(uri) + if mime: + return mime + else: + return None + + +@dataclass +class MediaEntry: + uri: str = "" + title: str = "" + artist: str = "" + match_confidence: int = 0 # 0 - 100 + skill_id: str = OCP_ID + playback: PlaybackType = PlaybackType.UNDEFINED + status: TrackState = TrackState.DISAMBIGUATION + media_type: MediaType = MediaType.GENERIC + length: int = 0 # in seconds + image: str = "" + skill_icon: str = "" + javascript: str = "" # to execute once webview is loaded + + def update(self, entry: dict, skipkeys: list = None, newonly: bool = False): + """ + Update this MediaEntry object with keys from the provided entry + @param entry: dict or MediaEntry object to update this object with + @param skipkeys: list of keys to not change + @param newonly: if True, only adds new keys; existing keys are unchanged + """ + skipkeys = skipkeys or [] + if isinstance(entry, (MediaEntry, PluginStream)): + entry = entry.as_dict + entry = entry or {} + for k, v in entry.items(): + if k not in skipkeys and hasattr(self, k): + if newonly and self.__getattribute__(k): + # skip, do not replace existing values + continue + self.__setattr__(k, v) + + @property + def infocard(self) -> dict: + """ + Return dict data used for a UI display + """ + return { + "duration": self.length, + "track": self.title, + "image": self.image, + "album": self.skill_id, + "source": self.skill_icon, + "uri": self.uri + } + + @property + def mpris_metadata(self) -> dict: + """ + Return dict data used by MPRIS + """ + from dbus_next.service import Variant + meta = {"xesam:url": Variant('s', self.uri)} + if self.artist: + meta['xesam:artist'] = Variant('as', [self.artist]) + if self.title: + meta['xesam:title'] = Variant('s', self.title) + if self.image: + meta['mpris:artUrl'] = Variant('s', self.image) + if self.length: + meta['mpris:length'] = Variant('d', self.length) + return meta + + @property + def as_dict(self) -> dict: + """ + Return a dict representation of this MediaEntry + """ + # orjson handles dataclasses directly + return orjson.loads(orjson.dumps(self).decode("utf-8")) + + @staticmethod + def from_dict(track: dict) -> 'MediaEntry': + if "uri" not in track: + LOG.error("track dictionary does not contain 'uri', it is not a valid MediaEntry") + # raise ValueError("track dictionary does not contain 'uri', it is not a valid MediaEntry") + LOG.warning("DEPRECATED: use dict2entry() for Playlists and PluginStreams," + " MediaEntry.from_dict is only for regular media, will start throwing ValueError in 0.1.0") + return dict2entry(track) + kwargs = {k: v for k, v in track.items() + if k in inspect.signature(MediaEntry).parameters} + return MediaEntry(**kwargs) + + @property + def mimetype(self) -> Optional[Tuple[Optional[str], Optional[str]]]: + """ + Get the detected mimetype tuple (type, encoding) if it can be determined + """ + if self.uri: + return find_mime(self.uri) + + def __eq__(self, other): + if isinstance(other, MediaEntry): + other = other.infocard + # dict comparison + return other == self.infocard + + +@dataclass +class PluginStream: + stream: str + extractor_id: str + title: str = "" + artist: str = "" + match_confidence: int = 0 # 0 - 100 + skill_id: str = OCP_ID + playback: PlaybackType = PlaybackType.UNDEFINED + status: TrackState = TrackState.DISAMBIGUATION + media_type: MediaType = MediaType.GENERIC + length: int = 0 # in seconds + image: str = "" + skill_icon: str = "" + + def extract_uri(self, video=True) -> str: + from ovos_plugin_manager.ocp import load_stream_extractors + xtract = load_stream_extractors() + meta = xtract.extract_stream(f"{self.extractor_id}//{self.stream}", + video=video) + return meta["uri"] + + def extract_media_entry(self, video=True) -> MediaEntry: + from ovos_plugin_manager.ocp import load_stream_extractors + xtract = load_stream_extractors() + if self.extractor_id not in xtract.supported_seis: + raise ImportError(f"stream extractor not installed, extractor_id: {self.extractor_id}\navailable plugins: {list(xtract.extractors)}") + + meta = xtract.extract_stream(f"{self.extractor_id}//{self.stream}", + video=video) + p = meta.get("playback", self.playback) + if p == PlaybackType.UNDEFINED: + meta["playback"] = PlaybackType.VIDEO if video else PlaybackType.AUDIO + for k, v in self.as_dict.items(): + if not meta.get(k): + meta[k] = v + kwargs = {k: v for k, v in meta.items() + if k in inspect.signature(MediaEntry).parameters} + return MediaEntry(**kwargs) + + @property + def infocard(self) -> dict: + """ + Return dict data used for a UI display + (model shared with MediaEntry) + """ + return { + "duration": self.length, + "track": self.title, + "image": self.image, + "album": self.skill_id, + "source": self.skill_icon, + "uri": f"{self.extractor_id}//{self.stream}" + } + + @property + def as_media_entry(self) -> MediaEntry: + kwargs = {k: v for k, v in self.as_dict.items() + if k in inspect.signature(MediaEntry).parameters} + # TODO - in a couple major versions this should be deprecated + kwargs["uri"] = f"{self.extractor_id}//{self.stream}" + return MediaEntry(**kwargs) + + @property + def as_dict(self) -> dict: + """ + Return a dict representation of this MediaEntry + """ + # orjson handles dataclasses directly + return orjson.loads(orjson.dumps(self).decode("utf-8")) + + @staticmethod + def from_dict(track: dict) -> 'PluginStream': + if "extractor_id" not in track: + raise ValueError("track dictionary does not contain 'extractor_id', it is not a valid PluginStream") + if "stream" not in track: + raise ValueError("track dictionary does not contain 'stream', it is not a valid PluginStream") + kwargs = {k: v for k, v in track.items() + if k in inspect.signature(PluginStream).parameters} + return PluginStream(**kwargs) + + +@dataclass +class Playlist(list): + title: str = "" + artist: str = "" + position: int = 0 + image: str = "" + match_confidence: int = 0 # 0 - 100 + skill_id: str = OCP_ID + skill_icon: str = "" + playback: PlaybackType = PlaybackType.UNDEFINED + media_type: MediaType = MediaType.GENERIC + + def __init__(self, *args, **kwargs): + super().__init__() + for k, v in kwargs.items(): + if hasattr(self, k): + try: + self.__setattr__(k, v) + except AttributeError: + continue + if len(args) == 1 and isinstance(args[0], list): + args = args[0] + for e in args: + self.add_entry(e) + + @property + def length(self): + """calc the length value based on all entries""" + # -1 is for live streams + return max(-1, sum([e.length for e in self.entries])) + + @property + def infocard(self) -> dict: + """ + Return dict data used for a UI display + (model shared with MediaEntry) + """ + return { + "duration": self.length, + "track": self.title, + "image": self.image, + "album": self.skill_id, + "source": self.skill_icon, + "uri": "" + } + + @staticmethod + def from_dict(track: dict) -> 'Playlist': + if "playlist" not in track: + raise ValueError("track dictionary does not contain 'playlist' entries, it is not a valid Playlist") + + playlist = Playlist(**track) + for e in track.get("playlist", []): + playlist.add_entry(e) + return playlist + + @property + def as_dict(self) -> dict: + """ + Return a dict representation of this MediaEntry + """ + data = { + "title": self.title, + "position": self.position, + "length": self.length, + "image": self.image, + "match_confidence": self.match_confidence, + "skill_id": self.skill_id, + "skill_icon": self.skill_icon, + "playlist": [e.as_dict for e in self.entries] + } + return data + + @property + def entries(self) -> List[Union[MediaEntry, PluginStream]]: + """ + Return a list of MediaEntry objects in the playlist + """ + entries = [] + for e in self: + if isinstance(e, dict): + e = dict2entry(e) + if isinstance(e, (MediaEntry, PluginStream)): + entries.append(e) + return entries + + @property + def current_track(self) -> Optional[Union[MediaEntry, PluginStream]]: + """ + Return the current MediaEntry or None if the playlist is empty + """ + if len(self) == 0: + return None + self._validate_position() + track = self[self.position] + if isinstance(track, dict): + track = dict2entry(track) + return track + + @property + def is_first_track(self) -> bool: + """ + Return `True` if the current position is the first track or if the + playlist is empty + """ + if len(self) == 0: + return True + return self.position == 0 + + @property + def is_last_track(self) -> bool: + """ + Return `True` if the current position is the last track of if the + playlist is empty + """ + if len(self) == 0: + return True + return self.position == len(self) - 1 + + def goto_start(self) -> None: + """ + Move to the first entry in the playlist + """ + self.position = 0 + + def clear(self) -> None: + """ + Remove all entries from the Playlist and reset the position + """ + super().clear() + self.position = 0 + + def sort_by_conf(self): + """ + Sort the Playlist by `match_confidence` with high confidence first + """ + self.sort( + key=lambda k: k.match_confidence if isinstance(k, (MediaEntry, Playlist)) + else k.get("match_confidence", 0), reverse=True) + + def add_entry(self, entry: Union[MediaEntry, PluginStream], index: int = -1) -> None: + """ + Add an entry at the requested index + @param entry: MediaEntry to add to playlist + @param index: index to insert entry at (default -1 to append) + """ + assert isinstance(index, int) + if index > len(self): + raise ValueError(f"Invalid index {index} requested, " + f"playlist only has {len(self)} entries") + + if isinstance(entry, dict): + entry = dict2entry(entry) + + assert isinstance(entry, (MediaEntry, Playlist, PluginStream)) + + if index == -1: + index = len(self) + + if index < self.position: + self.set_position(self.position + 1) + + self.insert(index, entry) + + def remove_entry(self, entry: Union[int, dict, MediaEntry, PluginStream]) -> None: + """ + Remove the requested entry from the playlist or raise a ValueError + @param entry: index or MediaEntry to remove from the playlist + """ + if isinstance(entry, int): + self.pop(entry) + return + if isinstance(entry, dict): + entry = dict2entry(entry) + assert isinstance(entry, (MediaEntry, PluginStream)) + for idx, e in enumerate(self.entries): + if e == entry: + self.pop(idx) + break + else: + raise ValueError(f"entry not in playlist: {entry}") + + def replace(self, new_list: List[Union[dict, MediaEntry, PluginStream]]) -> None: + """ + Replace the contents of this Playlist with new_list + @param new_list: list of MediaEntry or dict objects to set this list to + """ + self.clear() + for e in new_list: + self.add_entry(e) + + def set_position(self, idx: int): + """ + Set the position in the playlist to a specific index + @param idx: Index to set position to + """ + self.position = idx + self._validate_position() + + def goto_track(self, track: Union[MediaEntry, dict, PluginStream]) -> None: + """ + Go to the requested track in the playlist + @param track: MediaEntry to find and go to in the playlist + """ + if isinstance(track, dict): + track = dict2entry(track) + + assert isinstance(track, (MediaEntry, Playlist, PluginStream)) + + if isinstance(track, MediaEntry): + requested_uri = track.uri + elif isinstance(track, PluginStream): + requested_uri = track.stream + else: + requested_uri = track.title + + for idx, t in enumerate(self): + if isinstance(t, MediaEntry): + pl_entry_uri = t.uri + elif isinstance(t, PluginStream): + pl_entry_uri = t.stream + else: + pl_entry_uri = t.title + + if requested_uri == pl_entry_uri: + self.set_position(idx) + LOG.debug(f"New playlist position: {self.position}") + return + LOG.error(f"requested track not in the playlist: {track}") + + def next_track(self) -> None: + """ + Go to the next track in the playlist + """ + self.set_position(self.position + 1) + + def prev_track(self) -> None: + """ + Go to the previous track in the playlist + """ + self.set_position(self.position - 1) + + def _validate_position(self) -> None: + """ + Make sure the current position is valid; default `position` to 0 + """ + if self.position < 0 or self.position >= len(self): + LOG.error(f"Playlist pointer is in an invalid position " + f"({self.position}! Going to start of playlist") + self.position = 0 + + def __contains__(self, item): + if isinstance(item, dict): + item = dict2entry(item) + for e in self.entries: + if isinstance(item, PluginStream) and isinstance(e, PluginStream): + if e.stream == item.stream and e.extractor_id == item.extractor_id: + return True + elif isinstance(item, MediaEntry) and isinstance(e, MediaEntry): + if e.uri == item.uri: + return True + return False + + +def dict2entry(track: dict) -> Union[PluginStream, MediaEntry, Playlist]: + if track.get("playlist"): + return Playlist.from_dict(track) + elif track.get("extractor_id"): + return PluginStream.from_dict(track) + elif track.get("uri"): + return MediaEntry.from_dict(track) + raise ValueError("track dictionary is not a valid MediaEntry, Playlist or PluginStream") + + +if __name__ == "__main__": + m = MediaEntry("1") + m2 = MediaEntry("2") + m3 = MediaEntry("3") + p = Playlist("My Jams") # it's a list subclass + print(p) # Playlist(title='My Jams', position=0, length=0, image='', match_confidence=0, skill_id='ovos.common_play', skill_icon='') + p.append(m) + p += [m2] + p.add_entry(m3, 0) + assert p[0] is m3 + assert p[1] is m + assert m in p + assert m2 in p + assert m3 in p + print(p.entries) + np = [m3, m, m2] + assert p == np + assert np == p + assert p == p.entries + print(p.position) + p.goto_track(m2) + print(p.position) + p.goto_start() + print(p.position) + p.set_position(1) + print( + p) # Playlist(title='My Jams', position=1, length=0, image='', match_confidence=0, skill_id='ovos.common_play', skill_icon='') diff --git a/ovos_utils/ovos_service_api.py b/ovos_utils/ovos_service_api.py deleted file mode 100644 index 19df7757..00000000 --- a/ovos_utils/ovos_service_api.py +++ /dev/null @@ -1,227 +0,0 @@ -import requests -from json_database import JsonStorageXDG -from ovos_utils.log import deprecated - - -# TODO: This will be deprecated in v0.1 - - -class OVOSApiService: - @deprecated("Import from ovos-backend-client.api.BaseApi", "0.1.0") - def __init__(self) -> None: - self.uuid_storage = JsonStorageXDG("ovos_api_uuid") - self.token_storage = JsonStorageXDG("ovos_api_token") - - def register_device(self): - if self.check_if_uuid_exists(): - return - else: - created_challenge = requests.get('https://api.openvoiceos.com/create_challenge') - challenge_response = created_challenge.json() - register_device = requests.get( - 'https://api.openvoiceos.com/register_device/' + challenge_response['challenge'] + '/' + - challenge_response['secret']) - register_device_uuid = challenge_response['challenge'] - self.uuid_storage['uuid'] = register_device_uuid - self.uuid_storage.store() - - def check_if_uuid_exists(self): - if "uuid" in self.uuid_storage: - return True - return False - - def get_session_challenge(self): - session_challenge_request = requests.get('https://api.openvoiceos.com/get_session_challenge') - session_challenge_response = session_challenge_request.json() - self.token_storage["challenge"] = session_challenge_response['challenge'] - self.token_storage.store() - - def get_uuid(self): - return self.uuid_storage.get("uuid", "") - - def get_session_token(self): - return self.token_storage.get("challenge", "") - - -class OvosWeather: - @deprecated("Import ovos-backend-client.api.OpenWeatherMapApi", "0.1.0") - def __init__(self): - self.api = OVOSApiService() - - @property - def uuid(self): - return self.api.get_uuid() - - @property - def headers(self): - self.api.get_session_challenge() - return {'session_challenge': self.api.get_session_token(), 'backend': 'OWM'} - - def get_current(self, query): - reqdata = {"lat": query.get("lat"), - "lon": query.get("lon"), - "units": query.get("units"), - "lang": query.get("lang")} - url = f"https://api.openvoiceos.com/weather/generate_current_weather_report/{self.uuid}" - r = requests.post(url, data=reqdata, headers=self.headers) - return r.json() - - def get_hourly(self, query): - reqdata = {"lat": query.get("lat"), - "lon": query.get("lon"), - "units": query.get("units"), - "lang": query.get("lang")} - url = f"https://api.openvoiceos.com/weather/generate_hourly_weather_report/{self.uuid}" - r = requests.post(url, data=reqdata, headers=self.headers) - return r.json() - - def get_forecast(self, query): - # Requires Paid API - reqdata = {"lat": query.get("lat"), - "lon": query.get("lon"), - "units": query.get("units"), - "lang": query.get("lang")} - url = f"https://api.openvoiceos.com/weather/generate_forecast_weather_report/{self.uuid}" - r = requests.post(url, data=reqdata, headers=self.headers) - return r.json() - - def get_weather_onecall(self, query): - reqdata = {"lat": query.get("lat"), - "lon": query.get("lon"), - "units": query.get("units"), - "lang": query.get("lang")} - url = f'https://api.openvoiceos.com/weather/onecall_weather_report/{self.uuid}' - r = requests.post(url, data=reqdata, headers=self.headers) - return r.json() - - -class OvosWolframAlpha: - @deprecated("Import from ovos-backend-client.api.WolframAlphaApi", "0.1.0") - def __init__(self): - self.api = OVOSApiService() - - @property - def uuid(self): - return self.api.get_uuid() - - @property - def headers(self): - self.api.get_session_challenge() - return {'session_challenge': self.api.get_session_token()} - - def get_wolfram_spoken(self, query): - reqdata = {"input": query.get("input"), - "units": query.get("units")} - url = f'https://api.openvoiceos.com/wolframalpha/spoken/{self.uuid}' - r = requests.post(url, data=reqdata, headers=self.headers) - return r - - def get_wolfram_simple(self, query): - reqdata = {"input": query.get("input"), - "units": query.get("units")} - url = f'https://api.openvoiceos.com/wolframalpha/simple/{self.uuid}' - r = requests.post(url, data=reqdata, headers=self.headers) - return r - - def get_wolfram_full(self, query): - reqdata = {"input": query.get("input"), - "units": query.get("units"), - "output": query.get("output", "json")} - url = f'https://api.openvoiceos.com/wolframalpha/full/{self.uuid}' - r = requests.post(url, data=reqdata, headers=self.headers) - if reqdata["output"] == "json": - return r.json() - else: - return r - - -class OvosEdamamRecipe: - @deprecated("No direct replacement", "0.1.0") - def __init__(self): - self.api = OVOSApiService() - - @property - def uuid(self): - return self.api.get_uuid() - - @property - def headers(self): - self.api.get_session_challenge() - return {'session_challenge': self.api.get_session_token()} - - def get_recipe(self, query): - reqdata = {"query": query.get("query"), - "count": query.get("count", 5)} - url = f'https://api.openvoiceos.com/recipes/search_recipe/' - r = requests.post(url, data=reqdata, headers=self.headers) - return r.json() - - -class OvosOmdb: - @deprecated("No direct replacement", "0.1.0") - def __init__(self): - self.api = OVOSApiService() - - @property - def uuid(self): - return self.api.get_uuid() - - @property - def headers(self): - self.api.get_session_challenge() - return {'session_challenge': self.api.get_session_token()} - - def search_movie(self, query): - reqdata = {"movie_name": query.get("movie_name"), - "movie_year": query.get("movie_year"), - "movie_id": query.get("movie_id")} - - url = f'https://api.openvoiceos.com/omdb/search_movie/' - r = requests.post(url, data=reqdata, headers=self.headers) - return r.json() - - -class OvosGeolocate: - @deprecated("Import from ovos-backend-client.api.GeolocationApi", "0.1.0") - def __init__(self): - pass - - def geolocate_ip(self, ip): - reqdata = {"address": ip} - url = f'https://api.openvoiceos.com/geolocate/ip/' - r = requests.post(url, data=reqdata) - return r.json() - - def geolocate_address(self, address): - reqdata = {"address": address} - url = f'https://api.openvoiceos.com/geolocate/address/' - r = requests.post(url, data=reqdata) - return r.json() - - def geolocate_location_config(self, address): - reqdata = {"address": address} - url = f'https://api.openvoiceos.com/geolocate/location/config' - r = requests.post(url, data=reqdata) - return r.json() - - -class OvosSendMail: - @deprecated("Import from ovos-backend-client.api.EmailApi", "0.1.0") - def __init__(self): - self.api = OVOSApiService() - - @property - def uuid(self): - return self.api.get_uuid() - - @property - def headers(self): - self.api.get_session_challenge() - return {'session_challenge': self.api.get_session_token()} - - def send_mail(self, recipient, subject, body): - reqdata = {"recipient": recipient, - "subject": subject, - "body": body} - url = f"https://api.openvoiceos.com/send/mail/{self.uuid}" - r = requests.post(url, data=reqdata, headers=self.headers) diff --git a/ovos_utils/parse.py b/ovos_utils/parse.py index 9d39b38f..545a5fe9 100644 --- a/ovos_utils/parse.py +++ b/ovos_utils/parse.py @@ -1,6 +1,7 @@ -from difflib import SequenceMatcher import re +from difflib import SequenceMatcher from enum import IntEnum, auto + from ovos_utils.log import LOG try: @@ -18,6 +19,7 @@ class MatchStrategy(IntEnum): PARTIAL_TOKEN_RATIO = auto() PARTIAL_TOKEN_SORT_RATIO = auto() PARTIAL_TOKEN_SET_RATIO = auto() + DAMERAU_LEVENSHTEIN_SIMILARITY = auto() def _validate_matching_strategy(strategy): @@ -36,6 +38,7 @@ def fuzzy_match(x, against, strategy=MatchStrategy.SIMPLE_RATIO): down to 0.0 for no match at all. """ strategy = _validate_matching_strategy(strategy) + # LOG.debug(f"matching strategy: {strategy}") if strategy == MatchStrategy.RATIO: score = rapidfuzz.fuzz.ratio(x, against) / 100 elif strategy == MatchStrategy.PARTIAL_RATIO: @@ -50,6 +53,8 @@ def fuzzy_match(x, against, strategy=MatchStrategy.SIMPLE_RATIO): score = rapidfuzz.fuzz.partial_token_set_ratio(x, against) / 100 elif strategy == MatchStrategy.PARTIAL_TOKEN_RATIO: score = rapidfuzz.fuzz.partial_token_ratio(x, against) / 100 + elif strategy == MatchStrategy.DAMERAU_LEVENSHTEIN_SIMILARITY: + score = rapidfuzz.distance.DamerauLevenshtein.normalized_similarity(x, against) else: score = SequenceMatcher(None, x, against).ratio() @@ -113,4 +118,3 @@ def remove_parentheses(answer): if not answer: return None return answer - diff --git a/ovos_utils/process_utils.py b/ovos_utils/process_utils.py index 72e1b4e0..aef04cd8 100644 --- a/ovos_utils/process_utils.py +++ b/ovos_utils/process_utils.py @@ -17,14 +17,11 @@ from collections import namedtuple from dataclasses import dataclass from enum import IntEnum -from signal import signal, SIGKILL, SIGINT, SIGTERM, \ - SIG_DFL, default_int_handler, SIG_IGN # signals from threading import Event from time import sleep, monotonic - -from ovos_utils.log import LOG from ovos_utils.file_utils import get_temp_path +from ovos_utils.log import LOG @dataclass @@ -252,6 +249,9 @@ def __init__(self, sig_value, func): func: User supplied function that will act as the new signal handler. """ super(Signal, self).__init__() # python 3+ 'super().__init__() + + from signal import signal, SIG_DFL, default_int_handler, SIG_IGN + self.__sig_value = sig_value self.__user_func = func # store user passed function self.__previous_func = signal(sig_value, self) @@ -279,6 +279,8 @@ def __del__(self): Class destructor. Called during garbage collection. Resets the signal handler to the previous function. """ + + from signal import signal signal(self.__sig_value, self.__previous_func) @@ -290,6 +292,7 @@ class PIDLock: # python 3+ 'class Lock' of the same type is started, this class will 'attempt' to stop the previously running process and then change the process ID in the lock file. """ + @classmethod def init(cls): # TODO: Path to deprecation @@ -301,6 +304,7 @@ def init(cls): "'mycroft' basedir") base_dir = "mycroft" cls.DIRECTORY = cls.DIRECTORY or get_temp_path(base_dir) + # # Class constants DIRECTORY = None @@ -329,6 +333,7 @@ def set_handlers(self): """ Trap both SIGINT and SIGTERM to gracefully clean up PID files """ + from signal import SIGINT, SIGTERM self.__handlers = {SIGINT: Signal(SIGINT, self.delete), SIGTERM: Signal(SIGTERM, self.delete)} @@ -347,7 +352,8 @@ def exists(self): if not os.path.isfile(self.path): return with open(self.path, 'r') as L: - try: + try: # TODO - make it work in windows ? + from signal import SIGKILL os.kill(int(L.read()), SIGKILL) except Exception as e: LOG.error(f"Failed to kill PID {L}: {e}") @@ -401,4 +407,5 @@ def reset_sigint_handler(): This fixes KeyboardInterrupt not getting raised when started via start-mycroft.sh """ + from signal import signal, SIGINT, default_int_handler signal(SIGINT, default_int_handler) diff --git a/ovos_utils/res/fallback_mycroft.conf b/ovos_utils/res/fallback_mycroft.conf deleted file mode 100644 index 3f9eac0d..00000000 --- a/ovos_utils/res/fallback_mycroft.conf +++ /dev/null @@ -1,461 +0,0 @@ -{ - // Definition and documentation of all variables used by mycroft-core. - // - // Settings seen here are considered DEFAULT. Settings can also be - // overridden at the REMOTE level (set by the user via - // https://home.mycroft.ai), at the SYSTEM level (typically in the file - // '/etc/mycroft/mycroft.conf'), or at the USER level (typically in the - // file '~/.config/mycroft/mycroft.conf'). - // - // The load order of settings is: - // DEFAULT - // REMOTE - // SYSTEM - // USER - // - // The Override: comments below indicates where these settings are generally - // set outside of this file. The load order is always followed, so an - // individual systems can still apply changes at the SYSTEM or USER levels. - - // Language used for speech-to-text and text-to-speech. - // Code is a BCP-47 identifier (https://tools.ietf.org/html/bcp47), lowercased - // TODO: save unmodified, lowercase upon demand - "lang": "en-us", - - // Measurement units, either 'metric' or 'english' - // Override: REMOTE - "system_unit": "metric", - - // Time format, either 'half' (e.g. "11:37 pm") or 'full' (e.g. "23:37") - // Override: REMOTE - "time_format": "half", - - // Date format, either 'MDY' (e.g. "11-29-1978") or 'DMY' (e.g. "29-11-1978") - // Override: REMOTE - "date_format": "MDY", - - // Whether to opt in to data collection - // Override: REMOTE - "opt_in": false, - - // Play a beep when system begins to listen? - "confirm_listening": true, - - // File locations of sounds to play for system events - "sounds": { - "start_listening": "snd/start_listening.wav", - "end_listening": "snd/end_listening.wav", - "acknowledge": "snd/acknowledge.mp3" - }, - - // Mechanism used to play WAV audio files - // Override: SYSTEM - "play_wav_cmdline": "aplay %1", - - // Mechanism used to play MP3 audio files - // Override: SYSTEM - "play_mp3_cmdline": "mpg123 %1", - - // Mechanism used to play OGG audio files - // Override: SYSTEM - "play_ogg_cmdline": "ogg123 -q %1", - - // Location where the system resides - // NOTE: Although this is set here, an Enclosure can override the value. - // For example a mycroft-core running in a car could use the GPS. - // Override: REMOTE - "location": { - "city": { - "code": "Lawrence", - "name": "Lawrence", - "state": { - "code": "KS", - "name": "Kansas", - "country": { - "code": "US", - "name": "United States" - } - } - }, - "coordinate": { - "latitude": 38.971669, - "longitude": -95.23525 - }, - "timezone": { - "code": "America/Chicago", - "name": "Central Standard Time", - "dstOffset": 3600000, - "offset": -21600000 - } - }, - - // default to $XDG_DATA_DIRS/mycroft - // "data_dir": "/opt/mycroft", - - // whenever core needs to cache some files this directory will be used, - // the main use case is for TTS files to avoid synthesizing the same thing - // more than once, you may set this to a permanent directory to keep these - // files across reboots, but be careful with space usage! - // if not set defaults to "/tmp/mycroft/cache" - // TIP: use "/dev/shm/mycroft/cache" if you want to keep the cache in RAM - "cache_path": "/tmp/mycroft/cache", - - # emit mycroft.ready signal when all these conditions are met - # different setups will have different needs - # eg, a server does not care about audio - # pairing -> device is paired - # internet -> device is connected to the internet - NOT IMPLEMENTED - # skills -> skills reported ready - # speech -> stt reported ready - # audio -> audio playback reported ready - # gui -> gui websocket reported ready - NOT IMPLEMENTED - # enclosure -> enclosure/HAL reported ready - NOT IMPLEMENTED - "ready_settings": ["skills"], - - // General skill values - "skills": { - - // don't start loading skills until internet is detected - // this config value is not present in mycroft-core ()internet is required) - // mycroft-lib expects that some instances will be running fully offline - "wait_for_internet": false, - - // relative to "data_dir" - "directory": "skills", - - // used by selene to display skill settings in web interface - "upload_skill_manifest": false, - - // blacklisted skills to not load - // NB: This is the basename() of the directory where the skill lives, so if - // the skill you want to blacklist is in /usr/share/mycroft/skills/mycroft-alarm.mycroftai/ - // then you should write `["mycroft-alarm.mycroftai"]` below. - "blacklisted_skills": [], - - // priority skills to be loaded first - "priority_skills": ["mycroft-pairing", "mycroft-volume"], - - // fallback skill configuration - "fallbacks": { - // you can add skill_id: priority to override the developer defined - // priority of those skills, this allows customization - // of unknown intent handling for default_skills + user preferences - "fallback_priorities": { - // "skill_id": 10 - }, - // fallback skill handling has 3 modes of operations: - // - "accept_all" # default mycroft-core behavior - // - "whitelist" # only call fallback for skills in "fallback_whitelist" - // - "blacklist" # only call fallback for skills NOT in "fallback_blacklist" - "fallback_mode": "accept_all", - "fallback_whitelist": [], - "fallback_blacklist": [] - }, - - // converse stage configuration - "converse": { - // the default number of seconds a skill remains active - // if the user does not interact with the skill in this timespan it - // will be deactivated, default 5 minutes (same as mycroft) - "timeout": 300, - // override of "skill_timeouts" per skill_id - "skill_timeouts": {}, - - // conversational mode has 3 modes of operations: - // - "accept_all" # default mycroft-core behavior - // - "whitelist" # only call converse for skills in "converse_whitelist" - // - "blacklist" # only call converse for skills NOT in "converse_blacklist" - "converse_mode": "accept_all", - "converse_whitelist": [], - "converse_blacklist": [], - - // converse activation has 4 modes of operations: - // - "accept_all" # default mycroft-core behavior, any skill can - // # activate itself unconditionally - // - "priority" # skills can only activate themselves if no skill with - // # higher priority is active - // - "whitelist" # only skills in "converse_whitelist" can activate themselves - // - "blacklist" # only skills NOT in converse "converse_blacklist" can activate themselves - // NOTE: this does not apply for regular skill activation, only to skill - // initiated activation requests - "converse_activation": "accept_all", - - // number of consecutive times a skill is allowed to activate itself - // per minute, -1 for no limit (default), 0 to disable self-activation - "max_activations": -1, - // override of "max_activations" per skill_id - "skill_activations": {}, - - // if false only skills can activate themselves - // if true any skill can activate any other skill - "cross_activation": true, - - // if false only skills can deactivate themselves - // if true any skill can deactivate any other skill - // NOTE: skill deactivation is not yet implemented - "cross_deactivation": true, - - // you can add skill_id: priority to override the developer defined - // priority of those skills, currently there is no api for skills to - // define their default priority, it is assumed to be 50, the only current - // canonical source for converse priorities is this setting - "converse_priorities": { - // "skill_id": 10 - } - } - - }, - - // system administrators can define different constraints in how configurations are loaded - // this is a mechanism to require root to change these config options - "system": { - // do not allow users to tamper with settings at all - "disable_user_config": false, - // do not allow remote backend to tamper with settings at all - "disable_remote_config": false, - // protected keys are individual settings that can not be changed at remote/user level - // nested dictionary keys can be defined with "key1:key2" syntax, - // eg. {"a": {"b": True, "c": False}} - // to protect "c" you would enter "a:c" in the section below - "protected_keys": { - // NOTE: selene backend expects "opt_in" to be changeable in their web ui - // that effectively gives them a means to enable spying without your input - // Mycroft AI can be trusted, but you dont need to anymore! - // The other keys are not currently populated by the remote backend - // they are defined for protection against bugs and for future proofing - // (what if facebook buys mycroft tomorrow?) - "remote": [ - "enclosure", - "server", - "system", - "websocket", - "gui_websocket", - "network_tests", - "listener:wake_word_upload:disable", - "skills:upload_skill_manifest", - "skills:auto_update", - "skills:priority_skills", - "skills:blacklisted_skills", - "opt_in" - ], - "user": [] - } - }, - - // Address of the REMOTE server - "server": { - "url": "https://api.mycroft.ai", - "version": "v1", - "update": false, - "disabled": true, - "metrics": false, - "sync_skill_settings": false - }, - - // The mycroft-core messagebus websocket - "websocket": { - "host": "0.0.0.0", - "port": 8181, - "route": "/core", - "ssl": false, - // in mycroft-core all skills share a bus, this allows malicious skills - // to manipulate it and affect other skills, this option ensures each skill - // gets it's own websocket connection - "shared_connection": true - }, - - // The GUI messagebus websocket. Once port is created per connected GUI - "gui_websocket": { - "host": "0.0.0.0", - "base_port": 18181, - "route": "/gui", - "ssl": false - }, - - // URIs to use for testing network connection. - "network_tests": { - "dns_primary": "8.8.8.8", - "dns_secondary": "8.8.4.4", - "web_url": "https://www.google.com", - "ncsi_endpoint": "http://www.msftncsi.com/ncsi.txt", - "ncsi_expected_text": "Microsoft NCSI" - }, - - // Settings used by the wake-up-word listener - // Override: REMOTE - "listener": { - "sample_rate": 16000, - - // if enabled the noise level is saved to a ipc file, useful for - // debuging if microphone is working but writes a lot to disk, - // recommended that you set "ipc_path" to a tmpfs - "mic_meter_ipc": false, - - // Set 'save_path' to configure the location of files stored if - // 'record_wake_words' and/or 'save_utterances' are set to 'true'. - // WARNING: Make sure that user 'mycroft' has write-access on the - // directory! - // "save_path": "/tmp", - // Set 'record_wake_words' to save a copy of wake word triggers - // as .wav files under: /'save_path'/mycroft_wake_words - "record_wake_words": false, - // Set 'save_utterances' to save each sentence sent to STT -- by default - // they are only kept briefly in-memory. This can be useful for for - // debugging or other custom purposes. Recordings are saved - // under: /'save_path'/mycroft_utterances/.wav - "save_utterances": false, - "wake_word_upload": { - "disable": true, - "url": "https://training.mycroft.ai/precise/upload" - }, - - // Override as SYSTEM or USER to select a specific microphone input instead of - // the PortAudio default input. - // "device_name": "somename", // can be regex pattern or substring - // or - // "device_index": 12, - - // Stop listing to the microphone during playback to prevent accidental triggering - // This is enabled by default, but instances with good microphone noise cancellation - // can disable this to listen all the time, allowing 'barge in' functionality. - "mute_during_output" : true, - - // How much (if at all) to 'duck' the speaker output during listening. A - // setting of 0.0 will not duck at all. A 1.0 will completely mute output - // while in a listening state. Values in between will lower the volume - // partially (this is optional behavior, depending on the enclosure). - "duck_while_listening" : 0.3, - - // In milliseconds - "phoneme_duration": 120, - "multiplier": 1.0, - "energy_ratio": 1.5, - - // DEPRECATED, multiple hotwords are supported now, see "hotwords" section below - //"wake_word": "hey mycroft", - //"stand_up_word": "wake up", - - // Settings used by microphone to set recording timeout - "recording_timeout": 10.0, - "recording_timeout_with_silence": 3.0, - - // instant listen is an experimental setting, it removes the need for - // the pause between "hey mycroft" and starting to speak the utterance, - //however it might slightly downgrade STT accuracy depending on engine used - "instant_listen": false - }, - - // Settings used for any precise wake words - "precise": { - "use_precise": false, - "dist_url": "https://github.com/MycroftAI/precise-data/raw/dist/{arch}/latest", - "model_url": "https://raw.githubusercontent.com/MycroftAI/precise-data/models/{wake_word}.tar.gz" - }, - - // Hotword configurations - "hotwords": { - "hey mycroft": { - "module": "ovos-ww-plugin-pocketsphinx", - "phonemes": "HH EY . M AY K R AO F T", - "threshold": 1e-90, - "lang": "en-us", - "listen": true, - "sound": "snd/start_listening.wav" - // Specify custom model via: - // "local_model_file": "~/.local/share/mycroft/precise/models/something.pb" - // Precise options: - // "sensitivity": 0.5, // Higher = more sensitive - // "trigger_level": 3 // Higher = more delay & less sensitive - }, - - "wake up": { - "module": "ovos-ww-plugin-pocketsphinx", - "phonemes": "W EY K . AH P", - "threshold": 1e-20, - "lang": "en-us", - "wakeup": true - } - }, - - // DEPRECATED: the concept of enclosure will no longer exist in ovos-core - // this has been replaced with PHAL - "enclosure": { - // Platform name - // Override: SYSTEM (set by specific enclosures) - "platform": "OpenVoiceOS", - - // The NTP sync should only forced on Raspberry Pi based devices. - "ntp_sync_on_boot": false, - - // for backwards compat NTP sync is automatically enabled for - // ('mycroft_mark_1', 'picroft', 'mycroft_mark_2pi') - // to disable forced ntp_sync in official mycroft platforms - // set this to false - "force_mycroft_ntp": false - - }, - - // Level of logs to store, one of "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" - // NOTE: This configuration setting is special and can only be changed in the - // SYSTEM or USER configuration file, it will not be read if defined in the - // DEFAULT (here) or in the REMOTE mycroft config. - // If not defined, the default log level is INFO. - //"log_level": "INFO", - - // Messagebus types that will NOT be output to logs - "ignore_logs": ["enclosure.mouth.viseme", "enclosure.mouth.display"], - - // Settings related to remote sessions - // Overrride: none - "session": { - // Time To Live, in seconds - "ttl": 180 - }, - - // Speech to Text parameters - // Override: REMOTE - "stt": { - // Engine. Options: "mycroft", "google", "wit", "ibm", "kaldi", "bing", - // "houndify", "deepspeech_server", "govivace", "yandex" - "module": "google" - // "deepspeech_server": { - // "uri": "http://localhost:8080/stt" - // }, - // "kaldi": { - // "uri": "http://localhost:8080/client/dynamic/recognize" - // }, - //"govivace": { - // "uri": "https://services.govivace.com:49149/telephony", - // "credential": { - // "token": "xxxxx" - // } - //} - }, - - // Text to Speech parameters - // Override: REMOTE - "tts": { - "pulse_duck": false, - "module": "ovos-tts-plugin-mimic2", - "fallback_module": "ovos-tts-plugin-mimic" - }, - - "padatious": { - "intent_cache": "~/.local/share/mycroft/intent_cache", - "train_delay": 4, - "single_thread": false, - "padaos_only": true - }, - - "Audio": { - "backends": { - "local": { - "type": "simple", - "active": true - } - }, - "default-backend": "local" - }, - - "debug": true -} diff --git a/ovos_utils/res/platform_fingerprints/spoofed_ovos.json b/ovos_utils/res/platform_fingerprints/spoofed_ovos.json deleted file mode 100644 index e0814b2f..00000000 --- a/ovos_utils/res/platform_fingerprints/spoofed_ovos.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "platform": "Linux-5.4.0-58-generic-x86_64-with-glibc2.29", - "enclosure": "OpenVoiceOS", - "python_version": "3.8.5", - "system": "Linux", - "version": "#64-Ubuntu SMP Wed Dec 9 08:16:25 UTC 2020", - "arch": "x86_64", - "release": "5.4.0-58-generic", - "node": "user-Predator-PH315-51", - "desktop_env": "xfce4", - "ipc_path": "/ramdisk/mycroft/ipc/", - "input_device_name": "OpenVoiceOS", - "input_device_index": null, - "default_audio_backend": "vlc", - "msm_skills_dir": "skills", - "data_dir": "/opt/mycroft", - "priority_skills": [ - "mycroft-pairing", - "mycroft-volume" - ], - "backend_url": "https://api.mycroft.ai", - "mycroft_core_location": null, - "can_display": true, - "is_gui_installed": true, - "pulseaudio_running": true -} \ No newline at end of file diff --git a/ovos_utils/security.py b/ovos_utils/security.py index 3f746500..9b78bebf 100644 --- a/ovos_utils/security.py +++ b/ovos_utils/security.py @@ -1,11 +1,13 @@ -import platform -import pexpect -from socket import gethostname -from os.path import exists, join -from os import makedirs import os +import platform import random import string +from os import makedirs +from os.path import exists, join +from socket import gethostname + +import pexpect + from ovos_utils.log import LOG try: diff --git a/ovos_utils/signal.py b/ovos_utils/signal.py index dce01e86..1f6d3fa4 100644 --- a/ovos_utils/signal.py +++ b/ovos_utils/signal.py @@ -1,13 +1,12 @@ -import tempfile -import time - import os import os.path +import tempfile +import time - -from ovos_utils.log import LOG, log_deprecation +from ovos_utils.log import LOG, log_deprecation, deprecated +@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") def get_ipc_directory(domain=None, config=None): """Get the directory used for Inter Process Communication @@ -25,8 +24,8 @@ def get_ipc_directory(domain=None, config=None): if config is None: 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() + from ovos_config.config import Configuration + config = Configuration() except ImportError: LOG.warning("Config not provided and ovos_config not available") config = dict() @@ -37,6 +36,7 @@ def get_ipc_directory(domain=None, config=None): return ensure_directory_exists(path, domain) +@deprecated("use 'from ovos_utils.file_utils import ensure_directory_exists' instead", "0.2.0") def ensure_directory_exists(directory, domain=None): """ Create a directory and give access rights to all @@ -47,26 +47,11 @@ def ensure_directory_exists(directory, domain=None): Returns: str: a path to the directory """ - if domain: - directory = os.path.join(directory, domain) - - # Expand and normalize the path - directory = os.path.normpath(directory) - directory = os.path.expanduser(directory) - - if not os.path.isdir(directory): - try: - save = os.umask(0) - os.makedirs(directory, 0o777) # give everyone rights to r/w here - except OSError: - LOG.warning("Failed to create: " + directory) - pass - finally: - os.umask(save) - - return directory + from ovos_utils.file_utils import ensure_directory_exists as _ede + return _ede(directory, domain) +@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") def create_file(filename): """ Create the file filename and create any directories needed @@ -81,6 +66,7 @@ def create_file(filename): f.write('') +@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") def create_signal(signal_name, config=None): """Create a named signal @@ -98,6 +84,7 @@ def create_signal(signal_name, config=None): return False +@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") def check_for_signal(signal_name, sec_lifetime=0, config=None): """See if a named signal exists diff --git a/ovos_utils/skills.py b/ovos_utils/skills.py new file mode 100644 index 00000000..d38e041e --- /dev/null +++ b/ovos_utils/skills.py @@ -0,0 +1,35 @@ +from ovos_bus_client.util import wait_for_reply + + +def get_non_properties(obj): + """Get attributes that are not properties from object. + + Will return members of object class along with bases down to MycroftSkill. + + Args: + obj: object to scan + + Returns: + Set of attributes that are not a property. + """ + + def check_class(cls): + """Find all non-properties in a class.""" + # Current class + d = cls.__dict__ + np = [k for k in d if not isinstance(d[k], property)] + # Recurse through base classes excluding MycroftSkill and object + for b in [b for b in cls.__bases__ if b.__name__ not in ("object", "MycroftSkill")]: + np += check_class(b) + return np + + return set(check_class(obj.__class__)) + + +def skills_loaded(bus=None): + reply = wait_for_reply('mycroft.skills.all_loaded', + 'mycroft.skills.all_loaded.response', + bus=bus) + if reply: + return reply.data['status'] + return False diff --git a/ovos_utils/skills/__init__.py b/ovos_utils/skills/__init__.py deleted file mode 100644 index e861e313..00000000 --- a/ovos_utils/skills/__init__.py +++ /dev/null @@ -1,116 +0,0 @@ -from ovos_config.config import read_mycroft_config, update_mycroft_config -from ovos_utils.log import LOG, deprecated - - -def get_non_properties(obj): - """Get attributes that are not properties from object. - - Will return members of object class along with bases down to MycroftSkill. - - Args: - obj: object to scan - - Returns: - Set of attributes that are not a property. - """ - - def check_class(cls): - """Find all non-properties in a class.""" - # Current class - d = cls.__dict__ - np = [k for k in d if not isinstance(d[k], property)] - # Recurse through base classes excluding MycroftSkill and object - for b in [b for b in cls.__bases__ if b.__name__ not in ("object", "MycroftSkill")]: - np += check_class(b) - return np - - return set(check_class(obj.__class__)) - - -def skills_loaded(bus=None): - try: - from ovos_bus_client.util import wait_for_reply - except: - from ovos_utils.messagebus import wait_for_reply - reply = wait_for_reply('mycroft.skills.all_loaded', - 'mycroft.skills.all_loaded.response', - bus=bus) - if reply: - return reply.data['status'] - return False - - -@deprecated("This reference is deprecated, use " - "`ovos_workshop.permissions.blacklist_skill", "0.1.0") -def blacklist_skill(skill, config=None): - config = config or read_mycroft_config() - try: - from ovos_workshop.permissions import blacklist_skill as _wl - return _wl(skill, config) - except ImportError: - skills_config = config.get("skills", {}) - blacklisted_skills = skills_config.get("blacklisted_skills", []) - if skill not in blacklisted_skills: - blacklisted_skills.append(skill) - conf = { - "skills": { - "blacklisted_skills": blacklisted_skills - } - } - update_mycroft_config(conf) - return True - return False - - -@deprecated("This reference is deprecated, use " - "`ovos_workshop.permissions.whitelist_skill", "0.1.0") -def whitelist_skill(skill, config=None): - config = config or read_mycroft_config() - try: - from ovos_workshop.permissions import whitelist_skill as _wl - return _wl(skill, config) - except ImportError: - skills_config = config.get("skills", {}) - blacklisted_skills = skills_config.get("blacklisted_skills", []) - if skill in blacklisted_skills: - blacklisted_skills.pop(skill) - conf = { - "skills": { - "blacklisted_skills": blacklisted_skills - } - } - update_mycroft_config(conf) - return True - return False - - -@deprecated("This method is deprecated. Skills are now loaded based on " - "`runtime_requirements`", "0.1.0") -def make_priority_skill(skill, config=None): - # config = config or read_mycroft_config() - # skills_config = config.get("skills", {}) - # priority_skills = skills_config.get("priority_skills", []) - # if skill not in priority_skills: - # priority_skills.append(skill) - # conf = { - # "skills": { - # "priority_skills": priority_skills - # } - # } - # update_mycroft_config(conf) - # return True - return False - - -@deprecated("This reference is deprecated, use " - "`ovos_plugin_manager.skills.get_default_skill_dir", "0.1.0") -def get_skills_folder(config=None): - from ovos_utils.skills.locations import get_default_skills_directory - return get_default_skills_directory(config) - - -@deprecated("This reference is deprecated, use " - "`ovos_plugin_manager.skills.get_installed_skill_ids", "0.1.0") -def get_installed_skills(config=None): - from ovos_utils.skills.locations import get_installed_skill_ids - return get_installed_skill_ids(config) diff --git a/ovos_utils/skills/api.py b/ovos_utils/skills/api.py deleted file mode 100644 index 15186a1d..00000000 --- a/ovos_utils/skills/api.py +++ /dev/null @@ -1,74 +0,0 @@ -from ovos_utils.log import LOG, log_deprecation - -log_deprecation("ovos_utils.skills.api moved to ovos_workshop.skills.api", "0.1.0") - -try: - from ovos_workshop.skills.api import SkillApi -except: - from typing import Dict, Optional - from ovos_utils.fakebus import Message - - - class SkillApi: - """ - SkillApi provides a MessageBus interface to specific registered methods. - Methods decorated with `@skill_api_method` are exposed via the messagebus. - To use a skill's API methods, call `SkillApi.get` with the requested skill's - ID and an object is returned with an interface to all exposed methods. - """ - bus = None - - @classmethod - def connect_bus(cls, mycroft_bus): - """Registers the bus object to use.""" - cls.bus = mycroft_bus - - def __init__(self, method_dict: Dict[str, dict], timeout: int = 3): - """ - Initialize a SkillApi for the given methods - @param method_dict: dict of method name to dict containing: - `help` - method docstring - `type` - string Message type associated with this method - @param timeout: Seconds to wait for a Skill API response - """ - self.method_dict = method_dict - self.timeout = timeout - for key in method_dict: - def get_method(k): - def method(*args, **kwargs): - m = self.method_dict[k] - data = {'args': args, 'kwargs': kwargs} - method_msg = Message(m['type'], data) - response = \ - SkillApi.bus.wait_for_response(method_msg, - timeout=self.timeout) - if not response: - LOG.error(f"Timed out waiting for {method_msg}") - return None - elif 'result' not in response.data: - LOG.error(f"missing `result` in: {response.data}") - else: - return response.data['result'] - - return method - - self.__setattr__(key, get_method(key)) - - @staticmethod - def get(skill: str, api_timeout: int = 3) -> Optional[object]: - """ - Generate a SkillApi object for the requested skill if that skill exposes - and API methods. - @param skill: ID of skill to get an API object for - @param api_timeout: seconds to wait for a skill API response - @return: SkillApi object if available, else None - """ - if not SkillApi.bus: - raise RuntimeError("Requested update before `SkillAPI.bus` is set. " - "Call `SkillAPI.connect_bus` first.") - public_api_msg = f'{skill}.public_api' - api = SkillApi.bus.wait_for_response(Message(public_api_msg)) - if api: - return SkillApi(api.data, api_timeout) - else: - return None diff --git a/ovos_utils/skills/audioservice.py b/ovos_utils/skills/audioservice.py deleted file mode 100644 index 90ea7ecf..00000000 --- a/ovos_utils/skills/audioservice.py +++ /dev/null @@ -1,469 +0,0 @@ -# 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 -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# 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. -# -from datetime import timedelta - -from ovos_utils.log import deprecated, log_deprecation - -log_deprecation("ClassicAudioServiceInterface and OCPInterface moved to ovos_bus_client.apis.ocp", "0.1.0") - -try: - from ovos_bus_client.apis.ocp import OCPInterface, ClassicAudioServiceInterface, ensure_uri -except ImportError: - from os.path import abspath - from ovos_utils.messagebus import get_mycroft_bus - from ovos_utils.fakebus import Message, dig_for_message - - def ensure_uri(s: str): - """ - Interpret paths as file:// uri's. - - Args: - s: string path to be checked - - Returns: - if s is uri, s is returned otherwise file:// is prepended - """ - if isinstance(s, str): - 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]: - return 'file://' + abspath(s[0]), s[1] - else: - return s - else: - raise ValueError('Invalid track') - - - class ClassicAudioServiceInterface: - """AudioService class for interacting with the audio subsystem - - Audio is managed by OCP in the default implementation, - usually this class should not be directly used, see OCPInterface instead - - Args: - bus: Mycroft messagebus connection - """ - - def __init__(self, bus=None): - self.bus = bus or get_mycroft_bus() - - def queue(self, tracks=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 - """ - 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(Message('mycroft.audio.service.queue', - data={'tracks': tracks})) - - 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 - audio 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('mycroft.audio.service.play', - data={'tracks': tracks, - 'utterance': utterance, - 'repeat': repeat})) - - def stop(self): - """Stop the track.""" - self.bus.emit(Message('mycroft.audio.service.stop')) - - def next(self): - """Change to next track.""" - self.bus.emit(Message('mycroft.audio.service.next')) - - def prev(self): - """Change to previous track.""" - self.bus.emit(Message('mycroft.audio.service.prev')) - - def pause(self): - """Pause playback.""" - self.bus.emit(Message('mycroft.audio.service.pause')) - - def resume(self): - """Resume paused playback.""" - self.bus.emit(Message('mycroft.audio.service.resume')) - - def get_track_length(self): - """ - getting the duration of the audio in seconds - """ - length = 0 - info = self.bus.wait_for_response( - Message('mycroft.audio.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('mycroft.audio.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('mycroft.audio.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: - self.seek_forward(seconds) - - def seek_forward(self, seconds=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('mycroft.audio.service.seek_forward', - {"seconds": seconds})) - - def seek_backward(self, seconds=1): - """Rewind X seconds - - Args: - seconds (int): number of seconds to rewind - """ - if isinstance(seconds, timedelta): - seconds = seconds.total_seconds() - self.bus.emit(Message('mycroft.audio.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('mycroft.audio.service.track_info'), - reply_type='mycroft.audio.service.track_info_reply', - timeout=1) - return info.data if info else {} - - def available_backends(self): - """Return available audio backends. - - Returns: - dict with backend names as keys - """ - msg = Message('mycroft.audio.service.list_backends') - response = self.bus.wait_for_response(msg) - 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: Mycroft messagebus connection - """ - - 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) - 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 - - # OCP bus api - def queue(self, tracks): - """Queue up a track to OCP playing playlist. - - Args: - tracks: track dict or list of track dicts (OCP result style) - """ - - assert isinstance(tracks, list) - assert all(isinstance(t, dict) for t in tracks) - - msg = self._format_msg('ovos.common_play.playlist.queue', - {'tracks': tracks}) - self.bus.emit(msg) - - def play(self, tracks, utterance=None): - """Start playback. - Args: - tracks: track dict or list of track dicts (OCP result style) - utterance: forward utterance for further processing by OCP - - TODO handle utterance: - - allow services to register .voc files - - match utterance against vocs in OCP - - select audio service based on parsing - eg. "play X in spotify" - """ - assert isinstance(tracks, list) - assert all(isinstance(t, dict) for t in tracks) - - utterance = utterance or '' - - msg = self._format_msg('ovos.common_play.play', - {"media": tracks[0], - "playlist": tracks, - "utterance": utterance}) - self.bus.emit(msg) - - - -class AudioServiceInterface(ClassicAudioServiceInterface): - """ClassicAudioService compatible class for interacting with OCP subsystem - Args: - bus: Mycroft messagebus connection - """ - - @deprecated("AudioServiceInterface has been deprecated, compatibility " - "layer in use. please move to OCPInterface", "0.1.0") - def __init__(self, bus=None): - super().__init__(bus) - - @staticmethod - def _uri2meta(uri): - if isinstance(uri, list): - uri = uri[0] - try: - from ovos_ocp_files_plugin.plugin import OCPFilesMetadataExtractor - return OCPFilesMetadataExtractor.extract_metadata(uri) - except: - meta = {"uri": uri, - "skill_id": "mycroft.audio_interface", - "playback": 2, # PlaybackType.AUDIO, # TODO mime type check - "status": 33 # TrackState.QUEUED_AUDIO - } - meta["skill_id"] = "mycroft.audio_interface" - return meta - - 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) - 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 - - # OCP bus api - def queue(self, tracks=None): - """Queue up a track to playing playlist. - TODO allow rich metadata for OCP: - - support dicts in tracks - NOTE: skills using this won't be compatible with mycroft-core ... - 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 - """ - tracks = tracks or [] - if isinstance(tracks, (str, tuple)): - tracks = [tracks] - elif not isinstance(tracks, list): - raise ValueError - tracks = [self._uri2meta(t) for t in tracks] - msg = self._format_msg('ovos.common_play.playlist.queue', - {'tracks': tracks}) - self.bus.emit(msg) - - 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 - audio 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 = [self._uri2meta(t) for t in tracks] - - msg = self._format_msg('ovos.common_play.play', - {"media": tracks[0], - "playlist": tracks, - "utterance": utterance, - 'repeat': repeat}) - self.bus.emit(msg) - - def stop(self): - """Stop the track.""" - msg = self._format_msg("ovos.common_play.stop") - self.bus.emit(msg) - - def next(self): - """Change to next track.""" - msg = self._format_msg("ovos.common_play.next") - self.bus.emit(msg) - - def prev(self): - """Change to previous track.""" - msg = self._format_msg("ovos.common_play.previous") - self.bus.emit(msg) - - def pause(self): - """Pause playback.""" - msg = self._format_msg("ovos.common_play.pause") - self.bus.emit(msg) - - def resume(self): - """Resume paused playback.""" - msg = self._format_msg("ovos.common_play.resume") - self.bus.emit(msg) - - def seek_forward(self, seconds=1): - """Skip ahead X seconds. - Args: - seconds (int): number of seconds to skip - """ - if isinstance(seconds, timedelta): - seconds = seconds.total_seconds() - msg = self._format_msg('ovos.common_play.seek', - {"seconds": seconds}) - self.bus.emit(msg) - - def seek_backward(self, seconds=1): - """Rewind X seconds - Args: - seconds (int): number of seconds to rewind - """ - if isinstance(seconds, timedelta): - seconds = seconds.total_seconds() - msg = self._format_msg('ovos.common_play.seek', - {"seconds": seconds * -1}) - self.bus.emit(msg) - - def get_track_length(self): - """ - getting the duration of the audio in miliseconds - """ - length = 0 - msg = self._format_msg('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 - - def get_track_position(self): - """ - get current position in miliseconds - """ - pos = 0 - msg = self._format_msg('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 - - def set_track_position(self, miliseconds): - """Go to X position. - Arguments: - miliseconds (int): position to go to in miliseconds - """ - msg = self._format_msg('ovos.common_play.set_track_position', - {"position": miliseconds}) - self.bus.emit(msg) - - def track_info(self): - """Request information of current playing track. - Returns: - Dict with track info. - """ - msg = self._format_msg('ovos.common_play.track_info') - response = self.bus.wait_for_response(msg) - return response.data if response else {} - - def available_backends(self): - """Return available audio backends. - Returns: - dict with backend names as keys - """ - msg = self._format_msg('ovos.common_play.list_backends') - response = self.bus.wait_for_response(msg) - return response.data if response else {} diff --git a/ovos_utils/skills/locations.py b/ovos_utils/skills/locations.py deleted file mode 100644 index 9ab84428..00000000 --- a/ovos_utils/skills/locations.py +++ /dev/null @@ -1,155 +0,0 @@ -from os import makedirs, listdir -from os.path import join, isdir, dirname, expanduser, isfile -from typing import List, Optional - -from ovos_config.config import read_mycroft_config -from ovos_config.locations import get_xdg_data_save_path, get_xdg_data_dirs - -from ovos_utils.log import LOG, log_deprecation - -log_deprecation("ovos_utils.skills.locations moved to ovos_plugin_manager.skills", "0.1.0") - -try: - from ovos_plugin_manager.skills import * -except: - def get_installed_skill_ids(conf: Optional[dict] = None) -> List[str]: - """ - Gets a list of `skill_id`s for all installed skills - Args: - conf: Configuration, else loads from ovos_config.config.Configuration - Returns: - list of `skill_id` strings for all installed skills - """ - _, skill_ids = get_plugin_skills() - for d in get_skill_directories(conf): - for skill_dir in listdir(d): - if isdir(join(d, skill_dir)) and isfile(join(d, skill_dir, - "__init__.py")): - if skill_dir in skill_ids: - LOG.info(f"{skill_dir} installed as plugin and local dir") - continue - skill_ids.append(skill_dir) - return skill_ids - - - def get_skill_directories(conf: Optional[dict] = None) -> List[str]: - """ returns list of skill directories ordered by expected loading order - This corresponds to: - - XDG_DATA_DIRS - - default directory (see get_default_skills_directory method for details) - - user defined extra directories - Each directory contains individual skill folders to be loaded - If a skill exists in more than one directory (same folder name) previous instances will be ignored - ie. directories at the end of the list have priority over earlier directories - NOTE: empty folders are interpreted as disabled skills - new directories can be defined in mycroft.conf by specifying a full path - each extra directory is expected to contain individual skill folders to be loaded - the xdg folder name can also be changed, it defaults to "skills" - eg. ~/.local/share/mycroft/FOLDER_NAME - { - "skills": { - "directory": "skills", - "extra_directories": ["path/to/extra/dir/to/scan/for/skills"] - } - } - Args: - conf: Configuration, else loads from ovos_config.config.Configuration - Returns: - list of fully-qualified directories containing non-plugin skills - """ - # the contents of each skills directory must be individual skill folders - # we are still dependent on the mycroft-core structure of skill_id/__init__.py - - conf = conf or read_mycroft_config() - folder = conf["skills"].get("directory") or "skills" - # load all valid XDG paths - # NOTE: skills are actually code, but treated as user data! - # they should be considered applets rather than full applications - skill_locations = list(reversed( - [join(p, folder) for p in get_xdg_data_dirs() if - isdir(join(p, folder))] - )) - - # load the default skills folder - # only meaningful if xdg support is disabled - default = get_default_skills_directory(conf) - if default not in skill_locations: - skill_locations.append(default) - - # load additional explicitly configured directories - conf = conf.get("skills") or {} - # extra_directories is a list of directories containing skill subdirectories - # NOT a list of individual skill folders - # preserve order while removing any duplicate entries - extra_dirs = (expanduser(d) for d in conf.get("extra_directories") or []) - for d in extra_dirs: - if isdir(d) and d not in skill_locations: - skill_locations.append(d) - return skill_locations - - - def get_default_skills_directory(conf: Optional[dict] = None) -> str: - """ return default directory to scan for skills - This is only meaningful if xdg is disabled in ovos.conf - If xdg is enabled then data_dir is always XDG_DATA_DIR - If xdg is disabled then data_dir by default corresponds to /opt/mycroft - users can define the data directory in mycroft.conf - the skills folder name (relative to data_dir) can also be defined there - NOTE: folder name also impacts all XDG skill directories! - { - "data_dir": "/opt/mycroft", - "skills": { - "directory": "skills" - } - } - Args: - conf: Configuration, else loads from ovos_config.config.Configuration - Returns: - Absolute path to default skills directory - """ - conf = conf or read_mycroft_config() - path_override = conf["skills"].get("directory_override") - folder = conf["skills"].get("directory") or "skills" - - # if .conf wants to use a specific path, use it! - if path_override: - log_deprecation("'directory_override' is deprecated!" - "add the new path to 'extra_directories' instead", - "0.1.0") - skills_folder = expanduser(path_override) - elif conf["skills"].get("extra_directories") and \ - len(conf["skills"].get("extra_directories")) > 0: - skills_folder = expanduser(conf["skills"]["extra_directories"][0]) - else: - skills_folder = join(get_xdg_data_save_path(), folder) - # create folder if needed - try: - makedirs(skills_folder, exist_ok=True) - except PermissionError: # old style /opt/mycroft/skills not available - skills_folder = join(get_xdg_data_save_path(), folder) - makedirs(skills_folder, exist_ok=True) - - return skills_folder - - - def get_plugin_skills() -> (list, list): - """ - Get the package directories for any pip installed skill plugins - Returns: - lists of skill directories and plugin skill IDs - """ - import importlib.util - try: - from ovos_plugin_manager.skills import find_skill_plugins - except ImportError: - LOG.warning("ovos-plugin-manager not available to load plugin skills") - return [], [] - skill_dirs = list() - plugins = find_skill_plugins() - skill_ids = list(plugins.keys()) - for skill_class in plugins.values(): - skill_dir = dirname(importlib.util.find_spec( - skill_class.__module__).origin) - skill_dirs.append(skill_dir) - LOG.info(f"Located plugin skills: {skill_ids}") - return skill_dirs, skill_ids diff --git a/ovos_utils/skills/settings.py b/ovos_utils/skills/settings.py deleted file mode 100644 index 1d476040..00000000 --- a/ovos_utils/skills/settings.py +++ /dev/null @@ -1,132 +0,0 @@ -import json -from os.path import join, expanduser, exists - -import requests -from json_database import JsonStorageXDG, JsonStorage - -from ovos_utils.log import LOG, log_deprecation, deprecated - -log_deprecation("ovos_utils.skills.settings moved to ovos_workshop.settings", "0.1.0") - -try: - from ovos_workshop.settings import * - -except ImportError: - - def settings2meta(settings, section_name="Skill Settings"): - """ generates basic settingsmeta """ - fields = [] - - for k, v in settings.items(): - if k.startswith("_"): - continue - label = k.replace("-", " ").replace("_", " ").title() - if isinstance(v, bool): - fields.append({ - "name": k, - "type": "checkbox", - "label": label, - "value": str(v).lower() - }) - if isinstance(v, str): - fields.append({ - "name": k, - "type": "text", - "label": label, - "value": v - }) - if isinstance(v, int): - fields.append({ - "name": k, - "type": "number", - "label": label, - "value": str(v) - }) - return { - "skillMetadata": { - "sections": [ - { - "name": section_name, - "fields": fields - } - ] - } - } - - - class PrivateSettings(JsonStorageXDG): - def __init__(self, skill_id): - super(PrivateSettings, self).__init__(skill_id) - - @property - def settingsmeta(self): - return settings2meta(self, self.name) - - -@deprecated("deprecated without replacement, selene backend is dead", "0.1.0") -def get_remote_settings(skill_id, identity_file=None, backend_url=None): - """ WARNING: selene backend does not use proper skill_id, if you have - skills with same name but different author settings will overwrite each - other on the backend, THIS METHOD IS NOT SAFE - - skill matching is currently done by checking "if {skill} in string" - once mycroft fixes it on their side this will start using a proper - unique identifier - """ - data = get_all_remote_settings(identity_file, backend_url) - for k, v in data.items(): - if skill_id in k: - return v or {} - return data - - -@deprecated("deprecated without replacement, selene backend is dead", "0.1.0") -def get_all_remote_settings(identity_file=None, backend_url=None): - """ WARNING: selene backend does not use proper skill_id, if you have - skills with same name but different author settings will overwrite each - other on the backend, THIS METHOD IS NOT SAFE - """ - backend_url = backend_url or "https://api.mycroft.ai" - identity_file = identity_file or expanduser( - join("~", ".mycroft", "identity", "identity2.json")) - if not exists(identity_file): - return {} - with open(identity_file) as f: - identity = json.load(f) - url = backend_url + "/v1/device/" + identity["uuid"] + "/skill/settings" - params = {"Authorization": "Bearer " + identity["access"], - "Content-Type": "application/json" - } - return requests.get(url, headers=params).json() - - -@deprecated("deprecated without replacement, skill settings no longer shipped in skill folder", "0.1.0") -def get_local_settings(skill_dir, skill_name=None) -> dict: - """Build a JsonStorage using the JSON string stored in settings.json.""" - if skill_name: - log_deprecation("skill_name is an unused legacy argument", "0.1.0") - if skill_dir.endswith("/settings.json"): - settings_path = skill_dir - else: - settings_path = join(skill_dir, 'settings.json') - LOG.info(settings_path) - return JsonStorage(settings_path) - - -@deprecated("deprecated without replacement, skill settings no longer shipped in skill folder", "0.1.0") -def save_settings(skill_dir, skill_settings): - """Save skill settings to file.""" - if skill_dir.endswith("/settings.json"): - settings_path = skill_dir - else: - settings_path = join(skill_dir, 'settings.json') - - settings = JsonStorage(settings_path) - for k, v in skill_settings.items(): - settings[k] = v - try: - settings.store() - except Exception: - LOG.error(f'error saving skill settings to {settings_path}') - else: - LOG.info(f'Skill settings successfully saved to {settings_path}') diff --git a/ovos_utils/sound.py b/ovos_utils/sound.py new file mode 100644 index 00000000..bf87b648 --- /dev/null +++ b/ovos_utils/sound.py @@ -0,0 +1,166 @@ +import os +import subprocess +import wave +from copy import deepcopy +from os.path import isfile +from typing import Optional + +from distutils.spawn import find_executable + +from ovos_utils.log import LOG + +try: + from ovos_config.config import read_mycroft_config +except ImportError: + LOG.warning("Config not provided and ovos_config not available") + + + def read_mycroft_config(): + return dict() + +# Create a custom environment to use that can let duck a music role. +# This is kept separate from the normal os.environ to ensure that +# any thirdparty software launched through +# a ovos process can select if they wish to honor this. +_ENVIRONMENT = deepcopy(os.environ) +_ENVIRONMENT['PULSE_PROP'] = 'media.role=phone' + + +def _get_pulse_environment(config): + """Return environment for pulse audio depeding on ducking config.""" + tts_config = config.get('tts', {}) + if tts_config and tts_config.get('pulse_duck'): + return _ENVIRONMENT + else: + return os.environ + + +def _find_player(uri): + _, ext = os.path.splitext(uri) + + # scan installed executables that can handle playback + sox_play = find_executable("play") + # sox should handle almost every format, but fails in some urls + if sox_play: + return sox_play + f" --type {ext} %1" + # determine best available player + ogg123_play = find_executable("ogg123") + if "ogg" in ext and ogg123_play: + return ogg123_play + " -q %1" + pw_play = find_executable("pw-play") + # pw_play handles both wav and mp3 + if pw_play: + return pw_play + " %1" + # wav file + if 'wav' in ext: + pulse_play = find_executable("paplay") + if pulse_play: + return pulse_play + " %1" + alsa_play = find_executable("aplay") + if alsa_play: + return alsa_play + " %1" + # guess mp3 + mpg123_play = find_executable("mpg123") + if mpg123_play: + return mpg123_play + " %1" + LOG.error("Can't find player for: %s", uri) + return None + + +def play_audio(uri, play_cmd=None, environment=None): + """Play an audio file. + + This wraps the other play_* functions, choosing the correct one based on + the file extension. The function will return directly and play the file + in the background. + + Args: + uri: uri to play + environment (dict): optional environment for the subprocess call + + Returns: subprocess.Popen object. None if the format is not supported or + an error occurs playing the file. + """ + config = read_mycroft_config() + environment = environment or _get_pulse_environment(config) + + # NOTE: some urls like youtube streams will cause extension detection to fail + # let's handle it explicitly + uri = uri.split("?")[0] + # Replace file:// uri's with normal paths + uri = uri.replace('file://', '') + + _, ext = os.path.splitext(uri) + + if not play_cmd: + if "ogg" in ext: + play_cmd = config.get("play_ogg_cmdline") + elif "wav" in ext: + play_cmd = config.get("play_wav_cmdline") + elif "mp3" in ext: + play_cmd = config.get("play_mp3_cmdline") + + if not play_cmd: + play_cmd = _find_player(uri) + + if not play_cmd: + LOG.error(f"Failed to play: No playback functionality available") + return None + + play_cmd = play_cmd.split(" ") + + for index, cmd in enumerate(play_cmd): + if cmd == "%1": + play_cmd[index] = uri + + try: + return subprocess.Popen(play_cmd, env=environment) + except Exception as e: + LOG.error(f"Failed to play: {play_cmd}") + LOG.exception(e) + return None + +def get_sound_duration(path: str, base_dir: Optional[str] = "") -> float: + """return sound duration, in seconds""" + if base_dir and path.startswith("snd/"): + resolved_path = f"{base_dir}/{path}" + if isfile(resolved_path): + path = resolved_path + + if not isfile(path): + raise FileNotFoundError(f"could not resolve sound file: {path}") + + if path.endswith(".wav"): + with wave.open(path, 'r') as f: + frames = f.getnframes() + rate = f.getframerate() + return frames / float(rate) + ffprobe = find_executable("ffprobe") + if ffprobe: + args = (ffprobe, "-show_entries", "format=duration", "-i", path) + popen = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + popen.wait() + output = popen.stdout.read().decode("utf-8") + return float(output.split("duration=")[-1].split("\n")[0]) + media_info = find_executable("mediainfo") + if media_info: + args = (media_info, path) + popen = subprocess.Popen(args, stdout=subprocess.PIPE) + popen.wait() + output = popen.stdout.read().decode("utf-8").split("Duration")[1].split("\n")[0].split(":")[-1] + t = 0 + if " h" in output: + h, output = output.split(" h") + t += int(h) * 60 * 60 + if " min" in output: + m, output = output.split(" min") + t += int(m) * 60 + if " s" in output: + m, output = output.split(" s") + t += int(m) + if " ms" in output: + m, output = output.split(" ms") + t += int(m) / 1000 + return t + raise RuntimeError("Failed to determine sound length, please install mediainfo or ffprobe") + diff --git a/ovos_utils/sound/__init__.py b/ovos_utils/sound/__init__.py deleted file mode 100644 index 056b3ce0..00000000 --- a/ovos_utils/sound/__init__.py +++ /dev/null @@ -1,276 +0,0 @@ -import os -import subprocess -import time -from copy import deepcopy -from distutils.spawn import find_executable - -from ovos_utils.file_utils import resolve_resource_file -from ovos_utils.log import LOG, deprecated -from ovos_utils.signal import check_for_signal - -try: - from ovos_config.config import read_mycroft_config -except ImportError: - LOG.warning("Config not provided and ovos_config not available") - - - def read_mycroft_config(): - return dict() - -# Create a custom environment to use that can let duck a music role. -# This is kept separate from the normal os.environ to ensure that -# any thirdparty software launched through -# a ovos process can select if they wish to honor this. -_ENVIRONMENT = deepcopy(os.environ) -_ENVIRONMENT['PULSE_PROP'] = 'media.role=phone' - - -def _get_pulse_environment(config): - """Return environment for pulse audio depeding on ducking config.""" - tts_config = config.get('tts', {}) - if tts_config and tts_config.get('pulse_duck'): - return _ENVIRONMENT - else: - return os.environ - - -def _play_default_sound_locally(sound_name): - audio_file = resolve_resource_file( - read_mycroft_config().get('sounds', {}).get(sound_name)) - if not audio_file: - LOG.warning(f"Could not find '{sound_name}' audio file!") - return - process = play_audio(audio_file) - if not process: - LOG.warning(f"Unable to play '{sound_name}' audio file!") - return process - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_acknowledge_sound(): - """Acknowledge a successful request. - - This method plays a sound to acknowledge a request that does not - require a verbal response. This is intended to provide simple feedback - to the user that their request was handled successfully. - """ - return _play_default_sound_locally('acknowledge') - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_listening_sound(): - """Audibly indicate speech recording started.""" - return _play_default_sound_locally('start_listening') - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_end_listening_sound(): - """Audibly indicate speech recording is no longer happening.""" - return _play_default_sound_locally('end_listening') - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_error_sound(): - """Audibly indicate a failed request. - - This method plays a error sound to signal an error that does not - require a verbal response. This is intended to provide simple feedback - to the user that their request was NOT handled successfully. - """ - return _play_default_sound_locally('error') - - -def _find_player(uri): - _, ext = os.path.splitext(uri) - - # scan installed executables that can handle playback - sox_play = find_executable("play") - # sox should handle almost every format, but fails in some urls - if sox_play: - return sox_play + f" --type {ext} %1" - # determine best available player - ogg123_play = find_executable("ogg123") - if "ogg" in ext and ogg123_play: - return ogg123_play + " -q %1" - pw_play = find_executable("pw-play") - # pw_play handles both wav and mp3 - if pw_play: - return pw_play + " %1" - # wav file - if 'wav' in ext: - pulse_play = find_executable("paplay") - if pulse_play: - return pulse_play + " %1" - alsa_play = find_executable("aplay") - if alsa_play: - return alsa_play + " %1" - # guess mp3 - mpg123_play = find_executable("mpg123") - if mpg123_play: - return mpg123_play + " %1" - LOG.error("Can't find player for: %s", uri) - return None - - -def play_audio(uri, play_cmd=None, environment=None): - """Play an audio file. - - This wraps the other play_* functions, choosing the correct one based on - the file extension. The function will return directly and play the file - in the background. - - Args: - uri: uri to play - environment (dict): optional environment for the subprocess call - - Returns: subprocess.Popen object. None if the format is not supported or - an error occurs playing the file. - """ - config = read_mycroft_config() - environment = environment or _get_pulse_environment(config) - - # NOTE: some urls like youtube streams will cause extension detection to fail - # let's handle it explicitly - uri = uri.split("?")[0] - # Replace file:// uri's with normal paths - uri = uri.replace('file://', '') - - _, ext = os.path.splitext(uri) - - if not play_cmd: - if "ogg" in ext: - play_cmd = config.get("play_ogg_cmdline") - elif "wav" in ext: - play_cmd = config.get("play_wav_cmdline") - elif "mp3" in ext: - play_cmd = config.get("play_mp3_cmdline") - - if not play_cmd: - play_cmd = _find_player(uri) - - if not play_cmd: - LOG.error(f"Failed to play: No playback functionality available") - return None - - play_cmd = play_cmd.split(" ") - - for index, cmd in enumerate(play_cmd): - if cmd == "%1": - play_cmd[index] = uri - - try: - return subprocess.Popen(play_cmd, env=environment) - except Exception as e: - LOG.error(f"Failed to play: {play_cmd}") - LOG.exception(e) - return None - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_wav(uri, play_cmd=None, environment=None): - """ Play a wav-file. - - Returns: subprocess.Popen object - """ - config = read_mycroft_config() - environment = environment or _get_pulse_environment(config) - play_cmd = play_cmd or config.get("play_wav_cmdline") or "paplay %1" - play_wav_cmd = str(play_cmd).split(" ") - for index, cmd in enumerate(play_wav_cmd): - if cmd == "%1": - play_wav_cmd[index] = uri - try: - return subprocess.Popen(play_wav_cmd, env=environment) - except Exception as e: - LOG.error("Failed to launch WAV: {}".format(play_wav_cmd)) - LOG.debug("Error: {}".format(repr(e)), exc_info=True) - return None - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_mp3(uri, play_cmd=None, environment=None): - """ Play a mp3-file. - - Returns: subprocess.Popen object - """ - config = read_mycroft_config() - environment = environment or _get_pulse_environment(config) - play_cmd = play_cmd or config.get("play_mp3_cmdline") or "mpg123 %1" - play_mp3_cmd = str(play_cmd).split(" ") - for index, cmd in enumerate(play_mp3_cmd): - if cmd == "%1": - play_mp3_cmd[index] = uri - try: - return subprocess.Popen(play_mp3_cmd, env=environment) - except Exception as e: - LOG.error("Failed to launch MP3: {}".format(play_mp3_cmd)) - LOG.debug("Error: {}".format(repr(e)), exc_info=True) - return None - - -@deprecated("please emit mycroft.audio.play_sound instead", "0.1.0") -def play_ogg(uri, play_cmd=None, environment=None): - """ Play a ogg-file. - - Returns: subprocess.Popen object - """ - config = read_mycroft_config() - environment = environment or _get_pulse_environment(config) - play_cmd = play_cmd or config.get("play_ogg_cmdline") or "ogg123 -q %1" - play_ogg_cmd = str(play_cmd).split(" ") - for index, cmd in enumerate(play_ogg_cmd): - if cmd == "%1": - play_ogg_cmd[index] = uri - try: - return subprocess.Popen(play_ogg_cmd, env=environment) - except Exception as e: - LOG.error("Failed to launch OGG: {}".format(play_ogg_cmd)) - LOG.debug("Error: {}".format(repr(e)), exc_info=True) - return None - - -@deprecated("please use ovos-dinkum-listener in recording mode instead", "0.1.0") -def record(file_path, duration, rate, channels): - """Simple function to record from the default mic. - - The recording is done in the background by the arecord commandline - application. - - Args: - file_path: where to store the recorded data - duration: how long to record - rate: sample rate - channels: number of channels - - Returns: - process for performing the recording. - """ - command = ['arecord', '-r', str(rate), '-c', str(channels)] - command += ['-d', str(duration)] if duration > 0 else [] - command += [file_path] - return subprocess.Popen(command) - - -@deprecated("file signals have been removed," - " TODO add new bus message for this method", "0.1.0") -def is_speaking(): - """Determine if Text to Speech is occurring - - Returns: - bool: True while still speaking - """ - return check_for_signal("isSpeaking", -1) - - -@deprecated("file signals have been removed," - " use session_id and recognizer_loop:audio_output_end to track this", "0.1.0") -def wait_while_speaking(): - """Pause as long as Text to Speech is still happening - - Pause while Text to Speech is still happening. This always pauses - briefly to ensure that any preceeding request to speak has time to - begin. - """ - time.sleep(0.3) # Wait briefly in for any queued speech to begin - while is_speaking(): - time.sleep(0.1) diff --git a/ovos_utils/sound/alsa.py b/ovos_utils/sound/alsa.py deleted file mode 100644 index b77dc070..00000000 --- a/ovos_utils/sound/alsa.py +++ /dev/null @@ -1,102 +0,0 @@ -try: - import alsaaudio -except ImportError: - alsaaudio = None -from ovos_utils.log import LOG, deprecated - - -class AlsaControl: - _mixer = None - - @deprecated("This class is deprecated! Controls moved to " - "ovos_phal_plugin_alsa.AlsaVolumeControlPlugin", "0.1.0") - def __init__(self, control=None): - if alsaaudio is None: - LOG.error("pyalsaaudio not installed") - LOG.info("Run pip install pyalsaaudio==0.8.2") - raise ImportError - if control is None: - control = alsaaudio.mixers()[0] - self.get_mixer(control) - - @property - def mixer(self): - return self._mixer - - def get_mixer(self, control="Master"): - if self._mixer is None: - try: - mixer = alsaaudio.Mixer(control) - except Exception as e: - try: - mixer = alsaaudio.Mixer(control) - except Exception as e: - try: - if control != "Master": - LOG.warning("could not allocate requested mixer, " - "falling back to 'Master'") - mixer = alsaaudio.Mixer("Master") - else: - raise - except Exception as e: - LOG.error("Couldn't allocate mixer") - LOG.exception(e) - raise - self._mixer = mixer - return self.mixer - - def increase_volume(self, percent): - volume = self.get_volume() - if isinstance(volume, list): - volume = volume[0] - volume += percent - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self.mixer.setvolume(int(volume)) - - def decrease_volume(self, percent): - volume = self.get_volume() - if isinstance(volume, list): - volume = volume[0] - volume -= percent - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self.mixer.setvolume(int(volume)) - - def set_volume_percent(self, percent): - self.set_volume(percent) - - def set_volume(self, volume): - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self.mixer.setvolume(int(volume)) - - def volume_range(self): - return self.mixer.getrange() - - def is_muted(self): - return bool(self.mixer.getmute()[0]) - - def mute(self): - return self.mixer.setmute(1) - - def unmute(self): - return self.mixer.setmute(0) - - def toggle_mute(self): - if self.is_muted(): - self.unmute() - else: - self.mute() - - def get_volume(self): - return self.mixer.getvolume()[0] - - def get_volume_percent(self): - return self.get_volume() diff --git a/ovos_utils/sound/pulse.py b/ovos_utils/sound/pulse.py deleted file mode 100644 index 7286801f..00000000 --- a/ovos_utils/sound/pulse.py +++ /dev/null @@ -1,161 +0,0 @@ -import subprocess -import re -import collections -from ovos_utils.log import LOG, deprecated - - -class PulseAudio: - volume_re = re.compile('^set-sink-volume ([^ ]+) (.*)') - mute_re = re.compile('^set-sink-mute ([^ ]+) ((?:yes)|(?:no))') - - @deprecated("This class is deprecated! Controls moved to " - "ovos_phal_plugin_pulseaudio.PulseAudioVolumeControlPlugin", - "0.1.0") - def __init__(self): - self._mute = collections.OrderedDict() - self._volume = collections.OrderedDict() - self.update() - - def normalize_sinks(self): - self.unmute_all() - volume = self.get_volume() - self.set_all_volumes(volume) - - def update(self): - proc = subprocess.Popen(['pacmd', 'dump'], stdout=subprocess.PIPE) - - for line in proc.stdout: - line = line.decode("utf-8") - volume_match = PulseAudio.volume_re.match(line) - mute_match = PulseAudio.mute_re.match(line) - - if volume_match: - self._volume[volume_match.group(1)] = int( - volume_match.group(2), 16) - elif mute_match: - self._mute[mute_match.group(1)] = mute_match.group( - 2).lower() == "yes" - - def _vol_to_percent(self, vol): - max_vol = 65536 - percent = vol * 100 / max_vol - return percent - - def _percent_to_vol(self, percent): - max_vol = 65536 - vol = percent * max_vol / 100 - return vol - - def get_volume_percent(self, sink=None): - vol = self.get_sink_volume(sink) - return self._vol_to_percent(vol) - - def get_mute(self, sink=None): - if not sink: - sink = list(self._mute.keys())[0] - - return self._mute[sink] - - def get_volume(self, sink=None): - return self.get_sink_volume(sink) - - def get_sink_volume(self, sink=None): - if not sink: - sink = list(self._volume.keys())[0] - - return self._volume[sink] - - def set_mute(self, mute, sink=None): - if not sink: - sink = list(self._mute.keys())[0] - - subprocess.Popen( - ['pacmd', 'set-sink-mute', sink, 'yes' if mute else 'no'], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - self._mute[sink] = mute - - def set_volume(self, volume, sink=None): - self.set_sink_volume(volume, sink) - - def set_volume_percent(self, volume, sink=None): - self.set_sink_volume(self._percent_to_vol(volume), sink) - - def set_sink_volume(self, volume, sink=None): - if not sink: - sink = list(self._volume.keys())[0] - volume = int(volume) - subprocess.Popen(['pacmd', 'set-sink-volume', sink, hex(volume)], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - self._volume[sink] = volume - - def mute_all(self): - for sink in self.list_sinks(): - self.set_mute(True, sink) - - def unmute_all(self): - for sink in self.list_sinks(): - self.set_mute(False, sink) - - def set_all_volumes(self, volume): - self.set_all_sink_volumes(volume) - - def get_all_volumes(self): - return self.get_all_sink_volumes() - - def set_all_volumes_percent(self, percent): - volume = self._percent_to_vol(percent) - self.set_all_sink_volumes(volume) - - def get_all_volumes_percent(self): - return [self._vol_to_percent(volume) for volume in - self.get_all_sink_volumes()] - - def set_all_sink_volumes(self, volume): - for sink in self.list_sinks(): - self.set_volume(volume, sink) - - def get_all_sink_volumes(self): - volumes = [] - for sink in self.list_sinks(): - volumes.append(self.get_volume(sink)) - return volumes - - def list_sinks(self): - proc = subprocess.Popen(['pacmd', 'list-sinks'], - stdout=subprocess.PIPE) - sinks = [] - for line in proc.stdout: - line = line.decode("utf-8").strip() - if line.startswith("name: <"): - sink = line.replace("name: <", "")[:-1] - sinks.append(sink) - return sinks - - def list_sources(self): - proc = subprocess.Popen(['pacmd', 'list-sources'], - stdout=subprocess.PIPE) - sinks = [] - for line in proc.stdout: - line = line.decode("utf-8").strip() - if line.startswith("name: <"): - sink = line.replace("name: <", "")[:-1] - sinks.append(sink) - return sinks - - def increase_volume(self, percent): - volume = self.get_volume_percent() - volume += percent - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self.set_all_volumes_percent(volume) - - def decrease_volume(self, percent): - volume = self.get_volume_percent() - volume -= percent - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self.set_all_volumes_percent(volume) diff --git a/ovos_utils/ssml.py b/ovos_utils/ssml.py index b612be28..1d18d26c 100644 --- a/ovos_utils/ssml.py +++ b/ovos_utils/ssml.py @@ -70,7 +70,7 @@ def say_strong(self, text=None): raise TypeError('Parameter text must not be None') if len(self.text) and not self.text.endswith(" "): self.text += " " - self.text += "" + text\ + self.text += "" + text \ + "" return self @@ -80,7 +80,7 @@ def say_weak(self, text=None): raise TypeError('Parameter text must not be None') if len(self.text) and not self.text.endswith(" "): self.text += " " - self.text += "" + text\ + self.text += "" + text \ + " " return self diff --git a/ovos_utils/system.py b/ovos_utils/system.py index 1e64a127..88b9a3a2 100644 --- a/ovos_utils/system.py +++ b/ovos_utils/system.py @@ -4,26 +4,8 @@ import shutil import subprocess import sys -import sysconfig -from enum import Enum -from os.path import expanduser, exists, join - -from ovos_utils.log import LOG - - -# TODO: Deprecate MycroftRootLocations in 0.1.0 -class MycroftRootLocations(str, Enum): - PICROFT = "/home/pi/mycroft-core" - BIGSCREEN = "/home/mycroft/mycroft-core" - OVOS = "/usr/lib/python3.9/site-packages" - OLD_MARK1 = "/opt/venvs/mycroft-core/lib/python3.4/site-packages" - MARK1 = "/opt/venvs/mycroft-core/lib/python3.7/site-packages" - MARK2 = "/opt/mycroft" - HOME = expanduser("~/mycroft-core") # git clones - - -_USER_DEFINED_ROOT = None +from ovos_utils.log import LOG, deprecated def is_running_from_module(module_name): # Stack: @@ -48,15 +30,17 @@ def is_running_from_module(module_name): return True return False - # system utils +@deprecated("DEPRECATED: use ovos-PHAL-plugin-system", "0.2.0") def ntp_sync(): - # Force the system clock to synchronize with internet time servers + """ + Force the system clock to synchronize with internet time servers + """ subprocess.call('service ntp stop', shell=True) subprocess.call('ntpd -gq', shell=True) subprocess.call('service ntp start', shell=True) - +@deprecated("DEPRECATED: use ovos-PHAL-plugin-system", "0.2.0") def system_shutdown(sudo=True): """ Turn the system completely off (with no option to inhibit it) @@ -68,7 +52,7 @@ def system_shutdown(sudo=True): LOG.debug(cmd) subprocess.call(cmd, shell=True) - +@deprecated("DEPRECATED: use ovos-PHAL-plugin-system", "0.2.0") def system_reboot(sudo=True): """ Shut down and restart the system @@ -80,7 +64,7 @@ def system_reboot(sudo=True): LOG.debug(cmd) subprocess.call(cmd, shell=True) - +@deprecated("DEPRECATED: use ovos-PHAL-plugin-system", "0.2.0") def ssh_enable(sudo=True, user=False): """ Permanently allow SSH access @@ -89,7 +73,7 @@ def ssh_enable(sudo=True, user=False): """ enable_service("ssh.service", sudo=sudo, user=user) - +@deprecated("DEPRECATED: use ovos-PHAL-plugin-system", "0.2.0") def ssh_disable(sudo=True, user=False): """ Permanently block SSH access from the outside @@ -98,7 +82,7 @@ def ssh_disable(sudo=True, user=False): """ disable_service("ssh.service", sudo=sudo, user=user) - +@deprecated("DEPRECATED: use ovos-PHAL-plugin-system", "0.2.0") def restart_mycroft_service(sudo=True, user=False): """ Restarts the `mycroft.service` systemd service @@ -107,6 +91,28 @@ def restart_mycroft_service(sudo=True, user=False): """ restart_service("mycroft.service", sudo=sudo, user=user) +def is_running_from_module(module_name): + # Stack: + # [0] - _log() + # [1] - debug(), info(), warning(), or error() + # [2] - caller + stack = inspect.stack() + + # Record: + # [0] - frame object + # [1] - filename + # [2] - line number + # [3] - function + # ... + for record in stack[2:]: + mod = inspect.getmodule(record[0]) + name = mod.__name__ if mod else '' + # module name in file path of caller + # or import name matches module name + if f"/{module_name}/" in record[1] or \ + name.startswith(module_name.replace("-", "_").replace(" ", "_")): + return True + return False def restart_service(service_name, sudo=True, user=False): """ @@ -177,51 +183,25 @@ def check_service_active(service_name, sudo=False, user=False) -> bool: state = subprocess.run(status_command, shell=True).returncode return state == 0 - -# platform fingerprinting -def set_root_path(path): - global _USER_DEFINED_ROOT - _USER_DEFINED_ROOT = path - LOG.info(f"mycroft root set to {path}") - - -def find_root_from_sys_path(): - """Find mycroft root folder from sys.path, eg. venv site-packages.""" - for p in [path for path in sys.path if path != '']: - if exists(join(p, 'mycroft', 'configuration', 'mycroft.conf')): - return p - else: - return None - - -def find_root_from_sitepackages(): - """Find root from system or venv's sitepackages.""" - site = sysconfig.get_paths()['platlib'] - if exists(join(site, 'mycroft', 'configuration', 'mycroft.conf')): - return site - else: - return None - - -def search_mycroft_core_location(): - """Check python path (.venv), system packages and finally known mycroft - locations.""" - # downstream wants to override the root location - if _USER_DEFINED_ROOT: - return _USER_DEFINED_ROOT - # if we are in a .venv that should take precedence over everything else - if find_root_from_sitepackages(): - return find_root_from_sitepackages() - # if there is a system wide install that should take precedence over - # hardcoded locations - elif find_root_from_sys_path(): - return find_root_from_sys_path() - # finally look at default locations - for p in MycroftRootLocations: - if os.path.isdir(p): - return p - return None - +def check_service_installed(service_name, sudo=False, user=False) -> bool: + """ + Checks if a systemd service is installed using systemctl + @param service_name: name of service to check + @param user: pass --user flag when calling systemctl + @param sudo: use sudo when calling systemctl + @return: True if the service is installed, else False + """ + if not service_name.endswith('.service'): + service_name = f"{service_name}.service" + installed_base_command = f"systemctl list-unit-files -t service" + if user: + status_command = f"{installed_base_command} --user" + elif sudo: + status_command = f"sudo {installed_base_command}" + # Add a grep to the command + status_command = f"{status_command} | grep -i {service_name}" + state = subprocess.call(status_command, shell=True) + return state == 0 def get_desktop_environment(): # From http://stackoverflow.com/questions/2035657/what-is-my-current-desktop-environment @@ -306,9 +286,9 @@ def has_screen(): have_display = b"device_name=" in subprocess.check_output("tvservice -n 2>&1", shell=True) except Exception as e: pass - + # fallback check using matplotlib if available - # seems to be foolproof and OS agnostic + # seems to be foolproof and OS agnostic # but do not want to drag the dependency if not have_display: try: @@ -322,7 +302,6 @@ def has_screen(): pass return have_display - def module_property(func): """ Decorator to turn module functions into properties. @@ -345,4 +324,3 @@ def patched_getattr(name): module.__getattr__ = patched_getattr return func - diff --git a/ovos_utils/time.py b/ovos_utils/time.py index 28d2649f..0df363bd 100644 --- a/ovos_utils/time.py +++ b/ovos_utils/time.py @@ -1,7 +1,8 @@ from datetime import datetime -from dateutil.tz import gettz, tzlocal from typing import Any +from dateutil.tz import gettz, tzlocal + # used to calculate timespans DAYS_IN_1_YEAR = 365.2425 DAYS_IN_1_MONTH = 30.42 diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 4cbbbc84..316a5fd0 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,8 +1,7 @@ # The following lines are replaced during the release process. # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 0 -VERSION_BUILD = 38 -VERSION_POST = 0 +VERSION_MINOR = 1 +VERSION_BUILD = 0 VERSION_ALPHA = 0 # END_VERSION_BLOCK diff --git a/readme.md b/readme.md deleted file mode 100644 index fe8103d7..00000000 --- a/readme.md +++ /dev/null @@ -1,10 +0,0 @@ -# OVOS - utils - -collection of simple utilities for use across the mycroft ecosystem - -## Install - -```bash -pip install ovos_utils -``` - diff --git a/requirements/extras.txt b/requirements/extras.txt index 4f726695..60a6dc97 100644 --- a/requirements/extras.txt +++ b/requirements/extras.txt @@ -1,3 +1,5 @@ -rapidfuzz~=2.0 -ovos-bus-client < 0.1.0 -ovos-config < 0.1.0 \ No newline at end of file +rapidfuzz~=3.6 +ovos_plugin_manager>=0.0.25a2 +ovos-config>=0.0.12 +ovos-workshop>=0.0.13a22 +ovos-bus-client >=0.0.8a1 \ No newline at end of file diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 0c05ebaf..70a857a1 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,6 +1,10 @@ -pexpect~=4.6 +pexpect~=4.9 requests~=2.26 json_database~=0.7 kthread~=0.2 watchdog pyee +combo-lock~=0.2 +rich-click~=1.7 +rich~=13.7 +orjson \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 8c750061..00000000 --- a/requirements/test.txt +++ /dev/null @@ -1,3 +0,0 @@ -ovos_plugin_manager>=0.0.25a2 -ovos-config>=0.0.12a6 -ovos-workshop>=0.0.13a22 \ No newline at end of file diff --git a/setup.py b/setup.py index 4fd54b49..1f926149 100644 --- a/setup.py +++ b/setup.py @@ -51,18 +51,13 @@ def required(requirements_file): return [pkg for pkg in requirements if pkg.strip() and not pkg.startswith("#")] +with open("README.md", "r") as f: + long_description = f.read() setup( name='ovos_utils', version=get_version(), packages=['ovos_utils', - 'ovos_utils.intents', - 'ovos_utils.sound', - "ovos_utils.enclosure", - 'ovos_utils.enclosure.mark1', - 'ovos_utils.enclosure.mark1.eyes', - 'ovos_utils.enclosure.mark1.faceplate', - 'ovos_utils.skills', 'ovos_utils.lang'], url='https://github.com/OpenVoiceOS/ovos_utils', install_requires=required("requirements/requirements.txt"), @@ -73,6 +68,13 @@ def required(requirements_file): include_package_data=True, license='Apache', author='jarbasAI', - author_email='jarbasai@mailfence.com', - description='collection of simple utilities for use across the mycroft ecosystem' + author_email='jarbas@openvoiceos.com', + description='collection of simple utilities for use across the openvoiceos ecosystem', + long_description=long_description, + long_description_content_type="text/markdown", + entry_points={ + 'console_scripts': [ + 'ovos-logs=ovos_utils.log_parser:ovos_logs' + ] + } ) diff --git a/test/unittests/test_audio_utils.py b/test/unittests/test_audio_utils.py deleted file mode 100644 index f247979f..00000000 --- a/test/unittests/test_audio_utils.py +++ /dev/null @@ -1,173 +0,0 @@ - -from unittest import TestCase, mock - - -from ovos_utils.sound import (play_ogg, play_mp3, play_wav, play_audio, - record) -from ovos_utils.file_utils import get_temp_path - -test_config = { - 'play_wav_cmdline': 'mock_wav %1', - 'play_mp3_cmdline': 'mock_mp3 %1', - 'play_ogg_cmdline': 'mock_ogg %1' -} - - -class Anything: - """Class matching any object. - - Useful for assert_called_with arguments. - """ - def __eq__(self, other): - return True - - -@mock.patch('ovos_utils.sound.read_mycroft_config') -@mock.patch('ovos_utils.sound.subprocess') -class TestPlaySounds(TestCase): - def test_play_ogg(self, mock_subprocess, mock_conf): - mock_conf.return_value = test_config - play_ogg('insult.ogg') - mock_subprocess.Popen.assert_called_once_with(['mock_ogg', - 'insult.ogg'], - env=Anything()) - - @mock.patch('ovos_utils.sound.LOG') - def test_play_ogg_file_not_found(self, mock_log, - mock_subprocess, mock_conf): - """Test that simple log is raised when subprocess can't find command. - """ - def raise_filenotfound(*arg, **kwarg): - raise FileNotFoundError('TEST FILE NOT FOUND') - - mock_subprocess.Popen.side_effect = raise_filenotfound - mock_conf.return_value = test_config - self.assertEqual(play_ogg('insult.ogg'), None) - mock_log.error.called_once_with(Anything()) - - @mock.patch('ovos_utils.sound.LOG') - def test_play_ogg_exception(self, mock_log, - mock_subprocess, mock_conf): - """Test that stack trace is provided when unknown excpetion occurs""" - def raise_exception(*arg, **kwarg): - raise Exception - - mock_subprocess.Popen.side_effect = raise_exception - mock_conf.return_value = test_config - self.assertEqual(play_ogg('insult.ogg'), None) - mock_log.exception.called_once_with(Anything()) - - def test_play_mp3(self, mock_subprocess, mock_conf): - mock_conf.return_value = test_config - play_mp3('praise.mp3') - mock_subprocess.Popen.assert_called_once_with(['mock_mp3', - 'praise.mp3'], - env=Anything()) - - @mock.patch('ovos_utils.sound.LOG') - def test_play_mp3_file_not_found(self, mock_log, - mock_subprocess, mock_conf): - """Test that simple log is raised when subprocess can't find command. - """ - def raise_filenotfound(*arg, **kwarg): - raise FileNotFoundError('TEST FILE NOT FOUND') - - mock_subprocess.Popen.side_effect = raise_filenotfound - mock_conf.return_value = test_config - self.assertEqual(play_mp3('praise.mp3'), None) - mock_log.error.called_once_with(Anything()) - - @mock.patch('ovos_utils.sound.LOG') - def test_play_mp3_exception(self, mock_log, - mock_subprocess, mock_conf): - """Test that stack trace is provided when unknown excpetion occurs""" - def raise_exception(*arg, **kwarg): - raise Exception - - mock_subprocess.Popen.side_effect = raise_exception - mock_conf.return_value = test_config - self.assertEqual(play_mp3('praise.mp3'), None) - mock_log.exception.called_once_with(Anything()) - - def test_play_wav(self, mock_subprocess, mock_conf): - mock_conf.return_value = test_config - play_wav('indifference.wav') - mock_subprocess.Popen.assert_called_once_with(['mock_wav', - 'indifference.wav'], - env=Anything()) - - @mock.patch('ovos_utils.sound.LOG') - def test_play_wav_file_not_found(self, mock_log, - mock_subprocess, mock_conf): - """Test that simple log is raised when subprocess can't find command. - """ - def raise_filenotfound(*arg, **kwarg): - raise FileNotFoundError('TEST FILE NOT FOUND') - - mock_subprocess.Popen.side_effect = raise_filenotfound - mock_conf.return_value = test_config - self.assertEqual(play_wav('indifference.wav'), None) - mock_log.error.called_once_with(Anything()) - - @mock.patch('ovos_utils.sound.LOG') - def test_play_wav_exception(self, mock_log, - mock_subprocess, mock_conf): - """Test that stack trace is provided when unknown excpetion occurs""" - def raise_exception(*arg, **kwarg): - raise Exception - - mock_subprocess.Popen.side_effect = raise_exception - mock_conf.return_value = test_config - self.assertEqual(play_wav('indifference.wav'), None) - mock_log.exception.called_once_with(Anything()) - - def test_play_audio_file(self, mock_subprocess, mock_conf): - mock_conf.return_value = test_config - play_audio('indifference.wav') - mock_subprocess.Popen.assert_called_once_with(['mock_wav', - 'indifference.wav'], - env=Anything()) - mock_subprocess.Popen.reset_mock() - - play_audio('praise.mp3') - mock_subprocess.Popen.assert_called_once_with(['mock_mp3', - 'praise.mp3'], - env=Anything()) - mock_subprocess.Popen.reset_mock() - mock_conf.return_value = test_config - play_audio('insult.ogg') - mock_subprocess.Popen.assert_called_once_with(['mock_ogg', - 'insult.ogg'], - env=Anything()) - - -@mock.patch('ovos_utils.sound.subprocess') -class TestRecordSounds(TestCase): - def test_record_with_duration(self, mock_subprocess): - mock_proc = mock.Mock()(name='mock process') - mock_subprocess.Popen.return_value = mock_proc - rate = 16000 - channels = 1 - filename = get_temp_path('test.wav') - duration = 42 - res = record(filename, duration, rate, channels) - mock_subprocess.Popen.assert_called_once_with(['arecord', - '-r', str(rate), - '-c', str(channels), - '-d', str(duration), - filename]) - self.assertEqual(res, mock_proc) - - def test_record_without_duration(self, mock_subprocess): - mock_proc = mock.Mock(name='mock process') - mock_subprocess.Popen.return_value = mock_proc - rate = 16000 - channels = 1 - filename = get_temp_path('test.wav') - duration = 0 - res = record(filename, duration, rate, channels) - mock_subprocess.Popen.assert_called_once_with(['arecord', - '-r', str(rate), - '-c', str(channels), - filename]) - self.assertEqual(res, mock_proc) diff --git a/test/unittests/test_enclosure.py b/test/unittests/test_enclosure.py deleted file mode 100644 index 28963c3e..00000000 --- a/test/unittests/test_enclosure.py +++ /dev/null @@ -1,33 +0,0 @@ -import unittest -from unittest.mock import patch - -from ovos_utils.fakebus import FakeBus, Message - - -class TestEnclosureAPI(unittest.TestCase): - from ovos_utils.enclosure.api import EnclosureAPI - skill_id = "Enclosure Test" - bus = FakeBus() - api = EnclosureAPI(bus, skill_id) - # TODO: Test api methods - - @patch('ovos_utils.enclosure.api.dig_for_message') - def test_get_source_message(self, dig): - # No message in stack - dig.return_value = None - msg = self.api._get_source_message() - self.assertIsInstance(msg, Message) - self.assertEqual(msg.context["destination"], ["enclosure"]) - self.assertEqual(msg.context["skill_id"], self.skill_id) - - # With message in stack - test_message = Message("test", {"data": "something"}, - {"source": [""], "destination": [""]}) - dig.return_value = test_message - msg = self.api._get_source_message() - self.assertEqual(msg, test_message) - - -class TestMark1(unittest.TestCase): - # TODO Implement tests or move to separate PHAL plugin - pass diff --git a/test/unittests/test_events.py b/test/unittests/test_events.py index c8757695..18c5a5e1 100644 --- a/test/unittests/test_events.py +++ b/test/unittests/test_events.py @@ -155,7 +155,7 @@ def test_event_container(self): self.assertEqual(len(bus.ee.listeners("once_event")), 1) new_event = container.events[-1] self.assertEqual(new_event[0], "once_event") - self.assertNotEquals(new_event[1], handler) + self.assertNotEqual(new_event[1], handler) self.assertEqual(len(inspect.signature(new_event[1]).parameters), 1) # Test iterate events @@ -185,7 +185,7 @@ def test_event_container(self): class TestEventSchedulerInterface(unittest.TestCase): from ovos_utils.events import EventSchedulerInterface bus = FakeBus() - interface = EventSchedulerInterface(bus=bus, name="test") + interface = EventSchedulerInterface(bus=bus, skill_id="test") def test_00_init(self): from ovos_utils.events import EventContainer @@ -195,13 +195,9 @@ def test_00_init(self): self.assertEqual(self.interface.events.bus, self.bus) self.assertEqual(self.interface.scheduled_repeats, list()) - # Deprecated properties - self.assertEqual(self.interface.sched_id, self.interface.skill_id) - self.assertEqual(self.interface.name, self.interface.skill_id) - def test_set_bus(self): bus = FakeBus() - interface = self.EventSchedulerInterface(bus=bus, name="test") + interface = self.EventSchedulerInterface(bus=bus, skill_id="test") interface.set_bus(self.bus) self.assertEqual(interface.bus, self.bus) self.assertEqual(interface.events.bus, self.bus) diff --git a/test/unittests/test_gui.py b/test/unittests/test_gui.py deleted file mode 100644 index 77632d2b..00000000 --- a/test/unittests/test_gui.py +++ /dev/null @@ -1,450 +0,0 @@ -import unittest -from os.path import join, dirname, isfile -from threading import Event -from unittest.mock import patch, call, Mock - -from ovos_bus_client.message import Message - - -class TestGui(unittest.TestCase): - @patch("ovos_utils.gui.has_screen") - def test_can_display(self, has_screen): - from ovos_utils.gui import can_display - has_screen.return_value = False - self.assertFalse(can_display()) - has_screen.return_value = True - self.assertTrue(can_display()) - has_screen.return_value = None - self.assertFalse(can_display()) - - @patch("ovos_utils.gui.is_installed") - def test_is_gui_installed(self, is_installed): - from ovos_utils.gui import is_gui_installed - is_installed.return_value = False - self.assertFalse(is_gui_installed()) - is_installed.assert_has_calls([call("mycroft-gui-app"), - call("ovos-shell"), - call("mycroft-embedded-shell"), - call("plasmashell")]) - is_installed.return_value = True - self.assertTrue(is_gui_installed()) - is_installed.assert_called_with("mycroft-gui-app") - - # Test passed applications - self.assertTrue(is_gui_installed(["test"])) - is_installed.assert_called_with("test") - - @patch("ovos_utils.gui.is_process_running") - def test_is_gui_running(self, is_running): - from ovos_utils.gui import is_gui_running - is_running.return_value = False - self.assertFalse(is_gui_running()) - is_running.assert_has_calls([call("mycroft-gui-app"), - call("ovos-shell"), - call("mycroft-embedded-shell"), - call("plasmashell")]) - is_running.return_value = True - self.assertTrue(is_gui_running()) - is_running.assert_called_with("mycroft-gui-app") - - # Test passed applications - self.assertTrue(is_gui_running(["test"])) - is_running.assert_called_with("test") - - def test_is_gui_connected(self): - from ovos_utils.gui import is_gui_connected - # TODO - - def test_can_use_local_gui(self): - from ovos_utils.gui import can_use_local_gui - # TODO - - def test_can_use_gui(self): - from ovos_utils.gui import can_use_gui - # TODO - - def test_extend_about_data(self): - from ovos_utils.gui import extend_about_data - # TODO - - def test_gui_widgets(self): - from ovos_utils.gui import GUIWidgets - # TODO - - def test_gui_tracker(self): - from ovos_utils.gui import GUITracker - # TODO - - def test_gui_dict(self): - from ovos_utils.gui import _GUIDict - # TODO - - def test_get_ui_directories(self): - from ovos_utils.gui import get_ui_directories - test_dir = join(dirname(__file__), "test_ui") - - # gui dir (best practice) - dirs = get_ui_directories(test_dir) - self.assertEqual(dirs, {"all": join(test_dir, "gui")}) - - # ui and uid dirs (legacy) - dirs = get_ui_directories(join(test_dir, "legacy")) - self.assertEqual(dirs, {"qt5": join(test_dir, "legacy", "ui")}) - - -class TestGuiInterface(unittest.TestCase): - from ovos_utils.fakebus import FakeBus - from ovos_bus_client.apis.gui import GUIInterface - bus = FakeBus() - config = {"extension": "test"} - ui_base_dir = join(dirname(__file__), "test_ui") - ui_dirs = {'qt5': join(ui_base_dir, 'ui')} - iface_name = "test_interface" - - volunteered_upload = Mock() - bus.on('gui.volunteer_page_upload', volunteered_upload) - - interface = GUIInterface(iface_name, bus, None, config, ui_dirs) - - def test_00_gui_interface_init(self): - self.assertEqual(self.interface.config, self.config) - self.assertEqual(self.interface.bus, self.bus) - self.assertIsNone(self.interface.remote_url) - self.assertIsNone(self.interface.on_gui_changed_callback) - self.assertEqual(self.interface.ui_directories, self.ui_dirs) - self.assertEqual(self.interface.skill_id, self.iface_name) - self.assertIsNone(self.interface.page) - self.assertIsInstance(self.interface.connected, bool) - self.volunteered_upload.assert_called_once() - upload_message = self.volunteered_upload.call_args[0][0] - self.assertEqual(upload_message.data["skill_id"], self.iface_name) - - # Test GUI init with no ui directories - self.GUIInterface("no_ui_dirs_gui", self.bus, None, self.config) - self.volunteered_upload.assert_called_once_with(upload_message) - - def test_build_message_type(self): - name = "test" - self.assertEqual(self.interface.build_message_type(name), - f"{self.iface_name}.{name}") - - name = f"{self.iface_name}.{name}" - self.assertEqual(self.interface.build_message_type(name), name) - - def test_setup_default_handlers(self): - # TODO - pass - - def test_upload_gui_pages(self): - msg = Message("") - handled = Event() - - def on_pages(message): - nonlocal msg - msg = message - handled.set() - - self.bus.on('gui.page.upload', on_pages) - - # Upload default/legacy behavior (qt5 `ui` dir) - message = Message('test', {}, {'context': "Test"}) - self.interface.upload_gui_pages(message) - self.assertTrue(handled.wait(2)) - - self.assertEqual(msg.context['context'], message.context['context']) - self.assertEqual(msg.msg_type, "gui.page.upload") - self.assertEqual(msg.data['__from'], self.iface_name) - - pages = msg.data['pages'] - self.assertIsInstance(pages, dict) - for key, val in pages.items(): - self.assertIsInstance(key, str) - self.assertIsInstance(val, str) - - test_file_key = "test.qml" - self.assertEqual(bytes.fromhex(pages.get(test_file_key)), - b"Mock File Contents", pages) - - test_file_key = join("subdir", "test.qml") - self.assertEqual(bytes.fromhex(pages.get(test_file_key)), - b"Nested Mock", pages) - - # Upload all resources - handled.clear() - self.interface.ui_directories['all'] = join(dirname(__file__), - 'test_ui', 'gui') - message = Message('test', {"framework": "all"}, {'context': "All"}) - self.interface.upload_gui_pages(message) - self.assertTrue(handled.wait(2)) - - self.assertEqual(msg.context['context'], message.context['context']) - self.assertEqual(msg.msg_type, "gui.page.upload") - self.assertEqual(msg.data['__from'], self.iface_name) - - pages = msg.data['pages'] - self.assertIsInstance(pages, dict) - for key, val in pages.items(): - self.assertIsInstance(key, str) - self.assertIsInstance(val, str) - - self.assertEqual(bytes.fromhex(pages.get("qt5/test.qml")), - b"qt5", pages) - self.assertEqual(bytes.fromhex(pages.get("qt6/test.qml")), - b"qt6", pages) - - # Upload requested other skill - handled.clear() - message = Message('test', {"framework": "all", - "skill_id": "other_skill"}) - self.interface.upload_gui_pages(message) - self.assertFalse(handled.wait(2)) - - def test_register_handler(self): - # TODO - pass - - def test_set_on_gui_changed(self): - # TODO - pass - - def test_gui_set(self): - # TODO - pass - - def test_sync_data(self): - # TODO - pass - - def test_get(self): - # TODO - pass - - def test_clear(self): - # TODO - pass - - def test_send_event(self): - # TODO - pass - - @patch('ovos_bus_client.apis.gui.resolve_resource_file') - @patch('ovos_bus_client.apis.gui.resolve_ovos_resource_file') - def test_pages2uri(self, ovos_res, res): - def _resolve(name, config): - self.assertEqual(config, self.interface.config) - if name.startswith("ui/core"): - return f"res/{name}" - - def _ovos_resolve(name, extra_dirs): - self.assertEqual(extra_dirs, - list(self.interface.ui_directories.values())) - if name.startswith("ui/ovos"): - return f"ovos/{name}" - - # Mock actual resource resolution methods - ovos_res.side_effect = _ovos_resolve - res.side_effect = _resolve - - # remote_url is None - # OVOS Res - self.assertEqual(self.interface._pages2uri(["ui/ovos/test"]), - ["file://ovos/ui/ovos/test"]) - ovos_res.assert_called_once() - self.assertEqual(self.interface._pages2uri(["ovos/test"]), - ["file://ovos/ui/ovos/test"]) - res.assert_not_called() - # Core Res - self.assertEqual(self.interface._pages2uri(["ui/core/test"]), - ["file://res/ui/core/test"]) - res.assert_called_once() - self.assertEqual(self.interface._pages2uri(["core/test"]), - ["file://res/ui/core/test"]) - - def test_normalize_page_name(self): - legacy_name = "test.qml" - name_with_path = "subdir/test" - name_with_dot = "subdir/test.file" - self.assertEqual(self.interface._normalize_page_name(legacy_name), - "test") - self.assertEqual(self.interface._normalize_page_name(name_with_path), - "subdir/test") - self.assertEqual(self.interface._normalize_page_name(name_with_dot), - "subdir/test.file") - - def test_show_page(self): - real_show_pages = self.interface.show_pages - self.interface.show_pages = Mock() - - # Default args - self.interface.show_page("test") - self.interface.show_pages.assert_called_once_with(["test"], 0, - None, False) - - self.interface.show_page("test2", True, True) - self.interface.show_pages.assert_called_with(["test2"], 0, - True, True) - - self.interface.show_page("test3", 30, True) - self.interface.show_pages.assert_called_with(["test3"], 0, - 30, True) - self.interface.show_pages = real_show_pages - - def test_show_pages(self): - msg: Message = Message("") - handled = Event() - - def _gui_value_set(message): - self.assertEqual(message.data['__from'], self.interface.skill_id) - - def _gui_page_show(message): - nonlocal msg - msg = message - handled.set() - - self.bus.on('gui.value.set', _gui_value_set) - self.bus.on('gui.page.show', _gui_page_show) - - # Test resource absolute paths - file_base_dir = join(dirname(__file__), "test_ui", "ui") - files = [join(file_base_dir, "test.qml"), - join(file_base_dir, "subdir", "test.qml")] - self.interface.show_pages(files) - self.assertTrue(handled.wait(2)) - self.assertEqual(msg.msg_type, "gui.page.show") - for page in msg.data['page']: - self.assertTrue(page.startswith("file://")) - path = page.replace("file://", "") - self.assertTrue(isfile(path), page) - self.assertEqual(len(msg.data['page']), len(msg.data['page_names'])) - self.assertIsInstance(msg.data["index"], int) - self.assertEqual(msg.data['__from'], self.interface.skill_id) - self.assertIsNone(msg.data["__idle"]) - self.assertIsInstance(msg.data["__animations"], bool) - self.assertEqual(msg.data["ui_directories"], - self.interface.ui_directories) - - # Test resources resolved locally - handled.clear() - files = ["file.qml", "subdir/file.qml"] - index = 1 - override_idle = 30 - override_animations = True - self.interface.show_pages(files, index, override_idle, - override_animations) - self.assertTrue(handled.wait(2)) - self.assertEqual(msg.msg_type, "gui.page.show") - for page in msg.data['page']: - self.assertTrue(page.startswith("file://")) - path = page.replace("file://", "") - self.assertTrue(isfile(path), page) - self.assertEqual(msg.data["page_names"], ["file", "subdir/file"]) - self.assertEqual(msg.data["index"], index) - self.assertEqual(msg.data["__from"], self.interface.skill_id) - self.assertEqual(msg.data["__idle"], override_idle) - self.assertEqual(msg.data["__animations"], override_animations) - self.assertEqual(msg.data["ui_directories"], - self.interface.ui_directories) - - # Test resources not resolved locally - handled.clear() - files = ["file.qml", "other.page"] - index = 1 - override_idle = 30 - override_animations = True - self.interface.show_pages(files, index, override_idle, - override_animations) - self.assertTrue(handled.wait(2)) - self.assertEqual(msg.msg_type, "gui.page.show") - self.assertEqual(msg.data["page"], list()) - self.assertEqual(msg.data["page_names"], ["file", "other.page"]) - self.assertEqual(msg.data["index"], index) - self.assertEqual(msg.data["__from"], self.interface.skill_id) - self.assertEqual(msg.data["__idle"], override_idle) - self.assertEqual(msg.data["__animations"], override_animations) - self.assertEqual(msg.data["ui_directories"], - self.interface.ui_directories) - - def test_remove_page(self): - real_remove_pages = self.interface.remove_pages - self.interface.remove_pages = Mock() - self.interface.remove_page("test_page") - self.interface.remove_pages.assert_called_once_with(["test_page"]) - self.interface.remove_pages = real_remove_pages - - def test_remove_pages(self): - msg = Message("") - handled = Event() - - def _gui_page_delete(message): - nonlocal msg - msg = message - handled.set() - - self.bus.on("gui.page.delete", _gui_page_delete) - - # Test resolved page - pages = ["test.qml"] - self.interface.remove_pages(pages) - self.assertTrue(handled.wait(2)) - self.assertEqual(msg.msg_type, "gui.page.delete") - self.assertEqual(len(msg.data['page']), len(pages)) - for page in msg.data['page']: - self.assertTrue(page.startswith("file://")) - path = page.replace("file://", "") - self.assertTrue(isfile(path), page) - self.assertEqual(msg.data['page_names'], ["test"]) - self.assertEqual(msg.data['__from'], self.interface.skill_id) - - # Test unresolved pages - handled.clear() - pages = ['file.qml', 'dir/other.file'] - self.interface.remove_pages(pages) - self.assertTrue(handled.wait(2)) - self.assertEqual(msg.msg_type, "gui.page.delete") - self.assertEqual(msg.data['page'], []) - self.assertEqual(msg.data['page_names'], ["file", "dir/other.file"]) - self.assertEqual(msg.data['__from'], self.interface.skill_id) - - def test_show_notification(self): - # TODO - pass - - def test_show_controlled_notification(self): - # TODO - pass - - def test_remove_controlled_notification(self): - # TODO - pass - - def test_show_text(self): - # TODO - pass - - def test_show_image(self): - # TODO - pass - - def test_show_animated_image(self): - # TODO - pass - - def test_show_html(self): - # TODO - pass - - def test_show_url(self): - # TODO - pass - - def test_input_box(self): - # TODO - pass - - def test_release(self): - # TODO - pass - - def test_shutdown(self): - # TODO - pass diff --git a/test/unittests/test_intents.py b/test/unittests/test_intents.py deleted file mode 100644 index a98667ed..00000000 --- a/test/unittests/test_intents.py +++ /dev/null @@ -1,353 +0,0 @@ -import unittest -from threading import Event -from time import sleep -from unittest.mock import Mock - -from ovos_utils.fakebus import FakeBus - - -class TestIntent(unittest.TestCase): - from ovos_utils.intents import Intent - # TODO - - -class TestIntentBuilder(unittest.TestCase): - from ovos_utils.intents import IntentBuilder - # TODO - - -class TestAdaptIntent(unittest.TestCase): - from ovos_utils.intents import AdaptIntent - # TODO - pass - - -class TestConverse(unittest.TestCase): - from ovos_utils.intents.converse import ConverseTracker - # TODO - pass - - -class TestIntentServiceInterfaceFunctions(unittest.TestCase): - def test_to_alnum(self): - from ovos_utils.intents.intent_service_interface import to_alnum - test_alnum = "test_skill123" - self.assertEqual(test_alnum, to_alnum(test_alnum)) - test_dash = "test-skill123" - self.assertEqual(test_alnum, to_alnum(test_dash)) - test_slash = "test/skill123" - self.assertEqual(test_alnum, to_alnum(test_slash)) - - def test_munge_regex(self): - from ovos_utils.intents.intent_service_interface import munge_regex - skill_id = "test_skill" - non_regex = "just a string with no entity" - with_regex = "a string with this (?P.*)" - munged_regex = f"a string with this (?P<{skill_id}entity>.*)" - - self.assertEqual(non_regex, munge_regex(non_regex, skill_id)) - self.assertEqual(munged_regex, munge_regex(with_regex, skill_id)) - - def test_munge_intent_parser(self): - from ovos_utils.intents.intent_service_interface import \ - munge_intent_parser - # TODO - - def test_intent_query_api(self): - from ovos_utils.intents.intent_service_interface import IntentQueryApi - # TODO - - def test_open_intent_envelope(self): - pass - # TODO: Deprecated? - - -class TestIntentServiceInterface(unittest.TestCase): - from ovos_utils.intents.intent_service_interface import \ - IntentServiceInterface - bus = FakeBus() - intent_interface = IntentServiceInterface(bus) - test_id = "test_interface.test" - - def test_00_init(self): - self.assertEqual(self.intent_interface.bus, self.bus) - self.intent_interface._bus = None - self.assertIsNone(self.intent_interface._bus) - self.assertIsInstance(self.intent_interface.skill_id, str) - self.assertEqual(self.intent_interface.registered_intents, list()) - self.assertEqual(self.intent_interface.detached_intents, list()) - self.assertEqual(self.intent_interface.intent_names, list()) - - with self.assertRaises(RuntimeError): - _ = self.intent_interface.bus - self.intent_interface.set_bus(self.bus) - self.assertEqual(self.intent_interface.bus, self.bus) - - self.intent_interface.set_id(self.test_id) - self.assertEqual(self.intent_interface.skill_id, self.test_id) - - def test_register_adapt_keyword(self): - event = Event() - - register_vocab = Mock(side_effect=lambda x: event.set()) - - self.bus.on("register_vocab", register_vocab) - - # Test without aliases - event.clear() - self.intent_interface.register_adapt_keyword('test_intent', - 'test', lang='en-us') - self.assertTrue(event.wait(2)) - register_vocab.assert_called_once() - message = register_vocab.call_args[0][0] - self.assertEqual(message.msg_type, "register_vocab") - self.assertEqual(message.context, - {"skill_id": self.intent_interface.skill_id, - "session": message.context["session"]}) - data = message.data - self.assertEqual(data['entity_value'], 'test') - self.assertEqual(data['entity_type'], 'test_intent') - self.assertEqual(data['lang'], 'en-us') - - # Test with aliases - register_vocab.reset_mock() - event.clear() - self.intent_interface.register_adapt_keyword('test_intent', 'test', - ['test2', 'test3'], - 'en-us') - self.assertTrue(event.wait(2)) - while len(register_vocab.call_args_list) < 3: - # TODO: Better method to wait for all of the expected calls - sleep(0.2) - self.assertEqual(register_vocab.call_count, 3) - first_msg = register_vocab.call_args_list[0][0][0] - second_msg = register_vocab.call_args_list[1][0][0] - third_msg = register_vocab.call_args_list[2][0][0] - self.assertEqual(first_msg.serialize(), message.serialize()) - self.assertEqual(second_msg.context, message.context) - self.assertEqual(second_msg.data['entity_value'], 'test2') - self.assertEqual(second_msg.data['entity_type'], 'test_intent') - self.assertEqual(second_msg.data['lang'], 'en-us') - self.assertEqual(third_msg.context, message.context) - self.assertEqual(third_msg.data['entity_value'], 'test3') - self.assertEqual(third_msg.data['entity_type'], 'test_intent') - self.assertEqual(third_msg.data['lang'], 'en-us') - - self.bus.remove("register_vocab", register_vocab) - - def test_register_adapt_regex(self): - valid_regex = "(is|as) (?P.*)" - invalid_regex = "(is|as) no entity" - called = Event() - register_vocab = Mock(side_effect=lambda x: called.set()) - self.bus.on("register_vocab", register_vocab) - lang = "en-gb" - - # Test valid regex - self.intent_interface.register_adapt_regex(valid_regex, lang) - self.assertTrue(called.wait(2)) - message = register_vocab.call_args[0][0] - self.assertEqual(message.msg_type, "register_vocab") - self.assertEqual(message.data['regex'], valid_regex) - self.assertEqual(message.data['lang'], lang) - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - - # Test invalid regex (no validation in interface) - called.clear() - self.intent_interface.register_adapt_regex(invalid_regex, lang) - self.assertTrue(called.wait(2)) - message = register_vocab.call_args[0][0] - self.assertEqual(message.msg_type, "register_vocab") - self.assertEqual(message.data['regex'], invalid_regex) - self.assertEqual(message.data['lang'], lang) - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - - self.bus.remove("register_vocab", register_vocab) - - def test_register_adapt_intent(self): - mock_adapt_intent = Mock() - mock_intent_dict = {"name": "test_intent", - "requires": ['required_word'], - "at_least_one": ['opt1', 'opt2'], - "optional": []} - mock_adapt_intent.__dict__ = mock_intent_dict - - event = Event() - register_intent = Mock(side_effect=lambda x: event.set()) - self.bus.on("register_intent", register_intent) - - # Test register intent same name - self.intent_interface.register_adapt_intent("test_intent", - mock_adapt_intent) - self.assertTrue(event.wait(2)) - message = register_intent.call_args[0][0] - self.assertEqual(message.msg_type, "register_intent") - self.assertEqual(message.data, mock_intent_dict) - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - self.assertEqual(self.intent_interface.registered_intents[-1], - ("test_intent", mock_adapt_intent)) - self.assertNotIn(("test_intent", mock_adapt_intent), - self.intent_interface.detached_intents) - - # Test register intent different name no longer detached - event.clear() - self.intent_interface.detached_intents.append(("test_detached", - mock_intent_dict)) - self.intent_interface.register_adapt_intent("test_detached", - mock_adapt_intent) - self.assertTrue(event.wait(2)) - message = register_intent.call_args[0][0] - self.assertEqual(message.msg_type, "register_intent") - self.assertEqual(message.data, mock_intent_dict) - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - self.assertEqual(self.intent_interface.registered_intents[-1], - ("test_detached", mock_adapt_intent)) - self.assertNotIn(("test_detached", mock_adapt_intent), - self.intent_interface.detached_intents) - - self.bus.remove("register_intent", register_intent) - - def test_remove_intent(self): - test_name = "remove_intent" - test_data = {"name": "remove"} - - event = Event() - handle_detach = Mock(side_effect=lambda x: event.set()) - self.bus.on("detach_intent", handle_detach) - - # Test valid remove intent - self.intent_interface.registered_intents.append((test_name, test_data)) - self.intent_interface.registered_intents.append((test_name, test_data)) - self.intent_interface.remove_intent(test_name) - self.assertTrue(event.wait(2)) - handle_detach.assert_called_once() - self.assertNotIn((test_name, test_data), - self.intent_interface.registered_intents) - self.assertIn((test_name, test_data), - self.intent_interface.detached_intents) - message = handle_detach.call_args[0][0] - self.assertEqual(message.msg_type, "detach_intent") - self.assertEqual(message.data['intent_name'], - f"{self.intent_interface.skill_id}:{test_name}") - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - - # Test invalid remove intent (no change) - event.clear() - self.intent_interface.remove_intent(test_name) - self.assertTrue(event.wait(2)) - self.assertEqual(handle_detach.call_count, 2) - new_message = handle_detach.call_args[0][0] - self.assertEqual(message.serialize(), new_message.serialize()) - - self.bus.remove("detach_intent", handle_detach) - - def test_intent_is_detached(self): - detached_name = "is_detached" - detached_mock = Mock() - self.intent_interface.detached_intents.append((detached_name, - detached_mock)) - self.assertTrue(self.intent_interface.intent_is_detached(detached_name)) - self.assertFalse(self.intent_interface.intent_is_detached("not_detach")) - - def test_set_adapt_context(self): - context = "ctx_key" - word = "ctx_val" - origin = "origin" - - event = Event() - add_context = Mock(side_effect=lambda x: event.set()) - self.bus.on("add_context", add_context) - - self.intent_interface.set_adapt_context(context, word, origin) - self.assertTrue(event.wait(2)) - add_context.assert_called_once() - message = add_context.call_args[0][0] - self.assertEqual(message.msg_type, "add_context") - self.assertEqual(message.data, {"context": context, - "word": word, - "origin": origin}) - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - - self.bus.remove("add_context", add_context) - - def test_remove_adapt_context(self): - context = "ctx_key" - - event = Event() - remove_context = Mock(side_effect=lambda x: event.set()) - self.bus.on("remove_context", remove_context) - - self.intent_interface.remove_adapt_context(context) - self.assertTrue(event.wait(2)) - remove_context.assert_called_once() - message = remove_context.call_args[0][0] - self.assertEqual(message.msg_type, "remove_context") - self.assertEqual(message.data, {"context": context}) - self.assertEqual(message.context['skill_id'], - self.intent_interface.skill_id) - - self.bus.remove("remove_context", remove_context) - - def test_register_padatious_intent(self): - from pathlib import Path - intent_name = "test" - lang = "en-us" - with self.assertRaises(ValueError): - self.intent_interface.register_padatious_intent(intent_name, Path(), - lang) - with self.assertRaises(FileNotFoundError): - self.intent_interface.register_padatious_intent(intent_name, - "/test", lang) - - # TODO - - def test_register_padatious_entity(self): - from pathlib import Path - intent_name = "test" - lang = "en-us" - with self.assertRaises(ValueError): - self.intent_interface.register_padatious_entity(intent_name, Path(), - lang) - with self.assertRaises(FileNotFoundError): - self.intent_interface.register_padatious_entity(intent_name, - "/test", lang) - - # TODO - - def test_detach_all(self): - # TODO - pass - - def test_get_intent(self): - valid_intent = Mock() - disabled_intent = Mock() - self.intent_interface.registered_intents.append(("get_valid", - valid_intent)) - self.intent_interface.detached_intents.append(("get_disabled", - disabled_intent)) - self.assertEqual(self.intent_interface.get_intent("get_valid"), - valid_intent) - self.assertEqual(self.intent_interface.get_intent("get_disabled"), - disabled_intent) - self.assertIsNone(self.intent_interface.get_intent("invalid_intent")) - - def test_iter(self): - self.intent_interface.registered_intents.append(("test_iter", Mock())) - intents = [] - for intent in self.intent_interface: - self.assertIn(intent, self.intent_interface.registered_intents) - intents.append(intent) - self.assertEqual(len(intents), - len(self.intent_interface.registered_intents)) - - def test_contains(self): - test_intent = ("test_contains", Mock()) - self.intent_interface.registered_intents.append(test_intent) - self.assertTrue(test_intent[0] in self.intent_interface) - self.assertFalse("test_not_contains" in self.intent_interface) diff --git a/test/unittests/test_log.py b/test/unittests/test_log.py index 751f96ae..f816cf1b 100644 --- a/test/unittests/test_log.py +++ b/test/unittests/test_log.py @@ -17,6 +17,7 @@ def tearDownClass(cls) -> None: def test_log(self): import ovos_utils.log + importlib.reload(ovos_utils.log) from ovos_utils.log import LOG # Default log config self.assertEqual(LOG.base_path, "stdout") @@ -60,9 +61,76 @@ def test_log(self): self.assertEqual(len(lines), 1) self.assertTrue(lines[0].endswith("This will print\n")) - def test_init_service_logger(self): - from ovos_utils.log import init_service_logger - # TODO + # Init with backup + test_config['max_bytes'] = 2 + test_config['backup_count'] = 1 + test_config['level'] = 'INFO' + LOG.init(test_config) + LOG.name = "rotate" + LOG.info("first") + LOG.info("second") + LOG.debug("third") + log_1 = join(LOG.base_path, f"{LOG.name}.log.1") + log = join(LOG.base_path, f"{LOG.name}.log") + + # Log rotated once + with open(log_1) as f: + lines = f.readlines() + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].endswith("first\n")) + with open(log) as f: + lines = f.readlines() + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].endswith("second\n")) + + LOG.info("fourth") + # Log rotated again + with open(log_1) as f: + lines = f.readlines() + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].endswith("second\n")) + with open(log) as f: + lines = f.readlines() + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].endswith("fourth\n")) + + # Multiple log rotations within a short period of time + for i in range(100): + LOG.info(str(i)) + with open(log_1) as f: + lines = f.readlines() + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].endswith("98\n")) + with open(log) as f: + lines = f.readlines() + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].endswith("99\n")) + + @patch("ovos_utils.log.get_logs_config") + @patch("ovos_config.Configuration.set_config_watcher") + def test_init_service_logger(self, set_config_watcher, log_config): + from ovos_utils.log import init_service_logger, LOG + + # Test log init with default config + log_config.return_value = dict() + LOG.level = "ERROR" + init_service_logger("default") + from ovos_utils.log import LOG + set_config_watcher.assert_called_once() + self.assertEqual(LOG.name, "default") + self.assertEqual(LOG.level, "ERROR") + + # Test log init with config + set_config_watcher.reset_mock() + log_config.return_value = {"path": self.test_dir, + "level": "DEBUG"} + init_service_logger("configured") + from ovos_utils.log import LOG + set_config_watcher.assert_called_once() + self.assertEqual(LOG.name, "configured") + self.assertEqual(LOG.level, "DEBUG") + LOG.debug("This will print") + self.assertTrue(isfile(join(self.test_dir, "configured.log"))) @patch("ovos_utils.log.LOG.create_logger") def test_log_deprecation(self, create_logger): @@ -115,3 +183,123 @@ def _deprecated_function(test_arg): log_msg = log_warning.call_args[0][0] self.assertIn('version=1.0.0', log_msg, log_msg) self.assertIn('test deprecation', log_msg, log_msg) + + @patch("ovos_utils.log.get_logs_config") + @patch("ovos_utils.log.LOG") + def test_monitor_log_level(self, log, get_config): + from ovos_utils.log import _monitor_log_level + + log.name = "TEST" + get_config.return_value = {"changed": False} + + _monitor_log_level() + get_config.assert_called_once_with("TEST") + log.init.assert_called_once_with(get_config.return_value) + log.info.assert_called_once() + + # Callback with no change + _monitor_log_level() + self.assertEqual(get_config.call_count, 2) + log.init.assert_called_once_with(get_config.return_value) + log.info.assert_called_once() + + # Callback with change + get_config.return_value["changed"] = True + _monitor_log_level() + self.assertEqual(get_config.call_count, 3) + self.assertEqual(log.init.call_count, 2) + log.init.assert_called_with(get_config.return_value) + + def test_get_logs_config(self): + from ovos_utils.log import get_logs_config + valid_config = {"level": "DEBUG", + "path": self.test_dir, + "max_bytes": 1000, + "backup_count": 2, + "diagnostic": False} + valid_config_2 = {"max_bytes": 100000, + "diagnostic": True} + logs_config = {"path": self.test_dir, + "max_bytes": 1000, + "backup_count": 2, + "diagnostic": False} + legacy_config = {"log_level": "DEBUG", + "logs": logs_config} + + logging_config = {"logging": {"log_level": "DEBUG", + "logs": logs_config, + "test_service": {"log_level": "WARNING", + "logs": valid_config_2} + } + } + + # Test original config with `logs` section and no `logging` section + self.assertEqual(get_logs_config("", legacy_config), valid_config) + + # Test `logging.logs` config with no service config + self.assertEqual(get_logs_config("service", logging_config), valid_config) + + # Test `logging.logs` config with `logging.` overrides + expected_config = {**valid_config_2, **{"level": "WARNING"}} + self.assertEqual(get_logs_config("test_service", logging_config), + expected_config) + + # Test `logs` config with `logging.` overrides + logging_config["logs"] = logging_config["logging"].pop("logs") + self.assertEqual(get_logs_config("test_service", logging_config), + expected_config) + + # Test `logging.` config with no `logs` or `logging.logs` + logging_config["logging"].pop("log_level") + logging_config.pop("logs") + self.assertEqual(get_logs_config("test_service", logging_config), + expected_config) + + @patch("ovos_utils.log.get_logs_config") + def test_get_log_path(self, get_config): + from ovos_utils.log import get_log_path + + real_log_path = join(dirname(__file__), "test_logs") + test_paths = [self.test_dir, dirname(__file__), real_log_path] + + # Test with multiple populated directories + self.assertEqual(get_log_path("real", test_paths), real_log_path) + self.assertIsNone(get_log_path("fake", test_paths)) + + # Test path from configuration + get_config.return_value = {"path": self.test_dir} + self.assertEqual(get_log_path("test"), self.test_dir) + get_config.assert_called_once_with(service_name="test") + + @patch('ovos_config.Configuration') + def test_get_log_paths(self, config): + from ovos_utils.log import get_log_paths + + config_no_modules = {"logging": {"logs": {"path": "default_path"}}} + + # Test default config path from Configuration (no module overrides) + config.return_value = config_no_modules + self.assertEqual(get_log_paths(), {"default_path"}) + + # Test services with different configured paths + config_multi_modules = {"logging": {"logs": {"path": "default_path"}, + "module_1": {"path": "path_1"}, + "module_2": {"path": "path_2"}, + "module_3": {"path": "path_1"}}} + self.assertEqual(get_log_paths(config_multi_modules), + {"default_path", "path_1", "path_2"}) + + + @patch('ovos_utils.log.get_log_paths') + def test_get_available_logs(self, get_log_paths): + from ovos_utils.log import get_available_logs + + # Test with specified directories containing logs and other files + real_log_path = join(dirname(__file__), "test_logs") + get_log_paths.return_value = [dirname(__file__), real_log_path] + self.assertEqual(get_available_logs(), ["real"]) + + # Test with no log directories + self.assertEqual(get_available_logs([dirname(__file__)]), []) + get_log_paths.return_value = [] + self.assertEqual(get_available_logs(), []) diff --git a/test/unittests/test_logs/real.log b/test/unittests/test_logs/real.log new file mode 100644 index 00000000..e69de29b diff --git a/test/unittests/test_ocp_media.py b/test/unittests/test_ocp_media.py new file mode 100644 index 00000000..13c6fa18 --- /dev/null +++ b/test/unittests/test_ocp_media.py @@ -0,0 +1,237 @@ +import unittest + +from ovos_utils.ocp import MediaEntry, Playlist, MediaType, PlaybackType, TrackState + +dict_search_results = [ + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', + 'title': 'Orbiting A Distant Planet', + 'artist': 'Quantum Jazz', + 'skill_id': 'skill-free_music_archive.neongeckocom', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', + 'skill_id': 'skill-free_music_archive.neongeckocom', + 'title': 'Passing Fields', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_id': 'skill-free_music_archive.neongeckocom', + 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', + 'title': 'All About The Sun', + 'artist': 'Quantum Jazz', + 'match_confidence': 65} +] + +search_results = [ + MediaEntry(media_type=MediaType.MUSIC, + playback=PlaybackType.AUDIO, + image='https://freemusicarchive.org/legacy/fma-smaller.jpg', + uri='https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', + skill_icon='https://freemusicarchive.org/legacy/fma-smaller.jpg', + title='Orbiting A Distant Planet', + artist='Quantum Jazz', + skill_id='skill-free_music_archive.neongeckocom', + match_confidence=65 + ), + MediaEntry(media_type=MediaType.MUSIC, + playback=PlaybackType.AUDIO, + image='https://freemusicarchive.org/legacy/fma-smaller.jpg', + uri='https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', + skill_icon='https://freemusicarchive.org/legacy/fma-smaller.jpg', + title='Passing Fields', + artist='Quantum Jazz', + skill_id='skill-free_music_archive.neongeckocom', + match_confidence=65), + MediaEntry(media_type=MediaType.MUSIC, + playback=PlaybackType.AUDIO, + image='https://freemusicarchive.org/legacy/fma-smaller.jpg', + uri='https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', + skill_icon='https://freemusicarchive.org/legacy/fma-smaller.jpg', + title='All About The Sun', + artist='Quantum Jazz', + skill_id='skill-free_music_archive.neongeckocom', + match_confidence=65) +] + + +class TestMediaEntry(unittest.TestCase): + def test_init(self): + data = dict_search_results[0] + + # Test MediaEntry init + entry = MediaEntry(**data) + self.assertEqual(entry.title, data['title']) + self.assertEqual(entry.uri, data['uri']) + self.assertEqual(entry.artist, data['artist']) + self.assertEqual(entry.skill_id, data['skill_id']) + self.assertEqual(entry.status, TrackState.DISAMBIGUATION) + self.assertEqual(entry.playback, data['playback']) + self.assertEqual(entry.image, data['image']) + self.assertEqual(entry.skill_icon, data['skill_icon']) + self.assertEqual(entry.javascript, "") + + # Test playback passed as int + data['playback'] = int(data['playback']) + new_entry = MediaEntry(**data) + self.assertEqual(entry, new_entry) + + def test_as_dict(self): + for idx, track in enumerate(search_results): + for k, v in track.as_dict.items(): + if k in dict_search_results[idx]: + self.assertEqual(v, dict_search_results[idx][k]) + + for idx, track in enumerate(dict_search_results): + self.assertEqual(MediaEntry.from_dict(track), search_results[idx]) + + def test_from_dict(self): + dict_data = dict_search_results[1] + from_dict = MediaEntry.from_dict(dict_data) + self.assertIsInstance(from_dict, MediaEntry) + from_init = MediaEntry(dict_data["uri"], dict_data["title"], + image=dict_data["image"], + match_confidence=dict_data["match_confidence"], + playback=PlaybackType.AUDIO, + skill_icon=dict_data["skill_icon"], + skill_id=dict_data["skill_id"], + media_type=dict_data["media_type"], + artist=dict_data["artist"]) + self.assertEqual(from_init, from_dict) + + # Test int playback + dict_data['playback'] = int(dict_data['playback']) + new_entry = MediaEntry.from_dict(dict_data) + self.assertEqual(from_dict, new_entry) + + self.assertIsInstance(MediaEntry.from_dict({"uri": "xxx"}), MediaEntry) + + +class TestPlaylist(unittest.TestCase): + def test_properties(self): + # Empty Playlist + pl = Playlist(title="empty playlist") + self.assertEqual(pl.title, "empty playlist") + self.assertEqual(pl.position, 0) + self.assertEqual(pl.entries, []) + self.assertIsNone(pl.current_track) + self.assertTrue(pl.is_first_track) + self.assertTrue(pl.is_last_track) + + # Playlist of dicts + pl = Playlist(dict_search_results, title="my playlist") + self.assertEqual(pl.title, "my playlist") + self.assertEqual(pl.position, 0) + self.assertEqual(len(pl), len(dict_search_results)) + self.assertEqual(len(pl.entries), len(dict_search_results)) + for entry in pl.entries: + self.assertIsInstance(entry, MediaEntry) + self.assertIsInstance(pl.current_track, MediaEntry) + self.assertTrue(pl.is_first_track) + self.assertFalse(pl.is_last_track) + + # Playlist of MediaEntry + pl = Playlist(search_results, title="Test Jazz") + self.assertEqual(pl.title, "Test Jazz") + self.assertEqual(pl.position, 0) + self.assertEqual(len(pl), len(search_results)) + self.assertEqual(len(pl.entries), len(search_results)) + for entry in pl.entries: + self.assertIsInstance(entry, MediaEntry) + self.assertIsInstance(pl.current_track, MediaEntry) + self.assertTrue(pl.is_first_track) + self.assertFalse(pl.is_last_track) + + self.assertListEqual(pl.entries, search_results) + for idx, e in enumerate(pl.as_dict["playlist"]): + self.assertEqual(MediaEntry.from_dict(e), search_results[idx]) + + # test serialize/deserialize + self.assertEqual(Playlist.from_dict(pl.as_dict), pl) + + def test_goto_start(self): + # TODO + pass + + def test_clear(self): + # TODO + pass + + def test_sort_by_conf(self): + # TODO + pass + + def test_add_entry(self): + # TODO + pass + + def test_remove_entry(self): + # TODO + pass + + def test_replace(self): + # TODO + pass + + def test_set_position(self): + # TODO + pass + + def test_goto_track(self): + # TODO + pass + + def test_next_track(self): + # TODO + pass + + def test_prev_track(self): + # TODO + pass + + def test_validate_position(self): + # Test empty playlist + pl = Playlist() + pl.position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl.position = -1 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl.position = 1 + pl._validate_position() + self.assertEqual(pl.position, 0) + + # Test playlist of len 1 + pl = Playlist([dict_search_results[0]]) + pl.position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl.position = 1 + pl._validate_position() + self.assertEqual(pl.position, 0) + + # Test playlist of len>1 + pl = Playlist(dict_search_results) + pl.position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl.position = 1 + pl._validate_position() + self.assertEqual(pl.position, 1) + pl.position = 10 + pl._validate_position() + self.assertEqual(pl.position, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_skills.py b/test/unittests/test_skills.py deleted file mode 100644 index 2b698f45..00000000 --- a/test/unittests/test_skills.py +++ /dev/null @@ -1,225 +0,0 @@ -import unittest -from os import environ -from os.path import isdir, join, dirname, basename -from unittest.mock import patch - -from ovos_utils.fakebus import FakeBus, Message -from ovos_utils.skills.locations import get_skill_directories -from ovos_utils.skills.locations import get_default_skills_directory -from ovos_utils.skills.locations import get_installed_skill_ids -from ovos_utils.skills.locations import get_plugin_skills - -try: - import ovos_config -except ImportError: - ovos_config = None - - -def _api_method_1(message: Message) -> str: - return message.serialize() - - -def _api_method_2(**kwargs) -> int: - return len(kwargs) - - -class TestSkills(unittest.TestCase): - def test_get_non_properties(self): - from ovos_utils.skills import get_non_properties - # TODO - - def test_skills_loaded(self): - from ovos_utils.skills import skills_loaded - # TODO - - @patch("ovos_utils.skills.update_mycroft_config") - def test_blacklist_skill(self, update_config): - from ovos_utils.skills import blacklist_skill - # TODO - - @patch("ovos_utils.skills.update_mycroft_config") - def test_whitelist_skill(self, update_config): - from ovos_utils.skills import whitelist_skill - # TODO - - -class TestAudioservice(unittest.TestCase): - def test_ensure_uri(self): - from ovos_utils.skills.audioservice import ensure_uri - valid_uri = "file:///test" - non_uri = "/test" - # rel_uri = "test" - self.assertEqual(ensure_uri(valid_uri), valid_uri) - self.assertEqual(ensure_uri(non_uri), valid_uri) - # TODO: Relative path is relative to method and not caller? - # self.assertEqual(ensure_uri(rel_uri), - # f"file://{join(dirname(__file__), 'test')}") - - def test_classic_audio_service_interface(self): - from ovos_utils.skills.audioservice import ClassicAudioServiceInterface - # TODO - - def test_audio_service_interface(self): - from ovos_utils.skills.audioservice import AudioServiceInterface - # TODO - - def test_ocp_interface(self): - from ovos_utils.skills.audioservice import OCPInterface - # TODO - - -class TestLocations(unittest.TestCase): - @patch("ovos_plugin_manager.skills.get_plugin_skills") - def test_get_installed_skill_ids(self, plugins): - plugins.return_value = (['plugin_dir', 'plugin_dir_2'], - ['plugin_id', 'plugin_id_2']) - environ["XDG_DATA_DIRS"] = join(dirname(__file__), "test_skills_xdg") - config = {"skills": { - "extra_directories": [join(dirname(__file__), "test_skills_dir")] - }} - skill_ids = get_installed_skill_ids(config) - self.assertEqual(set(skill_ids), {"plugin_id", "plugin_id_2", - "skill-test-1.openvoiceos", - "skill-test-2.openvoiceos"}) - - def test_get_skill_directories(self): - if not ovos_config: - return # skip test since ovos.conf isn't taken into account - - # Default behavior, only one valid XDG path - environ["XDG_DATA_DIRS"] = environ["XDG_DATA_HOME"] = \ - join(dirname(__file__), "test_skills_xdg") - config = {"skills": {"extra_directories": []}} - default_dir = join(dirname(__file__), "test_skills_xdg", - "mycroft", "skills") - self.assertEqual(get_skill_directories(config), [default_dir]) - - # Define single extra directory to append - extra_dir = join(dirname(__file__), "test_skills_dir") - config['skills']['extra_directories'] = [extra_dir] - self.assertEqual(get_skill_directories(config), - [default_dir, extra_dir]) - - # Define duplicated directories in extra_directories - config['skills']['extra_directories'] += [extra_dir, default_dir] - self.assertEqual(get_skill_directories(config), - [default_dir, extra_dir]) - - # Define invalid directories in extra_directories - config['skills']['extra_directories'] += ["/not/a/directory"] - self.assertEqual(get_skill_directories(config), - [default_dir, extra_dir]) - - # Default directory - mock_config = {'skills': {}} - default_directories = get_skill_directories(mock_config) - for directory in default_directories: - self.assertEqual(basename(directory), 'skills') - # Configured directory - mock_config['skills']['directory'] = 'test' - test_directories = get_skill_directories(mock_config) - for directory in test_directories: - self.assertEqual(basename(directory), 'test') - self.assertEqual(len(default_directories), len(test_directories)) - # Extra directory - extra_dir = join(dirname(__file__), 'skills') - mock_config['skills']['extra_directories'] = [extra_dir] - extra_directories = get_skill_directories(mock_config) - self.assertEqual(extra_directories[-1], extra_dir) - for directory in test_directories: - self.assertIn(directory, extra_directories) - - def test_get_default_skills_directory(self): - if not ovos_config: - return # skip test since ovos.conf isn't taken into account - test_skills_dir = join(dirname(__file__), "test_skills_dir") - - # Configured override (legacy) - config = {"skills": {"directory_override": test_skills_dir}} - self.assertEqual(get_default_skills_directory(config), test_skills_dir) - - # Configured extra_directories - config = {"skills": {"extra_directories": [test_skills_dir, "/tmp"]}} - self.assertEqual(get_default_skills_directory(config), test_skills_dir) - - environ["XDG_DATA_HOME"] = join(dirname(__file__), "test_skills_xdg") - xdg_skills_dir = join(dirname(__file__), "test_skills_xdg", - "mycroft", "skills") - # XDG (undefined extra_directories) - config = {"skills": {}} - self.assertEqual(get_default_skills_directory(config), xdg_skills_dir) - - # XDG (empty extra_directories) - config = {"skills": {"extra_directories": []}} - self.assertEqual(get_default_skills_directory(config), xdg_skills_dir) - - # Default directory - mock_config = {'skills': {}} - default_dir = get_default_skills_directory(mock_config) - self.assertTrue(isdir(default_dir)) - self.assertEqual(basename(default_dir), 'skills') - self.assertEqual(dirname(dirname(default_dir)), - join(dirname(__file__), "test_skills_xdg")) - # Override directory - mock_config['skills']['directory'] = 'test' - test_dir = get_default_skills_directory(mock_config) - self.assertTrue(isdir(test_dir)) - self.assertEqual(basename(test_dir), 'test') - self.assertEqual(dirname(dirname(test_dir)), - join(dirname(__file__), "test_skills_xdg")) - - def test_get_plugin_skills(self): - dirs, ids = get_plugin_skills() - for d in dirs: - self.assertTrue(isdir(d)) - for s in ids: - self.assertIsInstance(s, str) - self.assertEqual(len(dirs), len(ids)) - - -class TestSkillApi(unittest.TestCase): - from ovos_utils.skills.api import SkillApi - bus = FakeBus() - SkillApi.connect_bus(bus) - - def test_skill_api_init(self): - from ovos_utils.skills.api import SkillApi - test_api = SkillApi({"serialize": {'help': '', - 'type': 'test._api_method_1'}, - "get_length": {'help': '', - 'type': 'test._api_method_2'}}) - self.assertEqual(test_api.bus, self.bus) - self.assertEqual(SkillApi.bus, self.bus) - self.assertIsNotNone(test_api.serialize) - self.assertIsNotNone(test_api.get_length) - self.assertTrue(callable(test_api.serialize)) - self.assertTrue(callable(test_api.get_length)) - - def test_skill_api_get(self): - from ovos_utils.skills.api import SkillApi - - def _valid_public_api(message): - self.bus.emit(message.response( - {"serialize": {'help': '', 'type': 'test._api_method_1'}, - "get_length": {'help': '', 'type': 'test._api_method_2'}})) - - self.bus.on("test_skill.public_api", _valid_public_api) - - # Test get valid API - api = SkillApi.get("test_skill") - self.assertIsInstance(api, SkillApi) - self.assertTrue(callable(api.serialize)) - self.assertTrue(callable(api.get_length)) - - # Test second API - api2 = SkillApi.get("test_skill") - self.assertEqual(api.method_dict, api2.method_dict) - - # Test invalid API - self.assertIsNone(SkillApi.get("other_skill")) - - # Test get without bus - SkillApi.bus = None - with self.assertRaises(RuntimeError): - SkillApi.get("test_skill") - SkillApi.connect_bus(self.bus) diff --git a/test/unittests/test_sound.py b/test/unittests/test_sound.py index b95e25c4..fca73be9 100644 --- a/test/unittests/test_sound.py +++ b/test/unittests/test_sound.py @@ -8,22 +8,6 @@ def test_get_pulse_environment(self): from ovos_utils.sound import _get_pulse_environment # TODO - def test_play_acknowledge_sound(self): - from ovos_utils.sound import play_acknowledge_sound - # TODO - - def test_play_listening_sound(self): - from ovos_utils.sound import play_listening_sound - # TODO - - def test_play_end_listening_sound(self): - from ovos_utils.sound import play_end_listening_sound - # TODO - - def test_play_error_sound(self): - from ovos_utils.sound import play_error_sound - # TODO - def test_find_player(self): from ovos_utils.sound import _find_player # TODO @@ -32,26 +16,3 @@ def test_play_audio(self): from ovos_utils.sound import play_audio # TODO - def test_play_wav(self): - from ovos_utils.sound import play_wav - # TODO - - def test_play_mp3(self): - from ovos_utils.sound import play_wav - # TODO - - def test_play_ogg(self): - from ovos_utils.sound import play_ogg - # TODO - - def test_record(self): - from ovos_utils.sound import record - # TODO - - def test_is_speaking(self): - from ovos_utils.sound import is_speaking - # TODO - - def test_wait_while_speaking(self): - from ovos_utils.sound import wait_while_speaking - # TODO diff --git a/test/unittests/test_system.py b/test/unittests/test_system.py index 4644731d..9cd1543d 100644 --- a/test/unittests/test_system.py +++ b/test/unittests/test_system.py @@ -8,10 +8,6 @@ def test_is_running_from_module(self): self.assertFalse(is_running_from_module("mycroft")) self.assertTrue(is_running_from_module("unittest")) - def test_ntp_sync(self): - # TODO - pass - @patch("subprocess.Popen") def test_system_shutdown(self, popen): from ovos_utils.system import system_shutdown @@ -56,30 +52,6 @@ def test_check_service_active(self): # TODO pass - def test_set_root_path(self): - from ovos_utils.system import set_root_path - set_root_path("test") - from ovos_utils.system import _USER_DEFINED_ROOT - self.assertEqual(_USER_DEFINED_ROOT, "test") - set_root_path("mycroft") - from ovos_utils.system import _USER_DEFINED_ROOT - self.assertEqual(_USER_DEFINED_ROOT, "mycroft") - set_root_path(None) - from ovos_utils.system import _USER_DEFINED_ROOT - self.assertIsNone(_USER_DEFINED_ROOT) - - def test_find_root_from_sys_path(self): - # TODO - pass - - def test_find_root_from_sitepackages(self): - # TODO - pass - - def test_search_mycroft_core_location(self): - # TODO - pass - def test_get_desktop_environment(self): # TODO pass