pax_global_header00006660000000000000000000000064146744052000014514gustar00rootroot0000000000000052 comment=7f7c0aa44abff560a4b7d994fd7281469b4233e8 pyjvcprojector-1.1.2/000077500000000000000000000000001467440520000146005ustar00rootroot00000000000000pyjvcprojector-1.1.2/.gitignore000066400000000000000000000040221467440520000165660ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # IDE .idea/ .vscode TODO.md pyjvcprojector-1.1.2/.pre-commit-config.yaml000066400000000000000000000001671467440520000210650ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.7 hooks: - id: ruff - id: ruff-formatpyjvcprojector-1.1.2/LICENSE000066400000000000000000000020551467440520000156070ustar00rootroot00000000000000MIT License Copyright (c) 2021 Steve Easley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pyjvcprojector-1.1.2/Makefile000066400000000000000000000001531467440520000162370ustar00rootroot00000000000000all: clean build build: python3 -m build python -m twine upload -u __token__ dist/* clean: rm -rf distpyjvcprojector-1.1.2/README.md000066400000000000000000000045661467440520000160720ustar00rootroot00000000000000# pyjvcprojector A python library for controlling a JVC Projector over a network connection. https://pypi.org/project/pyjvcprojector/ ## Features A full reference to the available commands is available from JVC here http://pro.jvc.com/pro/attributes/PRESENT/Manual/External%20Command%20Spec%20for%20D-ILA%20projector_V3.0.pdf. ### Convenience functions: * `JvcProjector::power_on()` turns on power. * `JvcProjector::power_off()` turns off power. * `JvcProjector::get_power()` gets power state (_standby, on, cooling, warming, error_) * `JvcProjector::get_input()` get current input (_hdmi1, hdmi2_). * `JvcProjector::get_signal()` get signal state (_signal, nosignal_). * `JvcProjector::get_state()` returns {_power, input, signal_}. * `JvcProjector::get_info()` returns {_model, mac address_}. ### Send remote control codes A wrapper for calling `JvcProjector::op(f"RC{code}")` * `JvcProjector::remote(code)` sends remote control command. ### Send raw command codes * `JvcProjector::ref(code)` sends reference commands to read data. `code` is formatted `f"{cmd}"`. * `JvcProjector::op(code)` sends operation commands to write data. `code` is formatted `f"{cmd}{val}"`. ## Installation ``` pip install pyjvcprojector ``` ## Usage ```python import asyncio from jvcprojector.projector import JvcProjector from jvcprojector import const async def main(): jp = JvcProjector("127.0.0.1") await jp.connect() print("Projector info:") print(await jp.get_info()) if await jp.get_power() != const.ON: await jp.power_on() print("Waiting for projector to warmup...") while await jp.get_power() != const.ON: await asyncio.sleep(3) print("Current state:") print(await jp.get_state()) # # Example sending remote code # print("Showing info window") await jp.remote(const.REMOTE_INFO) await asyncio.sleep(5) print("Hiding info window") await jp.remote(const.REMOTE_BACK) # # Example sending reference command (reads value from function) # print("Picture mode info:") print(await jp.ref("PMPM")) # # Example sending operation command (writes value to function) # # await jp.ref("PMPM01") # Sets picture mode to Film await jp.disconnect() ``` Password authentication is also supported for both older and newer models. ```python JvcProjector("127.0.0.1", password="1234567890") ```pyjvcprojector-1.1.2/examples/000077500000000000000000000000001467440520000164165ustar00rootroot00000000000000pyjvcprojector-1.1.2/examples/__init__.py000066400000000000000000000000001467440520000205150ustar00rootroot00000000000000pyjvcprojector-1.1.2/examples/simple.py000066400000000000000000000017361467440520000202700ustar00rootroot00000000000000import asyncio import logging from jvcprojector.projector import JvcProjector from jvcprojector import const logging.basicConfig(level=logging.WARNING) async def main(): jp = JvcProjector("192.168.0.30") await jp.connect() print("Projector info:") print(await jp.get_info()) if await jp.get_power() != const.ON: await jp.power_on() print("Waiting for projector to warmup...") while await jp.get_power() != const.ON: await asyncio.sleep(3) print("Current state:") print(await jp.get_state()) # # Example of sending remote codes # print("Showing info") await jp.remote(const.REMOTE_INFO) await asyncio.sleep(5) print("Hiding info") await jp.remote(const.REMOTE_BACK) # # Example of reference command (reads value from function) # print("Picture mode info:") print(await jp.ref("PMPM")) await jp.disconnect() if __name__ == "__main__": asyncio.run(main()) pyjvcprojector-1.1.2/jvcprojector/000077500000000000000000000000001467440520000173125ustar00rootroot00000000000000pyjvcprojector-1.1.2/jvcprojector/__init__.py000066400000000000000000000004471467440520000214300ustar00rootroot00000000000000"""A python library for controlling a JVC Projector over a network connection.""" # ruff: noqa: F401 from .error import ( JvcProjectorAuthError, JvcProjectorCommandError, JvcProjectorConnectError, JvcProjectorError, ) from .projector import JvcProjector __version__ = "1.1.2" pyjvcprojector-1.1.2/jvcprojector/command.py000066400000000000000000000260071467440520000213070ustar00rootroot00000000000000"""Module for representing a JVC Projector command.""" from __future__ import annotations from collections.abc import Callable import logging import re from typing import Final from . import const _LOGGER = logging.getLogger(__name__) 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" TEST: Final = "\0\0" VERSION: Final = "IFSV" MODEL: Final = "MD" MAC: Final = "LSMA" SOURCE: Final = "SC" POWER: Final = "PW" INPUT: Final = "IP" REMOTE: Final = "RC" AUTH_SALT: Final = "JVCKWPJ" class JvcCommand: """Class for representing a JVC Projector command.""" def __init__(self, code: str, is_ref=False): """Initialize class.""" self.code = code self.is_ref = is_ref self.ack = False self._response: str | None = None @property def response(self) -> str | None: """Return command response.""" if not self.is_ref or self._response is None: return None res = self.code + self._response val: str = res[len(self.code) :] for pat, fmt in self.formatters.items(): m = re.search(r"^" + pat + r"$", res) if m: if isinstance(fmt, list): try: return fmt[int(m[1], 16)] except ValueError: msg = "response '%s' not int for cmd '%s'" _LOGGER.warning(msg, val, self.code) except KeyError: msg = "response '%s' not mapped for cmd '%s'" _LOGGER.warning(msg, val, self.code) elif isinstance(fmt, dict): try: return fmt[m[1]] except KeyError: msg = "response '%s' not mapped for cmd '%s'" _LOGGER.warning(msg, val, self.code) elif callable(fmt): try: return fmt(m) except Exception as e: # noqa: BLE001 msg = "response format failed with %s for '%s (%s)'" _LOGGER.warning(msg, e, self.code, val) break return val @response.setter def response(self, data: str) -> None: """Set command response.""" self._response = data @property def is_power(self) -> bool: """Return if command is a power command.""" return self.code.startswith("PW") formatters: dict[str, list | dict | Callable] = { # Power "PW(.)": [const.STANDBY, const.ON, const.COOLING, const.WARMING, const.ERROR], # Input "(?:IP|IFIN)(.)": { "0": "svideo", "1": "video", "2": "component", "3": "pc", "6": const.HDMI1, "7": const.HDMI2, }, # Source "SC(.)": [const.NOSIGNAL, const.SIGNAL], # Model "MD(.+)": lambda r: re.sub(r"-+", "-", r[1].replace(" ", "-")), # Picture Mode "PMPM(..)": { "00": "film", "01": "cinema", "02": "animation", "03": "natural", "04": "hdr10", "06": "thx", "0B": "frameadapt_hdr", "0C": "user1", "0D": "user2", "0E": "user3", "0F": "user4", "10": "user5", "11": "user6", "14": "hlg", "15": "hdr10+", "16": "pana_pq", "17": "filmmaker", "18": "frameadapt_hdr2", "19": "frameadapt_hdr3", }, # Picture Mode - Intelligent Lens Aperture "PMDI(.)": ["off", "auto1", "auto2"], # Picture Mode - Color Profile "PMPR(..)": { "00": "off", "01": "film1", "02": "film2", "03": "bt709", "04": "cinema", "05": "cinema2", "06": "anime", "07": "anime2", "08": "video", "09": "vivid", "0A": "hdr", "0B": "bt2020(wide)", "0C": "3d", "0D": "thx", "0E": "custom1", "0F": "custom2", "10": "custom3", "11": "custom4", "12": "custom5", "21": "dci", "22": "custom6", "24": "bt2020(normal)", "25": "off(wide)", "26": "auto", }, # Picture Mode - Color Temp "PMCL(..)": { "00": "5500k", "02": "6500k", "04": "7500k", "08": "9300k", "09": "high", "0A": "custom1", "0B": "custom2", "0C": "hdr10", "0D": "xenon1", "0E": "xenon2", "14": "hlg", }, # Picture Mode - Color Correction "PMCC(..)": { "00": "5500k", "02": "6500k", "04": "7500k", "08": "9300k", "09": "high", "0D": "xenon1", "0E": "xenon2", }, # Picture Mode - Gamma Table "PMGT(..)": { "00": "2.2", "01": "cinema1", "02": "cinema2", "04": "custom1", "05": "custom2", "06": "custom3", "07": "hdr_hlg", "08": "2.4", "09": "2.6", "0A": "film1", "0B": "film2", "0C": "hdr_pq", "0D": "pana_pq", "10": "thx", "15": "hdr_auto", }, # Picture Mode - Color Management, Low Latency, 8K E-Shift "PM(?:CB|LL|US)(.)": ["off", "on"], # Picture Mode - Clear Motion Drive "PMCM(.)": ["off", None, None, "low", "high", "inverse_telecine"], # Picture Mode - Motion Enhance "PMME(.)": ["off", "low", "high"], # Picture Mode - Lamp Power "PMLP(.)": ["normal", "high"], # Picture Mode - Graphics Mode "PMGM(.)": ["standard", "high-res"], # Input Signal - HDMI Input Level "ISIL(.)": ["standard", "enhanced", "super_white", "auto"], # Input Signal - HDMI Color Space "ISHS(.)": ["auto", "ycbcr(4:4:4)", "ycbcr(4:2:2)", "rgb"], # Input Signal - HDMI 2D/3D "IS3D(.)": ["2d", "auto", None, "side_by_side", "top_bottom"], # Input Signal - Aspect "ISAS(.)": [None, None, "zoom", "auto", "native"], # Input Signal - Mask "ISMA(.)": [None, "on", "off"], # Installation - Lens Control "IN(?:FN|FF|ZT|ZW|SL|SR|SU|SD)(.)": ["stop", "start"], # Installation - Lens Image Pattern, Lens Lock, Screen Adjust "IN(?:IP|LL|SC|HA)(.)": ["off", "on"], # Installation - Style "INIS(.)": ["front", "front_ceiling", "rear", "rear_ceiling"], # Installation - Anamorphic "INVS(.)": ["off", "a", "b", "c", "d"], # Display - Back Color "DSBC(.)": ["blue", "black"], # Display - Menu Positions "DSMP(.)": [ "left-top", "right-top", "center", "left-bottom", "right-bottom", "left", "right", ], # Installation - Source Display, Logo Data "DS(?:SD|LO)(.)": ["off", "on"], # Function - Trigger "FUTR(.)": [ "off", "power", "anamo", "ins1", "ins2", "ins3", "ins4", "ins5", "ins6", "ins7", "ins8", "ins9", "ins10", ], # Function - Off Timer "FUOT(.)": ["off", "1hour", "2hour", "3hour", "4hour"], # Function - Eco Mode, Control4 "FU(?:EM|CF)(.)": ["off", "on"], # Function - Input "IFIN(.)": ["hdmi1", "hdmi2"], # Function - Source "IFIS(..)": { "02": "480p", "03": "576p", "04": "720p50", "05": "720p60", "06": "1080i50", "07": "1080i60", "08": "1080p24", "09": "1080p50", "0A": "1080p60", "0B": "nosignal", "0C": "720p3d", "0D": "1080i3d", "0E": "1080p3d", "0F": "outofrange", "10": "4k(4096)60", "11": "4k(4096)50", "12": "4k(4096)30", "13": "4k(4096)25", "14": "4k(4096)24", "15": "4k(3840)60", "16": "4k(3840)50", "17": "4k(3840)30", "18": "4k(3840)25", "19": "4k(3840)24", "1C": "1080p25", "1D": "1080p30", "1E": "2048x1080p24", "1F": "2048x1080p25", "20": "2048x1080p30", "21": "2048x1080p50", "22": "2048x1080p60", "23": "3840x2160p120", "24": "4096x2160p120", "25": "vga(640x480)", "26": "svga(800x600)", "27": "xga(1024x768)", "28": "sxga(1280x1024)", "29": "wxga(1280x768)", "2A": "wxga+(1440x900)", "2B": "wsxga+(1680x1050)", "2C": "wuxga(1920x1200)", "2D": "wxga(1280x800)", "2E": "fwxga(1366x768)", "2F": "wxga++(1600x900)", "30": "uxga(1600x1200)", "31": "qxga", "32": "wqxga", }, # Function - Deep Color "IFDC(.)": ["8bit", "10bit", "12bit"], # Function - Color Space "IFXV(.)": ["rgb", "yuv"], # Function - Colorimetry "IFCM(.)": [ "nodata", "bt601", "bt709", "xvycc601", "xvycc709", "sycc601", "adobe_ycc601", "adobe_rgb", "bt2020(constant_luminance)", "bt2020(non-constant_luminance)", "srgb", ], # Function - HDR "IFHR(.)": { "0": "sdr", "1": "hdr", "2": "smpte_st_2084", "3": "hybrid_log", "F": "none", }, # Picture Mode - HDR Level "PMHL(.)": ["auto", "-2", "-1", "0", "1", "2"], # Picture Mode - HDR Processing "PMHP(.)": ["static", "frame", "scene"], # Picture Mode - HDR Processing "PMCT(.)": ["auto", "sdr", None, "hdr10", "hlg"], # Picture Mode - Theater Optimizer "PMNM(.)": ["off", "on"], # Picture Mode - Theater Optimizer Level "PMNL(.)": ["reserved", "low", "medium", "high"], # Picture Mode - Theater Optimizer Processing "PMNP(.)": ["-", "start"], # Lan Setup "LSDS(.)": [const.OFF, const.ON], "LSMA(.+)": lambda r: re.sub(r"-+", "-", r[1].replace(" ", "-")), "LSIP(..)(..)(..)(..)": lambda r: f"{int(r[1], 16)}.{int(r[2], 16)}.{int(r[3], 16)}.{int(r[4], 16)}", } pyjvcprojector-1.1.2/jvcprojector/connection.py000066400000000000000000000045611467440520000220310ustar00rootroot00000000000000"""Module for representing a JVC Projector network connection.""" from __future__ import annotations import asyncio import socket import aiodns from .error import JvcProjectorConnectError class JvcConnection: """Class for representing a JVC Projector network connection.""" def __init__(self, ip: str, port: int, timeout: float): """Initialize class.""" self._ip = ip self._port = port self._timeout = timeout self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None @property def ip(self) -> str: """Return ip address.""" return self._ip @property def port(self) -> int: """Return port.""" return self._port def is_connected(self) -> bool: """Return if connected to device.""" return self._reader is not None and self._writer is not None async def connect(self) -> None: """Connect to device.""" assert self._reader is None and self._writer is None conn = asyncio.open_connection(self._ip, self._port) self._reader, self._writer = await asyncio.wait_for(conn, timeout=self._timeout) async def read(self, n: int) -> bytes: """Read n bytes from device.""" assert self._reader return await asyncio.wait_for(self._reader.read(n), timeout=self._timeout) async def readline(self) -> bytes: """Read all bytes up to newline from device.""" assert self._reader return await asyncio.wait_for(self._reader.readline(), timeout=self._timeout) async def write(self, data: bytes) -> None: """Write data to device.""" assert self._writer self._writer.write(data) await self._writer.drain() async def disconnect(self) -> None: """Disconnect from device.""" if self._writer: self._writer.close() self._writer = None self._reader = None async def resolve(host: str) -> str: """Resolve hostname to ip address.""" try: res = await aiodns.DNSResolver().gethostbyname(host, socket.AF_INET) if len(res.addresses) < 1: raise JvcProjectorConnectError("Unexpected zero length addresses response") except aiodns.error.DNSError as err: raise JvcProjectorConnectError(f"Failed to resolve host {host}") from err return res.addresses[0] pyjvcprojector-1.1.2/jvcprojector/const.py000066400000000000000000000024531467440520000210160ustar00rootroot00000000000000"""Device constants for a JVC Projector.""" from typing import Final POWER: Final = "power" OFF: Final = "off" STANDBY: Final = "standby" ON: Final = "on" WARMING: Final = "warming" COOLING: Final = "cooling" ERROR: Final = "error" INPUT: Final = "input" HDMI1 = "hdmi1" HDMI2 = "hdmi2" SOURCE: Final = "source" NOSIGNAL: Final = "nosignal" SIGNAL: Final = "signal" REMOTE_MENU: Final = "732E" REMOTE_UP: Final = "7301" REMOTE_DOWN: Final = "7302" REMOTE_LEFT: Final = "7336" REMOTE_RIGHT: Final = "7334" REMOTE_OK: Final = "732F" REMOTE_BACK: Final = "7303" REMOTE_MPC: Final = "73F0" REMOTE_HIDE: Final = "731D" REMOTE_INFO: Final = "7374" REMOTE_INPUT: Final = "7308" REMOTE_ADVANCED_MENU: Final = "7373" REMOTE_PICTURE_MODE: Final = "73F4" REMOTE_COLOR_PROFILE: Final = "7388" REMOTE_LENS_CONTROL: Final = "7330" REMOTE_SETTING_MEMORY: Final = "73D4" REMOTE_GAMMA_SETTINGS: Final = "73F5" REMOTE_CMD: Final = "738A" REMOTE_MODE_1: Final = "73D8" REMOTE_MODE_2: Final = "73D9" REMOTE_MODE_3: Final = "73DA" REMOTE_HDMI_1: Final = "7370" REMOTE_HDMI_2: Final = "7371" REMOTE_LENS_AP: Final = "7320" REMOTE_ANAMO: Final = "73C5" REMOTE_GAMMA: Final = "7375" REMOTE_COLOR_TEMP: Final = "7376" REMOTE_3D_FORMAT: Final = "73D6" REMOTE_PIC_ADJ: Final = "7372" REMOTE_NATURAL: Final = "736A" REMOTE_CINEMA: Final = "7368" pyjvcprojector-1.1.2/jvcprojector/device.py000066400000000000000000000163071467440520000211320ustar00rootroot00000000000000"""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 . import const from .command import ( AUTH_SALT, END, HEAD_ACK, HEAD_LEN, HEAD_OP, HEAD_REF, HEAD_RES, PJACK, PJNAK, PJNG, PJOK, PJREQ, JvcCommand, ) from .connection import JvcConnection from .error import ( JvcProjectorAuthError, JvcProjectorCommandError, JvcProjectorConnectError, ) KEEPALIVE_TTL = 2 _LOGGER = logging.getLogger(__name__) class JvcDevice: """Class for representing a JVC Projector device.""" def __init__( self, ip: str, port: int, timeout: float, password: str | None = None ) -> None: """Initialize class.""" self._conn = JvcConnection(ip, port, timeout) self._auth = b"" if password: self._auth = struct.pack(f"{max(10, len(password))}s", password.encode()) self._lock = asyncio.Lock() self._keepalive: asyncio.Task | None = None self._last: float = 0.0 async def send(self, cmds: list[JvcCommand]) -> None: """Send commands to device.""" async with self._lock: # Treat status refreshes with special handling is_refresh = len(cmds) > 1 and cmds[0].is_ref and cmds[0].is_power # Connection keepalive window for fast command repeats keepalive = True # Connection keepalive window for fast command repeats if self._keepalive: self._keepalive.cancel() self._keepalive = None elif is_refresh: # Don't extend window below if this was a refresh keepalive = False try: if not self._conn.is_connected(): await self._connect() cmd = None for cmd in cmds: await self._send(cmd) # Throttle since some projectors dont like back to back commands await asyncio.sleep(0.5) # If device is not powered on, skip remaining commands if is_refresh and cmds[0].response != const.ON: break except Exception: keepalive = False raise finally: # Delay disconnect to keep connection alive. if keepalive and cmd and cmd.ack: self._keepalive = asyncio.create_task( self._disconnect(KEEPALIVE_TTL) ) else: await self._disconnect() async def _connect(self) -> None: """Connect to device.""" assert not self._conn.is_connected() elapsed = time() - self._last if elapsed < 0.75: await asyncio.sleep(0.75 - elapsed) retries = 0 while retries < 10: try: _LOGGER.debug("Connecting to %s", self._conn.ip) await self._conn.connect() except ConnectionRefusedError: retries += 1 if retries == 5: _LOGGER.warning("Retrying refused connection") else: _LOGGER.debug("Retrying refused connection") await asyncio.sleep(0.2 * retries) continue except (asyncio.TimeoutError, ConnectionError) as err: raise JvcProjectorConnectError from err try: data = await self._conn.read(len(PJOK)) except asyncio.TimeoutError as err: raise JvcProjectorConnectError("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.25 * retries) continue if data != PJOK: raise JvcProjectorCommandError("Handshake init invalid") break else: raise JvcProjectorConnectError("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") auth = ( sha256(f"{self._auth.decode()}{AUTH_SALT}".encode()) .hexdigest() .encode() ) await self._conn.write(PJREQ + b"_" + auth) data = await self._conn.read(len(PJACK)) if data == PJACK: self._auth = auth if data == PJNAK: raise JvcProjectorAuthError if data != PJACK: raise JvcProjectorCommandError("Handshake ack invalid") except asyncio.TimeoutError as err: raise JvcProjectorConnectError("Handshake ack timeout") from err self._last = time() async def _send(self, cmd: JvcCommand) -> None: """Send command to device.""" assert self._conn.is_connected() assert len(cmd.code) >= 2 code = cmd.code.encode() data = (HEAD_REF if cmd.is_ref else HEAD_OP) + code + END _LOGGER.debug( "Sending %s '%s (%s)'", "ref" if cmd.is_ref else "op", cmd.code, data ) await self._conn.write(data) try: data = await self._conn.readline() except asyncio.TimeoutError: _LOGGER.warning("Response timeout for '%s'", cmd.code) return _LOGGER.debug("Received ack %s", data) if not data.startswith(HEAD_ACK + code[0:2]): raise JvcProjectorCommandError( f"Response ack invalid '{data!r}' for '{cmd.code}'" ) if cmd.is_ref: try: data = await self._conn.readline() except asyncio.TimeoutError: _LOGGER.warning("Ref response timeout for '%s'", cmd.code) return _LOGGER.debug("Received ref %s (%s)", data[HEAD_LEN + 2 : -1], data) if not data.startswith(HEAD_RES + code[0:2]): raise JvcProjectorCommandError( f"Ref ack invalid '{data!r}' for '{cmd.code}'" ) try: cmd.response = data[HEAD_LEN + 2 : -1].decode() except UnicodeDecodeError: cmd.response = data.hex() _LOGGER.warning("Failed to decode response '%s'", data) cmd.ack = True async def disconnect(self) -> None: """Disconnect from device.""" if self._keepalive: self._keepalive.cancel() await self._disconnect() async def _disconnect(self, delay: int = 0) -> None: """Disconnect from device.""" if delay: await asyncio.sleep(delay) self._keepalive = None await self._conn.disconnect() _LOGGER.debug("Disconnected") pyjvcprojector-1.1.2/jvcprojector/error.py000066400000000000000000000005721467440520000210210ustar00rootroot00000000000000"""Custom errors for a JVC Projector.""" class JvcProjectorError(Exception): """Projector Error.""" class JvcProjectorConnectError(JvcProjectorError): """Projector Connect Timeout.""" class JvcProjectorCommandError(JvcProjectorError): """Projector Command Error.""" class JvcProjectorAuthError(JvcProjectorError): """Projector Password Invalid Error.""" pyjvcprojector-1.1.2/jvcprojector/projector.py000066400000000000000000000121371467440520000216770ustar00rootroot00000000000000"""Module for interacting with a JVC Projector.""" from __future__ import annotations import logging from . import command, const from .command import JvcCommand from .connection import resolve from .device import JvcDevice from .error import JvcProjectorConnectError, JvcProjectorError _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 20554 DEFAULT_TIMEOUT = 15.0 class JvcProjector: """Class for interacting with a JVC Projector.""" def __init__( self, host: str, *, port: int = DEFAULT_PORT, timeout: float = DEFAULT_TIMEOUT, password: str | None = None, ) -> None: """Initialize class.""" self._host = host self._port = port self._timeout = timeout self._password = password self._device: JvcDevice | None = None self._ip: str = "" self._model: str = "" self._mac: str = "" self._version: str = "" @property def ip(self) -> str: """Returns ip.""" if not self._ip: raise JvcProjectorError("ip not initialized") return self._ip @property def host(self) -> str: """Returns host.""" return self._host @property def port(self) -> int: """Returns port.""" return self._port @property def model(self) -> str: """Returns model name.""" if not self._mac: raise JvcProjectorError("model not initialized") return self._model @property def mac(self) -> str: """Returns mac address.""" if not self._mac: raise JvcProjectorError("mac address not initialized") return self._mac @property def version(self) -> str: """Get device software version.""" if not self._version: raise JvcProjectorError("version address not initialized") return self._version async def connect(self, get_info: bool = False) -> None: """Connect to device.""" if self._device: return if not self._ip: self._ip = await resolve(self._host) self._device = JvcDevice(self._ip, self._port, self._timeout, self._password) if not await self.test(): raise JvcProjectorConnectError("Failed to verify connection") if get_info: await self.get_info() async def disconnect(self) -> None: """Disconnect from device.""" if self._device: await self._device.disconnect() self._device = None async def get_info(self) -> dict[str, str]: """Get device info.""" assert self._device model = JvcCommand(command.MODEL, True) mac = JvcCommand(command.MAC, True) await self._send([model, mac]) if mac.response is None: raise JvcProjectorError("Mac address not available") if model.response is None: model.response = "(unknown)" self._model = model.response self._mac = mac.response return {"model": self._model, "mac": self._mac} async def get_state(self) -> dict[str, str | None]: """Get device state.""" assert self._device pwr = JvcCommand(command.POWER, True) inp = JvcCommand(command.INPUT, True) src = JvcCommand(command.SOURCE, True) res = await self._send([pwr, inp, src]) return { "power": res[0] or None, "input": res[1] or const.NOSIGNAL, "source": res[2] or const.NOSIGNAL, } async def get_version(self) -> str | None: """Get device software version.""" return await self.ref(command.VERSION) async def get_power(self) -> str | None: """Get power state.""" return await self.ref(command.POWER) async def get_input(self) -> str | None: """Get current input.""" return await self.ref(command.INPUT) async def get_signal(self) -> str | None: """Get if has signal.""" return await self.ref(command.SOURCE) async def test(self) -> bool: """Run test command.""" cmd = JvcCommand(f"{command.TEST}") await self._send([cmd]) return cmd.ack async def power_on(self) -> None: """Run power on command.""" await self.op(f"{command.POWER}1") async def power_off(self) -> None: """Run power off command.""" await self.op(f"{command.POWER}0") async def remote(self, code: str) -> None: """Run remote code command.""" await self.op(f"{command.REMOTE}{code}") async def op(self, code: str) -> None: """Send operation code.""" await self._send([JvcCommand(code, False)]) async def ref(self, code: str) -> str | None: """Send reference code.""" return (await self._send([JvcCommand(code, True)]))[0] async def _send(self, cmds: list[JvcCommand]) -> list[str | None]: """Send command to device.""" if self._device is None: raise JvcProjectorError("Must call connect before sending commands") await self._device.send(cmds) return [cmd.response for cmd in cmds] pyjvcprojector-1.1.2/jvcprojector/py.typed000066400000000000000000000000001467440520000207770ustar00rootroot00000000000000pyjvcprojector-1.1.2/pyproject.toml000066400000000000000000000165071467440520000175250ustar00rootroot00000000000000[build-system] requires = ["setuptools>=75.1.0", "wheel"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] log_level = "DEBUG" testpaths = "tests" norecursedirs = ".git" filterwarnings = [ "ignore:.*loop argument is deprecated:DeprecationWarning" ] [tool.ruff] required-version = ">=0.6.7" [tool.ruff.lint] # These are copied from HomeAssistant's rules select = [ "A001", # Variable {name} is shadowing a Python builtin "ASYNC210", # Async functions should not call blocking HTTP methods "ASYNC220", # Async functions should not create subprocesses with blocking methods "ASYNC221", # Async functions should not run processes with blocking methods "ASYNC222", # Async functions should not wait on processes with blocking methods "ASYNC230", # Async functions should not open files with blocking methods like open "ASYNC251", # Async functions should not call time.sleep "B002", # Python does not support the unary prefix increment "B005", # Using .strip() with multi-character strings is misleading "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? "B904", # Use raise from to specify exception cause "B905", # zip() without an explicit strict= parameter "BLE", "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake "F541", # f-string without any placeholders "FLY", # flynt "FURB", # refurb "G", # flake8-logging-format "I", # isort "INP", # flake8-no-pep420 "ISC", # flake8-implicit-str-concat "ICN001", # import concentions; {name} should be imported as {asname} "LOG", # flake8-logging "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase "PERF", # Perflint "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style "PTH", # flake8-pathlib "PYI", # flake8-pyi "RET", # flake8-return "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file "S306", # suspicious-mktemp-usage "S307", # suspicious-eval-usage "S313", # suspicious-xmlc-element-tree-usage "S314", # suspicious-xml-element-tree-usage "S315", # suspicious-xml-expat-reader-usage "S316", # suspicious-xml-expat-builder-usage "S317", # suspicious-xml-sax-usage "S318", # suspicious-xml-mini-dom-usage "S319", # suspicious-xml-pull-dom-usage "S320", # suspicious-xmle-tree-usage "S601", # paramiko-call "S602", # subprocess-popen-with-shell-equals-true "S604", # call-with-shell-equals-true "S608", # hardcoded-sql-expression "S609", # unix-command-wildcard-injection "SIM", # flake8-simplify "SLF", # flake8-self "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print "TCH", # flake8-type-checking "TID", # Tidy imports "TRY", # tryceratops "UP", # pyupgrade "UP031", # Use format specifiers instead of percent format "UP032", # Use f-string instead of `format` call "W", # pycodestyle ] ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "D406", # Section name should end with a newline "D407", # Section name underlining "E501", # line too long "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives "PLR0911", # Too many return statements ({returns} > {max_returns}) "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PT004", # Fixture {fixture} does not return anything, add leading underscore "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT018", # Assertion should be broken down into multiple parts "RUF001", # String contains ambiguous unicode character. "RUF002", # Docstring contains ambiguous unicode character. "RUF003", # Comment contains ambiguous unicode character. "RUF015", # Prefer next(...) over single element slice "SIM102", # Use a single if statement instead of nested if statements "SIM103", # Return the condition {condition} directly "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files # Moving imports into type-checking blocks can mess with pytest.patch() "TCH001", # Move application import {} into a type-checking block "TCH002", # Move third-party import {} into a type-checking block "TCH003", # Move standard library import {} into a type-checking block "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", "E114", "E117", "D206", "D300", "Q", "COM812", "COM819", "ISC001", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605" ] [tool.ruff.lint.isort] force-sort-within-sections = true combine-as-imports = true split-on-trailing-comma = false [tool.ruff.lint.mccabe] max-complexity = 25 [tool.tox] legacy_tox_ini = """ [tox] envlist = py311,py312 isolated_build = True [testenv] deps = pytest pytest-asyncio commands = pytest """pyjvcprojector-1.1.2/requirements.txt000066400000000000000000000000171467440520000200620ustar00rootroot00000000000000aiodns>=3.2.0 pyjvcprojector-1.1.2/requirements_dev.txt000066400000000000000000000001341467440520000207200ustar00rootroot00000000000000mypy==1.11.2 pre-commit==3.8.0 pytest==8.3.3 pytest-asyncio==0.24.0 tox==4.20.0 ruff==0.6.7 pyjvcprojector-1.1.2/setup.cfg000066400000000000000000000013421467440520000164210ustar00rootroot00000000000000[metadata] name = pyjvcprojector version = attr: jvcprojector.__version__ description = "A python library for controlling a JVC Projector over a network connection." long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/SteveEasley/pyjvcprojector author = Steve Easley author_email = tardis74@yahoo.com licence = MIT classifiers = Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: MIT License Topic :: Home Automation Topic :: Software Development :: Libraries Programming Language :: Python :: 3.10 [options] packages = jvcprojector install_requires = aiodns >= 3.2.0 [options.package_data] jvcprojector = py.typed pyjvcprojector-1.1.2/setup.py000066400000000000000000000000451467440520000163110ustar00rootroot00000000000000import setuptools setuptools.setup() pyjvcprojector-1.1.2/tests/000077500000000000000000000000001467440520000157425ustar00rootroot00000000000000pyjvcprojector-1.1.2/tests/__init__.py000066400000000000000000000004231467440520000200520ustar00rootroot00000000000000"""pytest tests.""" from jvcprojector.device import END IP = "127.0.0.1" HOST = "localhost" PORT = 12345 TIMEOUT = 3.0 MAC = "abcd1234" MODEL = "model123" PASSWORD = "pass1234" def cc(hdr: bytes, cmd: str): """Create a command.""" return hdr + cmd.encode() + END pyjvcprojector-1.1.2/tests/conftest.py000066400000000000000000000042061467440520000201430ustar00rootroot00000000000000"""pytest fixtures.""" from __future__ import annotations from unittest.mock import patch import pytest from jvcprojector import command, const from jvcprojector.command import JvcCommand from jvcprojector.device import HEAD_ACK, PJACK, PJOK from . import IP, MAC, MODEL, PORT, cc @pytest.fixture(name="conn") def fixture_mock_connection(request): """Return a mocked connection.""" with patch("jvcprojector.device.JvcConnection", autospec=True) as mock: connected = False fixture = {"raise_on_connect": 0} if hasattr(request, "param"): fixture.update(request.param) def connect(): nonlocal connected if fixture["raise_on_connect"] > 0: fixture["raise_on_connect"] -= 1 raise ConnectionRefusedError connected = True def disconnect(): nonlocal connected connected = False conn = mock.return_value conn.ip = IP conn.port = PORT conn.is_connected.side_effect = lambda: connected conn.connect.side_effect = connect conn.disconnect.side_effect = disconnect conn.read.side_effect = [PJOK, PJACK] conn.readline.side_effect = [cc(HEAD_ACK, command.POWER)] conn.write.side_effect = lambda p: None yield conn @pytest.fixture(name="dev") def fixture_mock_device(request): """Return a mocked device.""" with patch("jvcprojector.projector.JvcDevice", autospec=True) as mock: fixture = { command.TEST: None, command.MAC: MAC, command.MODEL: MODEL, command.POWER: const.ON, command.INPUT: const.HDMI1, command.SOURCE: const.SIGNAL, } if hasattr(request, "param"): fixture.update(request.param) async def send(cmds: list[JvcCommand]): for cmd in cmds: if cmd.code in fixture: if fixture[cmd.code]: cmd.response = fixture[cmd.code] cmd.ack = True dev = mock.return_value dev.send.side_effect = send yield dev pyjvcprojector-1.1.2/tests/test_device.py000066400000000000000000000117111467440520000206130ustar00rootroot00000000000000"""Tests for device module.""" from hashlib import sha256 from unittest.mock import AsyncMock, call import pytest from jvcprojector import command, const from jvcprojector.command import JvcCommand from jvcprojector.device import ( AUTH_SALT, HEAD_ACK, HEAD_OP, HEAD_REF, HEAD_RES, PJACK, PJNAK, PJNG, PJOK, PJREQ, JvcDevice, ) from jvcprojector.error import JvcProjectorCommandError from . import IP, PORT, TIMEOUT, cc @pytest.mark.asyncio async def test_send_op(conn: AsyncMock): """Test send operation command succeeds.""" dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(f"{command.POWER}1") await dev.send([cmd]) await dev.disconnect() assert cmd.ack assert cmd.response is None conn.connect.assert_called_once() conn.write.assert_has_calls([call(PJREQ), call(cc(HEAD_OP, f"{command.POWER}1"))]) @pytest.mark.asyncio async def test_send_ref(conn: AsyncMock): """Test send reference command succeeds.""" conn.readline.side_effect = [ cc(HEAD_ACK, command.POWER), cc(HEAD_RES, command.POWER + "1"), ] dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(command.POWER, True) await dev.send([cmd]) await dev.disconnect() assert cmd.ack assert cmd.response == const.ON conn.connect.assert_called_once() conn.write.assert_has_calls([call(PJREQ), call(cc(HEAD_REF, command.POWER))]) @pytest.mark.asyncio async def test_send_with_password8(conn: AsyncMock): """Test send with 8 character password succeeds.""" dev = JvcDevice(IP, PORT, TIMEOUT, "passwd78") cmd = JvcCommand(f"{command.POWER}1") await dev.send([cmd]) await dev.disconnect() conn.write.assert_has_calls( [call(PJREQ + b"_passwd78\x00\x00"), call(cc(HEAD_OP, f"{command.POWER}1"))] ) @pytest.mark.asyncio async def test_send_with_password10(conn: AsyncMock): """Test send with 10 character password succeeds.""" dev = JvcDevice(IP, PORT, TIMEOUT, "passwd7890") cmd = JvcCommand(f"{command.POWER}1") await dev.send([cmd]) await dev.disconnect() conn.write.assert_has_calls( [call(PJREQ + b"_passwd7890"), call(cc(HEAD_OP, f"{command.POWER}1"))] ) @pytest.mark.asyncio async def test_send_with_password_sha256(conn: AsyncMock): """Test send with a projector requiring sha256 hashing.""" conn.read.side_effect = [PJOK, PJNAK, PJACK] dev = JvcDevice(IP, PORT, TIMEOUT, "passwd78901") cmd = JvcCommand(f"{command.POWER}1") await dev.send([cmd]) await dev.disconnect() auth = sha256(f"passwd78901{AUTH_SALT}".encode()).hexdigest().encode() conn.write.assert_has_calls( [call(PJREQ + b"_" + auth), call(cc(HEAD_OP, f"{command.POWER}1"))] ) @pytest.mark.asyncio @pytest.mark.parametrize("conn", [{"raise_on_connect": 1}], indirect=True) async def test_connection_refused_retry(conn: AsyncMock): """Test connection refused results in retry.""" dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(f"{command.POWER}1") await dev.send([cmd]) await dev.disconnect() assert cmd.ack assert conn.connect.call_count == 2 conn.write.assert_has_calls([call(PJREQ), call(cc(HEAD_OP, f"{command.POWER}1"))]) @pytest.mark.asyncio async def test_connection_busy_retry(conn: AsyncMock): """Test handshake busy results in retry.""" conn.read.side_effect = [PJNG, PJOK, PJACK] dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(f"{command.POWER}1") await dev.send([cmd]) await dev.disconnect() assert conn.connect.call_count == 2 conn.write.assert_has_calls([call(PJREQ), call(cc(HEAD_OP, f"{command.POWER}1"))]) @pytest.mark.asyncio async def test_connection_bad_handshake_error(conn: AsyncMock): """Test bad handshake results in error.""" conn.read.side_effect = [b"BAD"] dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(f"{command.POWER}1") with pytest.raises(JvcProjectorCommandError): await dev.send([cmd]) conn.connect.assert_called_once() conn.disconnect.assert_called_once() assert not cmd.ack @pytest.mark.asyncio async def test_send_op_bad_ack_error(conn: AsyncMock): """Test send operation with bad ack results in error.""" conn.readline.side_effect = [cc(HEAD_ACK, "ZZ")] dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(f"{command.POWER}1") with pytest.raises(JvcProjectorCommandError): await dev.send([cmd]) conn.connect.assert_called_once() conn.disconnect.assert_called_once() assert not cmd.ack @pytest.mark.asyncio async def test_send_ref_bad_ack_error(conn: AsyncMock): """Test send reference with bad ack results in error.""" conn.readline.side_effect = [cc(HEAD_ACK, command.POWER), cc(HEAD_RES, "ZZ1")] dev = JvcDevice(IP, PORT, TIMEOUT) cmd = JvcCommand(command.POWER, True) with pytest.raises(JvcProjectorCommandError): await dev.send([cmd]) conn.connect.assert_called_once() conn.disconnect.assert_called_once() assert not cmd.ack pyjvcprojector-1.1.2/tests/test_projector.py000066400000000000000000000043151467440520000213650ustar00rootroot00000000000000"""Tests for projector module.""" from unittest.mock import AsyncMock import pytest from jvcprojector import command, const from jvcprojector.error import JvcProjectorError from jvcprojector.projector import JvcProjector from . import HOST, IP, MAC, MODEL, PORT @pytest.mark.asyncio async def test_init(dev: AsyncMock): """Test init succeeds.""" p = JvcProjector(IP, port=PORT) assert p.host == IP assert p.port == PORT with pytest.raises(JvcProjectorError): assert p.ip with pytest.raises(JvcProjectorError): assert p.model with pytest.raises(JvcProjectorError): assert p.mac @pytest.mark.asyncio async def test_connect(dev: AsyncMock): """Test connect succeeds.""" p = JvcProjector(IP, port=PORT) await p.connect() assert p.ip == IP await p.disconnect() assert dev.disconnect.call_count == 1 @pytest.mark.asyncio async def test_connect_host(dev: AsyncMock): """Test connect succeeds.""" p = JvcProjector(HOST, port=PORT) await p.connect() assert p.ip == IP await p.disconnect() assert dev.disconnect.call_count == 1 @pytest.mark.asyncio @pytest.mark.parametrize("dev", [{command.MODEL: None}], indirect=True) async def test_unknown_model(dev: AsyncMock): """Test projector with unknown model succeeds.""" p = JvcProjector(IP) await p.connect() await p.get_info() assert p.mac == MAC assert p.model == "(unknown)" @pytest.mark.asyncio @pytest.mark.parametrize("dev", [{command.MAC: None}], indirect=True) async def test_unknown_mac(dev: AsyncMock): """Test projector with unknown mac uses model succeeds.""" p = JvcProjector(IP) await p.connect() with pytest.raises(JvcProjectorError): await p.get_info() @pytest.mark.asyncio async def test_get_info(dev: AsyncMock): """Test get_info succeeds.""" p = JvcProjector(IP) await p.connect() assert await p.get_info() == {"model": MODEL, "mac": MAC} @pytest.mark.asyncio async def test_get_state(dev: AsyncMock): """Test get_state succeeds.""" p = JvcProjector(IP) await p.connect() assert await p.get_state() == { "power": const.ON, "input": const.HDMI1, "source": const.SIGNAL, }