"""Receiver.""" from __future__ import annotations import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import dataclass, field from enum import Enum import logging from typing import TYPE_CHECKING, Final, Generic, Self, TypeVar from .common import BasicReceiverInfo, ReceiverInfo from .instruction import Instruction from .protocol import ( DiscoveryInfo, EISCPDiscovery, OnkyoConnectionError, read_message, write_message, ) from .status import Status _LOGGER = logging.getLogger(__name__) BROADCAST_ADDRESS: Final = "255.255.255.255" async def interview( host: str, *, port: int = 60128, ) -> ReceiverInfo: """Interview Onkyo Receiver.""" target_str = f"{host}:{port}" _LOGGER.debug("Interviewing receiver: %s", target_str) receiver_info_future: asyncio.Future[ReceiverInfo] = asyncio.Future() def callback(discovery: DiscoveryInfo) -> None: """Receiver interviewed, connection not yet active.""" if receiver_info_future.done(): return receiver_info = ReceiverInfo( host=host, ip=discovery.ip, port=discovery.iscp_port, model_name=discovery.model_name, identifier=discovery.identifier, ) receiver_info_future.set_result(receiver_info) protocol = EISCPDiscovery(target_str, callback) loop = asyncio.get_running_loop() await loop.create_datagram_endpoint(lambda: protocol, remote_addr=(host, port)) try: receiver_info = await receiver_info_future finally: protocol.close() _LOGGER.debug("Interviewed receiver %s: %s", target_str, receiver_info) return receiver_info async def discover( address: str = BROADCAST_ADDRESS, *, port: int = 60128, ) -> AsyncGenerator[ReceiverInfo]: """Discover Onkyo Receivers.""" target_str = f"{address}:{port}" _LOGGER.debug("Discovering receivers on %s", target_str) receivers_discovered: set[str] = set() receiver_info_queue: asyncio.Queue[ReceiverInfo] = asyncio.Queue() def callback(discovery: DiscoveryInfo) -> None: """Receiver discovered, connection not yet active.""" info = ReceiverInfo( host=discovery.ip, ip=discovery.ip, port=discovery.iscp_port, model_name=discovery.model_name, identifier=discovery.identifier, ) if info.identifier not in receivers_discovered: receivers_discovered.add(info.identifier) receiver_info_queue.put_nowait(info) protocol = EISCPDiscovery(target_str, callback) loop = asyncio.get_running_loop() await loop.create_datagram_endpoint( lambda: protocol, remote_addr=(address, port), allow_broadcast=True, ) try: while True: receiver_info = await receiver_info_queue.get() _LOGGER.debug("Discovered receiver on %s: %s", target_str, receiver_info) yield receiver_info finally: protocol.close() InfoT = TypeVar("InfoT", bound=BasicReceiverInfo, default=ReceiverInfo) async def _connect_receiver_retry(info: InfoT) -> Receiver[InfoT]: """Connect to the receiver, retrying on failure.""" sleep_time = 10 sleep_time_max = 180 while True: try: return await Receiver.open_connection(info) except OSError: await asyncio.sleep(sleep_time) sleep_time = min(sleep_time * 2, sleep_time_max) @asynccontextmanager async def connect( info: InfoT, *, retry: bool = False, run_queue: bool = False ) -> AsyncGenerator[Receiver[InfoT]]: """Connect to the receiver.""" _LOGGER.debug( "Async context manager connect (retry: %s, run_queue: %s): %s", retry, run_queue, info ) if retry: receiver = await _connect_receiver_retry(info) else: receiver = await Receiver.open_connection(info) try: if run_queue: async with asyncio.TaskGroup() as tg: tg.create_task(receiver.run_queue()) yield receiver else: yield receiver finally: receiver.close() class _ReceiverState(Enum): """Receiver state.""" CONNECTED = "CONNECTED" RUNNING_QUEUE = "RUNNING_QUEUE" CLOSED = "CLOSED" @dataclass class Receiver(Generic[InfoT]): """Receiver (connected).""" info: InfoT _reader: asyncio.StreamReader _writer: asyncio.StreamWriter _read_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _write_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _read_queue: asyncio.Queue[Status] | None = None _write_queue: asyncio.Queue[Instruction] | None = None _state: _ReceiverState = _ReceiverState.CONNECTED @classmethod async def open_connection(cls, info: InfoT) -> Self: """Open connection to the receiver.""" _LOGGER.debug("Connecting: %s", info) reader, writer = await asyncio.open_connection(info.host, info.port) _LOGGER.debug("Connected: %s", info) return cls(info, reader, writer) async def run_queue(self) -> None: """Run queue reader/writer.""" if self._state is not _ReceiverState.CONNECTED: raise RuntimeError( "Run queue called on receiver not in CONNECTED state, " f"current state: {self._state}, info: {self.info}" ) self._read_queue = asyncio.Queue() self._write_queue = asyncio.Queue() _LOGGER.debug("Run queue starting: %s", self.info) self._state = _ReceiverState.RUNNING_QUEUE try: async with asyncio.TaskGroup() as tg: tg.create_task(self._read_messages()) tg.create_task(self._write_messages()) except* OnkyoConnectionError as exc: _LOGGER.warning("Disconnect detected (%s): %s", exc.exceptions, self.info) finally: _LOGGER.debug("Run queue ending: %s", self.info) self._do_close() async def _read_messages(self) -> None: """Read messages.""" queue = self._read_queue if TYPE_CHECKING: assert queue is not None async with self._read_lock: while True: message = await read_message(self._reader) _LOGGER.debug("[%s] << %s", self.info.host, message) await queue.put(message) async def _write_messages(self) -> None: """Write messages.""" queue = self._write_queue if TYPE_CHECKING: assert queue is not None async with self._write_lock: while True: message = await queue.get() await write_message(self._writer, message) _LOGGER.debug("[%s] >>>> %s", self.info.host, message) async def read(self) -> Status | None: """Read from the receiver.""" if self._state is _ReceiverState.CLOSED: return None if self._read_queue is None: async with self._read_lock: try: message = await read_message(self._reader) except OnkyoConnectionError as exc: self._disconnect_from_read_write(exc) return None except Exception as exc: self._disconnect_from_read_write(exc) raise else: try: message = await self._read_queue.get() except asyncio.QueueShutDown: return None _LOGGER.debug("[%s] <<<< %s", self.info.host, message) return message async def write(self, message: Instruction) -> None: """Write to the receiver.""" if self._state is _ReceiverState.CLOSED: raise RuntimeError(f"Write called on receiver in CLOSED state, info: {self.info}") if self._write_queue is None: async with self._write_lock: try: await write_message(self._writer, message) except Exception as exc: self._disconnect_from_read_write(exc) raise _LOGGER.debug("[%s] >>>> %s", self.info.host, message) else: _LOGGER.debug("[%s] >> %s", self.info.host, message) self._write_queue.put_nowait(message) def _disconnect_from_read_write(self, exc: Exception) -> None: """Disconnect the receiver (called from read/write).""" if self._state is _ReceiverState.CLOSED: _LOGGER.debug("Disconnect - already closed: %s", self.info) return if isinstance(exc, OnkyoConnectionError): _LOGGER.warning("Disconnect detected (%s): %s", exc, self.info) self._do_close() def _do_close(self) -> None: """Close connection.""" if self._state is _ReceiverState.CLOSED: raise RuntimeError(f"Do close called on receiver in CLOSED state, info: {self.info}") _LOGGER.debug("Closing: %s", self.info) self._state = _ReceiverState.CLOSED self._writer.close() # writer closes the whole stream, including the reader if self._read_queue is not None: self._read_queue.shutdown(immediate=True) def close(self) -> None: """Close connection.""" if self._state is _ReceiverState.CLOSED: return if self._state is _ReceiverState.RUNNING_QUEUE: raise RuntimeError( f"Close called on receiver in RUNNING_QUEUE state, info: {self.info}" ) self._do_close() class BasicReceiver(Receiver[BasicReceiverInfo]): """Basic receiver (connected).""" __all__ = [ "interview", "discover", "connect", "Receiver", ]