Skip to content

Commit

Permalink
Merge pull request #11 from andrewsayre/dev
Browse files Browse the repository at this point in the history
v0.6.0
  • Loading branch information
andrewsayre authored Aug 25, 2019
2 parents 5b5d94b + a50ed00 commit 91955a3
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 77 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![image](https://img.shields.io/pypi/l/pyheos.svg)](https://pypi.org/project/pyheos/)
[![image](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)

An async python library for controlling HEOS devices through the HEOS CLI Protocol (version 1.13 for players with firmware 1.481.130 or newer).
An async python library for controlling HEOS devices through the HEOS CLI Protocol (version 1.14 for players with firmware 1.505.140 or newer).

## Installation
```bash
Expand Down
6 changes: 4 additions & 2 deletions pyheos/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
"""pyheos - a library for interacting with HEOS devices."""
from . import const
from .dispatch import Dispatcher
from .error import CommandError, CommandFailedError, HeosError
from .group import HeosGroup
from .heos import Heos
from .player import HeosNowPlayingMedia, HeosPlayer
from .response import CommandError
from .source import HeosSource, InputSource

__all__ = [
'const',
'CommandError',
'CommandFailedError',
'Dispatcher',
'Heos',
'HeosError',
'HeosGroup',
'HeosPlayer',
'HeosNowPlayingMedia',
'HeosSource',
'InputSource'
'InputSource',
]
63 changes: 40 additions & 23 deletions pyheos/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from . import const
from .command import HeosCommands
from .error import CommandError, HeosError, format_error_message
from .response import HeosResponse

SEPARATOR = '\r\n'
Expand All @@ -23,17 +24,24 @@
'%': '%25'
}

_MASKED_PARAMS = {
'pw',
}

_MASK = "********"


def _quote(string: str) -> str:
"""Quote a string per the CLI specification."""
return ''.join([_QUOTE_MAP.get(char, char) for char in str(string)])


def _encode_query(items: dict) -> str:
def _encode_query(items: dict, *, mask=False) -> str:
"""Encode a dict to query string per CLI specifications."""
pairs = []
for key, value in items.items():
item = key + "=" + _quote(value)
for key in sorted(items.keys()):
value = _MASK if mask and key in _MASKED_PARAMS else items[key]
item = "{}={}".format(key, _quote(value))
# Ensure 'url' goes last per CLI spec
if key == 'url':
pairs.append(item)
Expand Down Expand Up @@ -82,10 +90,13 @@ async def connect(self, *, auto_reconnect: bool = False,

async def _connect(self):
"""Perform core connection logic."""
open_future = asyncio.open_connection(
self.host, const.CLI_PORT)
self._reader, self._writer = await asyncio.wait_for(
open_future, self.timeout)
try:
open_future = asyncio.open_connection(
self.host, const.CLI_PORT)
self._reader, self._writer = await asyncio.wait_for(
open_future, self.timeout)
except (OSError, ConnectionError, asyncio.TimeoutError) as err:
raise HeosError(format_error_message(err)) from err
# Start response handler
self._response_handler_task = asyncio.ensure_future(
self._response_handler())
Expand Down Expand Up @@ -164,7 +175,7 @@ async def _reconnect(self):
await self._connect()
self._reconnect_task = None
return
except (ConnectionError, asyncio.TimeoutError) as err:
except HeosError as err:
# Occurs when we could not reconnect
_LOGGER.debug("Failed to reconnect to %s: %s", self.host, err)
await self._disconnect()
Expand Down Expand Up @@ -211,7 +222,7 @@ async def _response_handler(self):
# Occurs when the task is being killed
return
except (ConnectionError, asyncio.IncompleteReadError,
RuntimeError) as error:
RuntimeError, OSError) as error:
# Occurs when the connection breaks
asyncio.ensure_future(self._handle_connection_error(error))
return
Expand All @@ -223,40 +234,46 @@ async def _heart_beat(self):
if last_activity > threshold:
try:
await self.commands.heart_beat()
except (ConnectionError, asyncio.IncompleteReadError,
asyncio.TimeoutError):
except CommandError:
pass
await asyncio.sleep(self._heart_beat_interval / 2)

async def command(
self, command: str, params: Dict[str, Any] = None) -> HeosResponse:
"""Run a command and get it's response."""
if self._state != const.STATE_CONNECTED:
raise ValueError

# append sequence number
"""Execute a command and get it's response."""
# Build command URI
sequence = self._sequence
self._sequence += 1
params = params or {}
params['sequence'] = sequence
command_name = command
uri = const.BASE_URI + command + '?' + _encode_query(params)
uri = "{}{}?{}".format(const.BASE_URI, command, _encode_query(params))
masked_uri = "{}{}?{}".format(
const.BASE_URI, command, _encode_query(params, mask=True))

if self._state != const.STATE_CONNECTED:
_LOGGER.debug("Command failed '%s': %s", masked_uri,
"Not connected to device")
raise CommandError(command, "Not connected to device")

# Add reservation
event = ResponseEvent(sequence)
pending_commands = self._pending_commands[command_name]
pending_commands.append(event)
self._pending_commands[command].append(event)
# Send command
try:
self._writer.write((uri + SEPARATOR).encode())
await self._writer.drain()
response = await asyncio.wait_for(event.wait(), self.timeout)
except (ConnectionError, asyncio.TimeoutError) as error:
except (ConnectionError, asyncio.TimeoutError, OSError) as error:
# Occurs when the connection breaks
asyncio.ensure_future(self._handle_connection_error(error))
raise
message = format_error_message(error)
_LOGGER.debug("Command failed '%s': %s", masked_uri, message)
raise CommandError(command, message) from error

_LOGGER.debug("Executed command '%s': '%s'", command, response)
if response.result:
_LOGGER.debug("Command executed '%s': '%s'", masked_uri, response)
else:
_LOGGER.debug("Command failed '%s': %s", masked_uri, response)
response.raise_for_result()
return response

Expand Down
8 changes: 5 additions & 3 deletions pyheos/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Define consts for the pyheos package."""

__title__ = "pyheos"
__version__ = "0.5.2"
__version__ = "0.6.0"

CLI_PORT = 1255
DEFAULT_TIMEOUT = 10.0
Expand Down Expand Up @@ -160,7 +160,9 @@
INPUT_TV_AUDIO = "inputs/tvaudio"
INPUT_PHONO = "inputs/phono"
INPUT_USB_AC = "inputs/usbdac"
INPUT_ANALOG = "inputs/analog"
INPUT_ANALOG_IN_1 = "inputs/analog_in_1"
INPUT_ANALOG_IN_2 = "inputs/analog_in_2"
INPUT_RECORDER_IN_1 = "inputs/recorder_in_1"

VALID_INPUTS = (
INPUT_AUX_IN_1, INPUT_AUX_IN_2, INPUT_AUX_IN_3, INPUT_AUX_IN_4,
Expand All @@ -171,7 +173,7 @@
INPUT_HDMI_IN_3, INPUT_HDMI_IN_4, INPUT_HDMI_ARC_1, INPUT_CABLE_SAT,
INPUT_DVD, INPUT_BLURAY, INPUT_GAME, INPUT_MEDIA_PLAYER, INPUT_CD,
INPUT_TUNER, INPUT_HD_RADIO, INPUT_TV_AUDIO, INPUT_PHONO, INPUT_USB_AC,
INPUT_ANALOG)
INPUT_ANALOG_IN_1, INPUT_ANALOG_IN_2, INPUT_RECORDER_IN_1)

# Add to Queue Options
ADD_QUEUE_PLAY_NOW = 1
Expand Down
61 changes: 61 additions & 0 deletions pyheos/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Define the error module for HEOS."""
import asyncio

DEFAULT_ERROR_MESSAGES = {
asyncio.TimeoutError: "Command timed out",
ConnectionError: "Connection error",
BrokenPipeError: "Broken pipe",
ConnectionAbortedError: "Connection aborted",
ConnectionRefusedError: "Connection refused",
ConnectionResetError: "Connection reset",
OSError: "OS I/O error"
}


def format_error_message(error: Exception) -> str:
"""Format the error message based on a base error."""
error_message = str(error)
if not error_message:
error_message = DEFAULT_ERROR_MESSAGES.get(type(error))
return error_message


class HeosError(Exception):
"""Define an error from the heos library."""

pass


class CommandError(HeosError):
"""Define an error command response."""

def __init__(self, command: str, message: str):
"""Create a new instance of the error."""
self._command = command
super().__init__(message)

@property
def command(self) -> str:
"""Get the command that raised the error."""
return self._command


class CommandFailedError(CommandError):
"""Define an error when a HEOS command fails."""

def __init__(self, command: str, text: str, error_id: int):
"""Create a new instance of the error."""
self._command = command
self._error_text = text
self._error_id = error_id
super().__init__(command, "{} ({})".format(text, error_id))

@property
def error_text(self) -> str:
"""Get the error text from the response."""
return self._error_text

@property
def error_id(self) -> int:
"""Return the error id."""
return self._error_id
7 changes: 5 additions & 2 deletions pyheos/heos.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,11 @@ async def get_playlists(self) -> Sequence[HeosSource]:
"""Get available playlists."""
payload = await self._connection.commands.browse(
const.MUSIC_SOURCE_PLAYLISTS)
return [HeosSource(self._connection.commands, item)
for item in payload]
playlists = []
for item in payload:
item['sid'] = const.MUSIC_SOURCE_PLAYLISTS
playlists.append(HeosSource(self._connection.commands, item))
return playlists

@property
def dispatcher(self) -> Dispatcher:
Expand Down
30 changes: 3 additions & 27 deletions pyheos/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl

from .error import CommandFailedError


class HeosResponse:
"""Define a HEOS response representation."""
Expand Down Expand Up @@ -87,30 +89,4 @@ def raise_for_result(self):
text += " " + system_error_number
error_id = self.get_message('eid')
error_id = int(error_id) if error_id else error_id
raise CommandError(self._command, text, error_id)


class CommandError(Exception):
"""Define an error command response."""

def __init__(self, command: str, text: str, error_id: int):
"""Create a new instance of the error."""
self._command = command
self._error_text = text
self._error_id = error_id
super().__init__("{} ({})".format(text, error_id))

@property
def command(self) -> str:
"""Get the command that raised the error."""
return self._command

@property
def error_text(self) -> str:
"""Get the error text from the response."""
return self._error_text

@property
def error_id(self) -> int:
"""Return the error id."""
return self._error_id
raise CommandFailedError(self._command, text, error_id)
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
log_level=DEBUG
12 changes: 6 additions & 6 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
coveralls==1.7.0
flake8==3.7.7
flake8-docstrings==1.3.0
pydocstyle==3.0.0
coveralls==1.8.2
flake8==3.7.8
flake8-docstrings==1.3.1
pydocstyle==4.0.1
pylint==2.3.1
pytest==4.3.1
pytest==5.1.1
pytest-asyncio==0.10.0
pytest-cov==2.6.1
pytest-cov==2.7.1
pytest-timeout==1.3.3
Loading

0 comments on commit 91955a3

Please sign in to comment.