"""Module for representing a JVC Projector device.""" from __future__ import annotations import asyncio from hashlib import sha256 import logging import struct from time import time from typing import TYPE_CHECKING, Final from .connection import Connection from .error import ( JvcProjectorAuthError, JvcProjectorCommandError, JvcProjectorError, JvcProjectorReadWriteTimeoutError, JvcProjectorTimeoutError, ) if TYPE_CHECKING: from .command.base import Command PJOK: Final = b"PJ_OK" PJNG: Final = b"PJ_NG" PJREQ: Final = b"PJREQ" PJACK: Final = b"PJACK" PJNAK: Final = b"PJNAK" UNIT_ID: Final = b"\x89\x01" HEAD_OP: Final = b"!" + UNIT_ID HEAD_REF: Final = b"?" + UNIT_ID HEAD_RES: Final = b"@" + UNIT_ID HEAD_ACK: Final = b"\x06" + UNIT_ID HEAD_LEN: Final = 1 + len(UNIT_ID) END: Final = b"\n" AUTH_SALT: Final = "JVCKWPJ" KEEPALIVE_TTL: Final = 0.5 _LOGGER = logging.getLogger(__name__) class Device: """Class for representing a JVC Projector device.""" def __init__( self, ip: str, port: int, timeout: float, password: str | None ) -> None: """Initialize instance of class.""" self._conn = Connection(ip, port, timeout) self._auth = self._auth_hash = b"" if password: self._auth = struct.pack(f"{max(16, len(password))}s", password.encode()) self._auth_hash = ( sha256(f"{password}{AUTH_SALT}".encode()).hexdigest().encode() ) self._lock = asyncio.Lock() self._keepalive: asyncio.Task | None = None self._last_connect: float = 0.0 self._next_send: float = 0.0 async def send(self, cmd: Command) -> None: """Send command to the device.""" async with self._lock: if self._keepalive: self._keepalive.cancel() self._keepalive = None # Throttle commands to avoid known issues ts = time() if self._next_send and ts < self._next_send: await asyncio.sleep(self._next_send - ts) try: await self._send(cmd) except JvcProjectorCommandError: self._keepalive = asyncio.create_task(self.disconnect(KEEPALIVE_TTL)) raise except BaseException: await self.disconnect() raise self._keepalive = asyncio.create_task(self.disconnect(KEEPALIVE_TTL)) # Throttle next command. Give ops more time to take effect. self._next_send = time() + 0.1 if cmd.is_ref else 1.0 async def _connect(self) -> None: """Connect to device.""" assert not self._conn.is_connected() # Throttle new connections to avoid known issues elapsed = time() - self._last_connect if elapsed < 0.75: await asyncio.sleep(0.75 - elapsed) retries = 0 while retries < 12: try: _LOGGER.debug("Connecting to %s", self._conn.ip) await self._conn.connect() except (ConnectionRefusedError, asyncio.TimeoutError): retries += 1 if retries == 5: _LOGGER.warning("Retrying refused connection") else: _LOGGER.debug("Retrying refused connection") await asyncio.sleep(0.25 * (retries + 1)) continue except ConnectionError as e: raise JvcProjectorError from e try: data = await self._conn.read(len(PJOK)) except asyncio.TimeoutError as err: raise JvcProjectorTimeoutError("Handshake init timeout") from err _LOGGER.debug("Handshake received %s", data) if data == PJNG: _LOGGER.warning("Handshake retrying on busy") retries += 1 await asyncio.sleep(0.2 * retries) continue if data != PJOK: raise JvcProjectorError("Handshake init invalid") break else: raise JvcProjectorTimeoutError( f"Failed to connect to {self._conn.ip}; retries exceeded" ) _LOGGER.debug("Handshake sending '%s'", PJREQ.decode()) await self._conn.write(PJREQ + (b"_" + self._auth if self._auth else b"")) try: data = await self._conn.read(len(PJACK)) _LOGGER.debug("Handshake received %s", data) if data == PJNAK: _LOGGER.debug("Standard auth failed, trying SHA256 auth") await self._conn.write(PJREQ + b"_" + self._auth_hash) data = await self._conn.read(len(PJACK)) if data == PJACK: self._auth = self._auth_hash if data == PJNAK: raise JvcProjectorAuthError("Authentication failed") if data != PJACK: raise JvcProjectorError("Handshake ack invalid") except asyncio.TimeoutError as err: raise JvcProjectorTimeoutError("Handshake ack timeout") from err self._last_connect = time() async def _send(self, cmd: Command) -> None: """Send command to the device.""" if not self._conn.is_connected(): await self._connect() data = HEAD_REF if cmd.is_ref else HEAD_OP code = cmd.code.encode() data += code if cmd.is_op and cmd.op_value: data += cmd.op_value.encode() data += END _LOGGER.debug( "Sending %s %s (%s) %s", "ref" if cmd.is_ref else "op", cmd.name, cmd.code, data, ) await self._conn.write(data) try: data = await self._conn.readline( cmd.operation_timeout if cmd.is_op else None ) except asyncio.TimeoutError as e: raise JvcProjectorReadWriteTimeoutError( f"Read timeout for command {cmd.name} ({cmd.code})" ) from e if not data.startswith(HEAD_ACK + code[0:2]): raise JvcProjectorError( f"Invalid ack '{data!r}' for command {cmd.name} ({cmd.code})" ) _LOGGER.debug("Received ack %s", data) if cmd.is_ref: try: data = await self._conn.readline() except asyncio.TimeoutError as e: raise JvcProjectorReadWriteTimeoutError( f"Read timeout for command {cmd.name} ({cmd.code})" ) from e _LOGGER.debug("Received ref %s (%s)", data[HEAD_LEN + 2 : -1], data) if not data.startswith(HEAD_RES + code[0:2]): raise JvcProjectorError( f"Invalid header '{data!r}' for command {cmd.name} ({cmd.code})" ) try: cmd.ref_value = data[HEAD_LEN + 2 : -1].decode() except UnicodeDecodeError as e: raise JvcProjectorCommandError( f"Invalid response {data.hex()!r} for command {cmd.name} ({cmd.code})" ) from e cmd.ack = True async def disconnect(self, delay: float = 0.0) -> None: """Disconnect from the device.""" if delay: await asyncio.sleep(delay) if self._keepalive: self._keepalive.cancel() self._keepalive = None await self._conn.disconnect() _LOGGER.debug("Disconnected")