diff --git a/CHANGELOG.md b/CHANGELOG.md index ea24b4b0..d37ace71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,89 +1,77 @@ # Changelog -## [0.0.34](https://github.com/OpenVoiceOS/ovos-utils/tree/0.0.34) (2023-06-15) +## [V0.0.35a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a9) (2023-07-19) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a9...0.0.34) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a8...V0.0.35a9) -**Merged pull requests:** +**Implemented enhancements:** -- Fix dev branch coverage action [\#166](https://github.com/OpenVoiceOS/ovos-utils/pull/166) ([NeonDaniel](https://github.com/NeonDaniel)) +- feat/threaded\_timeout\_util [\#169](https://github.com/OpenVoiceOS/ovos-utils/pull/169) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.34a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a9) (2023-06-15) +## [V0.0.35a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a8) (2023-07-13) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a8...V0.0.34a9) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a7...V0.0.35a8) -**Merged pull requests:** +**Fixed bugs:** -- Add timeout to `get_ip` to prevent tests failing on timeout [\#165](https://github.com/OpenVoiceOS/ovos-utils/pull/165) ([NeonDaniel](https://github.com/NeonDaniel)) +- fix ui directores [\#179](https://github.com/OpenVoiceOS/ovos-utils/pull/179) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.34a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a8) (2023-06-15) +## [V0.0.35a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a7) (2023-07-07) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a7...V0.0.34a8) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a6...V0.0.35a7) **Merged pull requests:** -- Cleanup deprecation logs [\#164](https://github.com/OpenVoiceOS/ovos-utils/pull/164) ([NeonDaniel](https://github.com/NeonDaniel)) +- Volunteer GUI upload on init [\#178](https://github.com/OpenVoiceOS/ovos-utils/pull/178) ([NeonDaniel](https://github.com/NeonDaniel)) -## [V0.0.34a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a7) (2023-06-14) +## [V0.0.35a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a6) (2023-07-06) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a6...V0.0.34a7) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a5...V0.0.35a6) **Merged pull requests:** -- Add options to deprecation logging to configure logged caller [\#163](https://github.com/OpenVoiceOS/ovos-utils/pull/163) ([NeonDaniel](https://github.com/NeonDaniel)) +- Enable GUI module resource file resolution [\#177](https://github.com/OpenVoiceOS/ovos-utils/pull/177) ([NeonDaniel](https://github.com/NeonDaniel)) -## [V0.0.34a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a6) (2023-06-14) +## [V0.0.35a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a5) (2023-07-04) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a5...V0.0.34a6) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a4...V0.0.35a5) **Fixed bugs:** -- Fix compat. with `pages2uri` extra\_directories [\#162](https://github.com/OpenVoiceOS/ovos-utils/pull/162) ([NeonDaniel](https://github.com/NeonDaniel)) - -## [V0.0.34a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a5) (2023-06-13) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a3...V0.0.34a5) - -**Implemented enhancements:** - -- Add deprecation log helpers [\#161](https://github.com/OpenVoiceOS/ovos-utils/pull/161) ([NeonDaniel](https://github.com/NeonDaniel)) -- bus upload qml [\#140](https://github.com/OpenVoiceOS/ovos-utils/pull/140) ([JarbasAl](https://github.com/JarbasAl)) +- Fix bug in `dev` branch with refactoring and added unit test [\#176](https://github.com/OpenVoiceOS/ovos-utils/pull/176) ([NeonDaniel](https://github.com/NeonDaniel)) -**Fixed bugs:** +## [V0.0.35a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a4) (2023-07-04) -- Codecov automation [\#159](https://github.com/OpenVoiceOS/ovos-utils/pull/159) ([NeonDaniel](https://github.com/NeonDaniel)) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a3...V0.0.35a4) **Merged pull requests:** -- Fix codecov automation [\#160](https://github.com/OpenVoiceOS/ovos-utils/pull/160) ([NeonDaniel](https://github.com/NeonDaniel)) +- SkillApi docs and tests [\#174](https://github.com/OpenVoiceOS/ovos-utils/pull/174) ([NeonDaniel](https://github.com/NeonDaniel)) -## [V0.0.34a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a3) (2023-06-09) +## [V0.0.35a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a3) (2023-07-04) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a2...V0.0.34a3) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a2...V0.0.35a3) -**Fixed bugs:** +**Closed issues:** -- Annotate GUI Module and refactor resource resolution [\#158](https://github.com/OpenVoiceOS/ovos-utils/pull/158) ([NeonDaniel](https://github.com/NeonDaniel)) +- Non-blocking error when running on MacOS natively [\#175](https://github.com/OpenVoiceOS/ovos-utils/issues/175) -## [V0.0.34a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a2) (2023-06-08) +## [V0.0.35a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a2) (2023-06-28) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a1...V0.0.34a2) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a1...V0.0.35a2) **Merged pull requests:** -- Troubleshooting GUI shell plugin resource file resolution [\#157](https://github.com/OpenVoiceOS/ovos-utils/pull/157) ([NeonDaniel](https://github.com/NeonDaniel)) +- hotfix/missing\_value\_qml\_upload [\#173](https://github.com/OpenVoiceOS/ovos-utils/pull/173) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.34a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a1) (2023-06-08) +## [V0.0.35a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a1) (2023-06-21) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33...V0.0.34a1) - -**Fixed bugs:** - -- Event Scheduler Interface tests [\#156](https://github.com/OpenVoiceOS/ovos-utils/pull/156) ([NeonDaniel](https://github.com/NeonDaniel)) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34...V0.0.35a1) **Merged pull requests:** -- Add unit test coverage for SkillApi [\#145](https://github.com/OpenVoiceOS/ovos-utils/pull/145) ([NeonDaniel](https://github.com/NeonDaniel)) +- Add unit tests for shutdown/restart [\#170](https://github.com/OpenVoiceOS/ovos-utils/pull/170) ([NeonDaniel](https://github.com/NeonDaniel)) +- Fix dev branch coverage action [\#166](https://github.com/OpenVoiceOS/ovos-utils/pull/166) ([NeonDaniel](https://github.com/NeonDaniel)) diff --git a/ovos_utils/__init__.py b/ovos_utils/__init__.py index ed05196c..c4f829f9 100644 --- a/ovos_utils/__init__.py +++ b/ovos_utils/__init__.py @@ -26,6 +26,43 @@ from ovos_utils.log import LOG, deprecated +def threaded_timeout(timeout=5): + """ + Start a thread with a specified timeout. If timeout is exceeded, an + exception is raised and the thread is terminated. + 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): + res = [Exception(f'function [{func.__name__}] timeout ' + f'[{timeout}] exceeded!')] + + def func_wrapped(): + try: + res[0] = func(*args, **kwargs) + except Exception as e: + res[0] = e + + t = Thread(target=func_wrapped) + t.daemon = True + try: + t.start() + t.join(timeout) + except Exception as je: + raise je + ret = res[0] + if isinstance(ret, BaseException): + raise ret + return ret + + return wrapper + + return deco + + class classproperty(property): """Decorator for a Class-level property. Credit to Denis Rhyzhkov on Stackoverflow: https://stackoverflow.com/a/13624858/1280629""" diff --git a/ovos_utils/enclosure/api.py b/ovos_utils/enclosure/api.py index 91936b05..24d54683 100644 --- a/ovos_utils/enclosure/api.py +++ b/ovos_utils/enclosure/api.py @@ -1,4 +1,4 @@ -from ovos_utils.messagebus import FakeMessage as Message +from ovos_utils.messagebus import FakeMessage as Message, dig_for_message class EnclosureAPI: @@ -24,80 +24,76 @@ def set_bus(self, 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( - Message("enclosure.active_skill", - data={"skill_id": skill_id}, - context={"destination": ["enclosure"], - "skill_id": 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). """ - self.bus.emit(Message("enclosure.reset", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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.""" - self.bus.emit(Message("enclosure.system.reset", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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.""" - self.bus.emit(Message("enclosure.system.mute", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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.""" - self.bus.emit(Message("enclosure.system.unmute", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 """ - self.bus.emit(Message("enclosure.system.blink", - data={'times': times}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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.""" - self.bus.emit(Message("enclosure.eyes.on", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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.""" - self.bus.emit(Message("enclosure.eyes.off", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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' """ - self.bus.emit(Message("enclosure.eyes.blink", {'side': side}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message("enclosure.eyes.narrow", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 @@ -108,10 +104,9 @@ def eyes_look(self, side): 'd' for down 'c' for crossed """ - self.bus.emit(Message("enclosure.eyes.look", - data={'side': side}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 @@ -120,10 +115,9 @@ def eyes_color(self, r=255, g=255, b=255): g (int): 0-255, green value b (int): 0-255, blue value """ - self.bus.emit(Message("enclosure.eyes.color", - data={'r': r, 'g': g, 'b': b}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 @@ -133,99 +127,89 @@ def eyes_setpixel(self, idx, r=255, g=255, b=255): 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(Message("enclosure.eyes.setpixel", - data={'idx': idx, 'r': r, 'g': g, 'b': b}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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(Message("enclosure.eyes.fill", - data={'percentage': percentage}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 """ - self.bus.emit(Message("enclosure.eyes.level", {'level': level}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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.""" - self.bus.emit(Message("enclosure.eyes.reset", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + source_message = self._get_source_message() + self.bus.emit(source_message.forward("enclosure.eyes.reset")) def eyes_spin(self): """Make the eyes 'roll' """ - self.bus.emit(Message("enclosure.eyes.spin", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 """ - self.bus.emit(Message("enclosure.eyes.timedspin", - data={'length': length}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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(Message("enclosure.eyes.volume", - data={'volume': volume}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + self.bus.emit(source_message.forward("enclosure.eyes.volume", + {'volume': volume})) def mouth_reset(self): """Restore the mouth display to normal (blank)""" - self.bus.emit(Message("enclosure.mouth.reset", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message("enclosure.mouth.talk", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message("enclosure.mouth.think", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message("enclosure.mouth.listen", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message("enclosure.mouth.smile", - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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. @@ -244,21 +228,19 @@ def mouth_viseme(self, start, viseme_pairs): 5 = shape for sounds like 'f' or 'v' 6 = shape for sounds like 'oy' or 'ao' """ - self.bus.emit(Message("enclosure.mouth.viseme_list", - data={"start": start, - "visemes": viseme_pairs}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 """ - self.bus.emit(Message("enclosure.mouth.text", - data={'text': text}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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, @@ -273,13 +255,12 @@ def mouth_display(self, img_code="", x=0, y=0, refresh=True): Useful if you'd like to display multiple images on the faceplate at once. """ - self.bus.emit(Message('enclosure.mouth.display', - data={'img_code': img_code, - 'xOffset': x, - 'yOffset': y, - 'clearPrev': refresh}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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): @@ -294,15 +275,14 @@ def mouth_display_png(self, image_absolute_path, displaying the new image or not. Useful if you'd like to display muliple images on the faceplate at once. - """ - self.bus.emit(Message("enclosure.mouth.display_image", - data={'img_path': image_absolute_path, - 'xOffset': x, - 'yOffset': y, - 'invert': invert, - 'clearPrev': refresh}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + """ + 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 @@ -319,31 +299,29 @@ def weather_display(self, img_code, temp): 7 = wind/mist temp (int): the temperature (either C or F, not indicated) """ - self.bus.emit(Message("enclosure.weather.display", - data={'img_code': img_code, 'temp': temp}, - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message('enclosure.mouth.events.activate', - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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""" - self.bus.emit(Message('enclosure.mouth.events.deactivate', - context={"destination": ["enclosure"], - "skill_id": self.skill_id})) + 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 """ - message = Message("enclosure.eyes.rgb.get", - context={"source": "enclosure_api", - "destination": "enclosure"}) + 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"] diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py index 02a16b3c..409166ab 100644 --- a/ovos_utils/gui.py +++ b/ovos_utils/gui.py @@ -1,10 +1,10 @@ from os import walk -from typing import List, Union, Optional, Callable, Any +from typing import List, Union, Optional, Callable import time from collections import namedtuple from enum import IntEnum -from os.path import join +from os.path import join, splitext, isfile, isdir from ovos_utils import resolve_ovos_resource_file, resolve_resource_file from ovos_utils.log import LOG, log_deprecation @@ -79,6 +79,25 @@ def can_use_gui(bus=None, return can_use_local_gui() or is_gui_connected(bus) +def get_ui_directories(root_dir: str) -> dict: + """ + Get a dict of available UI directories by GUI framework. + @param root_dir: base directory to inspect for available UI directories + @return: Dict of framework name to UI resource directory + """ + ui_directories = dict() + base_directory = root_dir + if isdir(join(base_directory, "gui")): + 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`") + ui_directories["qt5"] = join(base_directory, "ui") + return ui_directories + + def extend_about_data(about_data: Union[list, dict], bus=None): """ @@ -488,7 +507,8 @@ def __setitem__(self, key, value): class GUIInterface: - """Interface to the Graphical User Interface, allows interaction with + """ + 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 @@ -500,7 +520,7 @@ class GUIInterface: text: sessionData.time """ - def __init__(self, skill_id: str, bus = None, + def __init__(self, skill_id: str, bus=None, remote_server: str = None, config: dict = None, ui_directories: dict = None): """ @@ -511,6 +531,8 @@ def __init__(self, skill_id: str, bus = None, @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") @@ -525,7 +547,7 @@ def __init__(self, skill_id: str, bus = None, 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._pages = [] self.current_page_idx = -1 self._skill_id = skill_id self.on_gui_changed_callback = None @@ -572,9 +594,9 @@ def skill_id(self, val: str): @property def page(self) -> Optional[str]: """ - Return the active GUI page (file path) to show + Return the active GUI page name to show """ - return self.pages[self.current_page_idx] if len(self.pages) else None + return self._pages[self.current_page_idx] if len(self._pages) else None @property def connected(self) -> bool: @@ -586,6 +608,13 @@ def connected(self) -> bool: 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` @@ -603,33 +632,53 @@ def setup_default_handlers(self): 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 resources managed by - this interface. + 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 - request_res_type = message.data.get("framework", "qt5") + + 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: - full_path: str = join(path, file) - rel_path = full_path.replace(f"{res_dir}/", "", 1) - fname = join(self.skill_id, rel_path) - with open(full_path, 'r') as f: - pages[fname] = f.read() - + 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): @@ -716,7 +765,7 @@ def clear(self): the `release` method. """ self.__session_data = {} - self.pages = [] + self._pages = [] self.current_page_idx = -1 if not self.bus: raise RuntimeError("bus not set, did you call self.bind() ?") @@ -742,7 +791,13 @@ def send_event(self, event_name: str, "params": params})) def _pages2uri(self, page_names: List[str]) -> List[str]: - # Convert pages to full reference + """ + 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: @@ -764,21 +819,35 @@ def _pages2uri(self, page_names: List[str]) -> List[str]: 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): """ - Begin showing the page in the GUI - - Arguments: - name (str): Name of page (e.g "mypage.qml") to display - 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. + 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) @@ -786,20 +855,12 @@ def show_pages(self, page_names: List[str], index: int = 0, override_idle: Union[bool, int] = None, override_animations: bool = False): """ - Begin showing the list of pages in the GUI. - - Arguments: - page_names (list): List of page names (str) to display, such as - ["Weather.qml", "Forecast.qml", "Details.qml"] - index (int): Page number (0-based) to show initially. For the - above list a value of 1 would start on "Forecast.qml" - 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. + 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() ?") @@ -812,7 +873,11 @@ def show_pages(self, page_names: List[str], index: int = 0, LOG.error('Default index is larger than page list length') index = len(page_names) - 1 - self.pages = page_names + # 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... @@ -821,39 +886,40 @@ def show_pages(self, page_names: List[str], index: int = 0, LOG.debug(f"Updating gui data: {data}") self.bus.emit(Message("gui.value.set", data)) - page_urls = self._pages2uri(page_names) - # 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. - - Arguments: - page (str): Page to remove from the GUI + """ + 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]): """ - Remove a list of pages in the GUI. - - Arguments: - page_names (list): List of page names (str) to display, such as - ["Weather.qml", "Forecast.qml", "Other.qml"] + 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 not isinstance(page_names, list): + 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 @@ -1090,8 +1156,8 @@ 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: + LOG.info(f"GUI pages length {len(self._pages)}") + if len(self._pages) > 1: self.remove_page("SYSTEM_InputBox.qml") else: self.release() diff --git a/ovos_utils/skills/api.py b/ovos_utils/skills/api.py index 82cd7950..438a125b 100644 --- a/ovos_utils/skills/api.py +++ b/ovos_utils/skills/api.py @@ -11,18 +11,18 @@ # 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. -"""Skill Api -The skill api allows skills interact with eachother over the message bus -just like interacting with any other object. -""" +from typing import Dict, Optional from ovos_bus_client.message import Message +from ovos_utils.log import LOG class SkillApi: - """SkillApi providing a simple interface to exported methods from skills - - Methods are built from a method_dict provided when initializing the skill. + """ + 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 @@ -31,37 +31,52 @@ def connect_bus(cls, mycroft_bus): """Registers the bus object to use.""" cls.bus = mycroft_bus - def __init__(self, method_dict): + 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) - if (response and response.data and - 'result' in response.data): - return response.data['result'] - else: + 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): - """Generate api object from skill id. - Args: - skill (str): skill id for target skill - - Returns: - SkillApi + 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 """ - public_api_msg = '{}.public_api'.format(skill) + 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) + return SkillApi(api.data, api_timeout) else: return None diff --git a/ovos_utils/system.py b/ovos_utils/system.py index a3f6009e..1e64a127 100644 --- a/ovos_utils/system.py +++ b/ovos_utils/system.py @@ -65,6 +65,7 @@ def system_shutdown(sudo=True): cmd = 'systemctl poweroff -i' if sudo: cmd = f'sudo {cmd}' + LOG.debug(cmd) subprocess.call(cmd, shell=True) @@ -76,6 +77,7 @@ def system_reboot(sudo=True): cmd = 'systemctl reboot -i' if sudo: cmd = f'sudo {cmd}' + LOG.debug(cmd) subprocess.call(cmd, shell=True) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index e3329005..2f36713e 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -2,6 +2,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 0 -VERSION_BUILD = 34 +VERSION_BUILD = 35 VERSION_ALPHA = 0 # END_VERSION_BLOCK diff --git a/test/unittests/test_enclosure.py b/test/unittests/test_enclosure.py index 2296983d..88991851 100644 --- a/test/unittests/test_enclosure.py +++ b/test/unittests/test_enclosure.py @@ -1,14 +1,32 @@ import unittest +from unittest.mock import patch -from ovos_utils.messagebus import FakeBus +from ovos_utils.messagebus import FakeBus, Message class TestEnclosureAPI(unittest.TestCase): from ovos_utils.enclosure.api import EnclosureAPI + skill_id = "Enclosure Test" bus = FakeBus() - api = EnclosureAPI(bus) + 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 diff --git a/test/unittests/test_gui.py b/test/unittests/test_gui.py index 10ffbd3b..a10df316 100644 --- a/test/unittests/test_gui.py +++ b/test/unittests/test_gui.py @@ -1,7 +1,7 @@ import unittest -from os.path import join, dirname +from os.path import join, dirname, isfile from threading import Event -from unittest.mock import patch, call +from unittest.mock import patch, call, Mock from ovos_bus_client.message import Message @@ -79,6 +79,18 @@ 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.messagebus import FakeBus @@ -88,6 +100,10 @@ class TestGuiInterface(unittest.TestCase): 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): @@ -99,6 +115,13 @@ def test_00_gui_interface_init(self): 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" @@ -113,7 +136,7 @@ def test_setup_default_handlers(self): pass def test_upload_gui_pages(self): - msg = None + msg = Message("") handled = Event() def on_pages(message): @@ -121,10 +144,38 @@ def on_pages(message): msg = message handled.set() - self.bus.once('gui.page.upload', on_pages) + 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(10)) + 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") @@ -136,12 +187,17 @@ def on_pages(message): self.assertIsInstance(key, str) self.assertIsInstance(val, str) - test_file_key = join(self.iface_name, "test.qml") - self.assertEqual(pages.get(test_file_key), "Mock File Contents", pages) + self.assertEqual(bytes.fromhex(pages.get("qt5/test.qml")), + b"qt5", pages) + self.assertEqual(bytes.fromhex(pages.get("qt6/test.qml")), + b"qt6", pages) - test_file_key = join(self.iface_name, "subdir", "test.qml") - self.assertEqual(pages.get(test_file_key), "Nested Mock", pages) - # TODO: Test other frameworks + # 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 @@ -171,25 +227,183 @@ def test_send_event(self): # TODO pass - def test_pages2uri(self): - # TODO - pass + @patch('ovos_utils.gui.resolve_resource_file') + @patch('ovos_utils.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): - # TODO - pass + 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): - # TODO - pass + 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): - # TODO - pass + 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): - # TODO - pass + 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 diff --git a/test/unittests/test_skills.py b/test/unittests/test_skills.py index 66bea0c1..4cb58d55 100644 --- a/test/unittests/test_skills.py +++ b/test/unittests/test_skills.py @@ -178,17 +178,48 @@ def test_get_plugin_skills(self): 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": _api_method_1, - "get_length": _api_method_2}) - test_api.connect_bus(self.bus) + 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")) - # TODO: Test SkillApi.get + # 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_system.py b/test/unittests/test_system.py index 1f518446..4644731d 100644 --- a/test/unittests/test_system.py +++ b/test/unittests/test_system.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import patch class TestSystem(unittest.TestCase): @@ -11,13 +12,21 @@ def test_ntp_sync(self): # TODO pass - def test_system_shutdown(self): - # TODO - pass + @patch("subprocess.Popen") + def test_system_shutdown(self, popen): + from ovos_utils.system import system_shutdown + system_shutdown() + popen.assert_called_with("sudo systemctl poweroff -i", shell=True) + system_shutdown(False) + popen.assert_called_with("systemctl poweroff -i", shell=True) - def test_system_reboot(self): - # TODO - pass + @patch("subprocess.Popen") + def test_system_reboot(self, popen): + from ovos_utils.system import system_reboot + system_reboot() + popen.assert_called_with("sudo systemctl reboot -i", shell=True) + system_reboot(False) + popen.assert_called_with("systemctl reboot -i", shell=True) def test_ssh_enable(self): # TODO diff --git a/test/unittests/test_ui/gui/qt5/test.qml b/test/unittests/test_ui/gui/qt5/test.qml new file mode 100644 index 00000000..75793eb6 --- /dev/null +++ b/test/unittests/test_ui/gui/qt5/test.qml @@ -0,0 +1 @@ +qt5 \ No newline at end of file diff --git a/test/unittests/test_ui/gui/qt6/test.qml b/test/unittests/test_ui/gui/qt6/test.qml new file mode 100644 index 00000000..3a5476f2 --- /dev/null +++ b/test/unittests/test_ui/gui/qt6/test.qml @@ -0,0 +1 @@ +qt6 \ No newline at end of file diff --git a/test/unittests/test_ui/legacy/ui/test.qml b/test/unittests/test_ui/legacy/ui/test.qml new file mode 100644 index 00000000..75793eb6 --- /dev/null +++ b/test/unittests/test_ui/legacy/ui/test.qml @@ -0,0 +1 @@ +qt5 \ No newline at end of file diff --git a/test/unittests/test_utils.py b/test/unittests/test_utils.py index fe2646ca..376ca553 100644 --- a/test/unittests/test_utils.py +++ b/test/unittests/test_utils.py @@ -3,29 +3,49 @@ from os.path import join, dirname from sys import executable from subprocess import Popen, TimeoutExpired +from time import sleep, time class TestHelpers(unittest.TestCase): + def test_threaded_timeout(self): + from ovos_utils import threaded_timeout + + @threaded_timeout(2) + def long_runner(): + sleep(10) + return True + + @threaded_timeout() + def valid_runner(): + return True + + # Test decorated valid method + self.assertTrue(valid_runner) + + start_time = time() + with self.assertRaises(Exception): + long_runner() + self.assertLess(time(), start_time + 3) def test_classproperty(self): + from ovos_utils import classproperty # TODO - pass def test_timed_lru_cache(self): + from ovos_utils import timed_lru_cache # TODO - pass def test_create_killable_daemon(self): + from ovos_utils import create_killable_daemon # TODO - pass def test_create_daemon(self): + from ovos_utils import create_daemon # TODO - pass def test_create_loop(self): + from ovos_utils import create_loop # TODO - pass def test_wait_for_exit_signal(self): test_file = join(dirname(__file__), "scripts", "wait_for_exit.py")