diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 0000000..f540664 --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b481729..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python -matrix: - fast_finish: true - include: - - python: "2.7" - env: TOXENV=py27 - - python: "3.5" - env: TOXENV=py35 - - python: "3.6" - env: TOXENV=py36 -install: pip install tox -script: tox diff --git a/MANIFEST.in b/MANIFEST.in index e69de29..e1e0072 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -0,0 +1,4 @@ + include *.rst + include Makefile + recursive-include tests *.py + recursive-include tests *.xml diff --git a/Makefile b/Makefile deleted file mode 100644 index 518b22c..0000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -test: - flake8 - isort --recursive --check-only --diff - tox - -isort: - isort --recursive --apply - -clean: - rm -rf .virtualenv dist build *.egg-info - -.PHONY: clean isort test \ No newline at end of file diff --git a/README.rst b/README.rst index 8c3422d..0a5fed5 100644 --- a/README.rst +++ b/README.rst @@ -1,13 +1,10 @@ rxv === -.. image:: https://travis-ci.org/wuub/rxv.svg?branch=master - :target: https://travis-ci.org/wuub/rxv +.. image:: https://badge.fury.io/py/rxv.svg + :target: https://badge.fury.io/py/rxv -.. image:: https://landscape.io/github/wuub/rxv/master/landscape.svg?style=flat - :target: https://landscape.io/github/wuub/rxv/master - :alt: Code Health -Automation Library for Yamaha RX-V473, RX-V573, RX-V673, RX-V773 receivers +Automation Library for Yamaha receivers Installation ============ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2e8be56 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "rxv/version.py" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 945c9b4..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -. \ No newline at end of file diff --git a/rxv/.gitignore b/rxv/.gitignore new file mode 100644 index 0000000..9852786 --- /dev/null +++ b/rxv/.gitignore @@ -0,0 +1 @@ +version.py diff --git a/rxv/__init__.py b/rxv/__init__.py index e71b14e..a6f11e3 100644 --- a/rxv/__init__.py +++ b/rxv/__init__.py @@ -16,12 +16,4 @@ def find(timeout=1.5): """Find all Yamah receivers on local network using SSDP search.""" - return [ - RXV( - ctrl_url=ri.ctrl_url, - model_name=ri.model_name, - friendly_name=ri.friendly_name, - unit_desc_url=ri.unit_desc_url - ) - for ri in ssdp.discover(timeout=timeout) - ] + return [RXV(**ri._asdict()) for ri in ssdp.discover(timeout=timeout)] diff --git a/rxv/rxv.py b/rxv/rxv.py index 7862d5b..97f5b19 100644 --- a/rxv/rxv.py +++ b/rxv/rxv.py @@ -50,6 +50,7 @@ def __init__(self, play=False, stop=False, pause=False, YamahaCommand = '{payload}' Zone = '<{zone}>{request_text}' BasicStatusGet = 'GetParam' +PartyMode = '{state}' PowerControl = '{state}' PowerControlSleep = '{sleep_value}' Input = '{input_name}' @@ -67,6 +68,8 @@ def __init__(self, play=False, stop=False, pause=False, VolumeMute = '{state}' SelectNetRadioLine = 'Line_{lineno}'\ '' +SelectServerLine = 'Line_{lineno}'\ + '' HdmiOut = '{command}'\ '' @@ -84,8 +87,8 @@ def __init__(self, play=False, stop=False, pause=False, class RXV(object): def __init__(self, ctrl_url, model_name="Unknown", - zone="Main_Zone", friendly_name='Unknown', - unit_desc_url=None): + serial_number=None, zone="Main_Zone", + friendly_name='Unknown', unit_desc_url=None): if re.match(r"\d{1,3}\.\d{1,3}\.\d{1,3}.\d{1,3}", ctrl_url): # backward compatibility: accept ip address as a contorl url warnings.warn("Using IP address as a Control URL is deprecated") @@ -93,6 +96,7 @@ def __init__(self, ctrl_url, model_name="Unknown", self.ctrl_url = ctrl_url self.unit_desc_url = unit_desc_url or re.sub('ctrl$', 'desc.xml', ctrl_url) self.model_name = model_name + self.serial_number = serial_number self.friendly_name = friendly_name self._inputs_cache = None self._zones_cache = None @@ -330,7 +334,7 @@ def surround_programs(self): if source_xml is None: return False - setup = source_xml.find('.//*[@Title_1="Setup"]') + setup = source_xml.find('.//Menu[@Title_1="Setup"]') if setup is None: return False @@ -590,6 +594,22 @@ def volume_fade(self, final_vol, sleep=0.5): self.volume = val time.sleep(sleep) + @property + def partymode(self): + request_text = PartyMode.format(state=GetParam) + response = self._request('GET', request_text, False) + pmode = response.find('System/Party_Mode/Mode').text + assert pmode in ["On", "Off"] + return pmode == "On" + + @partymode.setter + def partymode(self, state): + assert state in [True, False] + new_state = "On" if state else "Off" + request_text = PartyMode.format(state=new_state) + response = self._request('PUT', request_text, False) + return response + @property def mute(self): request_text = VolumeMute.format(state=GetParam) @@ -642,6 +662,39 @@ def net_radio(self, path): # print("Sleeping because we are not ready yet") time.sleep(1) + def _direct_sel_server(self, lineno): + request_text = SelectServerLine.format(lineno=lineno) + return self._request('PUT', request_text, zone_cmd=False) + + def server(self, path): + """Play from specified server + + This lets you play a SERVER address in a single command + with by encoding it with > as separators. For instance: + + Server>Playlists>GoodVibes + + This code is copied from the net_radio function. + + TODO: better error handling if we some how time out + """ + layers = path.split(">") + self.input = "SERVER" + + for attempt in range(20): + menu = self.menu_status() + if menu.ready: + for line, value in menu.current_list.items(): + if value == layers[menu.layer - 1]: + lineno = line[5:] + self._direct_sel_server(lineno) + if menu.layer == len(layers): + return + break + else: + # print("Sleeping because we are not ready yet") + time.sleep(1) + @property def sleep(self): request_text = PowerControlSleep.format(sleep_value=GetParam) diff --git a/rxv/ssdp.py b/rxv/ssdp.py index 5d62d56..180c0af 100644 --- a/rxv/ssdp.py +++ b/rxv/ssdp.py @@ -37,8 +37,15 @@ "{urn:schemas-upnp-org:device-1-0}device" "/{urn:schemas-upnp-org:device-1-0}friendlyName" ) +SERIAL_NUMBER_QUERY = ( + "{urn:schemas-upnp-org:device-1-0}device" + "/{urn:schemas-upnp-org:device-1-0}serialNumber" +) -RxvDetails = namedtuple("RxvDetails", "ctrl_url unit_desc_url, model_name friendly_name") +RxvDetails = namedtuple( + "RxvDetails", + "ctrl_url unit_desc_url, model_name friendly_name serial_number" +) def discover(timeout=1.5): @@ -85,8 +92,9 @@ def rxv_details(location): unit_desc_url = urljoin(url_base_el.text, unit_desc_url_local) model_name = res.find(MODEL_NAME_QUERY).text friendly_name = res.find(FRIENDLY_NAME_QUERY).text + serial_number = res.find(SERIAL_NUMBER_QUERY).text - return RxvDetails(ctrl_url, unit_desc_url, model_name, friendly_name) + return RxvDetails(ctrl_url, unit_desc_url, model_name, friendly_name, serial_number) if __name__ == '__main__': diff --git a/setup.cfg b/setup.cfg index ba1e011..64e52ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,94 @@ +[metadata] +name = rxv +description = Automation Library for Yamaha RX-V473, RX-V573, RX-V673, RX-V773 receivers +long_description = file: README.md +long_description_content_type = text/x-rst +maintainer = Wojciech Bederski +maintainer-email = github@wuub.net +author = Wojciech Bederski +author-email = github@wuub.net +url = https://github.com/wuub/rxv +project_urls = + Source=https://github.com/wuub/rxv + Tracker=https://github.com/wuub/rxv/issues +platforms = any +license = BSD +license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + Framework :: tox + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Topic :: Home Automation, + Topic :: Software Development :: Libraries + Topic :: Utilities + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: Implementation :: CPython + + +[options] +zip_safe = False +include_package_data = True +packages = find: +install_requires = + requests + defusedxml + +[options.extras_require] +testing = + black + flake8 + mock + pytest + pytest-cov + pytest-timeout + requests-mock + [wheel] universal = 1 +[tox:tox] +minversion = 3.7 +isolated_build = true +skip_missing_interpreters = true +envlist = + flake8 + manifest + py{37,38,39} + +[gh-actions] +python = + 3.7: py37 + 3.8: py38, flake8, manifest + 3.9: py39 + +[coverage:run] +source_pkgs= + rxv + +[testenv] +extras = testing +commands = py.test --cov --cov-report=term --cov-report=xml {posargs} + +[testenv:flake8] +description = run flake8 under {basepython} +commands = flake8 rxv/ tests/ +extras = testing + +[testenv:manifest] +basepython = python3.8 +deps = check-manifest +skip_install = true +commands = check-manifest + +[check-manifest] +ignore = + rxv/version.py + [flake8] exclude = .cache,.git,.tox,.eggs,build,docs/*,*.egg-info max-line-length = 100 diff --git a/setup.py b/setup.py deleted file mode 100644 index f33d8c9..0000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -from __future__ import absolute_import, division, print_function - -import sys - -from setuptools import find_packages, setup -from setuptools.command.test import test as TestCommand - - -class Tox(TestCommand): - - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True - - def run_tests(self): - import tox - errno = tox.cmdline(self.test_args) - sys.exit(errno) - - -setup( - name='rxv', - version='0.6.0', - description='Automation Library for Yamaha RX-V473, RX-V573, RX-V673, RX-V773 receivers', - long_description=open('README.rst').read(), - author='Wojciech Bederski', - url="https://github.com/wuub/rxv", - license='BSD', - author_email='github@wuub.net', - packages=find_packages(), - install_requires=['requests', 'defusedxml'], - tests_require=['tox'], - zip_safe=False, - cmdclass={'test': Tox}, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries", - "Topic :: Home Automation", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: Implementation :: PyPy" - ] -) diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 30e880c..0000000 --- a/test-requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -mock -pytest>=2.9.2 -pytest-cov>=2.3.1 -pytest-timeout>=1.0.0 -testtools -requests-mock diff --git a/tests/test_feature_support.py b/tests/test_feature_support.py index d4b791d..9e81288 100644 --- a/tests/test_feature_support.py +++ b/tests/test_feature_support.py @@ -1,12 +1,14 @@ from io import open import requests_mock -import testtools +import unittest + import rxv FAKE_IP = '10.0.0.0' DESC_XML = 'http://%s/YamahaRemoteControl/desc.xml' % FAKE_IP +CTRL_URL = 'http://%s/YamahaRemoteControl/ctrl' % FAKE_IP def sample_content(name): @@ -14,13 +16,13 @@ def sample_content(name): return f.read() -class TestFeaturesV675(testtools.TestCase): +class TestFeaturesV675(unittest.TestCase): @requests_mock.mock() def setUp(self, m): super(TestFeaturesV675, self).setUp() m.get(DESC_XML, text=sample_content('rx-v675-desc.xml')) - self.rec = rxv.RXV(FAKE_IP) + self.rec = rxv.RXV(CTRL_URL) def test_supports_method(self): rec = self.rec diff --git a/tests/test_rxv.py b/tests/test_rxv.py index f2f9447..9690369 100644 --- a/tests/test_rxv.py +++ b/tests/test_rxv.py @@ -1,12 +1,13 @@ from io import open import requests_mock -import testtools +import unittest import rxv FAKE_IP = '10.0.0.0' DESC_XML = 'http://%s/YamahaRemoteControl/desc.xml' % FAKE_IP +CTRL_URL = 'http://%s/YamahaRemoteControl/ctrl' % FAKE_IP def sample_content(name): @@ -14,26 +15,23 @@ def sample_content(name): return f.read() -class TestRXV(testtools.TestCase): +class TestRXV(unittest.TestCase): @requests_mock.mock() def test_basic_object(self, m): m.get(DESC_XML, text=sample_content('rx-v675-desc.xml')) - rec = rxv.RXV(FAKE_IP) - self.assertEqual( - rec.ctrl_url, - 'http://%s/YamahaRemoteControl/ctrl' % FAKE_IP) + rec = rxv.RXV(CTRL_URL) self.assertEqual( rec.unit_desc_url, 'http://%s/YamahaRemoteControl/desc.xml' % FAKE_IP) -class TestDesc(testtools.TestCase): +class TestDesc(unittest.TestCase): @requests_mock.mock() def test_discover_zones(self, m): m.get(DESC_XML, text=sample_content('rx-v675-desc.xml')) - rec = rxv.RXV(FAKE_IP) + rec = rxv.RXV(CTRL_URL) zones = rec.zone_controllers() self.assertEqual(len(zones), 2, zones) self.assertEqual(zones[0].zone, "Main_Zone") diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 22b4531..0000000 --- a/tox.ini +++ /dev/null @@ -1,11 +0,0 @@ -[tox] -envlist = py27,py34,py35,py36,py37,pypy -skip_missing_interpreters = True - -[testenv] -setenv = - LANG=en_US.UTF-8 - PYTHONPATH = {toxinidir} -deps = - -r{toxinidir}/test-requirements.txt -commands=py.test tests --timeout=30 --duration=10 --cov=rxv --cov-report html {posargs}