Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Version 1.0.2 #103

Merged
merged 4 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pyheos/command/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ async def load_players(self) -> PlayerUpdateResult:
for player_data in payload:
player_id = int(player_data[c.ATTR_PLAYER_ID])
name = player_data[c.ATTR_NAME]
version = player_data[c.ATTR_VERSION]
version = player_data.get(c.ATTR_VERSION)
serial = player_data.get(c.ATTR_SERIAL)
# Try matching by serial (if available), then try matching by player_id
# and fallback to matching name when firmware version is different
Expand All @@ -111,7 +111,11 @@ async def load_players(self) -> PlayerUpdateResult:
for player in existing
if (player.serial == serial and serial is not None)
or player.player_id == player_id
or (player.name == name and player.version != version)
or (
player.name == name
and player.version != version
and version is not None
)
),
None,
)
Expand Down
4 changes: 3 additions & 1 deletion pyheos/command/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,7 @@ async def get_system_info(self) -> HeosSystem:
response = await self._connection.command(HeosCommand(c.COMMAND_GET_PLAYERS))
payload = cast(Sequence[dict[str, Any]], response.payload)
hosts = list([HeosHost._from_data(item) for item in payload])
host = next(host for host in hosts if host.ip_address == self._options.host)
host = next(
(host for host in hosts if host.ip_address == self._options.host), None
)
return HeosSystem(self._signed_in_username, host, hosts)
44 changes: 44 additions & 0 deletions pyheos/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Define the common module."""

from typing import cast

from pyheos.const import TARGET_VERSION


def is_supported_version(
version: str | None, min_version: str = TARGET_VERSION
) -> bool:
"""Check if a version is supported.

Args:
version (str): The version to check.
min_version (str): The minimum version.

Returns:
bool: True if the version is supported, False otherwise.
"""
if version is None:
return False
try:
sem_ver = get_sem_ver(version)
except ValueError:
return False
min_sem_ver = get_sem_ver(min_version)
for i, ver in enumerate(sem_ver):
if ver < min_sem_ver[i]:
return False
if ver > min_sem_ver[i]:
return True
return True


def get_sem_ver(version: str) -> tuple[int, int, int]:
"""Get the semantic version from a string.

Args:
version (str): The version string.

