diff --git a/pyproject.toml b/pyproject.toml index 81d62ba..f450b49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "hatchling.build" [project] name = "jvc_projector_remote" -version = "0.2.0.a1" +version = "0.2.0.a2" authors = [{name="bezmi"}] description = "A package to control JVC projectors over IP" readme = "README.md" diff --git a/src/jvc_projector/__init__.py b/src/jvc_projector/__init__.py deleted file mode 100644 index d9a711c..0000000 --- a/src/jvc_projector/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .jvcprojector import * -from .jvccommands import * diff --git a/src/jvc_projector/jvccommands.py b/src/jvc_projector/jvccommands.py deleted file mode 100644 index 208b330..0000000 --- a/src/jvc_projector/jvccommands.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Commands for the jvc_projector library.""" - -import socket -import logging -from dataclasses import dataclass, field - -_LOGGER = logging.getLogger(__name__) - -# Headers -OPR = b"!\x89\x01" # operation (set) -REF = b"?\x89\x01" # reference (get) -RES = b"@\x89\x01" # response -ACKH = b"\x06\x89\x01" # projector ack -COM_ACK_LENGTH = 6 # length of ACKs sent by the projector - - -# use a dataclass so that we have a nice repr -@dataclass(init=False) -class Command: - """Base class for defining a set of read/write commands. - Args: - cmd (bytes): the base command bytes, for examplee b"PW" for power, b"PMPM" for picture mode. - *args (dict[str, bytes]): dictionaries of str:bytes pairs defining the read and write values. - If two dicts are provided as args, then the first is used for the write commands and - the second is used for read values. If only a single dict is provided, - we use it for both the write commands and the read values. - write_only(bool, optional): If true, this command group does not read from the projector - Defaults to False. - read_only(bool, optional): If True, this command group cannot write to the projector - Defaults to False. - verify_write (bool, optional): Whether we should wait for an ACK once we send a write command. - Defaults to True. - - Examples: - See jvccommands.Commands, jvcprojector.JVCProjector - """ - - cmd: bytes - name: str - readwritevals: tuple[dict[str, bytes]] = field(repr=False) - write_only: bool - read_only: bool - verify_write: bool - - write_vals: dict[str, bytes] - read_vals: dict[str, bytes] - - def __init__( - self, - cmd: bytes, - *readwritevals: dict[str, bytes], - write_only: bool = False, - read_only: bool = False, - verify_write: bool = True, - ): - self.cmd = cmd - self.verify_write = verify_write - - self.ack = ACKH + self.cmd[0:2] + b"\n" - - try: - assert len(readwritevals) <= 2 - except AssertionError: - raise AssertionError( - "(set_vals, get_vals) AND setget_vals cannot be defined at the same time." - ) - - self.write_only = write_only - self.read_only = read_only - if len(readwritevals) == 1: - if write_only: - self.write_vals = readwritevals[0] - self.read_vals = {} - elif read_only: - self.write_vals = {} - self.read_vals = readwritevals[0] - else: - self.write_vals = readwritevals[0] - self.read_vals = readwritevals[0] - - elif len(readwritevals) == 2: - self.write_vals = readwritevals[0] - self.read_vals = readwritevals[1] - else: - self.write_vals = {} - self.read_vals = {} - - self.write_valsinv = { - self.write_vals[key]: key for key in self.write_vals.keys() - } - self.read_valsinv = {self.read_vals[key]: key for key in self.read_vals.keys()} - - def __set_name__(self, owner, name: str): - self.name = name - - def __send(self, sock: socket.socket, command: bytes) -> None: - try: - sock.sendall(command) - except OSError as e: - sock.close() - raise JVCCommunicationError( - f"Socket exception of `{self.name}` command when sending bytes: `{command}`." - ) from e - - def __verify_ack(self, sock: socket.socket, command: bytes) -> None: - try: - ACK = sock.recv(COM_ACK_LENGTH) - - # check if the ACK is valid (compare to user provided ack if available) - if not ACK.startswith(ACKH): - sock.close() - raise JVCCommunicationError( - f"Malformed ACK response from the projector when sending command: `{self.name}` with bytes: `{command}`. " - f"Received ACK: `{ACK}` does not have the correct header" - ) - elif not ACK == self.ack: - sock.close() - raise JVCCommunicationError( - f"Malformed ACK response from the projector when sending command: `{self.name}` with bytes: `{command}`. " - f"Expected `{self.ack}`, received `{ACK}`" - ) - - except socket.timeout as e: - sock.close() - raise JVCCommunicationError( - f"Timeout when waiting for the specified ACK: `{self.ack}` for command: `{self.name}` with bytes: `{command}`" - ) from e - - except OSError as e: - sock.close() - raise JVCCommunicationError( - f"Socket exception when waiting for the specified ACK: `{self.ack}` for command: `{self.name}` with bytes: `{command}`" - ) from e - - def write(self, sock: socket.socket, value: str = "") -> None: - if self.read_only: - sock.close() - raise JVCCommandNotFoundError( - f"The command group `{self.name}` does not implement any writable properties" - ) - try: - command = OPR + self.cmd + self.write_vals[value] + b"\n" - except KeyError as e: - if not value and not self.write_vals: - command = OPR + self.cmd + b"\n" - elif not value and self.write_only: - sock.close() - raise JVCCommandNotFoundError( - f"The write_only command group: `{self.name}` requires a property key to be defined to do a write operation" - ) from e - else: - sock.close() - raise JVCCommandNotFoundError( - f"The command: `{self.name}` does not have write operation: `{value}`" - ) from e - - if not self.verify_write: - _LOGGER.debug( - f"ACK verification disabled for the command: `{jfrmt.highlight(self.name)} `. Error handling will be less robust" - ) - - self.__send(sock, command) - - # no need to wait for ACK or message as this is not a reference command without ACK specified - if not self.verify_write: - sock.close() - return - - self.__verify_ack(sock, command) - - sock.close() - - def read(self, sock: socket.socket) -> str: - if self.write_only: - sock.close() - raise JVCCommandNotFoundError( - f"The command group: `{self.name}` does not implement any readable properties" - ) - - command = REF + self.cmd + b"\n" - - self.__send(sock, command) - - self.__verify_ack(sock, command) - - try: - resp = sock.recv(1024) - except socket.timeout as e: - sock.close() - raise JVCCommunicationError( - f"Timeout when waiting for response for read command: `{self.name}`" - ) from e - except OSError as e: - sock.close() - raise JVCCommunicationError( - f"Socket exception when waiting for response for read command: `{self.name}`" - ) from e - - sock.close() - try: - assert resp.startswith(RES + self.cmd[0:2]) - except AssertionError as e: - raise JVCCommunicationError( - f"Malformed response header for read command: `{self.name}`" - ) from e - - resp = resp[len(RES) + 2 : -1] - - if not self.read_vals: - return resp.decode("ascii") - - return self.read_valsinv[resp] - - -class Commands: - """A container for Commands""" - - # power commands - power = Command( - b"PW", - {"on": b"1", "off": b"0"}, - { - "standby": b"0", - "lamp_on": b"1", - "cooling": b"2", - "reserved": b"3", - "emergency": b"4", - }, - ) - - # lens memory commands - memory = Command( - b"INML", - {"1": b"0", "2": b"1", "3": b"2", "4": b"3", "5": b"4"}, - ) - - # input commands, input is technically a keyword, but should be okay... - input = Command(b"IP", {"hdmi1": b"6", "hdmi2": b"7"}) - - # picture mode commands - picture_mode = Command( - b"PMPM", - { - "film": b"00", - "cinema": b"01", - "natural": b"03", - "hdr10": b"04", - "thx": b"06", - "user1": b"0C", - "user2": b"0D", - "user3": b"0E", - "user4": b"0F", - "user5": b"10", - "user6": b"11", - "hlg": b"14", - }, - ) - - # low latency enable/disable - low_latency = Command(b"PMLL", {"on": b"1", "off": b"0"}) - - # mask commands - mask = Command( - b"ISMA", - {"off": b"2", "custom1": b"0", "custom2": b"1", "custom3": b"3"}, - ) - - # lamp commands - lamp = Command( - b"PMLP", - {"high": b"1", "low": b"0"}, - ) - - # menu controls - menu = Command( - b"RC73", - { - "menu": b"2E", - "down": b"02", - "left": b"36", - "right": b"34", - "up": b"01", - "ok": b"2F", - "back": b"03", - }, - write_only=True, - ) - - # Intelligent Lens Aperture commands - aperture = Command( - b"PMDI", - {"off": b"0", "auto1": b"1", "auto2": b"2"}, - ) - - # Anamorphic commands - anamorphic = Command( - b"INVS", - {"off": b"0", "a": b"1", "b": b"2", "c": b"3"}, - ) - - # active signal - signal = Command( - b"SC", - {"no_signal": b"0", "active_signal": b"1"}, - read_only=True, - ) - - # MAC address, model, null command - macaddr = Command( - b"LSMA", - read_only=True, - ) - - modelinfo = Command( - b"MD", - read_only=True, - ) - nullcmd = Command( - b"\x00\x00", - write_only=True, - ) - - -class JVCConfigError(Exception): - """Exception when the user supplied config is wrong""" - - pass - - -class JVCCannotConnectError(Exception): - """Exception when we can't connect to the projector""" - - pass - - -class JVCHandshakeError(Exception): - """Exception when there was a problem with the 3 step handshake""" - - pass - - -class JVCCommunicationError(Exception): - """Exception when there was a communication issue""" - - pass - - -class JVCCommandNotFoundError(Exception): - """Exception when the requested command doesn't exist""" - - pass - - -class JVCPoweredOffError(Exception): - """Exception when projector is powered off and can't accept some commands.""" - - pass - - -class jfrmt: - @staticmethod - def highlight(value: str) -> str: - return "{:s}".format("\u035F".join(value)) diff --git a/src/jvc_projector/jvcprojector.py b/src/jvc_projector/jvcprojector.py deleted file mode 100644 index 29954b3..0000000 --- a/src/jvc_projector/jvcprojector.py +++ /dev/null @@ -1,209 +0,0 @@ -import socket -from time import sleep -import datetime -import logging -from typing import Optional - -from .jvccommands import * - -_LOGGER = logging.getLogger(__name__) - - -class JVCProjector: - """JVC Projector Control""" - - def __init__( - self, - host: str, - password: Optional[str] = None, - port: Optional[int] = None, - delay_ms: Optional[int] = None, - connect_timeout: Optional[int] = None, - max_retries: Optional[int] = None, - ): - self.host = host - self.port = port if port else 20554 - self.connect_timeout = connect_timeout if connect_timeout else 10 - self.delay = ( - datetime.timedelta(microseconds=(delay_ms * 1000)) - if delay_ms - else datetime.timedelta(microseconds=(600000)) - ) - self.last_command_time = datetime.datetime.now() - datetime.timedelta( - seconds=10 - ) - self.password = password if password else "" - self.max_retries = max_retries if max_retries else 10 - - _LOGGER.debug( - f"initialising JVCProjector with host={self.host}, password={self.password}, port={self.port}, delay={self.delay.total_seconds()*1000}, connect_timeout={self.connect_timeout}, max_retries={self.max_retries}" - ) - - self.JVC_REQ = b"PJREQ" - if self.password != None and len(self.password): - if len(self.password) == 10: - self.JVC_REQ = b"PJREQ_" + bytes(self.password, "ascii") - elif len(self.password) == 9: - self.JVC_REQ = b"PJREQ_" + bytes(self.password, "ascii") + b"\x00" - elif len(self.password) == 8: - self.JVC_REQ = b"PJREQ_" + bytes(self.password, "ascii") + b"\x00\x00" - else: - raise JVCConfigError( - "Specified network password invalid (too long/short)" - ) - - try: - _LOGGER.debug( - f"Sending nullcmd to {jfrmt.highlight(f'{self.host}:{self.port}')} to verify connection" - ) - self._send_command(Commands.nullcmd) - except JVCCannotConnectError as e: - raise JVCConfigError( - f"Couldn't verify connection to projector at the specified address: {self.host}:{self.port}. " - f"Make sure the host and port are set correctly and control4 is turned off in projector settings" - ) from e - - def __throttle(self, last_time: datetime.datetime) -> None: - if self.delay == 0: - return - - delta = datetime.datetime.now() - last_time - - if self.delay > delta: - sleep((self.delay - delta).total_seconds() * 1.1) - - def __connect(self, retry: int = 0) -> socket.socket: - jvc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - jvc_sock.settimeout(self.connect_timeout) - try: - jvc_sock.connect((self.host, self.port)) - return jvc_sock - except socket.timeout as e: - jvc_sock.close() - raise JVCCannotConnectError( - "Timed out when trying to connect to projector" - ) from e - except OSError as e: - jvc_sock.close() - if retry <= self.max_retries: - _LOGGER.debug( - f"Received error: {repr(e)} when trying to connect, retrying (we're on retry number {retry+1} of {self.max_retries})" - ) - self.__throttle(datetime.datetime.now()) - jvc_sock = self.__connect(retry + 1) - _LOGGER.debug(f"Connection successful") - return jvc_sock - raise JVCCannotConnectError( - "Could not establish connection to projector" - ) from e - - def __handshake(self) -> socket.socket: - - JVC_GRT = b"PJ_OK" - JVC_REQ = self.JVC_REQ - JVC_ACK = b"PJACK" - - jvc_sock = self.__connect() - - _LOGGER.debug(f"Attempting handshake") - - try: - message = jvc_sock.recv(len(JVC_GRT)) - if message != JVC_GRT: - jvc_sock.close() - raise JVCHandshakeError( - f"Projector did not reply with PJ_OK (got `{message}` instead)" - ) - - except socket.timeout as e: - jvc_sock.close() - raise JVCHandshakeError("Timeout when waiting to receive PJ_OK") from e - - # try sending PJREQ, if there's an error, raise exception - try: - jvc_sock.sendall(JVC_REQ) - except OSError as e: - jvc_sock.close() - raise JVCHandshakeError("Socket exception when sending PJREQ.") from e - - # see if we receive PJACK, if not, raise exception - try: - message = jvc_sock.recv(len(JVC_ACK)) - if message != JVC_ACK: - jvc_sock.close() - raise JVCHandshakeError( - f"Projector did not reply with PJACK (received `{message}` instead)" - ) - except socket.timeout as e: - jvc_sock.close() - raise JVCHandshakeError("Timeout when waiting to receive PJACK") from e - - _LOGGER.debug(f"Handshake successful") - return jvc_sock - - def _send_command(self, command: Command, value: str = "") -> Optional[str]: - """Call Commands.read() if not value, else Commands.write(value)""" - - self.__throttle(self.last_command_time) - - jvc_sock: socket.socket = self.__handshake() - - result: Optional[str] = None - - if value: - _LOGGER.debug( - f"writing property: {jfrmt.highlight(value)} to command group: {jfrmt.highlight(command.name)}" - ) - command.write(jvc_sock, value) - elif command.write_only: - _LOGGER.debug( - f"sending write_only operation: {jfrmt.highlight(command.name)}" - ) - command.write(jvc_sock) - else: - _LOGGER.debug( - f"reading from command group: {jfrmt.highlight(command.name)}" - ) - result = command.read(jvc_sock) - - self.last_command_time = datetime.datetime.now() - _LOGGER.debug(f"command sent successfully") - - if result is not None: - _LOGGER.debug( - f"the state of command group: {jfrmt.highlight(command.name)} is: {jfrmt.highlight(result)}" - ) - return result - - def power_on(self) -> None: - self._send_command(Commands.power, "on") - - def power_off(self) -> None: - self._send_command(Commands.power, "off") - - def command(self, command_string: str) -> Optional[str]: - ps = self.power_state() - if (command_string not in ["power-on", "power"]) and ps != "lamp_on": - raise JVCPoweredOffError( - f"Can't execute command: `{command_string}` unless the projector is in state `lamp_on`. " - f"Current power state is: `{ps}`" - ) - - commandl: list[str] = command_string.split("-") - - if not hasattr(Commands, commandl[0]): - raise JVCCommandNotFoundError( - f"The requested command: `{command_string}` is not in the list of recognised commands" - ) - else: - if len(commandl) > 1: - self._send_command(getattr(Commands, commandl[0]), commandl[1]) - else: - return self._send_command(getattr(Commands, commandl[0])) - - def power_state(self) -> Optional[str]: - return self._send_command(Commands.power) - - def is_on(self) -> bool: - on = ["lamp_on", "reserved"] - return self.power_state() in on