pax_global_header00006660000000000000000000000064150370465110014514gustar00rootroot0000000000000052 comment=151115f7877fe9f6f7d8c79bd77193283e0f28ed py-madvr-1.8.14/000077500000000000000000000000001503704651100133465ustar00rootroot00000000000000py-madvr-1.8.14/.env.template000066400000000000000000000000321503704651100157440ustar00rootroot00000000000000export MADVR_HOST="IPaddr"py-madvr-1.8.14/.github/000077500000000000000000000000001503704651100147065ustar00rootroot00000000000000py-madvr-1.8.14/.github/workflows/000077500000000000000000000000001503704651100167435ustar00rootroot00000000000000py-madvr-1.8.14/.github/workflows/build.yml000066400000000000000000000012051503704651100205630ustar00rootroot00000000000000name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/py-madvr2 permissions: id-token: write steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 py-madvr-1.8.14/.gitignore000066400000000000000000000056431503704651100153460ustar00rootroot00000000000000dist/ build/ jvc_projector_remote.egg-info/ __pycache__ .vscode .env test.py todo ######################################## ######################################## ######################################## # 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 # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.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/ # PyCharm # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .DS_Store py-madvr-1.8.14/.pre-commit-config.yaml000066400000000000000000000010341503704651100176250ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.12.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] exclude: ^(tests|scripts)/ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.16.1 hooks: - id: mypy additional_dependencies: [pydantic] exclude: ^(tests|scripts)/ py-madvr-1.8.14/LICENSE000066400000000000000000000020621503704651100143530ustar00rootroot00000000000000Copyright (c) 2022 The Python Packaging Authority 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. py-madvr-1.8.14/Makefile000066400000000000000000000004121503704651100150030ustar00rootroot00000000000000.PHONY: test dev_install build upload dev_install: python3 -m venv .venv . .venv/bin/activate && \ pip3 install -r requirements-test.txt test: . .venv/bin/activate && pytest -s tests upload: build twine upload dist/* build: rm -rf dist/* python3 -m build py-madvr-1.8.14/README.md000066400000000000000000000077551503704651100146430ustar00rootroot00000000000000# MadVR Envy Python Library This library implements the IP control specification for madVR Envy. It supports real time notifications and commands asynchronously. It is intended to be used with my official Home Assistant integration ([madvr](https://www.home-assistant.io/integrations/madvr/)) ## Installation ```bash pip install py-madvr2 ``` ## Usage ```python from pymadvr.madvr import Madvr # Create instance madvr = Madvr("192.168.1.100") # Replace with your MadVR IP # Connect and use await madvr.open_connection() response = await madvr.send_command(["GetMacAddress"]) await madvr.close_connection() ``` ## Connection Architecture This library uses an efficient connection management system: - **User Commands**: Uses a simple connection pool that keeps connections alive for 10 seconds after last use, automatically extending the timeout when new commands arrive - **Background Tasks**: Use direct connections to avoid interference with user commands - **Automatic Cleanup**: Idle connections are automatically closed to respect MadVR's 60-second connection limit This design eliminates connection race conditions and hanging issues while providing optimal performance. ## Wake On Lan If the client is initialized without a mac, it will assume you provide your own wake on lan automation. Standby does not respond to pings so you may as well do a full power off. You may also provide a mac when you call the power on function. ## Commands Command structure follows the same in the manual https://madvrenvy.com/wp-content/uploads/EnvyIpControl.pdf?r=112a For things that take values, use a comma -> `["KeyPress, MENU"]` Not every single command is implemented, such as submenus or changing complicated options. You can use commands for all the typical stuff the remote can do. ## Typing This module uses mypy with strict typing. ## Display Commands ```python async def demo_display_commands(): """Demonstrate the new display commands.""" # Initialize MadVR connection from pymadvr.madvr import Madvr madvr = Madvr("192.168.1.100") # Replace with your MadVR IP try: # Connect to MadVR await madvr.open_connection() # Display a message for 3 seconds await madvr.display_message(3, "Hello from Python!") await asyncio.sleep(4) # Wait for message to clear # Display audio volume control (0-100%, currently at 75%) await madvr.display_audio_volume(0, 75, 100, "%") await asyncio.sleep(3) # Show audio mute indicator await madvr.display_audio_mute() await asyncio.sleep(2) # Close the audio mute indicator await madvr.close_audio_mute() # Display decibel-based volume (AVR style: -80dB to 0dB, currently -25dB) await madvr.display_audio_volume(-80, -25, 0, "dB") await asyncio.sleep(3) except Exception as e: print(f"Error: {e}") finally: await madvr.close_connection() ``` ## Testing ### Unit Tests Run unit tests with: ```bash pytest tests/test_MadVR.py -v ``` ### Integration Tests Integration tests require a real MadVR device on the network. Set these environment variables: ```bash # Required for Wake-on-LAN functionality export MADVR_MAC=00:11:22:33:44:55 # Your device's MAC address # Optional (defaults shown) export MADVR_HOST=192.168.1.100 export MADVR_PORT=44077 ``` To get your device's MAC address (device must be on): ```bash python scripts/power_on_device.py --get-mac ``` To power on the device for testing: ```bash python scripts/power_on_device.py ``` Run integration tests: ```bash pytest tests/test_simple_integration.py -v ``` Run connection pool tests: ```bash pytest tests/test_connection_pool.py -v ``` Integration tests will automatically skip if the device is not available. ### Connection Pool Testing The connection pool tests verify: - Connection reuse and timeout behavior - Background task isolation - No hanging or race conditions These tests help ensure the reliability improvements in the connection management system. py-madvr-1.8.14/changelog000066400000000000000000000000071503704651100152150ustar00rootroot00000000000000# 1.0.0py-madvr-1.8.14/mypy.ini000066400000000000000000000003111503704651100150400ustar00rootroot00000000000000[mypy] ignore_missing_imports = True disallow_untyped_defs = True check_untyped_defs = True warn_redundant_casts = True no_implicit_optional = True strict_optional = True exclude = tests/.*|scripts/.* py-madvr-1.8.14/pymadvr/000077500000000000000000000000001503704651100150305ustar00rootroot00000000000000py-madvr-1.8.14/pymadvr/__init__.py000066400000000000000000000003601503704651100171400ustar00rootroot00000000000000import logging import os log_level = os.getenv("LOG_LEVEL", "info") level = getattr(logging, log_level.upper(), logging.INFO) log_format = "[L %(lineno)s - %(funcName)5s() ] %(message)s" logging.basicConfig(level=level, format=log_format) py-madvr-1.8.14/pymadvr/commands.py000066400000000000000000000105231503704651100172040ustar00rootroot00000000000000""" All the enums for commands """ from enum import Enum # pylint: disable=missing-class-docstring invalid-name class Connections(Enum): welcome = b"WELCOME" heartbeat = b"Heartbeat\r\n" bye = b"Bye\r\n" class Footer(Enum): footer = b"\x0d\x0a" class Headers(Enum): temperature = b"Temperatures" activate_profile = b"ActivateProfile" incoming_signal = b"IncomingSignalInfo" outgoing_signal = b"OngoingSignalInfo" aspect_ratio = b"AspectRatio" masking_ratio = b"MaskingRatio" mac = b"MacAddress" setting_page = b"SettingPage" config_page = b"ConfigPage" option = b"Option" class ACKs(Enum): reply = b"OK\r\n" error = b"ERROR" class Temperatures(Enum): msg = "Temperatures" class SignalInfo(Enum): msg = "IncomingSignalInfo" class OutgoingSignalInfo(Enum): msg = "OutgoingSignalInfo" class AspectRatio(Enum): msg = "AspectRatio" class Notifications(Enum): ActivateProfile = b"ActivateProfile" IncomingSignalInfo = b"IncomingSignalInfo" OngoingSignalInfo = b"OngoingSignalInfo" AspectRatio = b"AspectRatio" MaskingRatio = b"MaskingRatio" class KeyPress(Enum): MENU = b"MENU" UP = b"UP" DOWN = b"DOWN" LEFT = b"LEFT" RIGHT = b"RIGHT" OK = b"OK" INPUT = b"INPUT" SETTINGS = b"SETTINGS" RED = b"RED" GREEN = b"GREEN" BLUE = b"BLUE" YELLOW = b"YELLOW" POWER = b"POWER" class DisplayAlert(Enum): pass class DisplayMessage(Enum): """For DisplayMessage command parameters""" pass class DisplayAudioVolume(Enum): """For DisplayAudioVolume command parameters""" pass class Information(Enum): pass class SettingsPages(Enum): pass class Toggle(Enum): ToneMap = b"ToneMap" HighlightRecovery = b"HighlightRecovery" ContrastRecovery = b"ContrastRecovery" ShadowRecovery = b"ShadowRecovery" _3DLUT = b"3DLUT" ScreenBoundaries = b"ScreenBoundaries" Histogram = b"Histogram" DebugOSD = b"DebugOSD" class SingleCmd(Enum): """for things that are single words""" class IsInformational(Enum): true = True false = False class Menus(Enum): Info = b"Info" Settings = b"Settings" Configuration = b"Configuration" Profiles = b"Profiles" TestPatterns = b"TestPatterns" class Profiles(Enum): SOURCE = b"SOURCE" DISPLAY = b"DISPLAY" # CUSTOM 2 CUSTOM = b"CUSTOM" class Commands(Enum): # Power stuff PowerOff = b"PowerOff", SingleCmd, IsInformational.false Standby = b"Standby", SingleCmd, IsInformational.false Restart = b"Restart", SingleCmd, IsInformational.false ReloadSoftware = b"ReloadSoftware", SingleCmd, IsInformational.false Bye = b"Bye", SingleCmd, IsInformational.false ResetTemporary = b"ResetTemporary", SingleCmd, IsInformational.false ActivateProfile = b"ActivateProfile", Profiles, IsInformational.false # Menu OpenMenu = b"OpenMenu", Menus, IsInformational.false CloseMenu = b"CloseMenu", SingleCmd, IsInformational.false KeyPress = b"KeyPress", KeyPress, IsInformational.false KeyHold = b"KeyHold", KeyPress, IsInformational.false GetIncomingSignalInfo = b"GetIncomingSignalInfo", SignalInfo, IsInformational.true GetOutgoingSignalInfo = ( b"GetOutgoingSignalInfo", OutgoingSignalInfo, IsInformational.true, ) GetAspectRatio = b"GetAspectRatio", AspectRatio, IsInformational.true GetMaskingRatio = b"GetMaskingRatio", SingleCmd, IsInformational.true GetTemperatures = b"GetTemperatures", Temperatures, IsInformational.true GetMacAddress = b"GetMacAddress", SingleCmd, IsInformational.true Toggle = b"Toggle", Toggle, IsInformational.false ToneMapOn = b"ToneMapOn", SingleCmd, IsInformational.false ToneMapOff = b"ToneMapOff", SingleCmd, IsInformational.false Hotplug = b"Hotplug", SingleCmd, IsInformational.false RefreshLicenseInfo = b"RefreshLicenseInfo", SingleCmd, IsInformational.false Force1080p60Output = b"Force1080p60Output", SingleCmd, IsInformational.false # Display commands DisplayMessage = b"DisplayMessage", DisplayMessage, IsInformational.false DisplayAudioVolume = b"DisplayAudioVolume", DisplayAudioVolume, IsInformational.false DisplayAudioMute = b"DisplayAudioMute", SingleCmd, IsInformational.false CloseAudioMute = b"CloseAudioMute", SingleCmd, IsInformational.false py-madvr-1.8.14/pymadvr/connection_pool.py000066400000000000000000000173551503704651100206050ustar00rootroot00000000000000"""Connection pool for MadVR connections.""" import asyncio import logging import time from typing import List, Optional from pymadvr.commands import Connections from pymadvr.consts import ( COMMAND_RESPONSE_TIMEOUT, COMMAND_RETRY_ATTEMPTS, CONNECTION_POOL_MAX_SIZE, CONNECTION_TIMEOUT, HEARTBEAT_TIMEOUT, ) class MadvrConnection: """Individual MadVR connection wrapper.""" def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, logger: logging.Logger): self.reader = reader self.writer = writer self.logger = logger self.created_at = time.time() self.last_used = time.time() self._closed = False async def _send_heartbeat(self, timeout: float = HEARTBEAT_TIMEOUT) -> bool: """Send a heartbeat command to keep connection alive.""" try: self.writer.write(Connections.heartbeat.value) await asyncio.wait_for(self.writer.drain(), timeout=timeout) self.last_used = time.time() return True except Exception as e: self.logger.debug(f"Heartbeat failed: {e}") return False async def is_healthy(self) -> bool: """Check if connection is still healthy by sending a heartbeat.""" if self._closed: return False if self.writer.is_closing(): return False # Try to send a heartbeat with short timeout to verify connection try: if await asyncio.wait_for(self._send_heartbeat(), timeout=0.1): self.logger.debug("Connection health check passed") return True else: self.logger.debug("Connection health check failed") return False except asyncio.TimeoutError: self.logger.warning("Connection health check timed out") return False async def send_command(self, command: bytes) -> Optional[str]: """Send a command and return the response.""" try: self.writer.write(command) await self.writer.drain() # Read response response = await asyncio.wait_for(self.reader.read(1024), timeout=COMMAND_RESPONSE_TIMEOUT) self.last_used = time.time() return response.decode("utf-8", errors="ignore") except Exception as e: self.logger.debug(f"Command failed: {e}") await self.close() raise ConnectionError(f"Failed to send command: {e}") async def close(self) -> None: """Close the connection.""" if self._closed: return self._closed = True try: if not self.writer.is_closing(): self.writer.close() await self.writer.wait_closed() except Exception as e: self.logger.debug(f"Error closing connection: {e}") class ConnectionPool: """Pool of MadVR connections for reuse.""" def __init__(self, host: str, port: int, logger: logging.Logger): self.host = host self.port = port self.logger = logger self.pool: List[MadvrConnection] = [] async def send_command(self, command: bytes) -> Optional[str]: """Send a command using a pooled connection, with automatic retry.""" # Try to send command, retry if it fails for attempt in range(COMMAND_RETRY_ATTEMPTS): self.logger.debug(f"Sending command: {command.decode('utf-8', errors='ignore')}") conn = await self.get_connection() try: response = await conn.send_command(command) # Success - return connection to pool await self.return_connection(conn) return response except ConnectionError as e: # Connection failed - it's already closed by send_command if attempt == 0: self.logger.warning(f"Command failed on first attempt, retrying with new connection: {e}") continue else: self.logger.error(f"Command failed after retry: {e}") raise ConnectionError(f"Failed to send command after retry: {e}") except Exception as e: # Unexpected error - return connection to pool await self.return_connection(conn) self.logger.error(f"Unexpected error sending command: {e}") raise # This shouldn't be reached, but mypy requires explicit return return None async def get_connection(self) -> MadvrConnection: """Get a connection - create new one each time for simplicity.""" return await self._create_connection() async def return_connection(self, conn: MadvrConnection) -> None: """Close the connection - no pooling for simplicity.""" await conn.close() self.logger.debug("Closed connection after use") async def _create_connection(self) -> MadvrConnection: """Create a new MadVR connection.""" writer = None try: self.logger.debug(f"Creating new connection to {self.host}:{self.port}") # Use timeout_at instead of wait_for deadline = asyncio.get_event_loop().time() + CONNECTION_TIMEOUT async with asyncio.timeout_at(deadline): reader, writer = await asyncio.open_connection(self.host, self.port) self.logger.debug("waiting for welcome message...") # Wait for welcome message with timeout_at deadline = asyncio.get_event_loop().time() + COMMAND_RESPONSE_TIMEOUT async with asyncio.timeout_at(deadline): welcome = await reader.read(1024) if Connections.welcome.value not in welcome: raise ConnectionError("Did not receive welcome message") self.logger.debug("Successfully created new connection") return MadvrConnection(reader, writer, self.logger) except asyncio.TimeoutError as e: if writer: writer.close() await writer.wait_closed() self.logger.error(f"Connection timeout to {self.host}:{self.port}: {e}") raise ConnectionError(f"Connection timeout to {self.host}:{self.port}") except Exception as e: if writer: writer.close() await writer.wait_closed() self.logger.error(f"Failed to create connection: {type(e).__name__}: {e}") raise ConnectionError(f"Failed to connect to {self.host}:{self.port}: {type(e).__name__}: {e}") async def close_all(self) -> None: """Close all connections in the pool.""" for conn in self.pool: await conn.close() self.pool.clear() self.logger.debug("Closed all pooled connections") async def prewarm_pool(self) -> None: """Pre-warm the connection pool by creating connections up to max_size.""" current_size = len(self.pool) if current_size < CONNECTION_POOL_MAX_SIZE: # Create connections to fill the pool connections_to_create = CONNECTION_POOL_MAX_SIZE - current_size self.logger.debug(f"Pre-warming pool with {connections_to_create} connections") for _ in range(connections_to_create): try: conn = await self._create_connection() # Check if connection is healthy before adding to pool if await conn.is_healthy(): self.pool.append(conn) else: await conn.close() except Exception as e: self.logger.debug(f"Failed to create connection during pre-warm: {e}") break # Stop trying if we can't connect py-madvr-1.8.14/pymadvr/consts.py000066400000000000000000000013551503704651100167170ustar00rootroot00000000000000"""Constants for madvr module.""" REFRESH_TIME = 20 PING_DELAY = 30 COMMAND_TIMEOUT = 3 PING_INTERVAL = 10 # Check device availability every 10 seconds HEARTBEAT_INTERVAL = 15 CONNECT_TIMEOUT = 5 DEFAULT_PORT = 44077 READ_LIMIT = 8000 SMALL_DELAY = 2 # save some cpu cycles TASK_CPU_DELAY = 1.0 MAX_COMMAND_QUEUE_SIZE = 100 # Maximum number of commands to buffer # Connection pool timeouts - all network operations should complete quickly on local network COMMAND_RESPONSE_TIMEOUT = 0.5 HEARTBEAT_TIMEOUT = 0.5 CONNECTION_TIMEOUT = 1 # 1 second for establishing TCP connection COMMAND_RETRY_ATTEMPTS = 2 # Number of attempts to send a command (1 initial + 1 retry) CONNECTION_POOL_MAX_SIZE = 5 # Maximum number of connections to keep in pool py-madvr-1.8.14/pymadvr/errors.py000066400000000000000000000005501503704651100167160ustar00rootroot00000000000000"""Errors for madvr""" from __future__ import annotations class AckError(Exception): """An error when ACK is not correct""" class RetryExceededError(Exception): """Too many retries""" class HeartBeatError(Exception): """An error has occured with heartbeats""" class CannotConnect(Exception): """Error to indicate we cannot connect.""" py-madvr-1.8.14/pymadvr/madvr.py000066400000000000000000001042731503704651100165220ustar00rootroot00000000000000""" Implements the MadVR protocol with connection-per-command architecture """ import asyncio import logging import time from typing import Any, Final, Iterable from pymadvr.commands import Commands, Connections, Footer from pymadvr.consts import ( COMMAND_RESPONSE_TIMEOUT, COMMAND_TIMEOUT, CONNECT_TIMEOUT, CONNECTION_TIMEOUT, DEFAULT_PORT, MAX_COMMAND_QUEUE_SIZE, PING_INTERVAL, REFRESH_TIME, TASK_CPU_DELAY, ) from pymadvr.notifications import NotificationProcessor from pymadvr.simple_pool import SimpleConnectionPool from pymadvr.wol import send_magic_packet class Madvr: """MadVR Control with connection-per-command architecture""" def __init__( self, host: str, logger: logging.Logger = logging.getLogger(__name__), port: int = DEFAULT_PORT, mac: str = "", connect_timeout: int = CONNECT_TIMEOUT, loop: asyncio.AbstractEventLoop | None = None, ): self.host = host self.port = port self.mac = mac self.connect_timeout: int = connect_timeout self.logger = logger # Simple connection pool for user commands self.connection_pool = SimpleConnectionPool(host, port, logger) # Background tasks self.notification_task: asyncio.Task[None] | None = None self.notification_heartbeat_task: asyncio.Task[None] | None = None self.ping_task: asyncio.Task[None] | None = None self.refresh_task: asyncio.Task[None] | None = None self.queue_task: asyncio.Task[None] | None = None self.notification_reader: asyncio.StreamReader | None = None self.notification_writer: asyncio.StreamWriter | None = None # User command queue for FIFO processing self.user_command_queue: asyncio.Queue[list[str]] = asyncio.Queue(maxsize=MAX_COMMAND_QUEUE_SIZE) # Event to track if notification connection is ready self.notification_connected = asyncio.Event() # Event to signal when tasks should stop (device powered off) self.stop_tasks = asyncio.Event() self.loop = loop self.command_read_timeout: int = COMMAND_TIMEOUT # Const values self.MADVR_OK: Final = Connections.welcome.value self.HEARTBEAT: Final = Connections.heartbeat.value # Stores all attributes from notifications self.msg_dict: dict[str, Any] = {} # Callback for HA state updates self.update_callback: Any = None self.notification_processor = NotificationProcessor(self.logger) ########################## # Properties ########################## @property def is_on(self) -> bool: """Return true if the device is on.""" return self.msg_dict.get("is_on", False) @property def mac_address(self) -> str: """Return the mac address of the device.""" return self.msg_dict.get("mac_address", "") @property def connected(self) -> bool: """Return true if notification connection is established.""" return self.notification_connected.is_set() def set_update_callback(self, callback: Any) -> None: """Function to set the callback for updating HA state""" self.update_callback = callback def _should_tasks_sleep(self) -> bool: """Check if tasks should sleep due to device being off or stop signal.""" return self.stop_tasks.is_set() or not self.msg_dict.get("is_on", False) async def _set_device_power_state(self, is_on: bool, update_ha: bool = True) -> None: """Set device power state and corresponding task flags.""" old_state = self.msg_dict.get("is_on", False) self.msg_dict["is_on"] = is_on if is_on: # Device is on - allow tasks to run self.stop_tasks.clear() if old_state != is_on: self.logger.debug("Device state changed to online") else: # Device is off - make tasks sleep and clear pending commands self.stop_tasks.set() if old_state != is_on: self.logger.debug("Device state changed to offline") self.clear_queue() # Clear stale commands when device goes offline if update_ha and old_state != is_on: await self._update_ha_state() async def _clear_notification_connection(self) -> None: """Safely clear notification connection state.""" if self.notification_writer: try: self.notification_writer.close() await asyncio.wait_for(self.notification_writer.wait_closed(), timeout=2.0) except Exception as e: self.logger.debug(f"Error closing notification connection: {e}") self.notification_reader = None self.notification_writer = None self.notification_connected.clear() ########################## # Task Management ########################## async def async_add_tasks(self) -> None: """Start background tasks.""" if not self.loop: self.loop = asyncio.get_event_loop() # Start notification task self.notification_task = self.loop.create_task(self._notification_task_wrapper()) self.notification_task.set_name("notifications") # Start notification heartbeat task self.notification_heartbeat_task = self.loop.create_task(self._notification_heartbeat_wrapper()) self.notification_heartbeat_task.set_name("notification_heartbeat") # Start ping task for device monitoring self.ping_task = self.loop.create_task(self._ping_task_wrapper()) self.ping_task.set_name("ping") # Start refresh task for periodic data updates self.refresh_task = self.loop.create_task(self._refresh_task_wrapper()) self.refresh_task.set_name("refresh") # Start queue processing task for user commands self.queue_task = self.loop.create_task(self._queue_task_wrapper()) self.queue_task.set_name("queue") self.logger.debug("Started background tasks") async def _notification_task_wrapper(self) -> None: """Wrapper for notification task with error handling.""" try: await self.task_read_notifications() except asyncio.CancelledError: self.logger.debug("Notification task was cancelled") except Exception as e: self.logger.exception("Notification task failed: %s", e) async def _ping_task_wrapper(self) -> None: """Wrapper for ping task with error handling.""" try: await self.task_ping_device() except asyncio.CancelledError: self.logger.debug("Ping task was cancelled") except Exception as e: self.logger.exception("Ping task failed: %s", e) async def _refresh_task_wrapper(self) -> None: """Wrapper for refresh task with error handling.""" try: await self.task_refresh_info() except asyncio.CancelledError: self.logger.debug("Refresh task was cancelled") except Exception as e: self.logger.exception("Refresh task failed: %s", e) async def _queue_task_wrapper(self) -> None: """Wrapper for queue task with error handling.""" try: await self.task_process_command_queue() except asyncio.CancelledError: self.logger.debug("Queue task was cancelled") except Exception as e: self.logger.exception("Queue task failed: %s", e) async def _notification_heartbeat_wrapper(self) -> None: """Wrapper for notification heartbeat task with error handling.""" try: await self.task_notification_heartbeat() except asyncio.CancelledError: self.logger.debug("Notification heartbeat task was cancelled") except Exception as e: self.logger.exception("Notification heartbeat task failed: %s", e) async def async_cancel_tasks(self) -> None: """Cancel all background tasks including ping and refresh tasks.""" self.stop_tasks.set() # List of all tasks to cancel tasks_to_cancel = [ ("notification", self.notification_task), ("notification_heartbeat", self.notification_heartbeat_task), ("queue", self.queue_task), ("ping", self.ping_task), ("refresh", self.refresh_task), ] # Cancel all tasks for task_name, task in tasks_to_cancel: if task and not task.done(): self.logger.debug(f"Cancelling {task_name} task") task.cancel() try: await asyncio.wait_for(task, timeout=2.0) except (asyncio.CancelledError, asyncio.TimeoutError): self.logger.debug(f"{task_name} task cancelled") except Exception as e: self.logger.error(f"Error cancelling {task_name} task: {e}") # Clean up connections safely await self._clear_notification_connection() # Close the simple connection pool await self.connection_pool.close_all() self.logger.debug("Cancelled all tasks and closed connections") ########################## # Connection Management ########################## async def open_connection(self) -> None: """Start background tasks. The heartbeat task will handle establishing the notification connection.""" try: # Start all background tasks self.logger.debug("Starting background tasks") await self.async_add_tasks() # Wait for notification connection to be established by heartbeat task timeout = 10.0 # Maximum time to wait start_time = asyncio.get_event_loop().time() while not self.notification_connected.is_set(): if asyncio.get_event_loop().time() - start_time > timeout: raise ConnectionError("Timeout waiting for notification connection") await asyncio.sleep(0.1) # Get initial device information self.logger.debug("Fetching initial device information") await self._get_initial_device_info() self.logger.info("MadVR client initialized successfully") except Exception as e: self.logger.error(f"Failed to initialize MadVR client: {e}") raise ConnectionError(f"Failed to initialize client for {self.host}:{self.port}") from e async def _establish_notification_connection(self) -> None: """Establish dedicated connection for notifications.""" try: self.notification_reader, self.notification_writer = await asyncio.wait_for( asyncio.open_connection(self.host, self.port), timeout=self.connect_timeout ) if not self.notification_reader: raise ConnectionError("Reader not available") welcome = await asyncio.wait_for(self.notification_reader.read(1024), timeout=5.0) if self.MADVR_OK not in welcome: raise ConnectionError("Did not receive welcome message") self.notification_connected.set() self.logger.debug("Notification connection established") except Exception as e: # Clean up on failure with timeout protection if self.notification_writer: try: self.notification_writer.close() await asyncio.wait_for(self.notification_writer.wait_closed(), timeout=2.0) except Exception: pass # Ignore cleanup errors self.notification_reader = None self.notification_writer = None raise ConnectionError(f"Failed to establish notification connection: {e}") async def _get_initial_device_info(self) -> None: """Get initial device information using command connections.""" initial_commands = [ ["GetMacAddress"], ["GetTemperatures"], # Get signal info in case a change was missed ["GetIncomingSignalInfo"], ["GetOutgoingSignalInfo"], ["GetAspectRatio"], ["GetMaskingRatio"], ] for command in initial_commands: try: await self.send_command(command) except Exception as e: self.logger.debug(f"Failed to get initial info with {command}: {e}") async def close_connection(self) -> None: """Close all connections.""" await self.async_cancel_tasks() def stop(self) -> None: """Stop operations.""" self.stop_tasks.set() ########################## # Command Execution ########################## async def send_command(self, command: list[str], direct: bool = False) -> str | None: """ Send a command using connection pool or direct connection. Args: command: A list containing the command to send. direct: If True, use direct connection. If False, use connection pool. Returns: Response from the device or None Raises: NotImplementedError: If the command is not supported. ConnectionError: If there's any connection-related issue. """ try: cmd, _ = await self._construct_command(command) except NotImplementedError as err: self.logger.warning("Command not implemented: %s -- %s", command, err) raise self.logger.debug("Sending command: %s", cmd) try: if direct: # Use bespoke connection response = await self._send_command_direct(cmd) else: # Use connection pool response = await self.connection_pool.send_command(cmd) return response except Exception as e: self.logger.error(f"Failed to send command {command}: {e}") raise async def _send_command_direct(self, cmd: bytes) -> str | None: """Send a command using a direct connection - no pooling.""" writer = None try: deadline = asyncio.get_event_loop().time() + CONNECTION_TIMEOUT async with asyncio.timeout_at(deadline): reader, writer = await asyncio.open_connection(self.host, self.port) deadline = asyncio.get_event_loop().time() + COMMAND_RESPONSE_TIMEOUT async with asyncio.timeout_at(deadline): welcome = await reader.read(1024) if b"WELCOME" not in welcome: raise ConnectionError("Did not receive welcome message") writer.write(cmd) await writer.drain() deadline = asyncio.get_event_loop().time() + COMMAND_RESPONSE_TIMEOUT async with asyncio.timeout_at(deadline): response = await reader.read(1024) return response.decode("utf-8", errors="ignore").strip() if response else None except asyncio.TimeoutError: raise ConnectionError("Timeout sending command") except Exception as e: raise ConnectionError(f"Failed to send command: {e}") finally: if writer: writer.close() await writer.wait_closed() async def _send_command_via_notification(self, command: list[str]) -> bool: """ Send a command via the notification connection. The response will come back as a notification and be processed by the notification task. This is used by background tasks that want their responses processed as notifications. Returns True if command was sent successfully. """ if not self.notification_writer or not self.notification_connected.is_set(): self.logger.debug("Cannot send command via notification - connection not available") return False try: cmd, _ = await self._construct_command(command) self.notification_writer.write(cmd) await asyncio.wait_for(self.notification_writer.drain(), timeout=2.0) self.logger.debug(f"Sent command via notification connection: {command}") return True except Exception as e: self.logger.error(f"Failed to send command via notification connection: {e}") return False async def add_command_to_queue(self, command: Iterable[str]) -> None: """ Add user command to queue for FIFO processing. User commands (menu navigation, key presses) are queued to preserve ordering. System commands should use send_command() directly for immediate execution. """ command_list = list(command) try: self.user_command_queue.put_nowait(command_list) self.logger.debug(f"Added command to queue: {command_list}") except asyncio.QueueFull: self.logger.error(f"Command queue is full, dropping command: {command_list}") except Exception as e: self.logger.error(f"Failed to queue command {command_list}: {e}") def clear_queue(self) -> None: """Clear all pending commands from the user command queue.""" try: while not self.user_command_queue.empty(): self.user_command_queue.get_nowait() self.user_command_queue.task_done() self.logger.debug("Cleared command queue") except Exception as e: self.logger.error(f"Error clearing queue: {e}") ########################## # Notification Handling ########################## async def task_read_notifications(self) -> None: """ Read notifications from the dedicated notification connection. """ while True: # Sleep when device is off if self._should_tasks_sleep(): await asyncio.sleep(1.0) continue # Wait for notification connection to be established if not self.notification_connected.is_set(): self.logger.debug("Waiting for notification connection to be established...") await asyncio.sleep(1.0) continue try: if not self.notification_reader: # Connection lost - heartbeat task will handle reconnection await asyncio.sleep(TASK_CPU_DELAY) continue msg = await asyncio.wait_for( self.notification_reader.read(1024), timeout=COMMAND_RESPONSE_TIMEOUT, ) if not msg: self.logger.debug("Empty notification message") continue try: await self._process_notifications(msg.decode("utf-8")) except UnicodeDecodeError as e: self.logger.error("Failed to decode notification: %s", e) continue except asyncio.TimeoutError: # No notifications to read await asyncio.sleep(TASK_CPU_DELAY) continue except (ConnectionResetError, BrokenPipeError) as err: self.logger.error(f"Notification connection error: {err}") # Clear connection state safely - heartbeat task will handle reconnection await self._clear_notification_connection() await asyncio.sleep(TASK_CPU_DELAY) continue except Exception as e: self.logger.exception("Unexpected error in notification task: %s", e) await asyncio.sleep(TASK_CPU_DELAY) continue await asyncio.sleep(TASK_CPU_DELAY) async def _process_notifications(self, msg: str) -> None: """Process notification data in real time.""" processed_data = await self.notification_processor.process_notifications(msg) if processed_data.get("power_off"): await self._handle_power_off() return # Only update if the data has actually changed if processed_data != self.msg_dict: self.msg_dict["_last_update"] = time.time() self.msg_dict.update(processed_data) await self._update_ha_state() async def _handle_power_off(self) -> None: """Process power off notifications.""" await self._clear_attr() await self._set_device_power_state(False) async def _update_ha_state(self) -> None: """Update Home Assistant state.""" if self.update_callback is not None: try: self.logger.debug("Updating HA with %s", self.msg_dict) self.update_callback(self.msg_dict) except Exception as e: self.logger.error(f"Failed to update HA state: {e}") async def _clear_attr(self) -> None: """Clear device attributes.""" # Store MAC address and reinitialize mac_address = self.msg_dict.get("mac_address") self.msg_dict.clear() if mac_address: self.msg_dict["mac_address"] = mac_address ########################## # Device Control Methods ########################## async def power_on(self, mac: str = "") -> None: """Turn on the device using Wake on LAN.""" # Use explicitly passed MAC first, then fall back to stored values mac_to_use = mac or self.mac_address or self.mac if not mac_to_use: self.logger.error("No MAC address available for Wake on LAN") return try: send_magic_packet(mac_to_use, logger=self.logger) self.logger.debug("Sent Wake on LAN packet") # Clear stop flag to ensure tasks can resume when device comes online self.stop_tasks.clear() except Exception as e: self.logger.error(f"Failed to send WOL packet: {e}") async def power_off(self, standby: bool = False) -> None: """Turn off the device.""" command = ["Standby"] if standby else ["PowerOff"] try: await self.send_command(command) await self._clear_attr() await self._set_device_power_state(False) except Exception as e: self.logger.error(f"Failed to power off device: {e}") async def display_message(self, duration: int, message: str) -> None: """Display a message on the device.""" await self.add_command_to_queue(["DisplayMessage", str(duration), f'"{message}"']) async def display_audio_volume(self, channel: int, current: int, max_vol: int, unit: str) -> None: """Display audio volume information.""" await self.add_command_to_queue(["DisplayAudioVolume", str(channel), str(current), str(max_vol), f'"{unit}"']) async def display_audio_mute(self) -> None: """Display audio mute indicator.""" await self.add_command_to_queue(["DisplayAudioMute"]) async def close_audio_mute(self) -> None: """Close audio mute indicator.""" await self.add_command_to_queue(["CloseAudioMute"]) ########################## # Helper Methods ########################## async def _construct_command(self, raw_command: list[str]) -> tuple[bytes, str]: """ Transform commands into their byte values from the string value. Handles both new format: ["KeyPress", "MENU"] and old format: ["KeyPress, MENU"] """ if not raw_command: raise NotImplementedError("Empty command") self.logger.debug("raw_command: %s -- length: %s", raw_command, len(raw_command)) # Handle comma-separated commands from HA: ["KeyPress, MENU"] -> ["KeyPress", "MENU"] if len(raw_command) == 1 and "," in raw_command[0]: parts = [part.strip() for part in raw_command[0].split(",")] command_name = parts[0] values = parts[1:] if len(parts) > 1 else [] elif len(raw_command) > 1: # New format: ["KeyPress", "MENU"] command_name = raw_command[0] values = raw_command[1:] else: # Single command: ["PowerOff"] command_name = raw_command[0] values = [] self.logger.debug("Parsed command: %s, values: %s", command_name, values) # Find the command in the Commands enum for cmd_enum in Commands: if hasattr(cmd_enum.value, "__len__") and len(cmd_enum.value) >= 3: cmd_bytes, val_enum, _ = cmd_enum.value # Check if this matches our command name if cmd_bytes.decode("utf-8", errors="ignore").rstrip() == command_name: # Build the command full_command = cmd_bytes # Add parameters if any for value in values: if value.isnumeric(): # Numeric parameter full_command += b" " + value.encode("utf-8") elif hasattr(val_enum, value) and hasattr(val_enum, "__members__"): # Enum value (like MENU for KeyPress) enum_value = getattr(val_enum, value).value if isinstance(enum_value, bytes): full_command += b" " + enum_value else: full_command += b" " + str(enum_value).encode("utf-8") else: # String parameter (wrap in quotes for commands like DisplayMessage) if command_name in ["DisplayMessage", "DisplayAudioVolume"]: if not (value.startswith('"') and value.endswith('"')): value = f'"{value}"' full_command += b" " + value.encode("utf-8") # Add footer full_command += Footer.footer.value self.logger.debug("Constructed command: %s", full_command) return full_command, str(type(val_enum)) raise NotImplementedError(f"Command '{command_name}' not found") # Legacy compatibility methods (simplified) async def is_device_connectable(self) -> bool: """Check if device is connectable by trying a quick connection.""" try: deadline = asyncio.get_event_loop().time() + 1.0 # 1 second timeout async with asyncio.timeout_at(deadline): _, writer = await asyncio.open_connection(self.host, self.port) writer.close() await writer.wait_closed() return True except Exception: return False async def _set_connected(self, connected: bool) -> None: """Set connection state (compatibility method).""" if connected: self.notification_connected.set() else: self.notification_connected.clear() async def task_refresh_info(self) -> None: """ Refresh device information forever when device is on. This task runs forever and updates display information when the device is powered on. It automatically pauses when the device is off to save resources. """ # Add initial delay to prevent race condition with startup commands await asyncio.sleep(5) while True: try: # Sleep when device is off if self._should_tasks_sleep(): await asyncio.sleep(1) continue if self.connected and self.msg_dict.get("is_on", False): # Get current display information refresh_commands = [ ["GetMacAddress"], ["GetTemperatures"], # Get signal info in case a change was missed ["GetIncomingSignalInfo"], ["GetOutgoingSignalInfo"], ["GetAspectRatio"], ["GetMaskingRatio"], ] for command in refresh_commands: try: # Send via notification connection so responses are processed as notifications success = await self._send_command_via_notification(command) if not success: self.logger.debug( f"Failed to send refresh command {command[0]} via notification connection" ) except Exception as e: self.logger.debug(f"Failed to refresh {command[0]}: {e}") await asyncio.sleep(REFRESH_TIME) else: # Device is off or not connected, wait before checking again await asyncio.sleep(1) except Exception as e: self.logger.debug(f"Info refresh failed: {e}") await asyncio.sleep(REFRESH_TIME) async def task_ping_device(self) -> None: """ This task should not be cancelled during normal operation as it: - Determines if the device is on/off - Pre-warms the connection pool for faster command execution - Updates device power state based on connectivity Only stop this task during complete instance destruction. """ while True: try: # Try to establish a connection (this is our "ping") is_available = await self.is_device_connectable() if is_available: # Device is on - update state atomically if not self.msg_dict.get("is_on", False): await self._set_device_power_state(True) else: # Device is off - update state atomically if self.msg_dict.get("is_on", False): await self._set_device_power_state(False) # Wait before next ping await asyncio.sleep(PING_INTERVAL) except Exception as e: self.logger.error(f"Error in ping task: {e}") await asyncio.sleep(PING_INTERVAL) async def task_process_command_queue(self) -> None: """ Process user commands from queue in FIFO order. This task ensures user interactions (menu navigation, key presses) are executed in the correct order, which is critical for proper operation. """ while True: # Sleep when device is off if self._should_tasks_sleep(): await asyncio.sleep(1.0) continue try: # Wait for a command with timeout to allow checking stop event command = await asyncio.wait_for(self.user_command_queue.get(), timeout=1.0) try: # Execute the command immediately (no additional queuing) await self.send_command(command) self.logger.debug(f"Processed queued command: {command}") except Exception as e: self.logger.error(f"Failed to execute queued command {command}: {e}") finally: # Mark task as done regardless of success/failure self.user_command_queue.task_done() # Prevent CPU spinning await asyncio.sleep(0.1) except asyncio.TimeoutError: continue except Exception as e: self.logger.error(f"Error in queue processing task: {e}") await asyncio.sleep(TASK_CPU_DELAY) async def task_notification_heartbeat(self) -> None: """ Send heartbeat to notification connection to keep it alive. MadVR closes connections after 60 seconds without activity. Send heartbeat every 30 seconds to ensure connection stays alive. """ last_heartbeat = 0.0 while True: try: # Sleep when device is off if self._should_tasks_sleep(): await asyncio.sleep(1.0) continue # Only try to connect if device is on if not self.msg_dict.get("is_on", False): await asyncio.sleep(1.0) continue # Check if we need to establish/re-establish connection if ( not self.notification_connected.is_set() or not self.notification_writer or self.notification_writer.is_closing() ): self.logger.info("Heartbeat task establishing notification connection...") try: await self._establish_notification_connection() self.logger.info("Notification connection established by heartbeat task") last_heartbeat = time.time() except Exception as e: self.logger.error(f"Failed to establish notification connection: {e}") await asyncio.sleep(5.0) # Wait before retry continue # Check if it's time to send heartbeat (every 30 seconds) current_time = time.time() if current_time - last_heartbeat >= 30.0: # Send heartbeat command if self.notification_writer and not self.notification_writer.is_closing(): try: self.notification_writer.write(self.HEARTBEAT) await asyncio.wait_for(self.notification_writer.drain(), timeout=2.0) self.logger.debug("Sent heartbeat to notification connection") last_heartbeat = current_time except Exception as e: self.logger.error(f"Failed to send heartbeat: {e}") raise # Let outer exception handler deal with cleanup # Avoid busy loop await asyncio.sleep(1.0) except Exception as e: self.logger.error(f"Error sending heartbeat: {e}") # Clear state for reconnection await self._clear_notification_connection() await asyncio.sleep(5.0) # Wait a bit before retry py-madvr-1.8.14/pymadvr/notifications.py000066400000000000000000000120041503704651100202500ustar00rootroot00000000000000"""Implement notification processing for MadVR.""" import logging from typing import Any class NotificationProcessor: """Process notifications from MadVR.""" def __init__(self, logger: logging.Logger): self.logger = logger self.msg_dict: dict[str, Any] = {} async def process_notifications(self, msg: str) -> dict[str, Any]: """Parse a message and store the attributes and values in a dictionary""" self.logger.debug("Processing notifications: %s", msg) notifications = msg.strip().split("\r\n") # Create a fresh dict for this processing to avoid accumulation result_dict: dict[str, Any] = {} for notification in notifications: # ignore ok if not notification or notification == "OK": continue parts = notification.split(" ", 1) # ignore empty notifications if len(parts) < 2: continue title, signal_info = parts self.logger.debug("Processing notification Title: %s", title) if title == "PowerOff": result_dict["is_on"] = False result_dict["power_off"] = True elif title == "NoSignal": result_dict["is_signal"] = False elif title == "Standby": result_dict["is_on"] = False result_dict["power_off"] = True else: # Clear the internal dict before processing new signal info self.msg_dict.clear() self._process_signal_info(title, signal_info.split()) # Copy relevant processed data to result result_dict.update(self.msg_dict) return result_dict def _process_signal_info(self, title: str, signal_info: list[str]) -> None: processors = { "IncomingSignalInfo": self._process_incoming_signal, "OutgoingSignalInfo": self._process_outgoing_signal, "AspectRatio": self._process_aspect_ratio, "MaskingRatio": self._process_masking_ratio, "ActivateProfile": self._process_profile, "ActiveProfile": self._process_profile, "MacAddress": self._process_mac_address, "Temperatures": self._process_temperatures, } processor = processors.get(title) if processor: try: # Call the processor function processor(signal_info) except (KeyError, IndexError) as e: self.logger.error(f"Error processing {title}: {e}") self.logger.debug(f"Signal info: {signal_info}") def clear_state(self) -> None: """Clear all stored state - call this on reconnection""" self.msg_dict.clear() def _process_mac_address(self, info: list[str]) -> None: self.msg_dict["mac_address"] = info[0] def _process_temperatures(self, info: list[str]) -> None: self.msg_dict.update( { "temp_gpu": info[0], "temp_hdmi": info[1], "temp_cpu": info[2], "temp_mainboard": info[3], } ) def _process_incoming_signal(self, info: list[str]) -> None: self.msg_dict.update( { "is_signal": True, "incoming_res": info[0], "incoming_frame_rate": info[1], # 2D || 3D "incoming_signal_type": info[2], "incoming_color_space": info[3], "incoming_bit_depth": info[4], "hdr_flag": "HDR" in info[5], "incoming_colorimetry": info[6], "incoming_black_levels": info[7], "incoming_aspect_ratio": info[8], } ) def _process_outgoing_signal(self, info: list[str]) -> None: self.msg_dict.update( { "outgoing_res": info[0], "outgoing_frame_rate": info[1], "outgoing_signal_type": info[2], # 2D || 3D "outgoing_color_space": info[3], "outgoing_bit_depth": info[4], "outgoing_hdr_flag": "HDR" in info[5], "outgoing_colorimetry": info[6], "outgoing_black_levels": info[7], } ) def _process_aspect_ratio(self, info: list[str]) -> None: self.msg_dict.update( { "aspect_res": info[0], "aspect_dec": float(info[1]), "aspect_int": info[2], "aspect_name": info[3], } ) def _process_masking_ratio(self, info: list[str]) -> None: self.msg_dict.update( { "masking_res": info[0], "masking_dec": float(info[1]), "masking_int": info[2], } ) def _process_profile(self, info: list[str]) -> None: self.msg_dict.update( { "profile_name": info[0], "profile_num": info[1], } ) py-madvr-1.8.14/pymadvr/py.typed000066400000000000000000000000001503704651100165150ustar00rootroot00000000000000py-madvr-1.8.14/pymadvr/simple_pool.py000066400000000000000000000134211503704651100177250ustar00rootroot00000000000000"""Simple connection pool for MadVR commands.""" import asyncio import logging import time from typing import Optional from pymadvr.commands import Connections from pymadvr.consts import COMMAND_RESPONSE_TIMEOUT, CONNECTION_TIMEOUT class MadvrConnection: """Individual MadVR connection wrapper.""" def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, logger: logging.Logger): self.reader = reader self.writer = writer self.logger = logger self.created_at = time.time() self.last_used = time.time() async def is_healthy(self) -> bool: """Check if connection is still healthy.""" if self.writer.is_closing(): return False try: # Quick check - don't send heartbeat, just check if writer is open return not self.writer.is_closing() except Exception: return False async def send_command(self, command: bytes) -> Optional[str]: """Send a command and return the response.""" try: self.writer.write(command) await self.writer.drain() # Read response with timeout deadline = asyncio.get_event_loop().time() + COMMAND_RESPONSE_TIMEOUT async with asyncio.timeout_at(deadline): response = await self.reader.read(1024) self.last_used = time.time() return response.decode("utf-8", errors="ignore").strip() if response else None except Exception as e: self.logger.debug(f"Command failed: {e}") raise ConnectionError(f"Failed to send command: {e}") async def close(self) -> None: """Close the connection.""" if self.writer and not self.writer.is_closing(): self.writer.close() await self.writer.wait_closed() class SimpleConnectionPool: """Simple connection pool that keeps one connection alive for 10 seconds after last use.""" def __init__(self, host: str, port: int, logger: logging.Logger): self.host = host self.port = port self.logger = logger self.connection: Optional[MadvrConnection] = None self.close_timer: Optional[asyncio.Task] = None self.lock = asyncio.Lock() async def send_command(self, command: bytes) -> Optional[str]: """Send a command using the pooled connection.""" async with self.lock: # Get or create connection conn = await self._get_connection() try: response = await conn.send_command(command) # Reset the close timer since we just used the connection self._reset_close_timer() return response except Exception as e: # Connection failed, close it and create a new one for retry await self._close_connection() self.logger.debug(f"Command failed, retrying with new connection: {e}") # Retry once with new connection conn = await self._get_connection() response = await conn.send_command(command) self._reset_close_timer() return response async def _get_connection(self) -> MadvrConnection: """Get a healthy connection or create a new one.""" # Check if existing connection is healthy if self.connection and await self.connection.is_healthy(): return self.connection # Create new connection await self._close_connection() # Close any existing connection self.connection = await self._create_connection() return self.connection async def _create_connection(self) -> MadvrConnection: """Create a new connection.""" try: # Create connection with timeout deadline = asyncio.get_event_loop().time() + CONNECTION_TIMEOUT async with asyncio.timeout_at(deadline): reader, writer = await asyncio.open_connection(self.host, self.port) # Wait for welcome message deadline = asyncio.get_event_loop().time() + COMMAND_RESPONSE_TIMEOUT async with asyncio.timeout_at(deadline): welcome = await reader.read(1024) if Connections.welcome.value not in welcome: raise ConnectionError("Did not receive welcome message") return MadvrConnection(reader, writer, self.logger) except Exception as e: self.logger.error(f"Failed to create pooled connection: {e}") raise ConnectionError(f"Failed to connect to {self.host}:{self.port}: {e}") def _reset_close_timer(self) -> None: """Reset the timer to close the connection after 10 seconds of inactivity.""" # Cancel existing timer if self.close_timer and not self.close_timer.done(): self.close_timer.cancel() # Start new timer self.close_timer = asyncio.create_task(self._close_after_delay()) async def _close_after_delay(self) -> None: """Close the connection after 10 seconds of inactivity.""" try: await asyncio.sleep(10.0) # 10 second delay async with self.lock: if self.connection: await self._close_connection() except asyncio.CancelledError: # Timer was cancelled, connection is still being used pass async def _close_connection(self) -> None: """Close the current connection.""" if self.connection: await self.connection.close() self.connection = None async def close_all(self) -> None: """Close all connections and cancel timers.""" if self.close_timer and not self.close_timer.done(): self.close_timer.cancel() await self._close_connection() py-madvr-1.8.14/pymadvr/wol.py000066400000000000000000000061701503704651100162070ustar00rootroot00000000000000# ruff: noqa """Small module for use with the wake on lan protocol. taken from https://github.com/remcohaszing/pywakeonlan/blob/main/wakeonlan/__init__.py Provided under MIT license Copyright © 2012 Remco Haszing 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. """ import logging import socket BROADCAST_IP = "255.255.255.255" DEFAULT_PORT = 9 def create_magic_packet(macaddress: str) -> bytes: """Create a magic packet. A magic packet is a packet that can be used with the for wake on lan protocol to wake up a computer. The packet is constructed from the mac address given as a parameter. Args: macaddress: the mac address that should be parsed into a magic packet. """ if len(macaddress) == 17: sep = macaddress[2] macaddress = macaddress.replace(sep, "") elif len(macaddress) == 14: sep = macaddress[4] macaddress = macaddress.replace(sep, "") if len(macaddress) != 12: raise ValueError("Incorrect MAC address format") return bytes.fromhex("F" * 12 + macaddress * 16) def send_magic_packet( *macs: str, ip_address: str = BROADCAST_IP, port: int = DEFAULT_PORT, interface: str | None = None, logger: logging.Logger | None = None, ) -> None: """Wake up computers having any of the given mac addresses. Wake on lan must be enabled on the host device. Args: macs: One or more macaddresses of machines to wake. Keyword Args: ip_address: the ip address of the host to send the magic packet to. port: the port of the host to send the magic packet to. interface: the ip address of the network adapter to route the magic packet through. logger: a logger """ packets = [create_magic_packet(mac) for mac in macs] with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: if interface is not None: sock.bind((interface, 0)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.connect((ip_address, port)) for packet in packets: if logger: logger.debug("Sending magic packet to %s", macs) sock.send(packet) py-madvr-1.8.14/pyproject.toml000066400000000000000000000003331503704651100162610ustar00rootroot00000000000000[tool.ruff] select = ["E", "F", "I"] ignore = [] line-length = 120 exclude = ["tests", "scripts"] [tool.pytest.ini_options] markers = [ "integration: marks tests as integration tests that require a live device", ] py-madvr-1.8.14/requirements-test.txt000066400000000000000000000001461503704651100176100ustar00rootroot00000000000000python-dotenv black pylint build twine pytest pytest-asyncio pre-commit mypy ruff pydantic pre-commit py-madvr-1.8.14/scripts/000077500000000000000000000000001503704651100150355ustar00rootroot00000000000000py-madvr-1.8.14/scripts/power_on_device.py000077500000000000000000000055641503704651100205730ustar00rootroot00000000000000#!/usr/bin/env python3 """Script to power on MadVR device using Wake-on-LAN for testing.""" import asyncio import logging import os import sys import time from pathlib import Path # Add parent directory to path to import madvr module sys.path.insert(0, str(Path(__file__).parent.parent)) from pymadvr.madvr import Madvr from pymadvr.wol import send_magic_packet async def power_on_device(host: str, port: int, timeout: int = 60) -> bool: """ Power on MadVR device using Wake-on-LAN. Args: host: Device IP address port: Device port timeout: Maximum time to wait for device to come online Returns: True if device is powered on successfully """ logger = logging.getLogger(__name__) madvr = Madvr( host=host, port=port, logger=logger, connect_timeout=10, ) # Check if device is already on if await madvr.is_device_connectable(): logger.info(f"Device at {host}:{port} is already on") return True # MAC address must come from environment variable mac = os.getenv("MADVR_MAC", "") if not mac: logger.error("MADVR_MAC environment variable not set. Cannot power on device without MAC address.") return False logger.info(f"Sending Wake-on-LAN packet to MAC {mac}") try: send_magic_packet(mac, logger=logger) except Exception as e: logger.error(f"Failed to send WOL packet: {e}") return False # Wait for device to come online logger.info(f"Waiting up to {timeout} seconds for device to come online...") start_time = time.time() while time.time() - start_time < timeout: if await madvr.is_device_connectable(): logger.info(f"Device is now online! (took {int(time.time() - start_time)} seconds)") # Give device a moment to fully initialize await asyncio.sleep(3.0) return True await asyncio.sleep(2.0) sys.stdout.write(".") sys.stdout.flush() logger.error(f"Device did not come online after {timeout} seconds") return False async def main(): # Setup logging logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") # Get configuration from environment host = os.getenv("MADVR_HOST", "192.168.1.100") port = int(os.getenv("MADVR_PORT", "44077")) # Try to power on device success = await power_on_device(host, port) if success: print(f"\n✓ Device at {host}:{port} is powered on and ready") print("\nYou can now run integration tests:") print(" pytest tests/test_integration.py -m integration -v") sys.exit(0) else: print(f"\n✗ Failed to power on device at {host}:{port}") print("\nMake sure MADVR_MAC environment variable is set correctly") sys.exit(1) if __name__ == "__main__": asyncio.run(main()) py-madvr-1.8.14/setup.py000066400000000000000000000012651503704651100150640ustar00rootroot00000000000000import setuptools with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() setuptools.setup( name="py_madvr2", version="1.8.14", author="iloveicedgreentea2", description="A package to control MadVR Envy over IP", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/iloveicedgreentea/py-madvr", packages=setuptools.find_packages(exclude=["tests", "tests.*"]), package_data={"pymadvr": ["py.typed"]}, classifiers=[ "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], ) py-madvr-1.8.14/tests/000077500000000000000000000000001503704651100145105ustar00rootroot00000000000000py-madvr-1.8.14/tests/__init__.py000066400000000000000000000000001503704651100166070ustar00rootroot00000000000000py-madvr-1.8.14/tests/conftest.py000066400000000000000000000036151503704651100167140ustar00rootroot00000000000000# type: ignore from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from pymadvr.madvr import Madvr @pytest.fixture def mock_madvr(): with patch("pymadvr.madvr.asyncio.open_connection", new_callable=AsyncMock), patch( "pymadvr.madvr.Madvr.connected", new_callable=PropertyMock, return_value=True ): madvr = Madvr("192.168.1.100") # Mock connection pool madvr.connection_pool = MagicMock() madvr.connection_pool.get_connection = AsyncMock() madvr.connection_pool.return_connection = AsyncMock() madvr.connection_pool.close_all = AsyncMock() # Mock notification connection components madvr.notification_reader = AsyncMock() madvr.notification_writer = AsyncMock() madvr.notification_task = MagicMock() madvr.ping_task = MagicMock() madvr.refresh_task = MagicMock() madvr.queue_task = MagicMock() # Mock methods madvr._set_connected = AsyncMock() madvr._clear_attr = AsyncMock() madvr.is_device_connectable = AsyncMock() madvr.close_connection = AsyncMock() madvr._construct_command = AsyncMock() madvr.stop = MagicMock() madvr.add_command_to_queue = AsyncMock() madvr._establish_notification_connection = AsyncMock() madvr._get_initial_device_info = AsyncMock() # Mock the background tasks madvr.task_read_notifications = AsyncMock() madvr.task_refresh_info = AsyncMock() madvr.task_process_command_queue = AsyncMock() yield madvr @pytest.fixture def mock_send_magic_packet(): with patch("pymadvr.madvr.send_magic_packet") as mock: yield mock @pytest.fixture def mock_wait_for(): async def mock_wait_for_func(coro, timeout): # noqa: ARG001 return await coro with patch("asyncio.wait_for", mock_wait_for_func): yield py-madvr-1.8.14/tests/test_MadVR.py000066400000000000000000000122311503704651100170710ustar00rootroot00000000000000# type: ignore from unittest.mock import AsyncMock, MagicMock, patch import pytest @pytest.mark.asyncio async def test_init(mock_madvr): assert mock_madvr.host == "192.168.1.100" assert mock_madvr.port == 44077 # Assuming DEFAULT_PORT is 44077 assert mock_madvr.connection_pool is not None @pytest.mark.asyncio async def test_is_on_property(mock_madvr): mock_madvr.msg_dict = {"is_on": True} assert mock_madvr.is_on is True mock_madvr.msg_dict = {"is_on": False} assert mock_madvr.is_on is False @pytest.mark.asyncio async def test_mac_address_property(mock_madvr): mock_madvr.msg_dict = {"mac_address": "00:11:22:33:44:55"} assert mock_madvr.mac_address == "00:11:22:33:44:55" mock_madvr.msg_dict = {} assert mock_madvr.mac_address == "" @pytest.mark.asyncio async def test_set_update_callback(mock_madvr): callback = MagicMock() mock_madvr.set_update_callback(callback) assert mock_madvr.update_callback == callback @pytest.mark.asyncio async def test_async_add_tasks(mock_madvr): with patch("asyncio.get_event_loop") as mock_loop: mock_task = MagicMock() mock_task.set_name = MagicMock() mock_loop.return_value.create_task = MagicMock(return_value=mock_task) await mock_madvr.async_add_tasks() assert mock_madvr.notification_task is not None assert mock_madvr.ping_task is not None assert mock_madvr.refresh_task is not None assert mock_madvr.queue_task is not None # 4 tasks: notifications, ping, refresh, and queue @pytest.mark.asyncio async def test_send_command(mock_madvr): # Mock connection pool's send_command method mock_madvr.connection_pool.send_command = AsyncMock(return_value="OK") mock_madvr._construct_command = AsyncMock(return_value=(b"TestCommand\r\n", "enum_type")) result = await mock_madvr.send_command(["TestCommand"]) mock_madvr._construct_command.assert_called_once_with(["TestCommand"]) mock_madvr.connection_pool.send_command.assert_called_once_with(b"TestCommand\r\n") assert result == "OK" @pytest.mark.asyncio async def test_send_command_error(mock_madvr): # Mock connection pool's send_command to raise error mock_madvr.connection_pool.send_command = AsyncMock(side_effect=ConnectionError("Test error")) mock_madvr._construct_command = AsyncMock(return_value=(b"TestCommand\r\n", "enum_type")) with pytest.raises(ConnectionError): await mock_madvr.send_command(["TestCommand"]) @pytest.mark.asyncio async def test_open_connection(mock_madvr): # Mock the background tasks setup mock_madvr.async_add_tasks = AsyncMock() mock_madvr._get_initial_device_info = AsyncMock() await mock_madvr.open_connection() # Verify tasks were started and initial info was fetched mock_madvr.async_add_tasks.assert_called_once() mock_madvr._get_initial_device_info.assert_called_once() @pytest.mark.asyncio async def test_open_connection_error(mock_madvr): # Mock async_add_tasks to raise an error mock_madvr.async_add_tasks = AsyncMock(side_effect=ConnectionError("Test error")) with pytest.raises(ConnectionError): await mock_madvr.open_connection() @pytest.mark.asyncio async def test_power_on(mock_madvr, mock_send_magic_packet): mock_madvr.msg_dict = {"mac_address": "00:11:22:33:44:55"} mock_madvr.stop_commands_flag = MagicMock() mock_madvr.stop_commands_flag.is_set.return_value = False await mock_madvr.power_on() mock_send_magic_packet.assert_called_once_with("00:11:22:33:44:55", logger=mock_madvr.logger) @pytest.mark.asyncio async def test_power_off(mock_madvr): # Mock send_command to avoid actual connection mock_madvr.send_command = AsyncMock() await mock_madvr.power_off() mock_madvr.stop.assert_called_once() assert mock_madvr.powered_off_recently is True mock_madvr.send_command.assert_called_once_with(["PowerOff"]) mock_madvr.close_connection.assert_called_once() @pytest.mark.asyncio async def test_power_off_standby(mock_madvr): # Mock send_command to avoid actual connection mock_madvr.send_command = AsyncMock() await mock_madvr.power_off(standby=True) mock_madvr.stop.assert_called_once() assert mock_madvr.powered_off_recently is True mock_madvr.send_command.assert_called_once_with(["Standby"]) mock_madvr.close_connection.assert_called_once() @pytest.mark.asyncio async def test_display_message(mock_madvr): await mock_madvr.display_message(5, "Test message") mock_madvr.add_command_to_queue.assert_called_once_with(["DisplayMessage", "5", '"Test message"']) @pytest.mark.asyncio async def test_display_audio_volume(mock_madvr): await mock_madvr.display_audio_volume(0, 50, 100, "%") mock_madvr.add_command_to_queue.assert_called_once_with(["DisplayAudioVolume", "0", "50", "100", '"%"']) @pytest.mark.asyncio async def test_display_audio_mute(mock_madvr): await mock_madvr.display_audio_mute() mock_madvr.add_command_to_queue.assert_called_once_with(["DisplayAudioMute"]) @pytest.mark.asyncio async def test_close_audio_mute(mock_madvr): await mock_madvr.close_audio_mute() mock_madvr.add_command_to_queue.assert_called_once_with(["CloseAudioMute"]) py-madvr-1.8.14/tests/test_command_construction.py000066400000000000000000000112511503704651100223510ustar00rootroot00000000000000"""Test command construction for Home Assistant compatibility.""" import pytest from pymadvr.madvr import Madvr class TestCommandConstruction: """Test command construction with various formats.""" @pytest.fixture def madvr(self): """Create a MadVR instance for testing.""" return Madvr("192.168.1.100") @pytest.mark.asyncio async def test_comma_separated_commands(self, madvr): """Test Home Assistant format with comma-separated commands.""" test_cases = [ (["KeyPress, MENU"], b'KeyPress MENU\r\n'), (["KeyPress, SETTINGS"], b'KeyPress SETTINGS\r\n'), (["OpenMenu, Info"], b'OpenMenu Info\r\n'), (["OpenMenu, Settings"], b'OpenMenu Settings\r\n'), (["ActivateProfile, SOURCE"], b'ActivateProfile SOURCE\r\n'), ] for command, expected in test_cases: cmd_bytes, _ = await madvr._construct_command(command) assert cmd_bytes == expected, f"Failed for command {command}" @pytest.mark.asyncio async def test_list_format_commands(self, madvr): """Test new format with separate list elements.""" test_cases = [ (["KeyPress", "MENU"], b'KeyPress MENU\r\n'), (["KeyPress", "SETTINGS"], b'KeyPress SETTINGS\r\n'), (["OpenMenu", "Info"], b'OpenMenu Info\r\n'), (["OpenMenu", "Settings"], b'OpenMenu Settings\r\n'), (["ActivateProfile", "SOURCE"], b'ActivateProfile SOURCE\r\n'), ] for command, expected in test_cases: cmd_bytes, _ = await madvr._construct_command(command) assert cmd_bytes == expected, f"Failed for command {command}" @pytest.mark.asyncio async def test_single_commands(self, madvr): """Test single commands without parameters.""" test_cases = [ (["PowerOff"], b'PowerOff\r\n'), (["Standby"], b'Standby\r\n'), (["GetMacAddress"], b'GetMacAddress\r\n'), (["GetTemperatures"], b'GetTemperatures\r\n'), (["CloseMenu"], b'CloseMenu\r\n'), ] for command, expected in test_cases: cmd_bytes, _ = await madvr._construct_command(command) assert cmd_bytes == expected, f"Failed for command {command}" @pytest.mark.asyncio async def test_display_commands(self, madvr): """Test display commands with string parameters.""" test_cases = [ (["DisplayMessage", "3", "Hello World"], b'DisplayMessage 3 "Hello World"\r\n'), (["DisplayMessage", "5", "Test Message"], b'DisplayMessage 5 "Test Message"\r\n'), ] for command, expected in test_cases: cmd_bytes, _ = await madvr._construct_command(command) assert cmd_bytes == expected, f"Failed for command {command}" @pytest.mark.asyncio async def test_numeric_parameters(self, madvr): """Test commands with numeric parameters.""" test_cases = [ (["ActivateProfile", "CUSTOM", "2"], b'ActivateProfile CUSTOM 2\r\n'), (["DisplayAudioVolume", "0", "75", "100", "percent"], b'DisplayAudioVolume 0 75 100 "percent"\r\n'), ] for command, expected in test_cases: cmd_bytes, _ = await madvr._construct_command(command) assert cmd_bytes == expected, f"Failed for command {command}" @pytest.mark.asyncio async def test_invalid_commands(self, madvr): """Test that invalid commands raise NotImplementedError.""" invalid_commands = [ ["NonExistentCommand"], ["InvalidCommand", "PARAM"], ] for command in invalid_commands: with pytest.raises(NotImplementedError): await madvr._construct_command(command) # Test invalid enum values are passed through as strings cmd_bytes, _ = await madvr._construct_command(["KeyPress, INVALID"]) assert cmd_bytes == b'KeyPress INVALID\r\n' @pytest.mark.asyncio async def test_empty_command(self, madvr): """Test that empty command raises NotImplementedError.""" with pytest.raises(NotImplementedError, match="Empty command"): await madvr._construct_command([]) @pytest.mark.asyncio async def test_ha_compatibility_edge_cases(self, madvr): """Test edge cases from Home Assistant integration.""" # Test command with multiple commas cmd_bytes, _ = await madvr._construct_command(["ActivateProfile, CUSTOM, 2"]) assert cmd_bytes == b'ActivateProfile CUSTOM 2\r\n' # Test command with spaces around commas cmd_bytes, _ = await madvr._construct_command(["KeyPress , MENU"]) assert cmd_bytes == b'KeyPress MENU\r\n' py-madvr-1.8.14/tests/test_connection_pool.py000066400000000000000000000123731503704651100213170ustar00rootroot00000000000000"""Test the connection pool timeout behavior.""" import asyncio import os import pytest from pymadvr.madvr import Madvr @pytest.fixture def madvr_config(): """Fixture providing MadVR connection configuration.""" return {"host": os.getenv("MADVR_HOST", "192.168.1.100"), "port": int(os.getenv("MADVR_PORT", "44077"))} @pytest.mark.asyncio @pytest.mark.integration async def test_connection_pool_timeout(madvr_config): """Test connection pool timeout behavior.""" madvr = Madvr(madvr_config["host"], port=madvr_config["port"]) # Check if device is available if not await madvr.is_device_connectable(): pytest.skip(f"MadVR device not available at {madvr_config['host']}:{madvr_config['port']}") try: await madvr.open_connection() # Send first command - should create pooled connection response1 = await madvr.send_command(["GetMacAddress"]) assert response1 is not None # Send second command quickly - should reuse connection response2 = await madvr.send_command(["GetTemperatures"]) assert response2 is not None # Wait 5 seconds - connection should still be alive await asyncio.sleep(5) # Send third command - should reuse connection, reset timer response3 = await madvr.send_command(["GetMacAddress"]) assert response3 is not None # Wait 12 seconds - connection should timeout and close await asyncio.sleep(12) # Send fourth command - should create new connection response4 = await madvr.send_command(["GetMacAddress"]) assert response4 is not None finally: # Properly close connection and cancel all tasks await madvr.close_connection() # Cancel the immortal tasks that don't get cancelled by close_connection if madvr.ping_task and not madvr.ping_task.done(): madvr.ping_task.cancel() try: await madvr.ping_task except asyncio.CancelledError: pass if madvr.refresh_task and not madvr.refresh_task.done(): madvr.refresh_task.cancel() try: await madvr.refresh_task except asyncio.CancelledError: pass @pytest.mark.asyncio @pytest.mark.integration async def test_connection_reuse(madvr_config): """Test that multiple rapid commands reuse the same connection.""" madvr = Madvr(madvr_config["host"], port=madvr_config["port"]) # Check if device is available if not await madvr.is_device_connectable(): pytest.skip(f"MadVR device not available at {madvr_config['host']}:{madvr_config['port']}") try: await madvr.open_connection() # Send multiple commands rapidly responses = [] for i in range(5): response = await madvr.send_command(["GetMacAddress"]) responses.append(response) assert response is not None # All commands should succeed assert len(responses) == 5 assert all(r is not None for r in responses) finally: await madvr.close_connection() # Cancel immortal tasks for task in [madvr.ping_task, madvr.refresh_task]: if task and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass @pytest.mark.asyncio @pytest.mark.integration async def test_background_tasks_dont_interfere(madvr_config): """Test that background tasks don't interfere with user commands.""" madvr = Madvr(madvr_config["host"], port=madvr_config["port"]) # Check if device is available if not await madvr.is_device_connectable(): pytest.skip(f"MadVR device not available at {madvr_config['host']}:{madvr_config['port']}") try: await madvr.open_connection() # Wait a bit for background tasks to start await asyncio.sleep(2) # Send user command - should work despite background tasks response = await madvr.send_command(["GetMacAddress"]) assert response is not None # Send another command after a delay await asyncio.sleep(3) response2 = await madvr.send_command(["GetTemperatures"]) assert response2 is not None finally: await madvr.close_connection() # Cancel immortal tasks for task in [madvr.ping_task, madvr.refresh_task]: if task and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass if __name__ == "__main__": # Run tests directly async def main(): print("Running connection pool tests...") try: await test_connection_pool_timeout() print("✓ Connection pool timeout test passed") await test_connection_reuse() print("✓ Connection reuse test passed") await test_background_tasks_dont_interfere() print("✓ Background task interference test passed") print("\n✓ All tests passed!") except Exception as e: print(f"✗ Test failed: {e}") import traceback traceback.print_exc() asyncio.run(main()) py-madvr-1.8.14/tests/test_simple_integration.py000066400000000000000000000067721503704651100220310ustar00rootroot00000000000000"""Simple integration test - no fixtures, no bullshit.""" import asyncio import os import sys from pathlib import Path # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) import pytest from pymadvr.madvr import Madvr @pytest.mark.asyncio async def test_basic_connection(): """Test basic connection and data retrieval.""" host = os.getenv("MADVR_HOST", "192.168.1.100") port = int(os.getenv("MADVR_PORT", "44077")) # Create instance madvr = Madvr(host, port=port) try: # Connect await madvr.open_connection() # Wait a bit for data await asyncio.sleep(2) # Check basic properties assert madvr.connected is True assert isinstance(madvr.is_on, bool) # Check msg_dict assert "is_on" in madvr.msg_dict assert madvr.msg_dict["is_on"] is True # If MAC is available, check it if madvr.mac_address: assert len(madvr.mac_address) > 0 assert ":" in madvr.mac_address or "-" in madvr.mac_address # Send a command response = await madvr.send_command(["GetMacAddress"]) assert response is not None # Test menu commands await madvr.add_command_to_queue(["OpenMenu", "Info"]) await asyncio.sleep(1) await madvr.add_command_to_queue(["CloseMenu"]) await asyncio.sleep(1) print(f"✓ Test passed! Device info: {madvr.msg_dict}") finally: await madvr.close_connection() @pytest.mark.asyncio async def test_display_message(): """Test display message functionality.""" host = os.getenv("MADVR_HOST", "192.168.1.100") port = int(os.getenv("MADVR_PORT", "44077")) madvr = Madvr(host, port=port) try: await madvr.open_connection() await asyncio.sleep(1) # Send display message await madvr.display_message(3, "Hello from Python!") await asyncio.sleep(4) print("✓ Display message test passed!") finally: await madvr.close_connection() @pytest.mark.asyncio async def test_ha_command_formats(): """Test Home Assistant command formats that were failing.""" host = os.getenv("MADVR_HOST", "192.168.1.100") port = int(os.getenv("MADVR_PORT", "44077")) madvr = Madvr(host, port=port) try: await madvr.open_connection() await asyncio.sleep(1) # Test the exact commands that were failing in HA logs print("Testing HA command formats...") # Test KeyPress commands (comma-separated format from HA) await madvr.add_command_to_queue(["KeyPress, MENU"]) await asyncio.sleep(2) await madvr.add_command_to_queue(["KeyPress, MENU"]) # Close menu await asyncio.sleep(1) # Test OpenMenu command await madvr.add_command_to_queue(["OpenMenu, Info"]) await asyncio.sleep(2) await madvr.add_command_to_queue(["CloseMenu"]) await asyncio.sleep(1) print("✓ HA command format test passed!") finally: await madvr.close_connection() if __name__ == "__main__": # Run directly without pytest async def main(): print(f"Testing MadVR at {os.getenv('MADVR_HOST', '192.168.1.100')}") try: await test_basic_connection() await test_display_message() await test_ha_command_formats() except Exception as e: print(f"✗ Test failed: {e}") import traceback traceback.print_exc() asyncio.run(main())