diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml new file mode 100644 index 0000000..8466795 --- /dev/null +++ b/.github/workflows/build_tests.yml @@ -0,0 +1,39 @@ +name: Run Build Tests +on: + push: + branches: + - master + pull_request: + branches: + - dev + workflow_dispatch: + +jobs: + build_tests: + strategy: + max-parallel: 2 + matrix: + python-version: [3.8, 3.9, "3.10", "3.11" ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install Build Tools + run: | + python -m pip install build wheel + - name: Install System Dependencies + run: | + sudo apt-get update + sudo apt install python3-dev swig libssl-dev + - name: Build Source Packages + run: | + python setup.py sdist + - name: Build Distribution Packages + run: | + python setup.py bdist_wheel + - name: Install skill + run: | + pip install .[gui] \ No newline at end of file diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 0000000..7709abd --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,66 @@ +name: Run UnitTests +on: + pull_request: + branches: + - dev + paths-ignore: + - 'version.py' + - 'examples/**' + - '.github/**' + - '.gitignore' + - 'LICENSE' + - 'CHANGELOG.md' + - 'MANIFEST.in' + - 'README.md' + - 'scripts/**' + push: + branches: + - master + paths-ignore: + - 'version.py' + - 'examples/**' + - '.github/**' + - '.gitignore' + - 'LICENSE' + - 'CHANGELOG.md' + - 'MANIFEST.in' + - 'README.md' + - 'scripts/**' + workflow_dispatch: + +jobs: + unit_tests: + strategy: + matrix: + python-version: [3.9, "3.10" ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install System Dependencies + run: | + sudo apt-get update + sudo apt install python3-dev + python -m pip install build wheel + - name: Install core repo + run: | + pip install . + - name: Install test dependencies + run: | + pip install pytest pytest-timeout pytest-cov + - name: Install System Dependencies + run: | + sudo apt-get update + - name: Install ovos dependencies + run: | + pip install ovos-plugin-manager + - name: Run unittests + run: | + pytest --cov=ovos-skill-iss-location --cov-report xml test + - name: Upload coverage + env: + CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} + uses: codecov/codecov-action@v2 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..92de07b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include locale * +recursive-include gui * +include *.txt \ No newline at end of file diff --git a/__init__.py b/__init__.py index c1d91b5..d5237ad 100644 --- a/__init__.py +++ b/__init__.py @@ -3,12 +3,9 @@ from os.path import join from time import sleep -import matplotlib.pyplot as plt import pytz import requests -from lingua_franca.format import nice_duration -from matplotlib.offsetbox import OffsetImage, AnnotationBbox -from mpl_toolkits.basemap import Basemap +from ovos_date_parser import nice_duration from ovos_utils.time import to_local, now_local from ovos_workshop.decorators import intent_handler from ovos_workshop.decorators import resting_screen_handler @@ -16,11 +13,21 @@ from ovos_workshop.skills import OVOSSkill from skyfield.api import Topos, load +try: + import matplotlib.pyplot as plt + from matplotlib.offsetbox import OffsetImage, AnnotationBbox + from mpl_toolkits.basemap import Basemap + GUI = True +except ImportError: + GUI = False + class ISSLocationSkill(OVOSSkill): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + if "enable_gui" not in self.settings: + self.settings["enable_gui"] = False if "geonames_user" not in self.settings: self.settings["geonames_user"] = "jarbas" if "map_style" not in self.settings: @@ -38,46 +45,51 @@ def __init__(self, *args, **kwargs): if "dpi" not in self.settings: self.settings["dpi"] = 500 - def update_picture(self): + @property + def use_gui(self) -> bool: + return GUI and self.settings["enable_gui"] + + def get_iss_data(self): + data = requests.get("http://api.open-notify.org/iss-now.json").json() + astronauts = requests.get("http://api.open-notify.org/astros.json").json() + + lat = data['iss_position']['latitude'] + lon = data['iss_position']['longitude'] + + params = { + "username": self.settings["geonames_user"], + "lat": lat, + "lng": lon + } + ocean_names = "http://api.geonames.org/oceanJSON" + land_names = "http://api.geonames.org/countryCodeJSON" + + # reverse geo + data = requests.get(ocean_names, params=params).json() try: - data = requests.get("http://api.open-notify.org/iss-now.json").json() - astronauts = requests.get("http://api.open-notify.org/astros.json").json() - - lat = data['iss_position']['latitude'] - lon = data['iss_position']['longitude'] - - params = { - "username": self.settings["geonames_user"], - "lat": lat, - "lng": lon - } - ocean_names = "http://api.geonames.org/oceanJSON" - land_names = "http://api.geonames.org/countryCodeJSON" - - # reverse geo - data = requests.get(ocean_names, params=params).json() + toponym = "The " + data['ocean']['name'] + except: + try: - toponym = "The " + data['ocean']['name'] + params = { + "username": self.settings["geonames_user"], + "lat": lat, + "lng": lon, + "formatted": True, + "style": "full" + } + data = requests.get(land_names, + params=params).json() + toponym = data['countryName'] except: + toponym = "unknown" + if not self.lang.lower().startswith("en") and toponym != "unknown": + toponym = self.translator.translate(toponym, self.lang) + return toponym, lat, lon, astronauts - try: - params = { - "username": self.settings["geonames_user"], - "lat": lat, - "lng": lon, - "formatted": True, - "style": "full" - } - data = requests.get(land_names, - params=params).json() - toponym = data['countryName'] - except: - toponym = "unknown" - if not self.lang.lower().startswith("en") and toponym != "unknown": - toponym = self.translator.translate(toponym, self.lang) - + def update_picture(self, toponym, lat, lon, astronauts): + try: image = self.generate_map(lat, lon) - self.gui['imgLink'] = image self.gui['caption'] = f"{toponym} Lat: {lat} Lon: {lon}" self.gui['lat'] = lat @@ -90,7 +102,8 @@ def update_picture(self): @resting_screen_handler("ISS") def idle(self, message): - self.update_picture() # values available in self.gui + toponym, lat, lon, astronauts = self.get_iss_data() + self.update_picture(toponym, lat, lon, astronauts) # values available in self.gui self.gui.show_image(self.gui['imgLink'], fill='PreserveAspectFit') def generate_map(self, lat, lon): @@ -142,20 +155,23 @@ def handle_about_iss_intent(self, message): @intent_handler('where_iss.intent') def handle_iss(self, message): - self.update_picture() # values available in self.gui - self.gui.show_image(self.gui['imgLink'], - caption=self.gui['caption'], - fill='PreserveAspectFit') - if self.gui['toponym'] == "unknown": + toponym, lat, lon, astronauts = self.get_iss_data() + if self.use_gui: + self.update_picture(toponym, lat, lon, astronauts) + self.gui.show_image(self.gui['imgLink'], + caption=self.gui['caption'], + fill='PreserveAspectFit') + + if toponym == "unknown": self.speak_dialog("location.unknown", { - "latitude": self.gui['lat'], - "longitude": self.gui['lon'] + "latitude": lat, + "longitude": lon }, wait=True) else: self.speak_dialog("location.current", { - "latitude": self.gui['lat'], - "longitude": self.gui['lon'], - "toponym": self.gui['toponym'] + "latitude": lat, + "longitude":lon, + "toponym": toponym }, wait=True) sleep(1) self.gui.release() @@ -172,10 +188,10 @@ def handle_when(self, message): duration = nice_duration(dur, lang=self.lang) visible_dur = nice_duration(delta, lang=self.lang) - caption = self.location_pretty + " " + dt.strftime("%m/%d/%Y, %H:%M:%S") - image = self.generate_map(lat, lon) - - self.gui.show_image(image, caption=caption, fill='PreserveAspectFit') + if self.use_gui: + caption = self.location_pretty + " " + dt.strftime("%m/%d/%Y, %H:%M:%S") + image = self.generate_map(lat, lon) + self.gui.show_image(image, caption=caption, fill='PreserveAspectFit') self.speak_dialog("location.when", { "duration": duration, @@ -186,31 +202,32 @@ def handle_when(self, message): }, wait=True) self.gui.release() - @intent_handler( - IntentBuilder("WhoISSIntent").require("who").require( - "onboard").require("iss")) + @intent_handler(IntentBuilder("WhoISSIntent").require("who"). + require("onboard").require("iss")) def handle_who(self, message): - self.update_picture() # values available in self.gui + toponym, lat, lon, astronauts = self.get_iss_data() people = [ - p["name"] for p in self.gui["astronauts"] + p["name"] for p in astronauts if p["craft"] == "ISS" ] people = ", ".join(people) - self.gui.show_image(self.settings["iss_bg"], - override_idle=True, - fill='PreserveAspectFit', - caption=people) + if self.use_gui: + self.update_picture(toponym, lat, lon, astronauts) + self.gui.show_image(self.settings["iss_bg"], + override_idle=True, + fill='PreserveAspectFit', + caption=people) self.speak_dialog("who", {"people": people}, wait=True) sleep(1) self.gui.release() - @intent_handler( - IntentBuilder("NumberISSIntent").require("how_many").require( - "onboard").require("iss")) + @intent_handler(IntentBuilder("NumberISSIntent").require("how_many") + .require("onboard").require("iss")) def handle_number(self, message): - self.update_picture() # values available in self.gui + + toponym, lat, lon, astronauts = self.get_iss_data() people = [ - p["name"] for p in self.gui["astronauts"] + p["name"] for p in astronauts if p["craft"] == "ISS" ] num = len(people) @@ -336,6 +353,7 @@ def predict(self): from ovos_utils.fakebus import FakeBus from ovos_bus_client.message import Message from ovos_config.locale import setup_locale + setup_locale() diff --git a/gui-requirements.txt b/gui-requirements.txt new file mode 100644 index 0000000..f02fdf5 --- /dev/null +++ b/gui-requirements.txt @@ -0,0 +1,3 @@ +matplotlib +basemap +pillow \ No newline at end of file diff --git a/ui/iss.png b/gui/all/iss.png similarity index 100% rename from ui/iss.png rename to gui/all/iss.png diff --git a/ui/iss2.png b/gui/all/iss2.png similarity index 100% rename from ui/iss2.png rename to gui/all/iss2.png diff --git a/ui/iss3.png b/gui/all/iss3.png similarity index 100% rename from ui/iss3.png rename to gui/all/iss3.png diff --git a/locale/en-us/skill.json b/locale/en-us/skill.json new file mode 100644 index 0000000..56af623 --- /dev/null +++ b/locale/en-us/skill.json @@ -0,0 +1,17 @@ +{ + "skill_id": "ovos-skill-iss-location.openvoiceos", + "source": "https://github.com/OpenVoiceOS/ovos-skill-iss-location", + "name": "ISS Tracker", + "description": "Track the location of the ISS", + "examples": [ + "Where is the ISS", + "Who is on board of the space station?", + "When is the ISS passing over", + "Tell me about the IS", + "how many persons on board of the space station" + ], + "tags": [ + "Trivia", + "space" + ] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f654bce..886ac8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -matplotlib -basemap -pillow requests skyfield -ovos_workshop>=0.0.12,<2.0.0 \ No newline at end of file +ovos-date-parser>=0.0.3,<1.0.0 +ovos-workshop>=0.0.12,<3.0.0 +ovos-bus-client>=1.0.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 420dab8..ebb18f6 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def get_requirements(requirements_filename: str = "requirements.txt"): # Function to find resource files def find_resource_files(): - resource_base_dirs = ("locale", "ui", "res") + resource_base_dirs = ("locale", "gui", "res") base_dir = abspath(dirname(__file__)) package_data = ["*.json"] for res in resource_base_dirs: @@ -85,6 +85,9 @@ def get_version(): packages=[SKILL_PKG], include_package_data=True, install_requires=get_requirements(), + extras_require={ + 'gui': get_requirements('gui-requirements.txt') + }, keywords='ovos skill plugin', entry_points={'ovos.plugin.skill': PLUGIN_ENTRY_POINT} ) diff --git a/test/test_plugin.py b/test/test_plugin.py new file mode 100644 index 0000000..6046bbe --- /dev/null +++ b/test/test_plugin.py @@ -0,0 +1,13 @@ +import unittest +from ovos_plugin_manager.skills import find_skill_plugins + + +class TestPlugin(unittest.TestCase): + @classmethod + def setUpClass(self): + self.skill_id = "ovos-skill-iss-location.openvoiceos" + + def test_find_plugin(self): + plugins = find_skill_plugins() + self.assertIn(self.skill_id, list(plugins)) + diff --git a/test/test_skill_loading.py b/test/test_skill_loading.py new file mode 100644 index 0000000..f60e606 --- /dev/null +++ b/test/test_skill_loading.py @@ -0,0 +1,54 @@ +import unittest +from os.path import dirname + +from ovos_plugin_manager.skills import find_skill_plugins +from ovos_utils.messagebus import FakeBus +from ovos_workshop.skill_launcher import PluginSkillLoader, SkillLoader +from ovos_skill_iss_location import ISSLocationSkill + + +class TestSkillLoading(unittest.TestCase): + @classmethod + def setUpClass(self): + self.skill_id = "ovos-skill-iss-location.openvoiceos" + self.path = dirname(dirname(__file__)) + + def test_from_class(self): + bus = FakeBus() + skill = ISSLocationSkill() + skill._startup(bus, self.skill_id) + self.assertEqual(skill.bus, bus) + self.assertEqual(skill.skill_id, self.skill_id) + + def test_from_plugin(self): + bus = FakeBus() + for skill_id, plug in find_skill_plugins().items(): + if skill_id == self.skill_id: + skill = plug() + skill._startup(bus, self.skill_id) + self.assertEqual(skill.bus, bus) + self.assertEqual(skill.skill_id, self.skill_id) + break + else: + raise RuntimeError("plugin not found") + + def test_from_loader(self): + bus = FakeBus() + loader = SkillLoader(bus, self.path) + loader.load() + self.assertEqual(loader.instance.bus, bus) + self.assertEqual(loader.instance.root_dir, self.path) + + def test_from_plugin_loader(self): + bus = FakeBus() + loader = PluginSkillLoader(bus, self.skill_id) + for skill_id, plug in find_skill_plugins().items(): + if skill_id == self.skill_id: + loader.load(plug) + break + else: + raise RuntimeError("plugin not found") + + self.assertEqual(loader.skill_id, self.skill_id) + self.assertEqual(loader.instance.bus, bus) + self.assertEqual(loader.instance.skill_id, self.skill_id)