├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── mopidy_IRControl ├── __init__.py ├── actor.py └── ext.conf ├── setup.py └── tests ├── __init__.py └── test_extension.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */pyshared/* 4 | */python?.?/* 5 | */site-packages/nose/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.swp 4 | .coverage 5 | MANIFEST 6 | build/ 7 | dist/ 8 | *~ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | install: 4 | - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" 5 | - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" 6 | - "sudo apt-get update || true" 7 | - "sudo apt-get install mopidy liblircclient-dev python-gobject-2-dbg python-gst0.10-dev python-gst0.10-dbg gnome-keyring" 8 | - "pip install pylirc2 coveralls flake8 mopidy" 9 | 10 | before_script: 11 | - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" 12 | - "mopidy --version" 13 | 14 | script: 15 | - "flake8 $(find . -iname '*.py')" 16 | - "nosetests --with-coverage --cover-package=mopidy_IRControl" 17 | 18 | after_success: 19 | - "coveralls" 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2014 Camillo Dell'mour 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include MANIFEST.in 3 | include README.rst 4 | include mopidy_IRControl/ext.conf 5 | include mopidy_IRControl/*.py 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **************************** 2 | Mopidy-IRControl 3 | **************************** 4 | 5 | .. image:: https://img.shields.io/pypi/v/Mopidy-IRControl.svg 6 | :target: https://pypi.python.org/pypi/Mopidy-IRControl/ 7 | :alt: Latest PyPI version 8 | 9 | .. image:: https://img.shields.io/pypi/dm/Mopidy-IRControl.svg 10 | :target: https://pypi.python.org/pypi/Mopidy-IRControl/ 11 | :alt: Number of PyPI downloads 12 | 13 | .. image:: https://api.travis-ci.org/spjoe/mopidy-ircontrol.png?branch=master 14 | :target: https://travis-ci.org/spjoe/mopidy-ircontrol 15 | :alt: Travis CI build status 16 | 17 | .. image:: https://coveralls.io/repos/spjoe/mopidy-ircontrol/badge.png?branch=master 18 | :target: https://coveralls.io/r/spjoe/mopidy-ircontrol?branch=master 19 | :alt: Test coverage 20 | 21 | 22 | A Mopidy frontend to control mopidy with an infrared remote control. It is using lirc as IR receiver deamon. 23 | 24 | 25 | Installation 26 | ============ 27 | 28 | Install by running:: 29 | 30 | pip install Mopidy-IRControl 31 | 32 | Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com 33 | `_. 34 | 35 | 36 | Configuration 37 | ============= 38 | 39 | Before starting Mopidy, you must add configuration for 40 | Mopidy-IRControl to your Mopidy configuration file:: 41 | 42 | [IRControl] 43 | enabled = true 44 | #look at your lircd.conf (/etc/lirc/lircd.conf) to find you configured buttons names 45 | mute = KEY_MUTE 46 | next = KEY_NEXT 47 | previous = KEY_PREVIOUS 48 | playpause = KEY_PLAYPAUSE 49 | stop = KEY_STOP 50 | volumeup = KEY_VOLUMEUP 51 | volumedown = KEY_VOLUMEDOWN 52 | 53 | Project resources 54 | ================= 55 | 56 | - `Source code `_ 57 | - `Issue tracker `_ 58 | - `Download development snapshot `_ 59 | 60 | 61 | Changelog 62 | ========= 63 | 64 | v0.1.0 - 08.10.2015 65 | ---------------------------------------- 66 | 67 | - Initial release. 68 | 69 | Contributors 70 | ============ 71 | - `logogin `_ 72 | -------------------------------------------------------------------------------- /mopidy_IRControl/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import os 4 | from mopidy import config, ext 5 | 6 | 7 | __version__ = '0.1.0' 8 | 9 | 10 | class Extension(ext.Extension): 11 | 12 | dist_name = 'Mopidy-IRControl' 13 | ext_name = 'IRControl' 14 | version = __version__ 15 | 16 | def get_default_config(self): 17 | conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') 18 | return config.read(conf_file) 19 | 20 | def get_config_schema(self): 21 | schema = super(Extension, self).get_config_schema() 22 | schema['mute'] = config.String() 23 | schema['next'] = config.String() 24 | schema['previous'] = config.String() 25 | schema['playpause'] = config.String() 26 | schema['stop'] = config.String() 27 | schema['volumeup'] = config.String() 28 | schema['volumedown'] = config.String() 29 | schema['power'] = config.String() 30 | schema['menu'] = config.String() 31 | schema['favorites'] = config.String() 32 | schema['search'] = config.String() 33 | schema['playlist_uri_template'] = config.String() 34 | schema['num0'] = config.String() 35 | schema['num1'] = config.String() 36 | schema['num2'] = config.String() 37 | schema['num3'] = config.String() 38 | schema['num4'] = config.String() 39 | schema['num5'] = config.String() 40 | schema['num6'] = config.String() 41 | schema['num7'] = config.String() 42 | schema['num8'] = config.String() 43 | schema['num9'] = config.String() 44 | return schema 45 | 46 | def setup(self, registry): 47 | from .actor import IRControlFrontend 48 | registry.add('frontend', IRControlFrontend) 49 | -------------------------------------------------------------------------------- /mopidy_IRControl/actor.py: -------------------------------------------------------------------------------- 1 | import pykka 2 | import pylirc 3 | import logging 4 | import tempfile 5 | import threading 6 | import select 7 | 8 | from mopidy.core import PlaybackState 9 | from mopidy.core import CoreListener 10 | 11 | logger = logging.getLogger('mopidy_IRControl') 12 | 13 | LIRC_PROG_NAME = "mopidyIRControl" 14 | 15 | 16 | class Event(list): 17 | """Event subscription. 18 | 19 | A list of callable objects. Calling an instance of this will cause a 20 | call to each item in the list in ascending order by index.""" 21 | def __call__(self, *args, **kwargs): 22 | for f in self: 23 | f(*args, **kwargs) 24 | 25 | def __repr__(self): 26 | return "Event(%s)" % list.__repr__(self) 27 | 28 | 29 | class CommandDispatcher(object): 30 | def __init__(self, core, config, buttonPressEvent): 31 | self.core = core 32 | self.config = config 33 | self._handlers = {} 34 | self.registerHandler('playpause', self._playpauseHandler) 35 | self.registerHandler('mute', self._muteHandler) 36 | self.registerHandler('stop', lambda: self.core.playback.stop().get()) 37 | self.registerHandler('next', lambda: self.core.playback.next().get()) 38 | self.registerHandler('previous', 39 | lambda: self.core.playback.previous().get()) 40 | self.registerHandler('volumedown', 41 | self._volumeFunction(lambda vol: vol - 5)) 42 | self.registerHandler('volumeup', 43 | self._volumeFunction(lambda vol: vol + 5)) 44 | 45 | for i in range(10): 46 | self.registerHandler('num{0}'.format(i), self._playlistFunction(i)) 47 | 48 | buttonPressEvent.append(self.handleCommand) 49 | 50 | def handleCommand(self, cmd): 51 | if cmd in self._handlers: 52 | logger.debug("Command {0} was handled".format(cmd)) 53 | self._handlers[cmd]() 54 | else: 55 | logger.debug("Command {0} was not handled".format(cmd)) 56 | 57 | def registerHandler(self, cmd, handler): 58 | self._handlers[cmd] = handler 59 | 60 | def _playpauseHandler(self): 61 | state = self.core.playback.get_state().get() 62 | if(state == PlaybackState.PAUSED): 63 | self.core.playback.resume().get() 64 | elif (state == PlaybackState.PLAYING): 65 | self.core.playback.pause().get() 66 | elif (state == PlaybackState.STOPPED): 67 | self.core.playback.play().get() 68 | 69 | def _muteHandler(self): 70 | self.core.mixer.set_mute(not self.core.mixer.get_mute().get()) 71 | 72 | def _volumeFunction(self, changeFct): 73 | def volumeChange(): 74 | vol = self.core.mixer.get_volume().get() 75 | self.core.mixer.set_volume(min(max(0, changeFct(vol)), 100)) 76 | return volumeChange 77 | 78 | def _playPlaylist(self, uri): 79 | refs = self.core.playlists.get_items(uri).get() 80 | if not refs: 81 | logger.warn("Playlist '%s' does not exist", uri) 82 | return 83 | self.core.tracklist.clear() 84 | uris = map(lambda ref: ref.uri, refs) 85 | self.core.tracklist.add(uris=uris) 86 | self.core.tracklist.set_consume(False) 87 | self.core.tracklist.set_repeat(True) 88 | self.core.playback.play() 89 | 90 | def _playlistFunction(self, num): 91 | return lambda: self._playPlaylist(self.config['playlist_uri_template'].format(num)) 92 | 93 | class LircThread(threading.Thread): 94 | def __init__(self, configFile): 95 | threading.Thread.__init__(self) 96 | self.name = 'Lirc worker thread' 97 | self.configFile = configFile 98 | self.frontendActive = True 99 | self.ButtonPressed = Event() 100 | 101 | def run(self): 102 | try: 103 | self.run_inside_try() 104 | except Exception as e: 105 | logger.warning('IRControl has problems starting pylirc: ' + str(e)) 106 | 107 | def run_inside_try(self): 108 | self.startPyLirc() 109 | 110 | def startPyLirc(self): 111 | lircHandle = pylirc.init(LIRC_PROG_NAME, self.configFile, 0) 112 | if(lircHandle != 0): 113 | while(self.frontendActive): 114 | self.consumePylirc(lircHandle) 115 | pylirc.exit() 116 | 117 | def consumePylirc(self, lircHandle): 118 | try: 119 | if(select.select([lircHandle], [], [], 1) == ([], [], [])): 120 | pass 121 | else: 122 | s = pylirc.nextcode(1) 123 | self.handleNextCode(s) 124 | except Exception as e: 125 | logger.warning('Exception during handling a command: ' + str(e)) 126 | 127 | def handleNextCode(self, s): 128 | if s: 129 | self.handleLircCode(s) 130 | 131 | def handleLircCode(self, s): 132 | for code in s: 133 | self.handleCommand(code['config']) 134 | 135 | def handleCommand(self, cmd): 136 | logger.debug('Command: {0}'.format(cmd)) 137 | self.ButtonPressed(cmd) 138 | 139 | 140 | class IRControlFrontend(pykka.ThreadingActor, CoreListener): 141 | def __init__(self, config, core): 142 | super(IRControlFrontend, self).__init__() 143 | self.core = core 144 | self.config = config['IRControl'] 145 | self.configFile = self.generateLircConfigFile(config['IRControl']) 146 | logger.debug('lircrc file:{0}'.format(self.configFile)) 147 | 148 | def on_start(self): 149 | try: 150 | logger.debug('IRControl starting') 151 | self.thread = LircThread(self.configFile) 152 | self.dispatcher = CommandDispatcher( 153 | self.core, 154 | self.config, 155 | self.thread.ButtonPressed) 156 | self.thread.ButtonPressed.append(self.handleButtonPress) 157 | self.thread.start() 158 | logger.debug('IRControl started') 159 | except Exception as e: 160 | logger.warning('IRControl has not started: ' + str(e)) 161 | self.stop() 162 | 163 | def on_stop(self): 164 | logger.info('IRControl stopped') 165 | self.thread.frontendActive = False 166 | self.thread.join() 167 | 168 | def on_failure(self): 169 | logger.warning('IRControl failing') 170 | self.thread.frontendActive = False 171 | self.thread.join() 172 | 173 | def handleButtonPress(self, cmd): 174 | pass 175 | # CoreListener.send("IRButtonPressed", button=cmd) 176 | 177 | def generateLircConfigFile(self, config): 178 | '''Returns file name of generate config file for pylirc''' 179 | f = tempfile.NamedTemporaryFile(delete=False) 180 | skeleton = 'begin\n prog={2}\n button={0}\n config={1}\nend\n' 181 | for action in config: 182 | entry = skeleton.format(config[action], action, LIRC_PROG_NAME) 183 | f.write(entry) 184 | f.close() 185 | return f.name 186 | -------------------------------------------------------------------------------- /mopidy_IRControl/ext.conf: -------------------------------------------------------------------------------- 1 | [IRControl] 2 | enabled = true 3 | #look at your lircd.conf (/etc/lirc/lircd.conf) to find you configured buttons 4 | mute = KEY_MUTE 5 | next = KEY_NEXT 6 | previous = KEY_PREVIOUS 7 | playpause = KEY_PLAYPAUSE 8 | stop = KEY_STOP 9 | volumeup = KEY_VOLUMEUP 10 | volumedown = KEY_VOLUMEDOWN 11 | playlist_uri_template = m3u:playlist{0}.m3u 12 | num0 = KEY_0 13 | num1 = KEY_1 14 | num2 = KEY_2 15 | num3 = KEY_3 16 | num4 = KEY_4 17 | num5 = KEY_5 18 | num6 = KEY_6 19 | num7 = KEY_7 20 | num8 = KEY_8 21 | num9 = KEY_9 22 | # The following are not used yet. 23 | power = KEY_POWER 24 | menu = KEY_MENU 25 | favorites = KEY_FAVORITES 26 | search = KEY_SEARCH 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | from setuptools import setup, find_packages 5 | 6 | 7 | def get_version(filename): 8 | content = open(filename).read() 9 | metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) 10 | return metadata['version'] 11 | 12 | 13 | setup( 14 | name='Mopidy-IRControl', 15 | version=get_version('mopidy_IRControl/__init__.py'), 16 | url='https://github.com/spjoe/mopidy-ircontrol', 17 | license='Apache License, Version 2.0', 18 | author="Camillo Dell'mour", 19 | author_email='cdellmour@gmail.com', 20 | description='Mopidy frontend to be controlled with an IR controller', 21 | long_description=open('README.rst').read(), 22 | packages=find_packages(exclude=['tests', 'tests.*']), 23 | zip_safe=False, 24 | include_package_data=True, 25 | install_requires=[ 26 | 'setuptools', 27 | 'Mopidy >=1.1.1', 28 | 'Pykka >= 1.1', 29 | 'pylirc2 >= 0.1', 30 | ], 31 | test_suite='nose.collector', 32 | tests_require=[ 33 | 'nose', 34 | 'mock >= 1.0', 35 | ], 36 | entry_points={ 37 | 'mopidy.ext': [ 38 | 'IRControl = mopidy_IRControl:Extension', 39 | ], 40 | }, 41 | classifiers=[ 42 | 'Environment :: No Input/Output (Daemon)', 43 | 'Intended Audience :: End Users/Desktop', 44 | 'License :: OSI Approved :: Apache Software License', 45 | 'Operating System :: OS Independent', 46 | 'Programming Language :: Python :: 2', 47 | 'Topic :: Multimedia :: Sound/Audio :: Players', 48 | ], 49 | ) 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spjoe/mopidy-ircontrol/752a5df08c36845e580ae5256e26525a288e1438/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | import mopidy 5 | import pylirc 6 | import time 7 | 8 | from mock import Mock, patch, MagicMock 9 | from mopidy_IRControl import Extension, actor as lib 10 | from mopidy.models import Ref 11 | from pykka.threading import ThreadingFuture 12 | 13 | import sys 14 | import logging 15 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 16 | 17 | 18 | class ExtensionTest(unittest.TestCase): 19 | def test_get_default_config(self): 20 | ext = Extension() 21 | 22 | config = ext.get_default_config() 23 | 24 | self.assertIn('[IRControl]', config) 25 | self.assertIn('enabled = true', config) 26 | self.assertIn('playlist_uri_template = m3u:playlist{0}.m3u', config) 27 | 28 | def test_get_config_schema(self): 29 | ext = Extension() 30 | 31 | schema = ext.get_config_schema() 32 | self.assertIn('mute', schema) 33 | self.assertIn('playpause', schema) 34 | self.assertIn('next', schema) 35 | self.assertIn('previous', schema) 36 | self.assertIn('stop', schema) 37 | self.assertIn('volumedown', schema) 38 | self.assertIn('volumeup', schema) 39 | 40 | self.assertIn('playlist_uri_template', schema) 41 | for i in range(10): 42 | self.assertIn('num{0}'.format(i), schema) 43 | 44 | 45 | class FrontendTest(unittest.TestCase): 46 | 47 | def setUp(self): 48 | IRconfig = {} 49 | IRconfig['next'] = 'KEY_NEXT' 50 | IRconfig['previous'] = 'KEY_PREVIOUS' 51 | IRconfig['playpause'] = 'KEY_PLAYPAUSE' 52 | IRconfig['stop'] = 'KEY_STOP' 53 | IRconfig['volumeup'] = 'KEY_VOLUMEUP' 54 | IRconfig['volumedown'] = 'KEY_VOLUMEDOWN' 55 | IRconfig['enabled'] = True 56 | self.config = {'IRControl': IRconfig} 57 | 58 | def test_on_start_should_spawn_thread(self): 59 | actor = lib.IRControlFrontend(self.config, None) 60 | actor.on_start() 61 | assert actor.thread is not None 62 | actor.on_stop() 63 | 64 | def test_on_stop(self): 65 | actor = lib.IRControlFrontend(self.config, None) 66 | actor.on_start() 67 | actor.on_stop() 68 | assert not actor.thread.isAlive() 69 | 70 | def test_on_failure(self): 71 | actor = lib.IRControlFrontend(self.config, None) 72 | actor.on_start() 73 | 74 | actor.on_failure() 75 | assert not actor.thread.isAlive() 76 | 77 | @patch('mopidy_IRControl.actor.logger') 78 | def test_on_start_log_exception(self, mock_logger): 79 | actor = lib.IRControlFrontend(self.config, None) 80 | with patch('mopidy_IRControl.actor.LircThread.start') as MockMethod: 81 | MockMethod.side_effect = Exception('Boom!') 82 | actor.on_start() 83 | self.assertTrue(mock_logger.warning.called) 84 | 85 | 86 | class CommandDispatcherTest(unittest.TestCase): 87 | class WithGet: 88 | def __init__(self, value): 89 | self.value = value 90 | 91 | def get(self): 92 | return self.value 93 | 94 | def __call__(self): 95 | return self 96 | 97 | def setUp(self): 98 | self.coreMock = mopidy.core.Core(None, None, [], None) 99 | self.buttonPressEvent = lib.Event() 100 | playback = Mock() 101 | playback.get_state = self.WithGet(mopidy.core.PlaybackState.STOPPED) 102 | 103 | mixer = Mock() 104 | mixer.get_mute = self.WithGet(False) 105 | mixer.get_volume = self.WithGet(50) 106 | mixer.set_mute = MagicMock() 107 | mixer.set_volume = MagicMock() 108 | 109 | self.coreMock.playback = playback 110 | self.coreMock.mixer = mixer 111 | self.coreMock.playlists = Mock() 112 | self.coreMock.tracklist = Mock() 113 | 114 | self.dispatcher = lib.CommandDispatcher(self.coreMock, 115 | {'playlist_uri_template': 'local:playlist:playlist{0}.m3u'}, 116 | self.buttonPressEvent) 117 | 118 | def commandXYZHandler(self): 119 | self.executed = True 120 | 121 | def test_registerHandler(self): 122 | self.executed = False 123 | self.dispatcher.registerHandler('commandXYZ', self.commandXYZHandler) 124 | 125 | self.dispatcher.handleCommand('commandXYZ') 126 | assert self.executed 127 | 128 | def test_handleCommand(self): 129 | self.executed = False 130 | 131 | self.dispatcher.handleCommand('commandXYZ') 132 | assert not self.executed 133 | 134 | def test_default_registered_handler(self): 135 | self.assertIn('mute', self.dispatcher._handlers) 136 | self.assertIn('playpause', self.dispatcher._handlers) 137 | self.assertIn('next', self.dispatcher._handlers) 138 | self.assertIn('previous', self.dispatcher._handlers) 139 | self.assertIn('stop', self.dispatcher._handlers) 140 | self.assertIn('volumedown', self.dispatcher._handlers) 141 | self.assertIn('volumeup', self.dispatcher._handlers) 142 | 143 | for i in range(10): 144 | self.assertIn('num{0}'.format(i), self.dispatcher._handlers) 145 | 146 | def test_stop_handler(self): 147 | self.dispatcher.handleCommand('stop') 148 | self.coreMock.playback.stop.assert_called_with() 149 | 150 | def test_mute_handler(self): 151 | self.dispatcher.handleCommand('mute') 152 | self.coreMock.mixer.set_mute.assert_called_with(True) 153 | 154 | def test_next_handler(self): 155 | self.dispatcher.handleCommand('next') 156 | self.coreMock.playback.next.assert_called_with() 157 | 158 | def test_previous_handler(self): 159 | self.dispatcher.handleCommand('previous') 160 | self.coreMock.playback.previous.assert_called_with() 161 | 162 | def test_playpause_play_handler(self): 163 | self.dispatcher.handleCommand('playpause') 164 | self.coreMock.playback.play.assert_called_with() 165 | 166 | def test_playpause_resume_handler(self): 167 | self.coreMock.playback.get_state = \ 168 | self.WithGet(mopidy.core.PlaybackState.PAUSED) 169 | 170 | self.dispatcher.handleCommand('playpause') 171 | self.coreMock.playback.resume.assert_called_with() 172 | 173 | def test_playpause_pause_handler(self): 174 | self.coreMock.playback.get_state = \ 175 | self.WithGet(mopidy.core.PlaybackState.PLAYING) 176 | 177 | self.dispatcher.handleCommand('playpause') 178 | self.coreMock.playback.pause.assert_called_with() 179 | 180 | def test_volumedown_handler(self): 181 | self.dispatcher.handleCommand('volumedown') 182 | self.coreMock.mixer.set_volume.assert_called_with(45) 183 | 184 | def test_volumeup_handler(self): 185 | self.dispatcher.handleCommand('volumeup') 186 | self.coreMock.mixer.set_volume.assert_called_with(55) 187 | 188 | def test_num_handler_no_playlist(self): 189 | refs = ThreadingFuture() 190 | refs.set(None) 191 | self.coreMock.playlists.get_items = MagicMock(return_value=refs) 192 | 193 | self.dispatcher.handleCommand('num0') 194 | self.coreMock.playlists.get_items.assert_called_with('local:playlist:playlist0.m3u') 195 | self.assertEqual(0, len(self.coreMock.playback.mock_calls)) 196 | self.assertFalse(0, len(self.coreMock.tracklist.mock_calls)) 197 | 198 | def test_num_handler_add_items(self): 199 | refs = ThreadingFuture() 200 | refs.set([Ref(uri='track1'), Ref(uri='track2')]) 201 | self.coreMock.playlists.get_items = MagicMock(return_value=refs) 202 | 203 | self.dispatcher.handleCommand('num9') 204 | 205 | self.coreMock.playlists.get_items.assert_called_with('local:playlist:playlist9.m3u') 206 | self.coreMock.tracklist.add.assert_called_with(uris=['track1', 'track2']) 207 | self.coreMock.playback.play.assert_called_with() 208 | 209 | 210 | class LircThreadTest(unittest.TestCase): 211 | def setUp(self): 212 | pylirc.init = Mock(return_value=True) 213 | pylirc.nextcode = Mock(return_value=[]) 214 | pylirc.exit = Mock(return_value=True) 215 | 216 | def test_startPyLirc(self): 217 | thread = lib.LircThread(None) 218 | thread.start() 219 | thread.frontendActive = False 220 | thread.join() 221 | 222 | pylirc.init.assert_called_with('mopidyIRControl', None, 0) 223 | 224 | @patch('mopidy_IRControl.actor.logger') 225 | def test_handleCommand(self, mock_logger): 226 | pylirc.nextcode = Mock(return_value=[{'config': 'commandXYZ'}]) 227 | 228 | with patch('select.select') as MockClass: 229 | MockClass.return_value = ([1], [], []) 230 | thread = lib.LircThread(None) 231 | thread.start() 232 | time.sleep(0.1) 233 | thread.frontendActive = False 234 | thread.join() 235 | 236 | assert mock_logger.debug.called 237 | --------------------------------------------------------------------------------