Returns:
tuple: The semantic version as a tuple.
"""
return cast(tuple[int, int, int], tuple(map(int, version.split(".", 3))))
47 changes: 30 additions & 17 deletions pyheos/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,23 @@ async def _on_command_error(self, error: CommandFailedError) -> None:
for callback in self._on_command_error_callbacks:
await callback(error)

def _log_callback_exception(self, future: asyncio.Future[Any]) -> None:
"""Log uncaught exception that occurs in a callback."""
if not future.cancelled() and future.exception():
_LOGGER.exception(
"Unexpected exception in task: %s",
future,
exc_info=future.exception(),
)

def _register_task(
self, future: Coroutine[Any, Any, None], name: str | None = None
) -> None:
"""Register a task that is running in the background, so it can be canceled and reset later."""
task: asyncio.Task[None] = asyncio.create_task(future, name=name)
self._running_tasks.add(task)
task.add_done_callback(self._running_tasks.discard)
task.add_done_callback(self._log_callback_exception)

async def _reset(self) -> None:
"""Reset the state of the connection."""
Expand Down Expand Up @@ -137,11 +147,11 @@ async def _read_handler(self, reader: asyncio.StreamReader) -> None:
return
else:
self._last_activity = datetime.now()
await self._handle_message(
self._handle_message(
HeosMessage._from_raw_message(binary_result.decode())
)

async def _handle_message(self, message: HeosMessage) -> None:
def _handle_message(self, message: HeosMessage) -> None:
"""Handle a message received from the HEOS device."""
if message.is_under_process:
_LOGGER.debug("Command under process '%s'", message.command)
Expand All @@ -152,7 +162,10 @@ async def _handle_message(self, message: HeosMessage) -> None:
return

# Set the message on the pending command.
self._pending_command_event.set(message)
if not self._pending_command_event.set(message):
_LOGGER.debug(
"Unexpected response received: '%s': '%s'", message.command, message
)

async def command(self, command: HeosCommand) -> HeosMessage:
"""Send a command to the HEOS device."""
Expand All @@ -165,19 +178,19 @@ async def _command_impl() -> HeosMessage:
raise CommandError(command.command, "Not connected to device")
if TYPE_CHECKING:
assert self._writer is not None
assert not self._pending_command_event.is_set()

# Send the command
try:
self._writer.write((command.uri + SEPARATOR).encode())
await self._writer.drain()
except (ConnectionError, OSError, AttributeError) as error:
# Occurs when the connection is broken. Run in the background to ensure connection is reset.
self._register_task(
self._disconnect_from_error(error), "Disconnect From Error"
)
_LOGGER.debug(
"Command failed '%s': %s: %s", command, type(error).__name__, error
)
self._register_task(
self._disconnect_from_error(error), "Disconnect From Error"
)
raise CommandError(
command.command, f"Command failed: {error}"
) from error
Expand All @@ -192,7 +205,7 @@ async def _command_impl() -> HeosMessage:
# Wait for the response with a timeout
try:
response = await asyncio.wait_for(
self._pending_command_event.wait(), self._timeout
self._pending_command_event.wait(command.command), self._timeout
)
except asyncio.TimeoutError as error:
# Occurs when the command times out
Expand All @@ -201,9 +214,6 @@ async def _command_impl() -> HeosMessage:
finally:
self._pending_command_event.clear()

# The retrieved response should match the command
assert command.command == response.command

# Check the result
if not response.result:
_LOGGER.debug("Command failed '%s': '%s'", command, response)
Expand Down Expand Up @@ -340,24 +350,27 @@ def __init__(self) -> None:
"""Init a new instance of the CommandEvent."""
self._event: asyncio.Event = asyncio.Event()
self._response: HeosMessage | None = None
self._target_command: str | None = None

async def wait(self) -> HeosMessage:
async def wait(self, target_command: str) -> HeosMessage:
"""Wait until the event is set."""
self._target_command = target_command
await self._event.wait()
if TYPE_CHECKING:
assert self._response is not None
return self._response

def set(self, response: HeosMessage) -> None:
def set(self, response: HeosMessage) -> bool:
"""Set the response."""
if self._target_command is None or self._target_command != response.command:
return False
self._target_command = None
self._response = response
self._event.set()
return True

def clear(self) -> None:
"""Clear the event."""
self._response = None
self._target_command = None
self._event.clear()

def is_set(self) -> bool:
"""Return True if the event is set."""
return self._event.is_set()
2 changes: 2 additions & 0 deletions pyheos/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from typing import Final

TARGET_VERSION: Final = "3.34.0"

DEFAULT_TIMEOUT: Final = 10.0
DEFAULT_RECONNECT_DELAY: Final = 10.0
DEFAULT_RECONNECT_ATTEMPTS: Final = 0 # Unlimited
Expand Down
18 changes: 11 additions & 7 deletions pyheos/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from pyheos.abc import RemoveHeosFieldABC
from pyheos.command import optional_int, parse_enum, parse_optional_enum
from pyheos.common import is_supported_version
from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper
from pyheos.media import MediaItem, QueueItem, ServiceOption
from pyheos.message import HeosMessage
Expand Down Expand Up @@ -192,8 +193,9 @@ class HeosPlayer(RemoveHeosFieldABC):
player_id: int = field(repr=True, hash=True, compare=True)
model: str = field(repr=True, hash=False, compare=False)
serial: str | None = field(repr=False, hash=False, compare=False)
version: str = field(repr=True, hash=False, compare=False)
ip_address: str = field(repr=True, hash=False, compare=False)
version: str | None = field(repr=True, hash=False, compare=False)
supported_version: bool = field(repr=True, hash=False, compare=False)
ip_address: str | None = field(repr=True, hash=False, compare=False)
network: NetworkType = field(repr=False, hash=False, compare=False)
line_out: LineOutLevelType = field(repr=False, hash=False, compare=False)
control: VolumeControlType = field(
Expand All @@ -220,14 +222,15 @@ def _from_data(
heos: Optional["Heos"] = None,
) -> "HeosPlayer":
"""Create a new instance from the provided data."""

version = data.get(c.ATTR_VERSION)
return HeosPlayer(
name=data[c.ATTR_NAME],
player_id=int(data[c.ATTR_PLAYER_ID]),
model=data[c.ATTR_MODEL],
serial=data.get(c.ATTR_SERIAL),
version=data[c.ATTR_VERSION],
ip_address=data[c.ATTR_IP_ADDRESS],
version=version,
supported_version=is_supported_version(version),
ip_address=data.get(c.ATTR_IP_ADDRESS),
network=parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN),
line_out=parse_enum(
c.ATTR_LINE_OUT, data, LineOutLevelType, LineOutLevelType.UNKNOWN
Expand All @@ -245,8 +248,9 @@ def _update_from_data(self, data: dict[str, Any]) -> None:
self.player_id = int(data[c.ATTR_PLAYER_ID])
self.model = data[c.ATTR_MODEL]
self.serial = data.get(c.ATTR_SERIAL)
self.version = data[c.ATTR_VERSION]
self.ip_address = data[c.ATTR_IP_ADDRESS]
self.version = data.get(c.ATTR_VERSION)
self.supported_version = is_supported_version(self.version)
self.ip_address = data.get(c.ATTR_IP_ADDRESS)
self.network = parse_enum(
c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN
)
Expand Down
26 changes: 19 additions & 7 deletions pyheos/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any

from pyheos import command as c
from pyheos.common import is_supported_version
from pyheos.types import NetworkType


Expand All @@ -17,9 +18,10 @@ class HeosHost:
name: str
model: str
serial: str | None
version: str
ip_address: str
version: str | None
ip_address: str | None
network: NetworkType
supported_version: bool

@staticmethod
def _from_data(data: dict[str, Any]) -> "HeosHost":
Expand All @@ -31,13 +33,15 @@ def _from_data(data: dict[str, Any]) -> "HeosHost":
Returns:
HeosHost: The created HeosHost object.
"""
version = data.get(c.ATTR_VERSION)
return HeosHost(
data[c.ATTR_NAME],
data[c.ATTR_MODEL],
data.get(c.ATTR_SERIAL),
data[c.ATTR_VERSION],
data[c.ATTR_IP_ADDRESS],
version,
data.get(c.ATTR_IP_ADDRESS),
c.parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN),
is_supported_version(version),
)


Expand All @@ -49,7 +53,7 @@ class HeosSystem:
"""

signed_in_username: str | None
host: HeosHost
host: HeosHost | None
hosts: list[HeosHost]
is_signed_in: bool = field(init=False)
preferred_hosts: list[HeosHost] = field(init=False)
Expand All @@ -59,6 +63,14 @@ def __post_init__(self) -> None:
"""Post initialize the system."""
self.is_signed_in = self.signed_in_username is not None
self.preferred_hosts = list(
[host for host in self.hosts if host.network == NetworkType.WIRED]
[
host
for host in self.hosts
if host.network == NetworkType.WIRED
and host.supported_version
and host.ip_address is not None
]
)
self.connected_to_preferred_host = (
self.host is not None and self.host in self.preferred_hosts
)
self.connected_to_preferred_host = self.host in self.preferred_hosts
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pyheos"
version = "1.0.1"
version = "1.0.2"
description = "An async python library for controlling HEOS devices through the HEOS CLI Protocol"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Loading
Loading