pax_global_header00006660000000000000000000000064143262500750014516gustar00rootroot0000000000000052 comment=55bf73aeffa7a39714048596d0653173f4562613 etsinko-pymonoprice-55bf73a/000077500000000000000000000000001432625007500161525ustar00rootroot00000000000000etsinko-pymonoprice-55bf73a/.coveragerc000066400000000000000000000005501432625007500202730ustar00rootroot00000000000000[run] source = pymonoprice [report] # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: def __repr__ # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError etsinko-pymonoprice-55bf73a/.github/000077500000000000000000000000001432625007500175125ustar00rootroot00000000000000etsinko-pymonoprice-55bf73a/.github/workflows/000077500000000000000000000000001432625007500215475ustar00rootroot00000000000000etsinko-pymonoprice-55bf73a/.github/workflows/ci.yml000066400000000000000000000022261432625007500226670ustar00rootroot00000000000000name: CI on: workflow_dispatch: push: pull_request: jobs: unit-test: name: Python ${{ matrix.python-version}} unit tests runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - "3.7" - "3.8" - "3.9" - "3.10" - "3.11-dev" steps: - name: Checkout repo uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install -r requirements.txt - name: Run unit tests run: python -m unittest mypy-test: name: mypy test runs-on: ubuntu-latest env: python-version: "3.10" steps: - name: Checkout repo uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v4 with: python-version: ${{ env.python-version }} - name: Install dependencies run: | pip install -r requirements.txt pip install mypy - name: Run mypy test run: mypy -p pymonoprice etsinko-pymonoprice-55bf73a/.gitignore000066400000000000000000000000651432625007500201430ustar00rootroot00000000000000.mypy_cache __pycache__ *.swp build dist *.egg-info etsinko-pymonoprice-55bf73a/.travis.yml000066400000000000000000000003641432625007500202660ustar00rootroot00000000000000language: python python: - "3.4" - "3.5" - "3.6" cache: pip before_install: - pip3 install pytest pytest-cov - pip3 install coveralls install: - pip3 install -r requirements.txt script: - py.test --cov after_success: - coverallsetsinko-pymonoprice-55bf73a/LICENSE.txt000066400000000000000000000020501432625007500177720ustar00rootroot00000000000000MIT License Copyright (c) 2017 Egor Tsinko Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. etsinko-pymonoprice-55bf73a/MANIFEST.in000066400000000000000000000000771432625007500177140ustar00rootroot00000000000000include *.txt include .coveragerc recursive-include tests *.py etsinko-pymonoprice-55bf73a/README.md000066400000000000000000000043251432625007500174350ustar00rootroot00000000000000## Status [![Build Status](https://github.com/etsinko/pymonoprice/actions/workflows/ci.yml/badge.svg)](https://github.com/etsinko/pymonoprice/actions/workflows/ci.yml)[![Coverage Status](https://coveralls.io/repos/github/etsinko/pymonoprice/badge.svg)](https://coveralls.io/github/etsinko/pymonoprice) # pymonoprice Python3 interface implementation for Monoprice 6 zone amplifier ## Notes This is for use with [Home-Assistant](http://home-assistant.io) ## Usage ```python from pymonoprice import get_monoprice monoprice = get_monoprice('/dev/ttyUSB0') # Valid zones are 11-16 for main monoprice amplifier zone_status = monoprice.zone_status(11) # Print zone status print('Zone Number = {}'.format(zone_status.zone)) print('Power is {}'.format('On' if zone_status.power else 'Off')) print('Mute is {}'.format('On' if zone_status.mute else 'Off')) print('Public Anouncement Mode is {}'.format('On' if zone_status.pa else 'Off')) print('Do Not Disturb Mode is {}'.format('On' if zone_status.do_not_disturb else 'Off')) print('Volume = {}'.format(zone_status.volume)) print('Treble = {}'.format(zone_status.treble)) print('Bass = {}'.format(zone_status.bass)) print('Balance = {}'.format(zone_status.balance)) print('Source = {}'.format(zone_status.source)) print('Keypad is {}'.format('connected' if zone_status.keypad else 'disconnected')) # Turn off zone #11 monoprice.set_power(11, False) # Mute zone #12 monoprice.set_mute(12, True) # Set volume for zone #13 monoprice.set_volume(13, 15) # Set source 1 for zone #14 monoprice.set_source(14, 1) # Set treble for zone #15 monoprice.set_treble(15, 10) # Set bass for zone #16 monoprice.set_bass(16, 7) # Set balance for zone #11 monoprice.set_balance(11, 3) # Restore zone #11 to it's original state monoprice.restore_zone(zone_status) ``` ## Usage with asyncio With `asyncio` flavor all methods of Monoprice object are coroutines. ```python import asyncio from pymonoprice import get_async_monoprice async def main(loop): monoprice = await get_async_monoprice('/dev/ttyUSB0', loop) zone_status = await monoprice.zone_status(11) if zone_status.power: await monoprice.set_power(zone_status.zone, False) loop = asyncio.get_event_loop() loop.run_until_complete(main(loop)) ```etsinko-pymonoprice-55bf73a/pymonoprice/000077500000000000000000000000001432625007500205165ustar00rootroot00000000000000etsinko-pymonoprice-55bf73a/pymonoprice/__init__.py000066400000000000000000000423111432625007500226300ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging import re import serial from dataclasses import dataclass from functools import wraps from serial_asyncio import create_serial_connection, SerialTransport from threading import RLock from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVar _P = ParamSpec("_P") _T = TypeVar("_T") _AsyncLockable = TypeVar("_AsyncLockable", "MonopriceAsync", "MonopriceProtocol") _LOGGER = logging.getLogger(__name__) ZONE_PATTERN = re.compile( r">(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)" ) EOL = b"\r\n#" LEN_EOL = len(EOL) TIMEOUT = 2 # Number of seconds before serial operation timeout def synchronized( func: Callable[Concatenate[Monoprice, _P], _T] ) -> Callable[Concatenate[Monoprice, _P], _T]: @wraps(func) def wrapper(self: Monoprice, *args: _P.args, **kwargs: _P.kwargs) -> _T: with self._lock: return func(self, *args, **kwargs) return wrapper def locked_coro( coro: Callable[Concatenate[_AsyncLockable, _P], Awaitable[_T]] ) -> Callable[Concatenate[_AsyncLockable, _P], Awaitable[_T]]: @wraps(coro) async def wrapper(self: _AsyncLockable, *args: _P.args, **kwargs: _P.kwargs) -> _T: async with self._lock: return await coro(self, *args, **kwargs) # type: ignore[return-value] return wrapper def connected( coro: Callable[Concatenate[MonopriceProtocol, _P], Awaitable[_T]] ) -> Callable[Concatenate[MonopriceProtocol, _P], Awaitable[_T]]: @wraps(coro) async def wrapper( self: MonopriceProtocol, *args: _P.args, **kwargs: _P.kwargs ) -> _T: await self._connected.wait() return await coro(self, *args, **kwargs) return wrapper @dataclass class ZoneStatus: zone: int pa: bool power: bool mute: bool do_not_disturb: bool volume: int # 0 - 38 treble: int # 0 -> -7, 14-> +7 bass: int # 0 -> -7, 14-> +7 balance: int # 00 - left, 10 - center, 20 right source: int keypad: bool @classmethod def from_strings(cls, strings: list[str]) -> list[ZoneStatus]: if not strings: return list() return [zone for zone in (ZoneStatus.from_string(s) for s in strings) if zone is not None] @classmethod def from_string(cls, string: str) -> ZoneStatus | None: if not string: return None match = re.search(ZONE_PATTERN, string) if not match: return None ( zone, pa, power, mute, do_not_disturb, volume, treble, bass, balance, source, keypad, ) = map(int, match.groups()) return ZoneStatus( zone, bool(pa), bool(power), bool(mute), bool(do_not_disturb), volume, treble, bass, balance, source, bool(keypad), ) class Monoprice: def __init__(self, port_url: str, lock: RLock) -> None: """ Monoprice amplifier interface """ self._lock = lock self._port = serial.serial_for_url(port_url, do_not_open=True) self._port.baudrate = 9600 self._port.stopbits = serial.STOPBITS_ONE self._port.bytesize = serial.EIGHTBITS self._port.parity = serial.PARITY_NONE self._port.timeout = TIMEOUT self._port.write_timeout = TIMEOUT self._port.open() def _send_request(self, request: bytes) -> None: """ :param request: request that is sent to the monoprice """ _LOGGER.debug('Sending "%s"', request) # clear self._port.reset_output_buffer() self._port.reset_input_buffer() # send self._port.write(request) self._port.flush() def _process_request(self, request: bytes, num_eols_to_read: int = 1) -> str: """ :param request: request that is sent to the monoprice :param num_eols_to_read: number of EOL sequences to read. When last EOL is read, reading stops :return: ascii string returned by monoprice """ self._send_request(request) # receive result = bytearray() count = None while True: c = self._port.read(1) if not c: raise serial.SerialTimeoutException( "Connection timed out! Last received bytes {}".format( [hex(a) for a in result] ) ) result += c count = _subsequence_count(result, EOL, count) if count[1] >= num_eols_to_read: break ret = bytes(result) _LOGGER.debug('Received "%s"', ret) return ret.decode("ascii") @synchronized def zone_status(self, zone: int) -> ZoneStatus | None: """ Get the structure representing the status of the zone :param zone: zone 11..16, 21..26, 31..36 :return: status of the zone or None """ # Reading two lines as the response is in the form \r\n#>110001000010111210040\r\n# return ZoneStatus.from_string( self._process_request(_format_zone_status_request(zone), num_eols_to_read=2) ) @synchronized def all_zone_status(self, unit: int) -> list[ZoneStatus]: """ Get the structure representing the status of all zones in a unit :param unit: 1, 2, 3 :return: list of all statuses of the unit's zones or empty list if unit number is incorrect """ if unit < 1 or unit > 3: return [] # Reading 7 lines, since response starts with EOL and each zone's status is followed by EOL response = self._process_request( _format_all_zones_status_request(unit), num_eols_to_read=7 ) return ZoneStatus.from_strings(response.split(sep=EOL.decode('ascii'))) @synchronized def set_power(self, zone: int, power: bool) -> None: """ Turn zone on or off :param zone: zone 11..16, 21..26, 31..36 :param power: True to turn on, False to turn off """ self._process_request(_format_set_power(zone, power)) @synchronized def set_mute(self, zone: int, mute: bool) -> None: """ Mute zone on or off :param zone: zone 11..16, 21..26, 31..36 :param mute: True to mute, False to unmute """ self._process_request(_format_set_mute(zone, mute)) @synchronized def set_volume(self, zone: int, volume: int) -> None: """ Set volume for zone :param zone: zone 11..16, 21..26, 31..36 :param volume: integer from 0 to 38 inclusive """ self._process_request(_format_set_volume(zone, volume)) @synchronized def set_treble(self, zone: int, treble: int) -> None: """ Set treble for zone :param zone: zone 11..16, 21..26, 31..36 :param treble: integer from 0 to 14 inclusive, where 0 is -7 treble and 14 is +7 """ self._process_request(_format_set_treble(zone, treble)) @synchronized def set_bass(self, zone: int, bass: int) -> None: """ Set bass for zone :param zone: zone 11..16, 21..26, 31..36 :param bass: integer from 0 to 14 inclusive, where 0 is -7 bass and 14 is +7 """ self._process_request(_format_set_bass(zone, bass)) @synchronized def set_balance(self, zone: int, balance: int) -> None: """ Set balance for zone :param zone: zone 11..16, 21..26, 31..36 :param balance: integer from 0 to 20 inclusive, where 0 is -10(left), 0 is center and 20 is +10 (right) """ self._process_request(_format_set_balance(zone, balance)) @synchronized def set_source(self, zone: int, source: int) -> None: """ Set source for zone :param zone: zone 11..16, 21..26, 31..36 :param source: integer from 0 to 6 inclusive """ self._process_request(_format_set_source(zone, source)) @synchronized def restore_zone(self, status: ZoneStatus) -> None: """ Restores zone to it's previous state :param status: zone state to restore """ self.set_power(status.zone, status.power) self.set_mute(status.zone, status.mute) self.set_volume(status.zone, status.volume) self.set_treble(status.zone, status.treble) self.set_bass(status.zone, status.bass) self.set_balance(status.zone, status.balance) self.set_source(status.zone, status.source) class MonopriceAsync: def __init__( self, monoprice_protocol: MonopriceProtocol, lock: asyncio.Lock ) -> None: """ Async Monoprice amplifier interface """ self._protocol = monoprice_protocol self._lock = lock @locked_coro async def zone_status(self, zone: int) -> ZoneStatus | None: """ Get the structure representing the status of the zone :param zone: zone 11..16, 21..26, 31..36 :return: status of the zone or None """ # Reading two lines as the response is in the form \r\n#>110001000010111210040\r\n# string = await self._protocol.send(_format_zone_status_request(zone), num_eols_to_read=2) return ZoneStatus.from_string(string) @locked_coro async def all_zone_status(self, unit: int) -> list[ZoneStatus]: """ Get the structure representing the status of all zones in a unit :param unit: 1, 2, 3 :return: list of all statuses of the unit's zones or empty list if unit number is incorrect """ if unit < 1 or unit > 3: return [] # Reading 7 lines, since response starts with EOL and each zone's status is followed by EOL response = await self._protocol.send( _format_all_zones_status_request(unit), num_eols_to_read=7 ) return ZoneStatus.from_strings(response.split(sep=EOL.decode('ascii'))) @locked_coro async def set_power(self, zone: int, power: bool) -> None: """ Turn zone on or off :param zone: zone 11..16, 21..26, 31..36 :param power: True to turn on, False to turn off """ await self._protocol.send(_format_set_power(zone, power)) @locked_coro async def set_mute(self, zone: int, mute: bool) -> None: """ Mute zone on or off :param zone: zone 11..16, 21..26, 31..36 :param mute: True to mute, False to unmute """ await self._protocol.send(_format_set_mute(zone, mute)) @locked_coro async def set_volume(self, zone: int, volume: int) -> None: """ Set volume for zone :param zone: zone 11..16, 21..26, 31..36 :param volume: integer from 0 to 38 inclusive """ await self._protocol.send(_format_set_volume(zone, volume)) @locked_coro async def set_treble(self, zone: int, treble: int) -> None: """ Set treble for zone :param zone: zone 11..16, 21..26, 31..36 :param treble: integer from 0 to 14 inclusive, where 0 is -7 treble and 14 is +7 """ await self._protocol.send(_format_set_treble(zone, treble)) @locked_coro async def set_bass(self, zone: int, bass: int) -> None: """ Set bass for zone :param zone: zone 11..16, 21..26, 31..36 :param bass: integer from 0 to 14 inclusive, where 0 is -7 bass and 14 is +7 """ await self._protocol.send(_format_set_bass(zone, bass)) @locked_coro async def set_balance(self, zone: int, balance: int) -> None: """ Set balance for zone :param zone: zone 11..16, 21..26, 31..36 :param balance: integer from 0 to 20 inclusive, where 0 is -10(left), 0 is center and 20 is +10 (right) """ await self._protocol.send(_format_set_balance(zone, balance)) @locked_coro async def set_source(self, zone: int, source: int) -> None: """ Set source for zone :param zone: zone 11..16, 21..26, 31..36 :param source: integer from 0 to 6 inclusive """ await self._protocol.send(_format_set_source(zone, source)) @locked_coro async def restore_zone(self, status: ZoneStatus) -> None: """ Restores zone to it's previous state :param status: zone state to restore """ await self._protocol.send(_format_set_power(status.zone, status.power)) await self._protocol.send(_format_set_mute(status.zone, status.mute)) await self._protocol.send(_format_set_volume(status.zone, status.volume)) await self._protocol.send(_format_set_treble(status.zone, status.treble)) await self._protocol.send(_format_set_bass(status.zone, status.bass)) await self._protocol.send(_format_set_balance(status.zone, status.balance)) await self._protocol.send(_format_set_source(status.zone, status.source)) class MonopriceProtocol(asyncio.Protocol): def __init__(self) -> None: super().__init__() self._lock = asyncio.Lock() self._tasks: set[asyncio.Task[None]] = set() self._transport: SerialTransport = None self._connected = asyncio.Event() self.q: asyncio.Queue[bytes] = asyncio.Queue() def connection_made(self, transport: SerialTransport) -> None: self._transport = transport self._connected.set() _LOGGER.debug("port opened %s", self._transport) def data_received(self, data: bytes) -> None: task = asyncio.create_task(self.q.put(data)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) @connected @locked_coro async def send(self, request: bytes, num_eols_to_read: int = 1) -> str: """ :param request: request that is sent to the monoprice :param num_eols_to_read: number of EOL sequences to read. When last EOL is read, reading stops :return: ascii string returned by monoprice """ result = bytearray() self._transport.serial.reset_output_buffer() self._transport.serial.reset_input_buffer() while not self.q.empty(): self.q.get_nowait() self._transport.write(request) count = None try: while True: result += await asyncio.wait_for(self.q.get(), TIMEOUT) count = _subsequence_count(result, EOL, count) if count[1] >= num_eols_to_read: break except asyncio.TimeoutError: _LOGGER.error( "Timeout during receiving response for command '%s', received='%s'", request, result, ) raise ret = bytes(result) _LOGGER.debug('Received "%s"', ret) return ret.decode("ascii") # Helpers def _subsequence_count(sequence: bytearray, sub: bytes, previous: tuple[int, int] | None = None) -> tuple[int, int]: """ Counts number of subsequences in a sequence """ start, count = (previous or (0, 0)) while True: idx = sequence.find(sub, start) if idx < 0: return start, count start, count = idx + len(sub), count + 1 def _format_zone_status_request(zone: int) -> bytes: return "?{}\r".format(zone).encode() def _format_all_zones_status_request(unit: int) -> bytes: return "?{}\r".format(unit * 10).encode() def _format_set_power(zone: int, power: bool) -> bytes: return "<{}PR{}\r".format(zone, "01" if power else "00").encode() def _format_set_mute(zone: int, mute: bool) -> bytes: return "<{}MU{}\r".format(zone, "01" if mute else "00").encode() def _format_set_volume(zone: int, volume: int) -> bytes: volume = int(max(0, min(volume, 38))) return "<{}VO{:02}\r".format(zone, volume).encode() def _format_set_treble(zone: int, treble: int) -> bytes: treble = int(max(0, min(treble, 14))) return "<{}TR{:02}\r".format(zone, treble).encode() def _format_set_bass(zone: int, bass: int) -> bytes: bass = int(max(0, min(bass, 14))) return "<{}BS{:02}\r".format(zone, bass).encode() def _format_set_balance(zone: int, balance: int) -> bytes: balance = max(0, min(balance, 20)) return "<{}BL{:02}\r".format(zone, balance).encode() def _format_set_source(zone: int, source: int) -> bytes: source = int(max(1, min(source, 6))) return "<{}CH{:02}\r".format(zone, source).encode() def get_monoprice(port_url: str) -> Monoprice: """ Return synchronous version of Monoprice interface :param port_url: serial port, i.e. '/dev/ttyUSB0' :return: synchronous implementation of Monoprice interface """ lock = RLock() return Monoprice(port_url, lock) async def get_async_monoprice(port_url: str) -> MonopriceAsync: """ Return asynchronous version of Monoprice interface :param port_url: serial port, i.e. '/dev/ttyUSB0' :return: asynchronous implementation of Monoprice interface """ lock = asyncio.Lock() loop = asyncio.get_running_loop() _, protocol = await create_serial_connection( loop, MonopriceProtocol, port_url, baudrate=9600 ) return MonopriceAsync(protocol, lock) etsinko-pymonoprice-55bf73a/pymonoprice/py.typed000066400000000000000000000000001432625007500222030ustar00rootroot00000000000000etsinko-pymonoprice-55bf73a/requirements.txt000066400000000000000000000000431432625007500214330ustar00rootroot00000000000000pyserial>=3.4 pyserial-asyncio>=0.4etsinko-pymonoprice-55bf73a/setup.cfg000066400000000000000000000024041432625007500177730ustar00rootroot00000000000000[metadata] name = pymonoprice url = https://github.com/etsinko/pymonoprice license = MIT license_file = LICENSE.txt description = Python API for talking to Monoprice 6-zone amplifier long_description = file: README.md long_description_content_type = text/x-markdown author = Egor Tsinko author_email = etsinko@gmail.com classifiers = Development Status :: 4 - Beta Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 [options] packages = pymonoprice python_requires >= 3.7 zip_safe = True install_requires = pyserial>=3.4 pyserial-asyncio>=0.4 [options.package_data] pymonoprice = py.typed [mypy] python_version = 3.10 disallow_any_decorated = true disallow_any_explicit = true disallow_any_generics = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true no_implicit_optional = true show_error_codes = true strict_equality = true warn_no_return = true warn_redundant_casts = true warn_unreachable = true warn_unused_configs = True warn_unused_ignores = true [mypy-serial] ignore_missing_imports = True [mypy-serial_asyncio] ignore_missing_imports = True etsinko-pymonoprice-55bf73a/setup.py000077500000000000000000000005671432625007500176770ustar00rootroot00000000000000#!/usr/bin/env python import os import sys VERSION = '0.4' try: from setuptools import setup except ImportError: from distutils.core import setup if sys.argv[-1] == 'publish': os.system('python setup.py sdist upload') sys.exit() setup( version=VERSION, download_url='https://github.com/etsinko/pymonoprice/archive/{}.tar.gz'.format(VERSION), ) etsinko-pymonoprice-55bf73a/tests/000077500000000000000000000000001432625007500173145ustar00rootroot00000000000000etsinko-pymonoprice-55bf73a/tests/__init__.py000066400000000000000000000013421432625007500214250ustar00rootroot00000000000000import threading import os import pty def create_dummy_port(responses): def listener(port): # continuously listen to commands on the master device while 1: res = b'' while not res.endswith(b"\r"): # keep reading one byte at a time until we have a full line res += os.read(port, 1) print("command: %s" % res) # write back the response if res in responses: resp = responses[res] del responses[res] os.write(port, resp) master, slave = pty.openpty() thread = threading.Thread(target=listener, args=[master], daemon=True) thread.start() return os.ttyname(slave) etsinko-pymonoprice-55bf73a/tests/test_monoprice.py000066400000000000000000000331201432625007500227170ustar00rootroot00000000000000import unittest import serial import pymonoprice from pymonoprice import (get_monoprice, get_async_monoprice, ZoneStatus) from tests import create_dummy_port import asyncio class TestHelpers(unittest.TestCase): def test_subsequent_count(self): data = bytearray() search = b'\r\r' self.assertEqual((0, 0), pymonoprice._subsequence_count(data, search, None)) self.assertEqual((10, 0), pymonoprice._subsequence_count(data, search, (10, 0))) data += b'\r\r\r' self.assertEqual((10, 0), pymonoprice._subsequence_count(data, search, (10, 0))) self.assertEqual((2, 1), pymonoprice._subsequence_count(data, search, (0, 0))) data += b'\r' self.assertEqual((4, 2), pymonoprice._subsequence_count(data, search, (2, 1))) class TestZoneStatus(unittest.TestCase): def test_zone_status_broken(self): self.assertIsNone(ZoneStatus.from_string(None)) self.assertIsNone(ZoneStatus.from_string('\r\n#>110001000010111210040\r\n#')) self.assertIsNone(ZoneStatus.from_string('\r\n#>a100010000101112100401\r\n#')) self.assertIsNone(ZoneStatus.from_string('\r\n#>a1000100dfsf112100401\r\n#')) self.assertIsNone(ZoneStatus.from_string('\r\n#>\r\n#')) def test_zone_status_from_strings(self): self.assertTrue(len(ZoneStatus.from_strings(None)) == 0) self.assertTrue(len(ZoneStatus.from_strings(["aaa", "bbb", "ccc"])) == 0) self.assertTrue(len(ZoneStatus.from_strings(["aaa", "ignored>1100010000130707100600ignored", "ccc"])) == 1) class TestMonoprice(unittest.TestCase): def setUp(self): self.responses = {} self.monoprice = get_monoprice(create_dummy_port(self.responses)) def test_all_zone_status_invalid_unit(self): # try edge cases self.assertTrue(len(self.monoprice.all_zone_status(4)) == 0) self.assertTrue(len(self.monoprice.all_zone_status(0)) == 0) def test_all_zone_status_incomplete_data(self): self.responses[b'?10\r'] = b'?10\r\n#>1100010000130707100600' \ b'\r\r\n#>1200000001150707100600' \ b'\r\r\n#>13000001001107070600' \ b'\r\r\n#>1400000000707100600' \ b'\r\r\n#>1500000000200707100100' \ b'\r\r\n#>1600000000200707100100\r\r\n#' statuses = self.monoprice.all_zone_status(1) self.assertEqual(4, len(statuses)) # Zone 11 status = statuses[0] self.assertEqual(11, status.zone) self.assertFalse(status.pa) self.assertTrue(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(13, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(6, status.source) self.assertFalse(status.keypad) # Zone 12 status = statuses[1] self.assertEqual(12, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertTrue(status.do_not_disturb) self.assertEqual(15, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(6, status.source) self.assertFalse(status.keypad) # Zone 15 status = statuses[2] self.assertEqual(15, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(20, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(1, status.source) self.assertFalse(status.keypad) # Zone 16 status = statuses[3] self.assertEqual(16, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(20, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(1, status.source) self.assertFalse(status.keypad) def test_all_zone_status(self): self.responses[b'?10\r'] = b'?10\r\n#>1100010000130707100600' \ b'\r\r\n#>1200000001150707100600' \ b'\r\r\n#>1300000100110707100600' \ b'\r\r\n#>1400000000140707100600' \ b'\r\r\n#>1500000000200707100100' \ b'\r\r\n#>1600000000200707100100\r\r\n#' statuses = self.monoprice.all_zone_status(1) self.assertEqual(6, len(statuses)) # Zone 11 status = statuses[0] self.assertEqual(11, status.zone) self.assertFalse(status.pa) self.assertTrue(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(13, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(6, status.source) self.assertFalse(status.keypad) # Zone 12 status = statuses[1] self.assertEqual(12, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertTrue(status.do_not_disturb) self.assertEqual(15, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(6, status.source) self.assertFalse(status.keypad) # Zone 13 status = statuses[2] self.assertEqual(13, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertTrue(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(11, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(6, status.source) self.assertFalse(status.keypad) # Zone 14 status = statuses[3] self.assertEqual(14, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(14, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(6, status.source) self.assertFalse(status.keypad) # Zone 15 status = statuses[4] self.assertEqual(15, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(20, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(1, status.source) self.assertFalse(status.keypad) # Zone 16 status = statuses[5] self.assertEqual(16, status.zone) self.assertFalse(status.pa) self.assertFalse(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(20, status.volume) self.assertEqual(7, status.treble) self.assertEqual(7, status.bass) self.assertEqual(10, status.balance) self.assertEqual(1, status.source) self.assertFalse(status.keypad) self.assertEqual(0, len(self.responses)) def test_zone_status(self): self.responses[b'?11\r'] = b'?11\r\n#>1100010000131112100401\r\n#' status = self.monoprice.zone_status(11) self.assertEqual(11, status.zone) self.assertFalse(status.pa) self.assertTrue(status.power) self.assertFalse(status.mute) self.assertFalse(status.do_not_disturb) self.assertEqual(13, status.volume) self.assertEqual(11, status.treble) self.assertEqual(12, status.bass) self.assertEqual(10, status.balance) self.assertEqual(4, status.source) self.assertTrue(status.keypad) self.assertEqual(0, len(self.responses)) def test_set_power(self): self.responses[b'<13PR01\r'] = b'\r\n#' self.monoprice.set_power(13, True) self.responses[b'<13PR01\r'] = b'\r\n#' self.monoprice.set_power(13, 'True') self.responses[b'<13PR01\r'] = b'\r\n#' self.monoprice.set_power(13, 1) self.responses[b'<13PR00\r'] = b'\r\n#' self.monoprice.set_power(13, False) self.responses[b'<13PR00\r'] = b'\r\n#' self.monoprice.set_power(13, None) self.responses[b'<13PR00\r'] = b'\r\n#' self.monoprice.set_power(13, 0) self.responses[b'<13PR00\r'] = b'\r\n#' self.monoprice.set_power(13, '') self.assertEqual(0, len(self.responses)) def test_set_mute(self): self.responses[b'<13MU01\r'] = b'\r\n#' self.monoprice.set_mute(13, True) self.responses[b'<13MU01\r'] = b'\r\n#' self.monoprice.set_mute(13, 'True') self.responses[b'<13MU01\r'] = b'\r\n#' self.monoprice.set_mute(13, 1) self.responses[b'<13MU00\r'] = b'\r\n#' self.monoprice.set_mute(13, False) self.responses[b'<13MU00\r'] = b'\r\n#' self.monoprice.set_mute(13, None) self.responses[b'<13MU00\r'] = b'\r\n#' self.monoprice.set_mute(13, 0) self.responses[b'<13MU00\r'] = b'\r\n#' self.monoprice.set_mute(13, '') self.assertEqual(0, len(self.responses)) def test_set_volume(self): self.responses[b'<13VO01\r'] = b'\r\n#' self.monoprice.set_volume(13, 1) self.responses[b'<13VO38\r'] = b'\r\n#' self.monoprice.set_volume(13, 100) self.responses[b'<13VO00\r'] = b'\r\n#' self.monoprice.set_volume(13, -100) self.responses[b'<13VO20\r'] = b'\r\n#' self.monoprice.set_volume(13, 20) self.assertEqual(0, len(self.responses)) def test_set_treble(self): self.responses[b'<13TR01\r'] = b'\r\n#' self.monoprice.set_treble(13, 1) self.responses[b'<13TR14\r'] = b'\r\n#' self.monoprice.set_treble(13, 100) self.responses[b'<13TR00\r'] = b'\r\n#' self.monoprice.set_treble(13, -100) self.responses[b'<13TR13\r'] = b'\r\n#' self.monoprice.set_treble(13, 13) self.assertEqual(0, len(self.responses)) def test_set_bass(self): self.responses[b'<13BS01\r'] = b'\r\n#' self.monoprice.set_bass(13, 1) self.responses[b'<13BS14\r'] = b'\r\n#' self.monoprice.set_bass(13, 100) self.responses[b'<13BS00\r'] = b'\r\n#' self.monoprice.set_bass(13, -100) self.responses[b'<13BS13\r'] = b'\r\n#' self.monoprice.set_bass(13, 13) self.assertEqual(0, len(self.responses)) def test_set_balance(self): self.responses[b'<13BL01\r'] = b'\r\n#' self.monoprice.set_balance(13, 1) self.responses[b'<13BL20\r'] = b'\r\n#' self.monoprice.set_balance(13, 100) self.responses[b'<13BL00\r'] = b'\r\n#' self.monoprice.set_balance(13, -100) self.responses[b'<13BL13\r'] = b'\r\n#' self.monoprice.set_balance(13, 13) self.assertEqual(0, len(self.responses)) def test_set_source(self): self.responses[b'<13CH01\r'] = b'\r\n#' self.monoprice.set_source(13, 1) self.responses[b'<13CH06\r'] = b'\r\n#' self.monoprice.set_source(13, 100) self.responses[b'<13CH01\r'] = b'\r\n#' self.monoprice.set_source(13, -100) self.responses[b'<13CH03\r'] = b'\r\n#' self.monoprice.set_source(13, 3) self.assertEqual(0, len(self.responses)) def test_restore_zone(self): zone = ZoneStatus.from_string('\r\n#>1100010000131112100401\r\n#') self.responses[b'<11PR01\r'] = b'\r\n#' self.responses[b'<11MU00\r'] = b'\r\n#' self.responses[b'<11VO13\r'] = b'\r\n#' self.responses[b'<11TR11\r'] = b'\r\n#' self.responses[b'<11BS12\r'] = b'\r\n#' self.responses[b'<11BL10\r'] = b'\r\n#' self.responses[b'<11CH04\r'] = b'\r\n#' self.monoprice.restore_zone(zone) self.assertEqual(0, len(self.responses)) def test_timeout(self): with self.assertRaises(serial.SerialTimeoutException): self.monoprice.set_source(3, 3) class TestAsyncMonoprice(TestMonoprice): def setUp(self): self.responses = {} loop = asyncio.get_event_loop() monoprice = loop.run_until_complete(get_async_monoprice(create_dummy_port(self.responses))) # Dummy monoprice that converts async to sync class DummyMonoprice(): def __getattribute__(self, item): def f(*args, **kwargs): return loop.run_until_complete(monoprice.__getattribute__(item)(*args, **kwargs)) return f self.monoprice = DummyMonoprice() def test_timeout(self): with self.assertRaises(asyncio.TimeoutError): self.monoprice.set_source(3, 3) if __name__ == '__main__': unittest.main()