diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..727ec15 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,52 @@ +version: 2.1 + +orbs: + codecov: codecov/codecov@1.0.5 + +workflows: + version: 2 + test: + jobs: + - py38 + - py37 + - black + - check-manifest + - flake8 + +jobs: + py38: &test-template + docker: + - image: mopidy/ci-python:3.8 + steps: + - checkout + - restore_cache: + name: Restoring tox cache + key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} + - run: + name: Run tests + command: | + tox -e $CIRCLE_JOB -- \ + --junit-xml=test-results/pytest/results.xml \ + --cov-report=xml + - save_cache: + name: Saving tox cache + key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} + paths: + - ./.tox + - ~/.cache/pip + - codecov/upload: + file: coverage.xml + - store_test_results: + path: test-results + + py37: + <<: *test-template + docker: + - image: mopidy/ci-python:3.7 + + black: *test-template + + check-manifest: *test-template + + flake8: *test-template + diff --git a/.gitignore b/.gitignore index 3b29847..5f0c41c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ -*.egg-info *.pyc -.coverage -.pytest_cache/ -.tox/ -MANIFEST -build/ -dist/ -.vscode/ +/.coverage +/.mypy_cache/ +/.pytest_cache/ +/.tox/ +/*.egg-info +/build/ +/dist/ +/MANIFEST \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 32df99f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: python - -python: - - "2.7" - -virtualenv: - system_site_packages: true - -addons: - apt: - sources: - - mopidy-stable - packages: - - mopidy - -env: - - TOX_ENV=py27 - - TOX_ENV=flake8 - - TOX_ENV=check-manifest - -install: - - pip install tox - -script: - - tox -e $TOX_ENV - -after_success: - - if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi diff --git a/MANIFEST.in b/MANIFEST.in index 31fe726..1ac4cab 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,9 +1,15 @@ -include .travis.yml -include CHANGELOG.rst +include *.py +include *.rst +include .mailmap include LICENSE include MANIFEST.in -include README.rst -include mopidy_raspberry_gpio/ext.conf +include pyproject.toml include tox.ini +recursive-include .circleci * +recursive-include .github * + +include mopidy_*/ext.conf + recursive-include tests *.py +recursive-include tests/data * \ No newline at end of file diff --git a/README.rst b/README.rst index 4e98f17..a8bb1ab 100644 --- a/README.rst +++ b/README.rst @@ -2,17 +2,17 @@ Mopidy-Raspberry-GPIO **************************** -.. image:: https://img.shields.io/pypi/v/Mopidy-Raspberry-GPIO.svg?style=flat +.. image:: https://img.shields.io/pypi/v/Mopidy-Raspberry-GPIO.svg :target: https://pypi.org/project/Mopidy-Raspberry-GPIO/ :alt: Latest PyPI version -.. image:: https://img.shields.io/travis/pimoroni/mopidy-raspberry-gpio/master.svg?style=flat - :target: https://travis-ci.org/pimoroni/mopidy-raspberry-gpio - :alt: Travis CI build status +.. image:: https://img.shields.io/circleci/build/gh/pimoroni/mopidy-raspberry-gpio + :target: https://circleci.com/gh/pimoroni/mopidy-raspberry-gpio + :alt: CircleCI build status -.. image:: https://img.shields.io/coveralls/pimoroni/mopidy-raspberry-gpio/master.svg?style=flat - :target: https://coveralls.io/r/pimoroni/mopidy-raspberry-gpio - :alt: Test coverage +.. image:: https://img.shields.io/codecov/c/gh/pimoroni/mopidy-raspberry-gpio + :target: https://codecov.io/gh/pimoroni/mopidy-raspberry-gpio + :alt: Test coverage Mopidy extension for GPIO input on a Raspberry Pi @@ -22,7 +22,7 @@ Installation Install by running:: - pip install Mopidy-Raspberry-GPIO + python3 -m pip install Mopidy-Raspberry-GPIO Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. diff --git a/mopidy_raspberry_gpio/__init__.py b/mopidy_raspberry_gpio/__init__.py index 2187c7f..859f10a 100644 --- a/mopidy_raspberry_gpio/__init__.py +++ b/mopidy_raspberry_gpio/__init__.py @@ -1,7 +1,5 @@ -from __future__ import unicode_literals - import logging -import os +import pathlib from mopidy import config, ext @@ -20,15 +18,15 @@ class Extension(ext.Extension): version = __version__ def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), "ext.conf") - return config.read(conf_file) + return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self): - schema = super(Extension, self).get_config_schema() + schema = super().get_config_schema() for pin in range(28): - schema["bcm{:d}".format(pin)] = PinConfig() + schema[f"bcm{pin:d}"] = PinConfig() return schema def setup(self, registry): from .frontend import RaspberryGPIOFrontend + registry.add("frontend", RaspberryGPIOFrontend) diff --git a/mopidy_raspberry_gpio/frontend.py b/mopidy_raspberry_gpio/frontend.py index 829f0b7..1bbdf42 100644 --- a/mopidy_raspberry_gpio/frontend.py +++ b/mopidy_raspberry_gpio/frontend.py @@ -1,87 +1,82 @@ -from __future__ import unicode_literals - -import logging - -from mopidy import core - -import pykka - - -logger = logging.getLogger(__name__) - - -class RaspberryGPIOFrontend(pykka.ThreadingActor, core.CoreListener): - def __init__(self, config, core): - super(RaspberryGPIOFrontend, self).__init__() - import RPi.GPIO as GPIO - self.core = core - self.config = config["raspberry-gpio"] - self.pin_settings = {} - - GPIO.setwarnings(False) - GPIO.setmode(GPIO.BCM) - - # Iterate through any bcmN pins in the config - # and set them up as inputs with edge detection - for key in self.config: - if key.startswith("bcm"): - pin = int(key.replace("bcm", "")) - settings = self.config[key] - if settings is None: - continue - - pull = GPIO.PUD_UP - edge = GPIO.FALLING - if settings.active == 'active_high': - pull = GPIO.PUD_DOWN - edge = GPIO.RISING - - GPIO.setup( - pin, - GPIO.IN, - pull_up_down=pull) - - GPIO.add_event_detect( - pin, - edge, - callback=self.gpio_event, - bouncetime=settings.bouncetime) - - self.pin_settings[pin] = settings - - def gpio_event(self, pin): - settings = self.pin_settings[pin] - self.dispatch_input(settings.event) - - def dispatch_input(self, event): - handler_name = "handle_{}".format(event) - try: - getattr(self, handler_name)() - except AttributeError: - raise RuntimeError( - "Could not find input handler for event: {}".format(event) - ) - - def handle_play_pause(self): - if self.core.playback.state.get() == core.PlaybackState.PLAYING: - self.core.playback.pause() - else: - self.core.playback.play() - - def handle_next(self): - self.core.playback.next() - - def handle_prev(self): - self.core.playback.previous() - - def handle_volume_up(self): - volume = self.core.playback.volume.get() - volume += 5 - volume = min(volume, 100) - self.core.playback.volume = volume - - def handle_volume_down(self): - volume = self.core.playback.volume.get() - volume -= 5 - volume = max(volume, 0) - self.core.playback.volume = volume +import logging + +import pykka +from mopidy import core + +logger = logging.getLogger(__name__) + + +class RaspberryGPIOFrontend(pykka.ThreadingActor, core.CoreListener): + def __init__(self, config, core): + super().__init__() + import RPi.GPIO as GPIO + + self.core = core + self.config = config["raspberry-gpio"] + self.pin_settings = {} + + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + + # Iterate through any bcmN pins in the config + # and set them up as inputs with edge detection + for key in self.config: + if key.startswith("bcm"): + pin = int(key.replace("bcm", "")) + settings = self.config[key] + if settings is None: + continue + + pull = GPIO.PUD_UP + edge = GPIO.FALLING + if settings.active == "active_high": + pull = GPIO.PUD_DOWN + edge = GPIO.RISING + + GPIO.setup(pin, GPIO.IN, pull_up_down=pull) + + GPIO.add_event_detect( + pin, + edge, + callback=self.gpio_event, + bouncetime=settings.bouncetime, + ) + + self.pin_settings[pin] = settings + + def gpio_event(self, pin): + settings = self.pin_settings[pin] + self.dispatch_input(settings.event) + + def dispatch_input(self, event): + handler_name = f"handle_{event}" + try: + getattr(self, handler_name)() + except AttributeError: + raise RuntimeError( + f"Could not find input handler for event: {event}" + ) + + def handle_play_pause(self): + if self.core.playback.get_state().get() == core.PlaybackState.PLAYING: + self.core.playback.pause() + else: + self.core.playback.play() + + def handle_next(self): + self.core.playback.next() + + def handle_prev(self): + self.core.playback.previous() + + def handle_volume_up(self): + volume = self.core.mixer.get_volume().get() + volume += 5 + volume = min(volume, 100) + self.core.mixer.set_volume(volume) + + def handle_volume_down(self): + volume = self.core.mixer.get_volume().get() + volume -= 5 + volume = max(volume, 0) + self.core.mixer.set_volume(volume) diff --git a/mopidy_raspberry_gpio/pinconfig.py b/mopidy_raspberry_gpio/pinconfig.py index e8a8942..f6c7270 100644 --- a/mopidy_raspberry_gpio/pinconfig.py +++ b/mopidy_raspberry_gpio/pinconfig.py @@ -1,56 +1,60 @@ -from collections import namedtuple - -from mopidy import config - - -class PinConfig(config.ConfigValue): - tuple_pinconfig = namedtuple("PinConfig", - ("event", "active", "bouncetime")) - - valid_events = "play_pause", "prev", "next", "volume_up", "volume_down" - - valid_modes = "active_low", "active_high" - - def __init__(self): - pass - - def deserialize(self, value): - if value is None: - return None - - value = config.decode(value).strip() - - try: - event, active, bouncetime = value.split(',') - except ValueError: - return None - - if event not in self.valid_events: - raise ValueError( - "invalid event for pin config {:s} (Must be {})".format( - event, ", ".join(self.valid_events) - ) - ) - - if active not in self.valid_modes: - raise ValueError( - "invalid mode for pin config {:s} (Must be {})".format( - event, ", ".join(self.valid_events) - ) - ) - - try: - bouncetime = int(bouncetime) - except ValueError: - raise ValueError( - "invalid bouncetime value for pin config {}".format(bouncetime) - ) - - return self.tuple_pinconfig(event, active, bouncetime) - - def serialize(self, value, display=False): - if value is None: - return "" - value = "{:s},{:s},{:d}".format( - value.event, value.active, value.bouncetime) - return config.encode(value) +from collections import namedtuple + +from mopidy import config +from mopidy.config import types + + +class ValidList(list): + def __format__(self, format_string=None): + if format_string is None: + format_string = ", " + return format_string.join(self) + + +class PinConfig(config.ConfigValue): + tuple_pinconfig = namedtuple("PinConfig", ("event", "active", "bouncetime")) + + valid_events = ValidList( + ["play_pause", "prev", "next", "volume_up", "volume_down"] + ) + + valid_modes = ValidList(["active_low", "active_high"]) + + def __init__(self): + pass + + def deserialize(self, value): + if value is None: + return None + + value = types.decode(value).strip() + + try: + event, active, bouncetime = value.split(",") + except ValueError: + return None + + if event not in self.valid_events: + raise ValueError( + f"invalid event for pin config {event} (Must be {self.valid_events})" + ) + + if active not in self.valid_modes: + raise ValueError( + f"invalid event for pin config {active} (Must be one of {self.valid_modes})" + ) + + try: + bouncetime = int(bouncetime) + except ValueError: + raise ValueError( + f"invalid bouncetime value for pin config {bouncetime}" + ) + + return self.tuple_pinconfig(event, active, bouncetime) + + def serialize(self, value, display=False): + if value is None: + return "" + value = f"{value.event},{value.active},{value.bouncetime}" + return types.encode(value) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6a8423f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools >= 30.3.0", "wheel"] + + +[tool.black] +target-version = ["py37", "py38"] +line-length = 80 + + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 88 +known_tests = "tests" +sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 614c20c..b2ad4f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,85 @@ -[flake8] -application-import-names = mopidy_raspberry_gpio,tests -exclude = .git,.tox +[metadata] +name = mopidy-raspberry-gpio +version = 0.0.2 +url = https://github.com/pimoroni/mopidy-raspberry-gpio +author = Phil Howard +author_email = phil@pimoroni.com +license = Apache License, Version 2.0 +license_file = LICENSE +description = Mopidy extension for GPIO input on a Raspberry Pi +long_description = file: README.rst +classifiers = + Environment :: No Input/Output (Daemon) + Intended Audience :: End Users/Desktop + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Topic :: Multimedia :: Sound/Audio :: Players -[wheel] -universal = 1 + +[options] +zip_safe = False +include_package_data = True +packages = find: +python_requires = >= 3.7 +install_requires = + Mopidy >= 3.0.0a4 # Change to >= 3.0 once final is released + Pykka >= 2.0.1 + setuptools + + +[options.extras_require] +lint = + black + check-manifest + flake8 + flake8-bugbear + flake8-import-order + isort[pyproject] +release = + twine + wheel +test = + pytest + pytest-cov +dev = + %(lint)s + %(release)s + %(test)s + + +[options.packages.find] +exclude = + tests + tests.* + + +[options.entry_points] +mopidy.ext = + raspberry-gpio = mopidy_raspberry_gpio:Extension + + +[flake8] +application-import-names = mopidy_raspberry_gpio, tests +max-line-length = 80 +exclude = .git, .tox, build +select = + # Regular flake8 rules + C, E, F, W + # flake8-bugbear rules + B + # B950: line too long (soft speed limit) + B950 + # pep8-naming rules + N +ignore = + # E203: whitespace before ':' (not PEP8 compliant) + E203 + # E501: line too long (replaced by B950) + E501 + # W503: line break before binary operator (not PEP8 compliant) + W503 + # B305: .next() is not a thing on Python 3 (used by playback controller) + B305 \ No newline at end of file diff --git a/setup.py b/setup.py index cbbc9b2..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,45 +1,3 @@ -from __future__ import unicode_literals +from setuptools import setup -import re - -from setuptools import find_packages, setup - - -def get_version(filename): - with open(filename) as fh: - metadata = dict(re.findall('__([a-z]+)__ = "([^"]+)"', fh.read())) - return metadata["version"] - - -setup( - name="Mopidy-Raspberry-GPIO", - version=get_version("mopidy_raspberry_gpio/__init__.py"), - url="https://github.com/pimoroni/mopidy-raspberry-gpio", - license="Apache License, Version 2.0", - author="Phil Howard", - author_email="phil@pimoroni.com", - description="Mopidy extension for GPIO input on a Raspberry Pi", - long_description=open("README.rst").read(), - packages=find_packages(exclude=["tests", "tests.*"]), - zip_safe=False, - include_package_data=True, - python_requires="> 2.7, < 3", - install_requires=[ - "setuptools", - "Mopidy >= 2.2", - "Pykka >= 2.0", - ], - entry_points={ - "mopidy.ext": [ - "raspberry-gpio = mopidy_raspberry_gpio:Extension", - ] - }, - classifiers=[ - "Environment :: No Input/Output (Daemon)", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 2.7", - "Topic :: Multimedia :: Sound/Audio :: Players", - ], -) +setup() diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py new file mode 100644 index 0000000..8e85d0a --- /dev/null +++ b/tests/dummy_audio.py @@ -0,0 +1,137 @@ +"""A dummy audio actor for use in tests. + +This class implements the audio API in the simplest way possible. It is used in +tests of the core and backends. +""" + + +import pykka +from mopidy import audio + + +def create_proxy(config=None, mixer=None): + return DummyAudio.start(config, mixer).proxy() + + +# TODO: reset position on track change? +class DummyAudio(pykka.ThreadingActor): + def __init__(self, config=None, mixer=None): + super().__init__() + self.state = audio.PlaybackState.STOPPED + self._volume = 0 + self._position = 0 + self._callback = None + self._uri = None + self._stream_changed = False + self._live_stream = False + self._tags = {} + self._bad_uris = set() + + def set_uri(self, uri, live_stream=False): + assert self._uri is None, "prepare change not called before set" + self._position = 0 + self._uri = uri + self._stream_changed = True + self._live_stream = live_stream + self._tags = {} + + def set_appsrc(self, *args, **kwargs): + pass + + def emit_data(self, buffer_): + pass + + def get_position(self): + return self._position + + def set_position(self, position): + self._position = position + audio.AudioListener.send("position_changed", position=position) + return True + + def start_playback(self): + return self._change_state(audio.PlaybackState.PLAYING) + + def pause_playback(self): + return self._change_state(audio.PlaybackState.PAUSED) + + def prepare_change(self): + self._uri = None + return True + + def stop_playback(self): + return self._change_state(audio.PlaybackState.STOPPED) + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + return True + + def set_metadata(self, track): + pass + + def get_current_tags(self): + return self._tags + + def set_about_to_finish_callback(self, callback): + self._callback = callback + + def enable_sync_handler(self): + pass + + def wait_for_state_change(self): + pass + + def _change_state(self, new_state): + if not self._uri: + return False + + if new_state == audio.PlaybackState.STOPPED and self._uri: + self._stream_changed = True + self._uri = None + + if self._uri is not None: + audio.AudioListener.send("position_changed", position=0) + + if self._stream_changed: + self._stream_changed = False + audio.AudioListener.send("stream_changed", uri=self._uri) + + old_state, self.state = self.state, new_state + audio.AudioListener.send( + "state_changed", + old_state=old_state, + new_state=new_state, + target_state=None, + ) + + if new_state == audio.PlaybackState.PLAYING: + self._tags["audio-codec"] = ["fake info..."] + audio.AudioListener.send("tags_changed", tags=["audio-codec"]) + + return self._uri not in self._bad_uris + + def trigger_fake_playback_failure(self, uri): + self._bad_uris.add(uri) + + def trigger_fake_tags_changed(self, tags): + self._tags.update(tags) + audio.AudioListener.send("tags_changed", tags=self._tags.keys()) + + def get_about_to_finish_callback(self): + # This needs to be called from outside the actor or we lock up. + def wrapper(): + if self._callback: + self.prepare_change() + self._callback() + + if not self._uri or not self._callback: + self._tags = {} + audio.AudioListener.send("reached_end_of_stream") + else: + audio.AudioListener.send("position_changed", position=0) + audio.AudioListener.send("stream_changed", uri=self._uri) + + return wrapper diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py new file mode 100644 index 0000000..f8e6908 --- /dev/null +++ b/tests/dummy_backend.py @@ -0,0 +1,152 @@ +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. +""" + + +import pykka +from mopidy import backend +from mopidy.models import Playlist, Ref, SearchResult + + +def create_proxy(config=None, audio=None): + return DummyBackend.start(config=config, audio=audio).proxy() + + +class DummyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): + super().__init__() + + self.library = DummyLibraryProvider(backend=self) + if audio: + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + else: + self.playback = DummyPlaybackProvider(audio=audio, backend=self) + self.playlists = DummyPlaylistsProvider(backend=self) + + self.uri_schemes = ["dummy"] + + +class DummyLibraryProvider(backend.LibraryProvider): + root_directory = Ref.directory(uri="dummy:/", name="dummy") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dummy_library = [] + self.dummy_get_distinct_result = {} + self.dummy_browse_result = {} + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() + + def browse(self, path): + return self.dummy_browse_result.get(path, []) + + def get_distinct(self, field, query=None): + return self.dummy_get_distinct_result.get(field, set()) + + def lookup(self, uri): + uri = Ref.track(uri=uri).uri + return [t for t in self.dummy_library if uri == t.uri] + + def refresh(self, uri=None): + pass + + def search(self, query=None, uris=None, exact=False): + if exact: # TODO: remove uses of dummy_find_exact_result + return self.dummy_find_exact_result + return self.dummy_search_result + + +class DummyPlaybackProvider(backend.PlaybackProvider): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._uri = None + self._time_position = 0 + + def pause(self): + return True + + def play(self): + return self._uri and self._uri != "dummy:error" + + def change_track(self, track): + """Pass a track with URI 'dummy:error' to force failure""" + self._uri = track.uri + self._time_position = 0 + return True + + def prepare_change(self): + pass + + def resume(self): + return True + + def seek(self, time_position): + self._time_position = time_position + return True + + def stop(self): + self._uri = None + return True + + def get_time_position(self): + return self._time_position + + +class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): + super().__init__(backend) + self._playlists = [] + self._allow_save = True + + def set_dummy_playlists(self, playlists): + """For tests using the dummy provider through an actor proxy.""" + self._playlists = playlists + + def set_allow_save(self, enabled): + self._allow_save = enabled + + def as_list(self): + return [ + Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists + ] + + def get_items(self, uri): + playlist = self.lookup(uri) + if playlist is None: + return + return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + + def lookup(self, uri): + uri = Ref.playlist(uri=uri).uri + for playlist in self._playlists: + if playlist.uri == uri: + return playlist + + def refresh(self): + pass + + def create(self, name): + playlist = Playlist(name=name, uri=f"dummy:{name}") + self._playlists.append(playlist) + return playlist + + def delete(self, uri): + playlist = self.lookup(uri) + if playlist: + self._playlists.remove(playlist) + + def save(self, playlist): + if not self._allow_save: + return None + + old_playlist = self.lookup(playlist.uri) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py new file mode 100644 index 0000000..b80e681 --- /dev/null +++ b/tests/dummy_mixer.py @@ -0,0 +1,30 @@ +import pykka +from mopidy import mixer + + +def create_proxy(config=None): + return DummyMixer.start(config=None).proxy() + + +class DummyMixer(pykka.ThreadingActor, mixer.Mixer): + def __init__(self, config): + super().__init__() + # These must be initialised to avoid none type error in tests + self._volume = 50 + self._mute = False + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + self.trigger_volume_changed(volume=volume) + return True + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/test_config.py b/tests/test_config.py index c363999..0ca5765 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,4 @@ -from __future__ import unicode_literals - import pytest - from mopidy_raspberry_gpio import Extension, PinConfig diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 60105b8..393c69e 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,43 +1,142 @@ -from __future__ import unicode_literals - -import sys - -import mock - -import pytest - -from mopidy_raspberry_gpio import Extension, pinconfig -from mopidy_raspberry_gpio import frontend as frontend_lib - - -deserialize = pinconfig.PinConfig().deserialize - -dummy_config = { - "raspberry-gpio": { - # Plugins expect settings to be deserialized - "bcm1": deserialize("play_pause,active_low,30") - } -} - - -def test_get_frontend_classes(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - - ext = Extension() - registry = mock.Mock() - - ext.setup(registry) - - registry.add.assert_called_once_with( - 'frontend', frontend_lib.RaspberryGPIOFrontend) - - -def test_frontend_handler_dispatch(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - - frontend = frontend_lib.RaspberryGPIOFrontend(dummy_config, mock.Mock()) - - with pytest.raises(RuntimeError): - frontend.dispatch_input('tomato') +import sys +from unittest import mock + +import pykka +from mopidy import core + +import pytest +from mopidy_raspberry_gpio import Extension +from mopidy_raspberry_gpio import frontend as frontend_lib +from mopidy_raspberry_gpio import pinconfig + +from . import dummy_audio, dummy_backend, dummy_mixer + +deserialize = pinconfig.PinConfig().deserialize + +dummy_config = { + "raspberry-gpio": { + # Plugins expect settings to be deserialized + "bcm1": deserialize("play_pause,active_low,30"), + "bcm2": deserialize("volume_up,active_high,30"), + "bcm3": deserialize("volume_down,active_high,30"), + } +} + + +def stop_mopidy_core(): + pykka.ActorRegistry.stop_all() + + +def dummy_mopidy_core(): + mixer = dummy_mixer.create_proxy() + audio = dummy_audio.create_proxy() + backend = dummy_backend.create_proxy(audio=audio) + return core.Core.start(audio=audio, mixer=mixer, backends=[backend]).proxy() + + +def test_get_frontend_classes(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + ext = Extension() + registry = mock.Mock() + + ext.setup(registry) + + registry.add.assert_called_once_with( + "frontend", frontend_lib.RaspberryGPIOFrontend + ) + + stop_mopidy_core() + + +def test_frontend_handler_dispatch_play_pause(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + frontend.dispatch_input("play_pause") + + stop_mopidy_core() + + +def test_frontend_handler_dispatch_next(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + frontend.dispatch_input("next") + + stop_mopidy_core() + + +def test_frontend_handler_dispatch_prev(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + frontend.dispatch_input("prev") + + stop_mopidy_core() + + +def test_frontend_handler_dispatch_volume_up(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + frontend.dispatch_input("volume_up") + + stop_mopidy_core() + + +def test_frontend_handler_dispatch_volume_down(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + frontend.dispatch_input("volume_down") + + stop_mopidy_core() + + +def test_frontend_handler_dispatch_invalid_event(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + with pytest.raises(RuntimeError): + frontend.dispatch_input("tomato") + + stop_mopidy_core() + + +def test_frontend_gpio_event(): + sys.modules["RPi"] = mock.Mock() + sys.modules["RPi.GPIO"] = mock.Mock() + + frontend = frontend_lib.RaspberryGPIOFrontend( + dummy_config, dummy_mopidy_core() + ) + + frontend.gpio_event(3) + + stop_mopidy_core() diff --git a/tox.ini b/tox.ini index 1e07a84..d4deebc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,23 @@ [tox] -envlist = py27, py27-flake8, py27-check-manifest +envlist = py37, py38, black, check-manifest, flake8 [testenv] sitepackages = true -deps = - mock - pytest - pytest-cov - pytest-xdist +deps = .[test] commands = - pytest \ - -v -r wsx \ + python -m pytest \ --basetemp={envtmpdir} \ --cov=mopidy_raspberry_gpio --cov-report=term-missing \ {posargs} -[testenv:py27-flake8] -deps = - flake8 - flake8-import-order -skip_install = true -commands = python -m flake8 +[testenv:black] +deps = .[lint] +commands = python -m black --check . -[testenv:py27-check-manifest] -deps = check-manifest -commands = check-manifest +[testenv:check-manifest] +deps = .[lint] +commands = python -m check_manifest + +[testenv:flake8] +deps = .[lint] +commands = python -m flake8 --ignore=B950,B305,E501 --show-source --statistics