diff --git a/intg-zidoo/config.py b/intg-zidoo/config.py index d9bfb7c..c4f4bcb 100644 --- a/intg-zidoo/config.py +++ b/intg-zidoo/config.py @@ -46,16 +46,7 @@ class DeviceInstance: address: str net_mac_address: str wifi_mac_address: str - - def __init__(self, id, name, address, net_mac_address=None, wifi_mac_address=None): - """Initialize device instance config.""" - self.id = id - self.name = name - self.address = address - if net_mac_address: - self.net_mac_address = net_mac_address - if wifi_mac_address: - self.wifi_mac_address = wifi_mac_address + always_on: bool class _EnhancedJSONEncoder(json.JSONEncoder): @@ -123,6 +114,7 @@ def update(self, device_instance: DeviceInstance) -> bool: item.name = device_instance.name item.net_mac_address = device_instance.net_mac_address item.wifi_mac_address = device_instance.wifi_mac_address + item.always_on = device_instance.always_on return self.store() return False @@ -175,10 +167,16 @@ def load(self) -> bool: with open(self._cfg_file_path, "r", encoding="utf-8") as f: data = json.load(f) for item in data: - try: - self._config.append(DeviceInstance(**item)) - except TypeError as ex: - _LOG.warning("Invalid configuration entry will be ignored: %s", ex) + # not using AtvDevice(**item) to be able to migrate old configuration files with missing attributes + device_instance = DeviceInstance( + item.get("id"), + item.get("name"), + item.get("address"), + item.get("net_mac_address", ""), + item.get("wifi_mac_address", ""), + item.get("always_on", False), + ) + self._config.append(device_instance) return True except OSError: _LOG.error("Cannot open the config file") diff --git a/intg-zidoo/driver.py b/intg-zidoo/driver.py index 3f8ced5..e53e4bd 100644 --- a/intg-zidoo/driver.py +++ b/intg-zidoo/driver.py @@ -35,22 +35,6 @@ _R2_IN_STANDBY = False -async def device_status_poller(interval: float = 10.0) -> None: - """Receiver data poller.""" - while True: - await asyncio.sleep(interval) - if _R2_IN_STANDBY: - continue - try: - for device in _configured_devices.values(): - if device.state == States.OFF: - continue - # TODO #20 run in parallel, join, adjust interval duration based on execution time for next update - await device.async_update_data() - except (KeyError, ValueError): - pass - - @api.listens_to(ucapi.Events.CONNECT) async def on_r2_connect_cmd() -> None: """Connect all configured receivers when the Remote Two sends the connect command.""" @@ -59,7 +43,7 @@ async def on_r2_connect_cmd() -> None: for device in _configured_devices.values(): # start background task await device.connect() - await device.async_update_data() + await _LOOP.create_task(device.update()) await api.set_device_state(ucapi.DeviceStates.CONNECTED) @@ -100,7 +84,7 @@ async def on_r2_exit_standby() -> None: for device in _configured_devices.values(): # start background task - await device.async_update_data() + await _LOOP.create_task(device.update()) @api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES) @@ -316,7 +300,7 @@ def _configure_new_device( if connect: # start background connection task - _LOOP.create_task(device.async_update_data()) + _LOOP.create_task(device.update()) _LOOP.create_task(on_device_connected(device_config.id)) _register_available_entities(device_config, device) @@ -423,9 +407,8 @@ async def main(): for device in _configured_devices.values(): if device.state in [States.OFF, States.UNKNOWN]: continue - _LOOP.create_task(device.async_update_data()) + _LOOP.create_task(device.update()) - _LOOP.create_task(device_status_poller()) # Patched method # pylint: disable = W0212 IntegrationAPI._broadcast_ws_event = patched_broadcast_ws_event diff --git a/intg-zidoo/setup_flow.py b/intg-zidoo/setup_flow.py index 36a1e9f..ca11f7b 100644 --- a/intg-zidoo/setup_flow.py +++ b/intg-zidoo/setup_flow.py @@ -317,7 +317,15 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr "de": "Wähle deinen Zidoo", "fr": "Choisissez votre décodeur Zidoo", }, - } + }, + { + "id": "always_on", + "label": { + "en": "Keep connection alive (faster initialization, but consumes more battery)", + "fr": "Conserver la connexion active (lancement plus rapide, mais consomme plus de batterie)", + }, + "field": {"checkbox": {"value": False}}, + }, ], ) @@ -333,6 +341,7 @@ async def handle_device_choice(msg: UserDataResponse) -> SetupComplete | SetupEr """ # pylint: disable = W0718 host = msg.input_values["choice"] + always_on = msg.input_values.get("always_on") == "true" _LOG.debug( "Chosen Zidoo: %s. Trying to connect and retrieve device information...", host ) @@ -368,6 +377,7 @@ async def handle_device_choice(msg: UserDataResponse) -> SetupComplete | SetupEr address=host, net_mac_address=net_mac_address, wifi_mac_address=wifi_mac_address, + always_on=always_on ) ) # triggers ZidooAVR instance creation config.devices.store() diff --git a/intg-zidoo/zidooaio.py b/intg-zidoo/zidooaio.py index d742a74..056ba7c 100644 --- a/intg-zidoo/zidooaio.py +++ b/intg-zidoo/zidooaio.py @@ -13,10 +13,13 @@ import socket import struct import urllib.parse -from asyncio import AbstractEventLoop, Lock +from asyncio import AbstractEventLoop, Lock, CancelledError from datetime import datetime, timedelta from enum import IntEnum, StrEnum +from functools import wraps +from typing import TypeVar, ParamSpec, Callable, Concatenate, Awaitable, Any, Coroutine +import ucapi from aiohttp import ClientError, ClientSession, CookieJar from config import DeviceInstance from pyee import AsyncIOEventEmitter @@ -26,6 +29,8 @@ SCAN_INTERVAL = timedelta(seconds=10) SCAN_INTERVAL_RAPID = timedelta(seconds=1) +CONNECTION_RETRIES = 10 + # pylint: disable = C0302 @@ -226,17 +231,40 @@ class ZKEYS(StrEnum): ZMEDIA_TYPE_PLAYLIST: 5, } +_ZidooDeviceT = TypeVar("_ZidooDeviceT", bound="ZidooRC") +_P = ParamSpec("_P") + + +def cmd_wrapper( + func: Callable[Concatenate[_ZidooDeviceT, _P], Awaitable[dict[str, Any] | None]], +) -> Callable[Concatenate[_ZidooDeviceT, _P], Coroutine[Any, Any, ucapi.StatusCodes | None]]: + """Catch command exceptions.""" + + @wraps(func) + async def wrapper(obj: _ZidooDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> ucapi.StatusCodes: + """Wrap all command methods.""" + res = await func(obj, *args, **kwargs) + await obj.start_polling() + if res and isinstance(res, dict): + result: dict[str, Any] | None = res.get("result", None) + if result and result.get("responseCode", None) == "0": + return ucapi.StatusCodes.OK + return ucapi.StatusCodes.BAD_REQUEST + return ucapi.StatusCodes.OK + + return wrapper + class ZidooRC: """Zidoo Media Player Remote Control.""" def __init__( - self, - host: str, - device_config: DeviceInstance | None = None, - psk: str = None, - mac: str = None, - loop: AbstractEventLoop | None = None, + self, + host: str, + device_config: DeviceInstance | None = None, + psk: str = None, + mac: str = None, + loop: AbstractEventLoop | None = None, ) -> None: """Initialize the Zidoo class. @@ -258,8 +286,8 @@ def __init__( self._wifi_mac = device_config.wifi_mac_address else: self.id = host - self.event_loop = loop or asyncio.get_running_loop() - self.events = AsyncIOEventEmitter(self.event_loop) + self._event_loop = loop or asyncio.get_running_loop() + self.events = AsyncIOEventEmitter(self._event_loop) self._source_list = None self._media_type: MediaType | None = None self._host = f"{host}:{CONF_PORT}" @@ -284,6 +312,8 @@ def __init__( self._media_info = {} self._update_interval = SCAN_INTERVAL self._connect_lock = Lock() + self._update_task = None + self._update_lock = Lock() @property def state(self) -> States: @@ -357,6 +387,7 @@ async def async_turn_off(self) -> None: if self._state != States.OFF: await self.turn_off() + @cmd_wrapper async def async_power_toggle(self) -> None: """Turn off media player.""" if self._state != States.OFF: @@ -401,7 +432,7 @@ def _filter_updated_media_info(self, media_info: any, updated_data: any) -> any: updated_data[MediaAttr.MEDIA_DURATION] = duration return updated_data - async def async_update_data(self) -> None: + async def update(self) -> None: """Update data callback.""" # pylint: disable = R0915,R1702 if not self.is_connected(): @@ -537,6 +568,7 @@ async def connect(self) -> json: self._power_status = True await self._init_network() return response + await self.start_polling() except Exception as ex: _LOGGER.error("Error during connection %s", ex) finally: @@ -544,11 +576,50 @@ async def connect(self) -> json: async def disconnect(self) -> None: """Async Close connection.""" + await self.stop_polling() if self._session: await self._session.close() self._psk = None self._session = None + async def start_polling(self): + """Start polling task.""" + if self._update_task is not None: + return + await self._update_lock.acquire() + if self._update_task is not None: + return + _LOGGER.debug("Start polling task for device %s", self.id) + self._update_task = self._event_loop.create_task(self._background_update_task()) + self._update_lock.release() + + async def stop_polling(self): + """Stop polling task.""" + if self._update_task: + try: + self._update_task.cancel() + except CancelledError: + pass + self._update_task = None + + async def _background_update_task(self): + self._reconnect_retry = 0 + while True: + if not self._device_config.always_on: + if self.state == States.OFF: + self._reconnect_retry += 1 + if self._reconnect_retry > CONNECTION_RETRIES: + _LOGGER.debug("Stopping update task as the device %s is off", self.id) + break + _LOGGER.debug("Device %s is off, retry %s", self.id, self._reconnect_retry) + elif self._reconnect_retry > 0: + self._reconnect_retry = 0 + _LOGGER.debug("Device %s is on again", self.id) + await self.update() + await asyncio.sleep(10) + + self._update_task = None + def is_connected(self) -> bool: """Check connection status. @@ -585,6 +656,7 @@ def _wakeonlan(self) -> None: socket_instance.sendto(msg, ("", 9)) socket_instance.close() + @cmd_wrapper async def send_key(self, key: str, log_errors: bool = False) -> bool: """Async Send Remote Control button command to device. @@ -606,13 +678,13 @@ async def send_key(self, key: str, log_errors: bool = False) -> bool: return False async def _req_json( - self, - url: str, - # pylint: disable = W0102 - params: dict = {}, - log_errors: bool = True, - timeout: int = TIMEOUT, - max_retries: int = RETRIES, + self, + url: str, + # pylint: disable = W0102 + params: dict = {}, + log_errors: bool = True, + timeout: int = TIMEOUT, + max_retries: int = RETRIES, ) -> json: """Async Send request command via HTTP json to player. @@ -652,11 +724,11 @@ async def _req_json( self._cookies = None async def _send_cmd( - self, - url: str, - params: dict = None, - log_errors: bool = True, - timeout: int = TIMEOUT, + self, + url: str, + params: dict = None, + log_errors: bool = True, + timeout: int = TIMEOUT, ): """Async Send request command via HTTP json to player. @@ -793,9 +865,9 @@ async def _get_video_playing_info(self) -> json: return_value["audio"] = result.get("audioInfo") return_value["video"] = result.get("output") if ( - return_value["status"] is True - and return_value["uri"] - and return_value["uri"] != self._last_video_path + return_value["status"] is True + and return_value["uri"] + and return_value["uri"] != self._last_video_path ): self._last_video_path = return_value["uri"] self._video_id = await self._get_id_from_uri(self._last_video_path) @@ -1219,7 +1291,7 @@ async def _collection_video_id(self, movie_id: int) -> int: return movie_id async def get_music_list( - self, music_type: int = 0, music_id: int = None, max_count: int = DEFAULT_COUNT + self, music_type: int = 0, music_id: int = None, max_count: int = DEFAULT_COUNT ) -> json: """Async Return list of music. @@ -1259,7 +1331,7 @@ async def _get_song_list(self, max_count: int = DEFAULT_COUNT) -> json: return response async def _get_album_list( - self, album_id: int = None, max_count: int = DEFAULT_COUNT + self, album_id: int = None, max_count: int = DEFAULT_COUNT ) -> json: """Async Return list of albums or album music. @@ -1285,7 +1357,7 @@ async def _get_album_list( return response async def _get_artist_list( - self, artist_id: int = None, max_count: int = DEFAULT_COUNT + self, artist_id: int = None, max_count: int = DEFAULT_COUNT ) -> json: """Async Return list of artists or artist music. @@ -1345,7 +1417,7 @@ async def _get_playlist_list(self, playlist_id=None, max_count=DEFAULT_COUNT): return response async def search_movies( - self, search_type: int | str = 0, max_count: int = DEFAULT_COUNT + self, search_type: int | str = 0, max_count: int = DEFAULT_COUNT ) -> json: """Async Return list of video based on query. @@ -1371,11 +1443,11 @@ async def search_movies( return response async def search_music( - self, - query: str, - search_type: int | str = 0, - max_count: int = DEFAULT_COUNT, - play: bool = False, + self, + query: str, + search_type: int | str = 0, + max_count: int = DEFAULT_COUNT, + play: bool = False, ) -> json: """Async Return list of music based on query. @@ -1563,7 +1635,7 @@ def _get_music_ids(self, data, key="id", sub=None): return ids async def play_music( - self, media_id: int = None, media_type: int = "music", music_id: int = None + self, media_id: int = None, media_type: int = "music", music_id: int = None ) -> bool: """Async Play video content by id. @@ -1684,7 +1756,7 @@ async def get_host_list(self, uri: str, host_type: int = 1005) -> json: return return_value def generate_image_url( - self, media_id: int, media_type: int, width: int = 400, height: int = None + self, media_id: int, media_type: int, width: int = 400, height: int = None ) -> str: """Get link to thumbnail.""" if media_type in ZVIDEO_SEARCH_TYPES: @@ -1700,7 +1772,7 @@ def generate_image_url( return None def _generate_movie_image_url( - self, movie_id: int, width: int = 400, height: int = 600 + self, movie_id: int, width: int = 400, height: int = 600 ) -> str: """Get link to thumbnail. @@ -1721,7 +1793,7 @@ def _generate_movie_image_url( # pylint: disable = W0613 def _generate_music_image_url( - self, music_id: int, music_type: int = 0, width: int = 200, height: int = 200 + self, music_id: int, music_type: int = 0, width: int = 200, height: int = 200 ) -> str: """Get link to thumbnail. @@ -1778,30 +1850,36 @@ async def turn_on(self): # return await self._send_key(ZKEYS.ZKEY_POWER_ON, False) return True + @cmd_wrapper async def turn_off(self, standby=False): """Async Turn off media player.""" return await self.send_key( ZKEYS.ZKEY_POWER_STANDBY if standby else ZKEYS.ZKEY_POWER_OFF ) + @cmd_wrapper async def volume_up(self): """Async Volume up the media player.""" return await self.send_key(ZKEYS.ZKEY_VOLUME_UP) + @cmd_wrapper async def volume_down(self): """Async Volume down media player.""" return await self.send_key(ZKEYS.ZKEY_VOLUME_DOWN) + @cmd_wrapper async def mute_volume(self): """Async Send mute command.""" return self.send_key(ZKEYS.ZKEY_MUTE) + @cmd_wrapper async def media_play_pause(self): """Async Send play or Pause command.""" if self.state == States.PLAYING: return await self.media_pause() return await self.media_play() + @cmd_wrapper async def media_play(self) -> any: """Async Send play command.""" # self._send_key(ZKEYS.ZKEY_OK) @@ -1811,28 +1889,33 @@ async def media_play(self) -> any: return await self._req_json("MusicControl/v2/playOrPause") return await self.send_key(ZKEYS.ZKEY_MEDIA_PLAY) + @cmd_wrapper async def media_pause(self): """Async Send media pause command to media player.""" if self._current_source == ZCONTENT_MUSIC: return await self._req_json("MusicControl/v2/playOrPause") return await self.send_key(ZKEYS.ZKEY_MEDIA_PAUSE) + @cmd_wrapper async def media_stop(self): """Async Send media pause command to media player.""" return await self.send_key(ZKEYS.ZKEY_MEDIA_STOP) + @cmd_wrapper async def media_next_track(self): """Async Send next track command.""" if self._current_source == ZCONTENT_MUSIC: return await self._req_json("MusicControl/v2/playNext") return await self.send_key(ZKEYS.ZKEY_MEDIA_NEXT) + @cmd_wrapper async def media_previous_track(self): """Async Send the previous track command.""" if self._current_source == ZCONTENT_MUSIC: return await self._req_json("MusicControl/v2/playLast") await self.send_key(ZKEYS.ZKEY_MEDIA_PREVIOUS) + @cmd_wrapper async def set_media_position(self, position): """Async Set the current playing position. diff --git a/requirements.txt b/requirements.txt index 7dc729e..3beb029 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pyee>=9.0 +pyee>=11.1.0 ucapi==0.2.0 httpx~=0.27.0