From 33d23f2216d14dbc53674ea28fd0a6375e6d7b5b Mon Sep 17 00:00:00 2001 From: Albaintor Date: Thu, 8 May 2025 19:05:36 +0200 Subject: [PATCH 01/32] Added configurable volume step --- src/config.py | 4 ++++ src/setup_flow.py | 26 ++++++++++++++++++++++++++ src/tv.py | 4 ++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/config.py b/src/config.py index b37b66f..6a66c28 100644 --- a/src/config.py +++ b/src/config.py @@ -38,6 +38,8 @@ class AtvDevice: """Enable External Metadata.""" use_chromecast: bool = False """Enable Chromecast features.""" + volume_step: float = 10 + """Volume step (1 to 100).""" class _EnhancedJSONEncoder(json.JSONEncoder): @@ -133,6 +135,7 @@ def update(self, atv: AtvDevice) -> bool: item.auth_error = atv.auth_error item.use_external_metadata = atv.use_external_metadata item.use_chromecast = atv.use_chromecast + item.volume_step = atv.volume_step if atv.volume_step else 10 return self.store() return False @@ -236,6 +239,7 @@ def load(self) -> bool: item.get("auth_error", False), item.get("use_external_metadata", False), item.get("use_chromecast", False), + item.get("volume_step", 10) ) self._config.append(atv) return True diff --git a/src/setup_flow.py b/src/setup_flow.py index 4fff16f..1ed2e48 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -48,6 +48,7 @@ class SetupSteps(IntEnum): _use_external_metadata: bool = False _reconfigured_device: AtvDevice | None = None _use_chromecast: bool = False +_volume_step: float = 10 # TODO #9 externalize language texts _user_input_discovery = RequestUserInput( @@ -300,6 +301,7 @@ async def handle_configuration_mode( use_external_metadata = ( selected_device.use_external_metadata if selected_device.use_external_metadata else False ) + volume_step = selected_device.volume_step if selected_device.volume_step else 10 return RequestUserInput( { @@ -325,6 +327,16 @@ async def handle_configuration_mode( }, "field": {"checkbox": {"value": use_external_metadata}}, }, + { + "id": "volume_step", + "label": { + "en": "Volume step in percent", + "fr": "Pallier de volume en pourcent", + }, + "field": { + "number": {"value": volume_step, "min": 1, "max": 50, "steps": 1, "decimals": 0} + }, + }, ], ) case "reset": @@ -446,6 +458,16 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr }, "field": {"checkbox": {"value": False}}, }, + { + "id": "volume_step", + "label": { + "en": "Volume step in percent", + "fr": "Pallier de volume en pourcent", + }, + "field": { + "number": {"value": 10, "min": 1, "max": 50, "steps": 1, "decimals": 0} + }, + }, ], ) @@ -463,10 +485,12 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu global _use_chromecast global _setup_step global _use_external_metadata + global _volume_step choice = msg.input_values["choice"] _use_external_metadata = msg.input_values.get("external_metadata", "false") == "true" _use_chromecast = msg.input_values.get("chromecast", "false") == "true" + _volume_step = msg.input_values.get("volume_step", 10) name = "" for discovered_tv in _discovered_android_tvs: @@ -484,6 +508,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu id="", use_external_metadata=False, use_chromecast=False, + volume_step=_volume_step ), ) _LOG.info("Chosen Android TV: %s. Start pairing process...", choice) @@ -566,6 +591,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupEr use_chromecast=_use_chromecast, manufacturer=device_info.get("manufacturer", ""), model=device_info.get("model", ""), + volume_step=_volume_step ) config.devices.add_or_update(device) # triggers AndroidTv instance creation diff --git a/src/tv.py b/src/tv.py index fa3051d..a5a905e 100644 --- a/src/tv.py +++ b/src/tv.py @@ -964,7 +964,7 @@ async def volume_up(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_up() + self._chromecast.volume_up(delta=self._device_config.volume_step/100) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) @@ -975,7 +975,7 @@ async def volume_down(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_down() + self._chromecast.volume_down(delta=self._device_config.volume_step/100) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) From edf094d644a8e7e8396481d4bf1f3d7e5b01843c Mon Sep 17 00:00:00 2001 From: Albaintor Date: Thu, 8 May 2025 19:07:45 +0200 Subject: [PATCH 02/32] Linting --- src/config.py | 2 +- src/setup_flow.py | 12 ++++-------- src/tv.py | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/config.py b/src/config.py index 6a66c28..d650754 100644 --- a/src/config.py +++ b/src/config.py @@ -239,7 +239,7 @@ def load(self) -> bool: item.get("auth_error", False), item.get("use_external_metadata", False), item.get("use_chromecast", False), - item.get("volume_step", 10) + item.get("volume_step", 10), ) self._config.append(atv) return True diff --git a/src/setup_flow.py b/src/setup_flow.py index 1ed2e48..e718603 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -333,9 +333,7 @@ async def handle_configuration_mode( "en": "Volume step in percent", "fr": "Pallier de volume en pourcent", }, - "field": { - "number": {"value": volume_step, "min": 1, "max": 50, "steps": 1, "decimals": 0} - }, + "field": {"number": {"value": volume_step, "min": 1, "max": 50, "steps": 1, "decimals": 0}}, }, ], ) @@ -464,9 +462,7 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr "en": "Volume step in percent", "fr": "Pallier de volume en pourcent", }, - "field": { - "number": {"value": 10, "min": 1, "max": 50, "steps": 1, "decimals": 0} - }, + "field": {"number": {"value": 10, "min": 1, "max": 50, "steps": 1, "decimals": 0}}, }, ], ) @@ -508,7 +504,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu id="", use_external_metadata=False, use_chromecast=False, - volume_step=_volume_step + volume_step=_volume_step, ), ) _LOG.info("Chosen Android TV: %s. Start pairing process...", choice) @@ -591,7 +587,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupEr use_chromecast=_use_chromecast, manufacturer=device_info.get("manufacturer", ""), model=device_info.get("model", ""), - volume_step=_volume_step + volume_step=_volume_step, ) config.devices.add_or_update(device) # triggers AndroidTv instance creation diff --git a/src/tv.py b/src/tv.py index a5a905e..23fc448 100644 --- a/src/tv.py +++ b/src/tv.py @@ -964,7 +964,7 @@ async def volume_up(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_up(delta=self._device_config.volume_step/100) + self._chromecast.volume_up(delta=self._device_config.volume_step / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) @@ -975,7 +975,7 @@ async def volume_down(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_down(delta=self._device_config.volume_step/100) + self._chromecast.volume_down(delta=self._device_config.volume_step / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) From d489b32ae7278420f6e1b0ae0e6bdbc49411530b Mon Sep 17 00:00:00 2001 From: Albaintor Date: Thu, 8 May 2025 19:18:25 +0200 Subject: [PATCH 03/32] Updated version --- driver.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/driver.json b/driver.json index 299ca47..5eca623 100644 --- a/driver.json +++ b/driver.json @@ -1,6 +1,6 @@ { "driver_id": "androidtv", - "version": "0.7.2", + "version": "0.7.3", "min_core_api": "0.20.0", "name": { "en": "Android TV" }, "icon": "uc:integration", @@ -41,5 +41,5 @@ } ] }, - "release_date": "2025-04-27" + "release_date": "2025-05-08" } From 5ff83c6d06ea11f88d85941e1e7f12e04ceedcee Mon Sep 17 00:00:00 2001 From: Albaintor Date: Fri, 9 May 2025 19:15:56 +0200 Subject: [PATCH 04/32] Removed duplicate volume/mute features --- .idea/runConfigurations/check_black.xml | 35 ------------------------- src/profiles.py | 3 --- 2 files changed, 38 deletions(-) delete mode 100644 .idea/runConfigurations/check_black.xml diff --git a/.idea/runConfigurations/check_black.xml b/.idea/runConfigurations/check_black.xml deleted file mode 100644 index 5925ed7..0000000 --- a/.idea/runConfigurations/check_black.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/profiles.py b/src/profiles.py index ac5eace..f4b261a 100644 --- a/src/profiles.py +++ b/src/profiles.py @@ -189,9 +189,6 @@ def __init__(self): media_player.Features.SUBTITLE, media_player.Features.RECORD, media_player.Features.STOP, - media_player.Features.VOLUME, - media_player.Features.VOLUME_UP_DOWN, - media_player.Features.MUTE_TOGGLE, ], [], {}, From f2fa96a38951013a2c01c79a17a8dfc69a5af3b9 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Fri, 9 May 2025 19:20:02 +0200 Subject: [PATCH 05/32] Fixed wrong volume step management --- src/setup_flow.py | 2 +- src/tv.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index e718603..add7d37 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -486,7 +486,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu choice = msg.input_values["choice"] _use_external_metadata = msg.input_values.get("external_metadata", "false") == "true" _use_chromecast = msg.input_values.get("chromecast", "false") == "true" - _volume_step = msg.input_values.get("volume_step", 10) + _volume_step = float(msg.input_values.get("volume_step", 10)) name = "" for discovered_tv in _discovered_android_tvs: diff --git a/src/tv.py b/src/tv.py index 23fc448..d64a8ca 100644 --- a/src/tv.py +++ b/src/tv.py @@ -964,7 +964,7 @@ async def volume_up(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_up(delta=self._device_config.volume_step / 100) + self._chromecast.volume_up(delta=float(self._device_config.volume_step/100)) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) @@ -975,7 +975,7 @@ async def volume_down(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_down(delta=self._device_config.volume_step / 100) + self._chromecast.volume_down(delta=float(self._device_config.volume_step/100)) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) From 7f5b0441465579162073ee4cdf7530e90eb2f84f Mon Sep 17 00:00:00 2001 From: Albaintor Date: Fri, 9 May 2025 19:27:01 +0200 Subject: [PATCH 06/32] Fixed wrong volume step management --- src/tv.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tv.py b/src/tv.py index d64a8ca..c68b5f1 100644 --- a/src/tv.py +++ b/src/tv.py @@ -964,7 +964,7 @@ async def volume_up(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_up(delta=float(self._device_config.volume_step/100)) + self._chromecast.volume_up(delta=float(self._device_config.volume_step / 100)) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) @@ -975,7 +975,7 @@ async def volume_down(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: - self._chromecast.volume_down(delta=float(self._device_config.volume_step/100)) + self._chromecast.volume_down(delta=float(self._device_config.volume_step / 100)) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) From af816ca2e913f5b429b4f62c7ff3eb892254aaeb Mon Sep 17 00:00:00 2001 From: Albaintor Date: Fri, 9 May 2025 20:09:05 +0200 Subject: [PATCH 07/32] Fixed volume control replaced by chromecast volume commands even if not activated --- src/driver.py | 21 +++++++++++---------- src/tv.py | 5 +++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/driver.py b/src/driver.py index 50ee0e4..101d791 100644 --- a/src/driver.py +++ b/src/driver.py @@ -154,16 +154,17 @@ async def media_player_cmd_handler( if params is None or "source" not in params: return ucapi.StatusCodes.BAD_REQUEST return await android_tv.select_source(params["source"]) - if cmd_id == media_player.Commands.VOLUME_UP: - return await android_tv.volume_up() - if cmd_id == media_player.Commands.VOLUME_DOWN: - return await android_tv.volume_down() - if cmd_id == media_player.Commands.MUTE_TOGGLE: - return await android_tv.volume_mute_toggle() - if cmd_id == media_player.Commands.VOLUME: - return await android_tv.volume_set(params.get("volume")) - if cmd_id == media_player.Commands.SEEK: - return await android_tv.media_seek(params.get("media_position", 0)) + if android_tv.device_config.use_chromecast: + if cmd_id == media_player.Commands.VOLUME_UP: + return await android_tv.volume_up() + if cmd_id == media_player.Commands.VOLUME_DOWN: + return await android_tv.volume_down() + if cmd_id == media_player.Commands.MUTE_TOGGLE: + return await android_tv.volume_mute_toggle() + if cmd_id == media_player.Commands.VOLUME: + return await android_tv.volume_set(params.get("volume")) + if cmd_id == media_player.Commands.SEEK: + return await android_tv.media_seek(params.get("media_position", 0)) return await android_tv.send_media_player_command(cmd_id) diff --git a/src/tv.py b/src/tv.py index c68b5f1..3fc390a 100644 --- a/src/tv.py +++ b/src/tv.py @@ -346,6 +346,11 @@ def is_on(self) -> bool | None: """Whether the Android TV is on or off. Returns None if not connected.""" return self._atv.is_on + @property + def device_config(self) -> AtvDevice: + """Return current device configuration.""" + return self._device_config + @property def media_title(self) -> str | None: """Return media title.""" From a454aa0395231a3b22b951c771317aa40f69042d Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 08:50:08 +0200 Subject: [PATCH 08/32] Added 3 simple commands for volume control because Chromecast when enabled will replace volume up/down and mute commands --- config/profiles/default.json | 14 +++++++++++++- src/driver.py | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/config/profiles/default.json b/config/profiles/default.json index 7db6d9e..75af037 100644 --- a/config/profiles/default.json +++ b/config/profiles/default.json @@ -91,7 +91,10 @@ "KEYCODE_F9", "KEYCODE_F10", "KEYCODE_F11", - "KEYCODE_F12" + "KEYCODE_F12", + "KEY_VOLUME_UP", + "KEY_VOLUME_DOWN", + "KEY_VOLUME_MUTE" ], "command_map": { "CURSOR_ENTER_LONG": { @@ -105,6 +108,15 @@ "MENU_LONG": { "keycode": "MENU", "action": "LONG" + }, + "KEY_VOLUME_UP": { + "keycode": "VOLUME_UP" + }, + "KEY_VOLUME_DOWN": { + "keycode": "VOLUME_DOWN" + }, + "KEY_VOLUME_MUTE": { + "keycode": "VOLUME_MUTE" } } } diff --git a/src/driver.py b/src/driver.py index 101d791..89a78ca 100644 --- a/src/driver.py +++ b/src/driver.py @@ -155,6 +155,7 @@ async def media_player_cmd_handler( return ucapi.StatusCodes.BAD_REQUEST return await android_tv.select_source(params["source"]) if android_tv.device_config.use_chromecast: + # If chromecast disabled, default mapping will be used if cmd_id == media_player.Commands.VOLUME_UP: return await android_tv.volume_up() if cmd_id == media_player.Commands.VOLUME_DOWN: From b54674f0ad6f8d63ac78c2c07f528a0d1ee0b0c5 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 10:24:29 +0200 Subject: [PATCH 09/32] Fixed bug in volume set --- src/tv.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tv.py b/src/tv.py index 3fc390a..788274d 100644 --- a/src/tv.py +++ b/src/tv.py @@ -998,14 +998,14 @@ async def volume_mute_toggle(self) -> ucapi.StatusCodes: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) return ucapi.StatusCodes.BAD_REQUEST - async def volume_set(self, volume: float | None) -> ucapi.StatusCodes: + async def volume_set(self, volume: int | None) -> ucapi.StatusCodes: """Set volume.""" if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED if volume is None: return ucapi.StatusCodes.BAD_REQUEST try: - await self._chromecast.set_volume(volume / 100) + self._chromecast.set_volume(float(volume) / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) From 1e333bd199a5f1d00fc7db7fb2190300b625e4b3 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 10:41:04 +0200 Subject: [PATCH 10/32] Added new simple commands to other profiles where volume up/down is supported (all profiles actually) --- config/profiles/DUNE HD HOMATICS.json | 14 +++++++++++++- config/profiles/Google_Chromecast.json | 14 +++++++++++++- config/profiles/NVIDIA_SHIELD.json | 14 +++++++++++++- config/profiles/TPV.json | 14 +++++++++++++- config/profiles/onn_Streaming_Device.json | 12 ++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/config/profiles/DUNE HD HOMATICS.json b/config/profiles/DUNE HD HOMATICS.json index cd9e1eb..4df0a17 100644 --- a/config/profiles/DUNE HD HOMATICS.json +++ b/config/profiles/DUNE HD HOMATICS.json @@ -46,7 +46,10 @@ "PRIMEVIDEO", "PLAYSTORE", "FACTORYTEST", - "DISNEY" + "DISNEY", + "KEY_VOLUME_UP", + "KEY_VOLUME_DOWN", + "KEY_VOLUME_MUTE" ], "command_map": { "CURSOR_ENTER_LONG": { @@ -84,6 +87,15 @@ "DISNEY": { "keycode": 142, "action": "SHORT" + }, + "KEY_VOLUME_UP": { + "keycode": "VOLUME_UP" + }, + "KEY_VOLUME_DOWN": { + "keycode": "VOLUME_DOWN" + }, + "KEY_VOLUME_MUTE": { + "keycode": "VOLUME_MUTE" } } } diff --git a/config/profiles/Google_Chromecast.json b/config/profiles/Google_Chromecast.json index 0848010..33522cc 100644 --- a/config/profiles/Google_Chromecast.json +++ b/config/profiles/Google_Chromecast.json @@ -25,7 +25,10 @@ "search" ], "simple_commands": [ - "SCREENSAVER" + "SCREENSAVER", + "KEY_VOLUME_UP", + "KEY_VOLUME_DOWN", + "KEY_VOLUME_MUTE" ], "command_map": { "context_menu": { @@ -39,6 +42,15 @@ "SCREENSAVER": { "keycode": "BACK", "action": "DOUBLE_CLICK" + }, + "KEY_VOLUME_UP": { + "keycode": "VOLUME_UP" + }, + "KEY_VOLUME_DOWN": { + "keycode": "VOLUME_DOWN" + }, + "KEY_VOLUME_MUTE": { + "keycode": "VOLUME_MUTE" } } } diff --git a/config/profiles/NVIDIA_SHIELD.json b/config/profiles/NVIDIA_SHIELD.json index a97fde5..cc73b46 100644 --- a/config/profiles/NVIDIA_SHIELD.json +++ b/config/profiles/NVIDIA_SHIELD.json @@ -25,7 +25,10 @@ ], "simple_commands": [ "APPS", - "APP_SWITCHER" + "APP_SWITCHER", + "KEY_VOLUME_UP", + "KEY_VOLUME_DOWN", + "KEY_VOLUME_MUTE" ], "command_map": { "context_menu": { @@ -43,6 +46,15 @@ "APP_SWITCHER": { "keycode": "HOME", "action": "DOUBLE_CLICK" + }, + "KEY_VOLUME_UP": { + "keycode": "VOLUME_UP" + }, + "KEY_VOLUME_DOWN": { + "keycode": "VOLUME_DOWN" + }, + "KEY_VOLUME_MUTE": { + "keycode": "VOLUME_MUTE" } } } diff --git a/config/profiles/TPV.json b/config/profiles/TPV.json index f356fe2..8d06e3c 100644 --- a/config/profiles/TPV.json +++ b/config/profiles/TPV.json @@ -42,7 +42,10 @@ "HOME_LONG", "MENU_LONG", "ENTER", - "TELETEXT" + "TELETEXT", + "KEY_VOLUME_UP", + "KEY_VOLUME_DOWN", + "KEY_VOLUME_MUTE" ], "command_map": { "CURSOR_ENTER_LONG": { @@ -64,6 +67,15 @@ "TELETEXT": { "keycode": 233, "action": "SHORT" + }, + "KEY_VOLUME_UP": { + "keycode": "VOLUME_UP" + }, + "KEY_VOLUME_DOWN": { + "keycode": "VOLUME_DOWN" + }, + "KEY_VOLUME_MUTE": { + "keycode": "VOLUME_MUTE" } } } diff --git a/config/profiles/onn_Streaming_Device.json b/config/profiles/onn_Streaming_Device.json index 76bb17c..15d5723 100644 --- a/config/profiles/onn_Streaming_Device.json +++ b/config/profiles/onn_Streaming_Device.json @@ -25,6 +25,9 @@ "search" ], "simple_commands": [ + "KEY_VOLUME_UP", + "KEY_VOLUME_DOWN", + "KEY_VOLUME_MUTE" ], "command_map": { "context_menu": { @@ -34,6 +37,15 @@ "settings": { "keycode": "HOME", "action": "LONG" + }, + "KEY_VOLUME_UP": { + "keycode": "VOLUME_UP" + }, + "KEY_VOLUME_DOWN": { + "keycode": "VOLUME_DOWN" + }, + "KEY_VOLUME_MUTE": { + "keycode": "VOLUME_MUTE" } } } From f8dc641f8137bfbdcd3c65087c8dcfb6f8a8ea06 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 16:38:38 +0200 Subject: [PATCH 11/32] Fixed volume step not updated in reconfiguration and added traces --- src/setup_flow.py | 9 ++++++++- src/tv.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index add7d37..d73d468 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -618,14 +618,21 @@ async def _handle_device_reconfigure( use_chromecast = msg.input_values.get("chromecast", "false") == "true" use_external_metadata = msg.input_values.get("external_metadata", "false") == "true" + volume_step = float(msg.input_values.get("volume_step", 10)) _LOG.debug("User has changed configuration") _reconfigured_device.use_chromecast = use_chromecast _reconfigured_device.use_external_metadata = use_external_metadata + _reconfigured_device.volume_step = volume_step config.devices.add_or_update(_reconfigured_device) # triggers ATV instance update await asyncio.sleep(1) - _LOG.info("Setup successfully completed for %s", _reconfigured_device.name) + _LOG.info( + "Setup successfully completed for %s (chromecast %s, external metadata %s, volume step %s)", + _reconfigured_device.name, + _reconfigured_device.use_chromecast, + _reconfigured_device.volume_step, + ) return SetupComplete() diff --git a/src/tv.py b/src/tv.py index 788274d..874b6e5 100644 --- a/src/tv.py +++ b/src/tv.py @@ -969,6 +969,12 @@ async def volume_up(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: + _LOG.debug( + "[%s] Volume up : current %s + step %s", + self.log_id, + self._chromecast.status.volume_level, + self._device_config.volume_step / 100, + ) self._chromecast.volume_up(delta=float(self._device_config.volume_step / 100)) return ucapi.StatusCodes.OK except PyChromecastError as ex: @@ -980,6 +986,12 @@ async def volume_down(self) -> ucapi.StatusCodes: if self._chromecast is None: return ucapi.StatusCodes.NOT_IMPLEMENTED try: + _LOG.debug( + "[%s] Volume down : current %s - step %s", + self.log_id, + self._chromecast.status.volume_level, + self._device_config.volume_step / 100, + ) self._chromecast.volume_down(delta=float(self._device_config.volume_step / 100)) return ucapi.StatusCodes.OK except PyChromecastError as ex: @@ -992,6 +1004,7 @@ async def volume_mute_toggle(self) -> ucapi.StatusCodes: return ucapi.StatusCodes.NOT_IMPLEMENTED try: self._muted = not self._muted + _LOG.debug("[%s] Mute toggle : %s", self.log_id, self._muted) self._chromecast.set_volume_muted(self._muted) return ucapi.StatusCodes.OK except PyChromecastError as ex: @@ -1005,6 +1018,7 @@ async def volume_set(self, volume: int | None) -> ucapi.StatusCodes: if volume is None: return ucapi.StatusCodes.BAD_REQUEST try: + _LOG.debug("[%s] Set volume : %s", self.log_id, volume) self._chromecast.set_volume(float(volume) / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: From bb06ebce1e08ba682f9809b17d4ba2a832d8af18 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 16:45:18 +0200 Subject: [PATCH 12/32] Fixed volume step not updated in reconfiguration and added traces --- src/setup_flow.py | 1 + src/tv.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index d73d468..64b6d1e 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -631,6 +631,7 @@ async def _handle_device_reconfigure( "Setup successfully completed for %s (chromecast %s, external metadata %s, volume step %s)", _reconfigured_device.name, _reconfigured_device.use_chromecast, + _reconfigured_device.use_external_metadata, _reconfigured_device.volume_step, ) diff --git a/src/tv.py b/src/tv.py index 874b6e5..2c46425 100644 --- a/src/tv.py +++ b/src/tv.py @@ -970,7 +970,7 @@ async def volume_up(self) -> ucapi.StatusCodes: return ucapi.StatusCodes.NOT_IMPLEMENTED try: _LOG.debug( - "[%s] Volume up : current %s + step %s", + "[%s] Volume up : current %.2f + step %s", self.log_id, self._chromecast.status.volume_level, self._device_config.volume_step / 100, @@ -987,7 +987,7 @@ async def volume_down(self) -> ucapi.StatusCodes: return ucapi.StatusCodes.NOT_IMPLEMENTED try: _LOG.debug( - "[%s] Volume down : current %s - step %s", + "[%s] Volume down : current %.2f - step %s", self.log_id, self._chromecast.status.volume_level, self._device_config.volume_step / 100, @@ -1018,7 +1018,7 @@ async def volume_set(self, volume: int | None) -> ucapi.StatusCodes: if volume is None: return ucapi.StatusCodes.BAD_REQUEST try: - _LOG.debug("[%s] Set volume : %s", self.log_id, volume) + _LOG.debug("[%s] Set volume : %%.2f", self.log_id, float(volume) / 100) self._chromecast.set_volume(float(volume) / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: From 800e7be06ad153d776ef9cb686afe50661a669d4 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 16:47:37 +0200 Subject: [PATCH 13/32] Fix --- src/tv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tv.py b/src/tv.py index 2c46425..eb88cc7 100644 --- a/src/tv.py +++ b/src/tv.py @@ -1018,7 +1018,7 @@ async def volume_set(self, volume: int | None) -> ucapi.StatusCodes: if volume is None: return ucapi.StatusCodes.BAD_REQUEST try: - _LOG.debug("[%s] Set volume : %%.2f", self.log_id, float(volume) / 100) + _LOG.debug("[%s] Set volume : %.2f", self.log_id, float(volume)/100) self._chromecast.set_volume(float(volume) / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: From 932793b906a9100d56ef25edf65aaac17a058682 Mon Sep 17 00:00:00 2001 From: Albaintor Date: Sat, 10 May 2025 16:49:16 +0200 Subject: [PATCH 14/32] Linting --- src/tv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tv.py b/src/tv.py index eb88cc7..1cff0c0 100644 --- a/src/tv.py +++ b/src/tv.py @@ -1018,7 +1018,7 @@ async def volume_set(self, volume: int | None) -> ucapi.StatusCodes: if volume is None: return ucapi.StatusCodes.BAD_REQUEST try: - _LOG.debug("[%s] Set volume : %.2f", self.log_id, float(volume)/100) + _LOG.debug("[%s] Set volume : %.2f", self.log_id, float(volume) / 100) self._chromecast.set_volume(float(volume) / 100) return ucapi.StatusCodes.OK except PyChromecastError as ex: From 857f0eba7ccc5d37a28c81967832776cadac2ea2 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:05:34 +0200 Subject: [PATCH 15/32] Potential fix for failed reconnection --- src/tv.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tv.py b/src/tv.py index ab28acf..d1ffa7b 100644 --- a/src/tv.py +++ b/src/tv.py @@ -12,7 +12,7 @@ import os import socket import time -from asyncio import AbstractEventLoop, timeout +from asyncio import AbstractEventLoop, timeout, Lock from enum import IntEnum from functools import wraps from typing import Any, Awaitable, Callable, Concatenate, Coroutine, ParamSpec, TypeVar @@ -232,6 +232,7 @@ def __init__( self._use_app_url = not device_config.use_chromecast self._player_state = media_player.States.ON self._muted = False + self._connect_lock = Lock() def __del__(self): """Destructs instance, disconnect AndroidTVRemote.""" @@ -422,14 +423,17 @@ async def connect(self, max_timeout: int | None = None) -> bool: :return: True if connected or connecting, False if timeout or authentication error occurred. """ # if we are already connecting, simply ignore further connect calls - if self._state == DeviceState.CONNECTING: + if self._connect_lock.locked(): _LOG.debug("[%s] Connection task already running", self.log_id) return True + await self._connect_lock.acquire() + if isinstance(self._atv.is_on, bool) and self._atv.is_on: _LOG.debug("[%s] Android TV is already connected", self.log_id) # just to make sure the state is up-to-date self.events.emit(Events.CONNECTED, self._identifier) + self._connect_lock.release() return True self._state = DeviceState.CONNECTING @@ -485,6 +489,7 @@ async def connect(self, max_timeout: int | None = None) -> bool: ) break + self._connect_lock.release() if not success: if self._state == DeviceState.CONNECTING: self._state = DeviceState.ERROR From cf6a9838047580d24ccf32cbf8d33d571c60b623 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:16:12 +0200 Subject: [PATCH 16/32] Added remote entity in addition of media player entity --- driver.json | 4 +- src/config.py | 19 +++ src/driver.py | 368 +++++++++++++++++++++++++------------------- src/media_player.py | 83 ++++++++++ src/remote.py | 152 ++++++++++++++++++ src/tv.py | 32 ++++ 6 files changed, 494 insertions(+), 164 deletions(-) create mode 100644 src/media_player.py create mode 100644 src/remote.py diff --git a/driver.json b/driver.json index 4dc2137..08ca268 100644 --- a/driver.json +++ b/driver.json @@ -1,6 +1,6 @@ { "driver_id": "androidtv", - "version": "0.7.4", + "version": "0.8.0", "min_core_api": "0.20.0", "name": { "en": "Android TV" }, "icon": "uc:integration", @@ -41,5 +41,5 @@ } ] }, - "release_date": "2025-05-15" + "release_date": "2025-09-06" } diff --git a/src/config.py b/src/config.py index 72ca8d4..5cd3706 100644 --- a/src/config.py +++ b/src/config.py @@ -13,11 +13,30 @@ from dataclasses import dataclass from typing import Iterator +from ucapi import EntityTypes + _LOG = logging.getLogger(__name__) _CFG_FILENAME = "config.json" +def create_entity_id(avr_id: str, entity_type: EntityTypes) -> str: + """Create a unique entity identifier for the given receiver and entity type.""" + return f"{entity_type.value}.{avr_id}" + + +def device_from_entity_id(entity_id: str) -> str | None: + """ + Return the avr_id prefix of an entity_id. + + The prefix is the part before the first dot in the name and refers to the AVR device identifier. + + :param entity_id: the entity identifier + :return: the device prefix, or None if entity_id doesn't contain a dot + """ + return entity_id.split(".", 1)[1] + + @dataclass class AtvDevice: """Android TV device configuration.""" diff --git a/src/driver.py b/src/driver.py index 50ee0e4..ca4a723 100644 --- a/src/driver.py +++ b/src/driver.py @@ -14,10 +14,11 @@ from typing import Any import ucapi -from ucapi import MediaPlayer, media_player from ucapi.media_player import Attributes as MediaAttr import config +import media_player +import remote import setup_flow import tv from profiles import DeviceProfile, Profile @@ -65,6 +66,30 @@ async def on_standby(): configured.disconnect() +async def connect_device(device: tv.AndroidTv): + """Connect device and send state""" + try: + _LOG.debug("Connecting device %s...", device.device_config.id) + await device.connect() + _LOG.debug("Device %s connected, sending attributes for subscribed entities", device.device_config.id) + state = device.state + for entity in api.configured_entities.get_all(): + entity_id = entity.get("entity_id", "") + device_id = config.device_from_entity_id(entity_id) + if device_id != device.device_config.id: + continue + # Return all attributes according to entity type + if isinstance(entity, media_player.AndroidTVMediaPlayer): + _LOG.debug("Sending attributes %s : %s", entity_id, device.attributes) + api.configured_entities.update_attributes(entity_id, device.attributes) + if isinstance(entity, remote.AndroidTVRemote): + api.configured_entities.update_attributes( + entity_id, {ucapi.remote.Attributes.STATE: remote.REMOTE_STATE_MAPPING.get(state)} + ) + except RuntimeError as ex: + _LOG.error("Error while reconnecting to Kodi %s", ex) + + @api.listens_to(ucapi.Events.EXIT_STANDBY) async def on_exit_standby(): """ @@ -87,19 +112,24 @@ async def on_subscribe_entities(entity_ids) -> None: """ _LOG.debug("Subscribe entities event: %s", entity_ids) for entity_id in entity_ids: - # Simple mapping at the moment: one entity per device (with the same id) - atv_id = entity_id - if atv_id in _configured_android_tvs: - atv = _configured_android_tvs[atv_id] + entity = api.configured_entities.get(entity_id) + device_id = config.device_from_entity_id(entity_id) + if device_id in _configured_android_tvs: + atv = _configured_android_tvs[device_id] if atv.is_on is None: - state = media_player.States.UNAVAILABLE + state = ucapi.media_player.States.UNAVAILABLE else: - state = media_player.States.ON if atv.is_on else media_player.States.OFF - api.configured_entities.update_attributes(entity_id, {media_player.Attributes.STATE: state}) + state = ucapi.media_player.States.ON if atv.is_on else ucapi.media_player.States.OFF + if isinstance(entity, media_player.AndroidTVMediaPlayer): + api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: state}) + if isinstance(entity, remote.AndroidTVRemote): + api.configured_entities.update_attributes( + entity_id, {ucapi.remote.Attributes.STATE: remote.REMOTE_STATE_MAPPING.get(state)} + ) _LOOP.create_task(atv.connect()) continue - device = config.devices.get(atv_id) + device = config.devices.get(device_id) if device: _add_configured_android_tv(device) else: @@ -110,88 +140,94 @@ async def on_subscribe_entities(entity_ids) -> None: async def on_unsubscribe_entities(entity_ids) -> None: """On unsubscribe, we disconnect the devices and remove listeners for events.""" _LOG.debug("Unsubscribe entities event: %s", entity_ids) - # Simple mapping at the moment: one entity per device (with the same id) + devices_to_remove = set() for entity_id in entity_ids: - _configured_android_tvs[entity_id].disconnect() - _configured_android_tvs[entity_id].events.remove_all_listeners() - - -# pylint: disable=too-many-return-statements -async def media_player_cmd_handler( - entity: MediaPlayer, cmd_id: str, params: dict[str, Any] | None -) -> ucapi.StatusCodes: - """ - Media-player entity command handler. + device_id = config.device_from_entity_id(entity_id) + if device_id is None: + continue + devices_to_remove.add(device_id) - Called by the integration-API if a command is sent to a configured media-player entity. + # Keep devices that are used by other configured entities not in this list + for entity in api.configured_entities.get_all(): + entity_id = entity.get("entity_id", "") + if entity_id in entity_ids: + continue + device_id = config.device_from_entity_id(entity_id) + if device_id is None: + continue + if device_id in devices_to_remove: + devices_to_remove.remove(device_id) - :param entity: media-player entity - :param cmd_id: command - :param params: optional command parameters - :return: - """ - # Simple mapping at the moment: one entity per device (with the same id) - atv_id = entity.id - - if atv_id not in _configured_android_tvs: - _LOG.warning( - "Cannot execute command %s %s: no Android TV device found for entity %s", - cmd_id, - params if params else "", - entity.id, - ) - return ucapi.StatusCodes.NOT_FOUND - - android_tv = _configured_android_tvs[atv_id] - - _LOG.info("[%s] command: %s %s", android_tv.log_id, cmd_id, params if params else "") - - if cmd_id == media_player.Commands.ON: - return await android_tv.turn_on() - if cmd_id == media_player.Commands.OFF: - return await android_tv.turn_off() - if cmd_id == media_player.Commands.SELECT_SOURCE: - if params is None or "source" not in params: - return ucapi.StatusCodes.BAD_REQUEST - return await android_tv.select_source(params["source"]) - if cmd_id == media_player.Commands.VOLUME_UP: - return await android_tv.volume_up() - if cmd_id == media_player.Commands.VOLUME_DOWN: - return await android_tv.volume_down() - if cmd_id == media_player.Commands.MUTE_TOGGLE: - return await android_tv.volume_mute_toggle() - if cmd_id == media_player.Commands.VOLUME: - return await android_tv.volume_set(params.get("volume")) - if cmd_id == media_player.Commands.SEEK: - return await android_tv.media_seek(params.get("media_position", 0)) - - return await android_tv.send_media_player_command(cmd_id) + for device_id in devices_to_remove: + if device_id in _configured_android_tvs: + _configured_android_tvs[device_id].disconnect() + _configured_android_tvs[device_id].events.remove_all_listeners() async def handle_connected(identifier: str): """Handle Android TV connection.""" device = config.devices.get(identifier) + if identifier not in _configured_android_tvs: + _LOG.warning("Device %s is not configured", identifier) + return + _LOG.debug("[%s] device connected", device.name if device else identifier) - if device and device.auth_error: - device.auth_error = False - config.devices.update(device) + for entity_id in _entities_from_device_id(identifier): + configured_entity = api.configured_entities.get(entity_id) + if configured_entity is None: + continue - # TODO is this the correct state? - api.configured_entities.update_attributes(identifier, {media_player.Attributes.STATE: media_player.States.STANDBY}) - await api.set_device_state(ucapi.DeviceStates.CONNECTED) # just to make sure the device state is set + if configured_entity.entity_type == ucapi.EntityTypes.MEDIA_PLAYER: + if ( + configured_entity.attributes[ucapi.media_player.Attributes.STATE] + == ucapi.media_player.States.UNAVAILABLE + ): + # TODO why STANDBY? + api.configured_entities.update_attributes( + entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.STANDBY} + ) + else: + api.configured_entities.update_attributes( + entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON} + ) + elif configured_entity.entity_type == ucapi.EntityTypes.REMOTE: + if configured_entity.attributes[ucapi.remote.Attributes.STATE] == ucapi.remote.States.UNAVAILABLE: + api.configured_entities.update_attributes( + entity_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.OFF} + ) + + if device and device.auth_error: + device.auth_error = False + config.devices.update(device) + + # TODO is this the correct state? + api.configured_entities.update_attributes(identifier, {MediaAttr.STATE: ucapi.media_player.States.STANDBY}) + + await api.set_device_state(ucapi.DeviceStates.CONNECTED) # just to make sure the device state is set async def handle_disconnected(identifier: str): """Handle Android TV disconnection.""" + + for entity_id in _entities_from_device_id(identifier): + configured_entity = api.configured_entities.get(entity_id) + if configured_entity is None: + continue + + if configured_entity.entity_type == ucapi.EntityTypes.MEDIA_PLAYER: + api.configured_entities.update_attributes( + entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.UNAVAILABLE} + ) + elif configured_entity.entity_type == ucapi.EntityTypes.REMOTE: + api.configured_entities.update_attributes( + entity_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.UNAVAILABLE} + ) + if _LOG.isEnabledFor(logging.DEBUG): device = config.devices.get(identifier) _LOG.debug("[%s] device disconnected", device.name if device else identifier) - api.configured_entities.update_attributes( - identifier, {media_player.Attributes.STATE: media_player.States.UNAVAILABLE} - ) - async def handle_authentication_error(identifier: str): """Set entities of Android TV to state UNAVAILABLE if authentication error occurred.""" @@ -200,9 +236,19 @@ async def handle_authentication_error(identifier: str): device.auth_error = True config.devices.update(device) - api.configured_entities.update_attributes( - identifier, {media_player.Attributes.STATE: media_player.States.UNAVAILABLE} - ) + for entity_id in _entities_from_device_id(identifier): + configured_entity = api.configured_entities.get(entity_id) + if configured_entity is None: + continue + + if configured_entity.entity_type == ucapi.EntityTypes.MEDIA_PLAYER: + api.configured_entities.update_attributes( + entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.UNAVAILABLE} + ) + elif configured_entity.entity_type == ucapi.EntityTypes.REMOTE: + api.configured_entities.update_attributes( + entity_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.UNAVAILABLE} + ) async def handle_android_tv_address_change(atv_id: str, address: str) -> None: @@ -222,12 +268,8 @@ async def handle_android_tv_update(atv_id: str, update: dict[str, Any]) -> None: :param atv_id: AndroidTV identifier :param update: dictionary containing the updated properties """ - attributes = {} - # Simple mapping at the moment: one entity per device (with the same id) - entity_id = atv_id - - configured_entity = api.configured_entities.get(entity_id) - if configured_entity is None: + configured_entities = _entities_from_device_id(atv_id) + if len(configured_entities) == 0: _LOG.debug("[%s] ignoring non-configured device update: %s", atv_id, update) return @@ -235,68 +277,93 @@ async def handle_android_tv_update(atv_id: str, update: dict[str, Any]) -> None: device = config.devices.get(atv_id) _LOG.debug("[%s] device update: %s", device.name if device else atv_id, filter_data_img_properties(update)) - old_state = ( - configured_entity.attributes[MediaAttr.STATE] - if MediaAttr.STATE in configured_entity.attributes - else media_player.States.UNKNOWN - ) + for entity_id in configured_entities: + _LOG.info("Update device %s for configured entity %s", atv_id, entity_id) + configured_entity = api.configured_entities.get(entity_id) + if configured_entity is None: + continue + attributes = {} + if isinstance(configured_entity, media_player.AndroidTVMediaPlayer): + old_state = ( + configured_entity.attributes[MediaAttr.STATE] + if MediaAttr.STATE in configured_entity.attributes + else ucapi.media_player.States.UNKNOWN + ) + + if MediaAttr.STATE in update and update[MediaAttr.STATE] != old_state: + attributes[MediaAttr.STATE] = update[MediaAttr.STATE] + + if MediaAttr.MEDIA_TITLE in update: + attributes[MediaAttr.MEDIA_TITLE] = update[MediaAttr.MEDIA_TITLE] - if MediaAttr.STATE in update and update[MediaAttr.STATE] != old_state: - attributes[MediaAttr.STATE] = update[MediaAttr.STATE] + if MediaAttr.MEDIA_ALBUM in update: + attributes[MediaAttr.MEDIA_ALBUM] = update[MediaAttr.MEDIA_ALBUM] - if MediaAttr.MEDIA_TITLE in update: - attributes[MediaAttr.MEDIA_TITLE] = update[MediaAttr.MEDIA_TITLE] + if MediaAttr.MEDIA_ARTIST in update: + attributes[MediaAttr.MEDIA_ARTIST] = update[MediaAttr.MEDIA_ARTIST] - if MediaAttr.MEDIA_ALBUM in update: - attributes[MediaAttr.MEDIA_ALBUM] = update[MediaAttr.MEDIA_ALBUM] + if MediaAttr.MEDIA_POSITION in update: + attributes[MediaAttr.MEDIA_POSITION] = update[MediaAttr.MEDIA_POSITION] + attributes["media_position_updated_at"] = datetime.now(tz=UTC).isoformat() - if MediaAttr.MEDIA_ARTIST in update: - attributes[MediaAttr.MEDIA_ARTIST] = update[MediaAttr.MEDIA_ARTIST] + if MediaAttr.MEDIA_DURATION in update: + attributes[MediaAttr.MEDIA_DURATION] = update[MediaAttr.MEDIA_DURATION] - if MediaAttr.MEDIA_POSITION in update: - attributes[MediaAttr.MEDIA_POSITION] = update[MediaAttr.MEDIA_POSITION] - attributes["media_position_updated_at"] = datetime.now(tz=UTC).isoformat() + if MediaAttr.MEDIA_IMAGE_URL in update: + attributes[MediaAttr.MEDIA_IMAGE_URL] = update[MediaAttr.MEDIA_IMAGE_URL] - if MediaAttr.MEDIA_DURATION in update: - attributes[MediaAttr.MEDIA_DURATION] = update[MediaAttr.MEDIA_DURATION] + if MediaAttr.VOLUME in update: + attributes[MediaAttr.VOLUME] = update[MediaAttr.VOLUME] - if MediaAttr.MEDIA_IMAGE_URL in update: - attributes[MediaAttr.MEDIA_IMAGE_URL] = update[MediaAttr.MEDIA_IMAGE_URL] + if MediaAttr.MUTED in update: + attributes[MediaAttr.MUTED] = update[MediaAttr.MUTED] - if MediaAttr.VOLUME in update: - attributes[MediaAttr.VOLUME] = update[MediaAttr.VOLUME] + if MediaAttr.SOURCE_LIST in update: + attributes[MediaAttr.SOURCE_LIST] = update[MediaAttr.SOURCE_LIST] - if MediaAttr.MUTED in update: - attributes[MediaAttr.MUTED] = update[MediaAttr.MUTED] + if MediaAttr.SOURCE in update: + attributes[MediaAttr.SOURCE] = update[MediaAttr.SOURCE] - if MediaAttr.SOURCE_LIST in update: - attributes[MediaAttr.SOURCE_LIST] = update[MediaAttr.SOURCE_LIST] + if attributes: + if MediaAttr.STATE not in attributes and old_state in ( + ucapi.media_player.States.UNAVAILABLE, + ucapi.media_player.States.UNKNOWN, + ): + attributes[MediaAttr.STATE] = ucapi.media_player.States.ON - if MediaAttr.SOURCE in update: - attributes[MediaAttr.SOURCE] = update[MediaAttr.SOURCE] + api.configured_entities.update_attributes(entity_id, attributes) + attributes = update + elif isinstance(configured_entity, remote.AndroidTVRemote): + attributes = configured_entity.filter_changed_attributes(update) - if attributes: - if MediaAttr.STATE not in attributes and old_state in ( - media_player.States.UNAVAILABLE, - media_player.States.UNKNOWN, - ): - attributes[media_player.Attributes.STATE] = media_player.States.ON + if attributes: + api.configured_entities.update_attributes(entity_id, attributes) - api.configured_entities.update_attributes(entity_id, attributes) + +def _entities_from_device_id(device_id: str) -> list[str]: + """ + Return all associated entity identifiers of the given device. + + :param device_id: the device identifier + :return: list of entity identifiers + """ + # dead simple for now: one media_player entity per device! + # TODO #21 support multiple zones: one media-player per zone + return [f"media_player.{device_id}", f"remote.{device_id}"] -def _add_configured_android_tv(device: config.AtvDevice, connect: bool = True) -> None: - profile = device_profile.match(device.manufacturer, device.model, device.use_chromecast) +def _add_configured_android_tv(device_config: config.AtvDevice, connect: bool = True) -> None: + profile = device_profile.match(device_config.manufacturer, device_config.model, device_config.use_chromecast) # the device should not yet be configured, but better be safe - if device.id in _configured_android_tvs: - android_tv = _configured_android_tvs[device.id] + if device_config.id in _configured_android_tvs: + android_tv = _configured_android_tvs[device_config.id] android_tv.disconnect() else: android_tv = tv.AndroidTv( - certfile=config.devices.certfile(device.id), - keyfile=config.devices.keyfile(device.id), - device_config=device, + certfile=config.devices.certfile(device_config.id), + keyfile=config.devices.keyfile(device_config.id), + device_config=device_config, profile=profile, loop=_LOOP, ) @@ -306,11 +373,11 @@ def _add_configured_android_tv(device: config.AtvDevice, connect: bool = True) - android_tv.events.on(tv.Events.UPDATE, handle_android_tv_update) android_tv.events.on(tv.Events.IP_ADDRESS_CHANGED, handle_android_tv_address_change) - _configured_android_tvs[device.id] = android_tv + _configured_android_tvs[device_config.id] = android_tv _LOG.info( "[%s] Configured Android TV device %s with profile and features : %s %s %s", - device.name, - device.id, + device_config.name, + device_config.id, profile.manufacturer, profile.model, profile.features, @@ -324,45 +391,23 @@ async def start_connection(): # start background task _LOOP.create_task(start_connection()) - _register_available_entities(device, profile) + _register_available_entities(device_config, android_tv, profile) -def _register_available_entities(device: config.AtvDevice, profile: Profile) -> None: +def _register_available_entities(device_config: config.AtvDevice, device: tv.AndroidTv, profile: Profile) -> None: """ Create entities for given Android TV device and register them as available entities. :param device: Android TV configuration """ - # Simple mapping at the moment: one entity per device (with the same id) - entity_id = device.id - features = profile.features - options = {} - if profile.simple_commands: - options[media_player.Options.SIMPLE_COMMANDS] = profile.simple_commands - - entity = media_player.MediaPlayer( - entity_id, - device.name, - features, - { - media_player.Attributes.STATE: media_player.States.UNKNOWN, - media_player.Attributes.VOLUME: 0, - media_player.Attributes.MUTED: False, - media_player.Attributes.MEDIA_TITLE: "", - media_player.Attributes.MEDIA_ALBUM: "", - media_player.Attributes.MEDIA_ARTIST: "", - media_player.Attributes.MEDIA_POSITION: 0, - media_player.Attributes.MEDIA_DURATION: 0, - media_player.Attributes.MEDIA_IMAGE_URL: "", - }, - device_class=media_player.DeviceClasses.TV, - options=options, - cmd_handler=media_player_cmd_handler, - ) - - if api.available_entities.contains(entity.id): - api.available_entities.remove(entity.id) - api.available_entities.add(entity) + entities = [ + media_player.AndroidTVMediaPlayer(device_config, device, profile), + remote.AndroidTVRemote(device_config, device, profile), + ] + for entity in entities: + if api.available_entities.contains(entity.id): + api.available_entities.remove(entity.id) + api.available_entities.add(entity) def on_device_added(device: config.AtvDevice) -> None: @@ -387,10 +432,9 @@ def on_device_removed(device: config.AtvDevice | None) -> None: atv = _configured_android_tvs.pop(device.id) atv.disconnect() atv.events.remove_all_listeners() - # Simple mapping at the moment: one entity per device (with the same id) - entity_id = atv.identifier - api.configured_entities.remove(entity_id) - api.available_entities.remove(entity_id) + for entity_id in _entities_from_device_id(device.id): + api.configured_entities.remove(entity_id) + api.available_entities.remove(entity_id) async def main(): diff --git a/src/media_player.py b/src/media_player.py new file mode 100644 index 0000000..64918d7 --- /dev/null +++ b/src/media_player.py @@ -0,0 +1,83 @@ +""" +Media-player entity functions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +import logging +from typing import Any + +from ucapi import EntityTypes, MediaPlayer, StatusCodes +from ucapi.media_player import Commands, DeviceClasses, Options + +import tv +from config import AtvDevice, create_entity_id +from profiles import Profile + +_LOG = logging.getLogger(__name__) + + +class AndroidTVMediaPlayer(MediaPlayer): # pylint: disable=too-few-public-methods + """Representation of a AndroidTV Media Player entity.""" + + def __init__(self, device_config: AtvDevice, device: tv.AndroidTv, profile: Profile): + """Initialize the class.""" + # pylint: disable = R0801 + _LOG.debug("[%s] AndroidTVMediaPlayer init", device_config.address) + self._device = device + self._device_config = device_config + self._profile = profile + + entity_id = create_entity_id(device_config.id, EntityTypes.MEDIA_PLAYER) + attributes = device.attributes + options: dict[str, Any] = {} + if profile.simple_commands: + options[Options.SIMPLE_COMMANDS] = profile.simple_commands + super().__init__( + entity_id, device_config.name, profile.features, attributes, device_class=DeviceClasses.TV, options=options + ) + + # pylint: disable=too-many-return-statements + async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: + """ + Media-player entity command handler. + + Called by the integration-API if a command is sent to a configured media-player entity. + + :param cmd_id: command + :param params: optional command parameters + :return: status code of the command request + """ + + if self._device is None: + _LOG.warning( + "Cannot execute command %s %s: no Android TV device found for entity %s", + cmd_id, + params if params else "", + self._device_config.id, + ) + return StatusCodes.NOT_FOUND + + _LOG.info("[%s] command: %s %s", self._device.log_id, cmd_id, params if params else "") + + if cmd_id == Commands.ON: + return await self._device.turn_on() + if cmd_id == Commands.OFF: + return await self._device.turn_off() + if cmd_id == Commands.SELECT_SOURCE: + if params is None or "source" not in params: + return StatusCodes.BAD_REQUEST + return await self._device.select_source(params["source"]) + if cmd_id == Commands.VOLUME_UP: + return await self._device.volume_up() + if cmd_id == Commands.VOLUME_DOWN: + return await self._device.volume_down() + if cmd_id == Commands.MUTE_TOGGLE: + return await self._device.volume_mute_toggle() + if cmd_id == Commands.VOLUME: + return await self._device.volume_set(params.get("volume")) + if cmd_id == Commands.SEEK: + return await self._device.media_seek(params.get("media_position", 0)) + + return await self._device.send_media_player_command(cmd_id) diff --git a/src/remote.py b/src/remote.py new file mode 100644 index 0000000..c3820b1 --- /dev/null +++ b/src/remote.py @@ -0,0 +1,152 @@ +""" +Media-player entity functions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +import asyncio +import logging +from typing import Any + +from ucapi import EntityTypes, Remote, StatusCodes +from ucapi.media_player import States as MediaStates +from ucapi.remote import Attributes, Commands, Features, Options +from ucapi.remote import States as RemoteStates + +import tv +from config import AtvDevice, create_entity_id +from profiles import Profile + +_LOG = logging.getLogger(__name__) + +# A device state map should be defined and then mapped to both entity types +REMOTE_STATE_MAPPING = { + MediaStates.OFF: RemoteStates.OFF, + MediaStates.ON: RemoteStates.ON, + MediaStates.STANDBY: RemoteStates.ON, + MediaStates.PLAYING: RemoteStates.ON, + MediaStates.PAUSED: RemoteStates.ON, + MediaStates.UNAVAILABLE: RemoteStates.UNAVAILABLE, + MediaStates.UNKNOWN: RemoteStates.UNKNOWN, +} + + +class AndroidTVRemote(Remote): + """Representation of a AndroidTV Remote entity.""" + + def __init__(self, device_config: AtvDevice, device: tv.AndroidTv, profile: Profile): + """Initialize the class.""" + # pylint: disable = R0801 + _LOG.debug("[%s] AndroidTVRemote init", device_config.address) + self._device = device + self._device_config = device_config + self._profile = profile + + entity_id = create_entity_id(device_config.id, EntityTypes.REMOTE) + features = [Features.SEND_CMD, Features.ON_OFF] + attributes = { + Attributes.STATE: REMOTE_STATE_MAPPING.get(device.player_state), + } + + super().__init__( + entity_id, + device_config.name, + features, + attributes, + simple_commands=profile.simple_commands if profile.simple_commands else [], + button_mapping=[], # TODO + ui_pages=[], # TODO + ) + + def get_int_param(self, param: str, params: dict[str, Any], default: int): + """Get parameter in integer format.""" + # TODO bug to be fixed on UC Core : some params are sent as (empty) strings by remote (hold == "") + value = params.get(param, default) + if isinstance(value, str) and len(value) > 0: + return int(float(value)) + return default + + async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: + """ + Media-player entity command handler. + + Called by the integration-API if a command is sent to a configured media-player entity. + + :param cmd_id: command + :param params: optional command parameters + :return: status code of the command request + """ + _LOG.info("[%s] Got command request: %s %s", self.id, cmd_id, params) + + if self._device is None: + _LOG.warning("[%s] No AndroidTV instance for this remote entity", self.id) + return StatusCodes.NOT_FOUND + + repeat = self.get_int_param("repeat", params, 1) + res = StatusCodes.OK + for _i in range(0, repeat): + res = await self.handle_command(cmd_id, params) + return res + + async def handle_command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: + """Handle command.""" + # hold = self.get_int_param("hold", params, 0) + delay = self.get_int_param("delay", params, 0) + command = params.get("command", "") + + if command == Commands.ON: + res = await self._device.turn_on() + elif command == Commands.OFF: + res = await self._device.turn_off() + elif command == Commands.TOGGLE: + if self._device.is_on: + res = await self._device.turn_off() + else: + res = await self._device.turn_on() + elif command in self.options[Options.SIMPLE_COMMANDS]: + res = await self._device.send_media_player_command(command) + elif cmd_id == Commands.SEND_CMD: + res = await self._device.send_media_player_command(command) + elif cmd_id == Commands.SEND_CMD_SEQUENCE: + commands = params.get("sequence", []) # .split(",") + res = StatusCodes.OK + for command in commands: + res = await self.handle_command(Commands.SEND_CMD, {"command": command, "params": params}) + if delay > 0: + await asyncio.sleep(delay) + else: + return StatusCodes.NOT_IMPLEMENTED + if delay > 0 and cmd_id != Commands.SEND_CMD_SEQUENCE: + await asyncio.sleep(delay) + return res + + def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: + """ + Filter the given attributes and return only the changed values. + + :param update: dictionary with attributes. + :return: filtered entity attributes containing changed attributes only. + """ + attributes = {} + + if Attributes.STATE in update: + state = REMOTE_STATE_MAPPING.get(update[Attributes.STATE]) + attributes = key_update_helper(self.attributes, Attributes.STATE, state, attributes) + + _LOG.debug("[%s] AndroidTV remote update attributes %s", self._device_config.id, attributes) + return attributes + + +def key_update_helper(input_attributes, key: str, value: str | None, attributes): + """Return modified attributes only.""" + if value is None: + return attributes + + if key in input_attributes: + if input_attributes[key] != value: + attributes[key] = value + else: + attributes[key] = value + + return attributes diff --git a/src/tv.py b/src/tv.py index ab28acf..a113de0 100644 --- a/src/tv.py +++ b/src/tv.py @@ -360,6 +360,38 @@ def media_title(self) -> str | None: return apps.IdMappings[self._media_app] return self._media_app + @property + def volume_level(self) -> float | None: + """Returns the volume level if supported and enabled.""" + if not self.device_config.use_chromecast_volume: + return None + if self._chromecast: + return self._chromecast.status.volume_level + return 0 + + @property + def player_state(self) -> media_player.States: + """Return the media player state.""" + return self._player_state + + @property + def attributes(self) -> dict[str, any]: + """Return the device attributes.""" + attributes = { + MediaAttr.STATE: self._player_state, + MediaAttr.MUTED: self._muted, + MediaAttr.MEDIA_TYPE: self._media_type, + MediaAttr.MEDIA_IMAGE_URL: self._media_image_url if self._media_image_url else "", + MediaAttr.MEDIA_TITLE: self.media_title if self.media_title else "", + MediaAttr.MEDIA_ALBUM: self._media_album if self._media_album else "", + MediaAttr.MEDIA_ARTIST: self._media_artist if self._media_artist else "", + MediaAttr.MEDIA_POSITION: self._media_position, + MediaAttr.MEDIA_DURATION: self._media_duration, + } + if self.device_config.use_chromecast_volume: + attributes[MediaAttr.VOLUME] = self.volume_level + return attributes + def _backoff(self) -> float: delay = self._reconnect_delay * BACKOFF_FACTOR if delay >= BACKOFF_MAX: From 8a9b303476d5bfccf3b67b05302ec0d5a4c9a44d Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:20:53 +0200 Subject: [PATCH 17/32] Linting --- src/driver.py | 3 +-- src/media_player.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/driver.py b/src/driver.py index ca4a723..adcfb70 100644 --- a/src/driver.py +++ b/src/driver.py @@ -67,7 +67,7 @@ async def on_standby(): async def connect_device(device: tv.AndroidTv): - """Connect device and send state""" + """Connect device and send state.""" try: _LOG.debug("Connecting device %s...", device.device_config.id) await device.connect() @@ -209,7 +209,6 @@ async def handle_connected(identifier: str): async def handle_disconnected(identifier: str): """Handle Android TV disconnection.""" - for entity_id in _entities_from_device_id(identifier): configured_entity = api.configured_entities.get(entity_id) if configured_entity is None: diff --git a/src/media_player.py b/src/media_player.py index 64918d7..f2d9bb5 100644 --- a/src/media_player.py +++ b/src/media_player.py @@ -40,8 +40,7 @@ def __init__(self, device_config: AtvDevice, device: tv.AndroidTv, profile: Prof # pylint: disable=too-many-return-statements async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: - """ - Media-player entity command handler. + """Media-player entity command handler. Called by the integration-API if a command is sent to a configured media-player entity. @@ -49,7 +48,6 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St :param params: optional command parameters :return: status code of the command request """ - if self._device is None: _LOG.warning( "Cannot execute command %s %s: no Android TV device found for entity %s", From a095050160a238dd32c6b4764f6ccdb222a4528b Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:26:20 +0200 Subject: [PATCH 18/32] Updated readme and fix on remote entity --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/remote.py | 2 +- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9bc723d..9b9e044 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ with Android TV devices. It can be run as an external integration for developmen - [Requirements and setting](docs/settings.md). - Multiple Android TV devices are supported with version 0.5.0 and newer. -- A [media player entity](https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_media_player.md) - is exposed per Android TV device to the Remote. +- A [media player entity](https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_media_player.md) and a [remote entity](https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_remote.md) + are exposed per Android TV device to the Remote. - Device profiles allow device-specific support and custom key bindings, for example, double-click or long-press actions. See [command mappings](docs/command_mapping.md) for more information. @@ -25,6 +25,78 @@ Preview features: The preview features are not enabled by default. They can be enabled in the device configuration of the setup flow. +## Configuration + +After running the setup flow and configuring your device, 2 new entities will be available : +- Media Player entity : should cover most needs with predefined commands +- Remote entity : should be used to run custom commands or command sequences + +The available commands depend on the device capabilities : + +| Command | Description | +|-------------------|---------------------| +| on | Turn on | +| off | Turn off | +| toggle | Power toggle | +| play_pause | Play/pause | +| stop | Stop | +| previous | Previous chapter | +| next | Next chapter | +| fast_forward | Fast forward | +| rewind | Rewind | +| volume_up | Volume up | +| volume_down | Volume down | +| mute_toggle | Mute toggle | +| mute | Mute | +| unmute | Unmute | +| repeat | Repeat | +| shuffle | Shuffle | +| channel_up | Channel up | +| channel_down | Channel down | +| cursor_up | Cursor up | +| cursor_down | Cursor down | +| cursor_left | Cursor left | +| cursor_right | Cursor right | +| cursor_enter | Cursor enter | +| digit_0 | 0 | +| digit_1 | 1 | +| digit_2 | 2 | +| digit_3 | 3 | +| digit_4 | 4 | +| digit_5 | 5 | +| digit_6 | 6 | +| digit_7 | 7 | +| digit_8 | 8 | +| digit_9 | 9 | +| function_red | Red | +| function_green | Green | +| function_yellow | Yellow | +| function_blue | Blue | +| home | Home | +| menu | Menu | +| context_menu | Context menu | +| guide | Guide | +| info | Info | +| back | Back | +| record | Record | +| my_recordings | My recordings | +| live | Live | +| audio_track | Next audio track | +| subtitle | Next subtitle track | +| settings | Settings | + +In addition of these specific commands depending on the device capabilities : + +**Default devices :** +`CURSOR_ENTER_LONG, HOME_LONG, MENU_LONG, KEYCODE_STAR, KEYCODE_POUND, KEYCODE_A to KEYCODE_Z, KEYCODE_COMMA, KEYCODE_PERIOD, KEYCODE_SPACE, KEYCODE_DEL, KEYCODE_MINUS, KEYCODE_EQUALS, KEYCODE_LEFT_BRACKET, KEYCODE_RIGHT_BRACKET, KEYCODE_BACKSLASH, KEYCODE_SEMICOLON, KEYCODE_APOSTROPHE, KEYCODE_SLASH, KEYCODE_AT, KEYCODE_PLUS, KEYCODE_PAGE_UP, KEYCODE_PAGE_DOWN, KEYCODE_F1 to KEYCODE_F12` + +**Dune HD :** `YOUTUBE, NETFLIX, PRIMEVIDEO, FACTORYTEST, DISNEY` + +**Shield TV :** `SCREENSAVER` + +**onn. Streaming Device 4K pro:** `TELETEXT` + + ## Standalone Usage ### Setup diff --git a/src/remote.py b/src/remote.py index c3820b1..a778d51 100644 --- a/src/remote.py +++ b/src/remote.py @@ -104,7 +104,7 @@ async def handle_command(self, cmd_id: str, params: dict[str, Any] | None = None res = await self._device.turn_off() else: res = await self._device.turn_on() - elif command in self.options[Options.SIMPLE_COMMANDS]: + elif command in self.options.get(Options.SIMPLE_COMMANDS, {}): res = await self._device.send_media_player_command(command) elif cmd_id == Commands.SEND_CMD: res = await self._device.send_media_player_command(command) From 87c8191a7c91089caede973b67700ac9857cf8cc Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:29:29 +0200 Subject: [PATCH 19/32] Typo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b9e044..76a763d 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,9 @@ The available commands depend on the device capabilities : | subtitle | Next subtitle track | | settings | Settings | -In addition of these specific commands depending on the device capabilities : +In addition these specific commands are also available depending on the device capabilities : -**Default devices :** +**Any device :** `CURSOR_ENTER_LONG, HOME_LONG, MENU_LONG, KEYCODE_STAR, KEYCODE_POUND, KEYCODE_A to KEYCODE_Z, KEYCODE_COMMA, KEYCODE_PERIOD, KEYCODE_SPACE, KEYCODE_DEL, KEYCODE_MINUS, KEYCODE_EQUALS, KEYCODE_LEFT_BRACKET, KEYCODE_RIGHT_BRACKET, KEYCODE_BACKSLASH, KEYCODE_SEMICOLON, KEYCODE_APOSTROPHE, KEYCODE_SLASH, KEYCODE_AT, KEYCODE_PLUS, KEYCODE_PAGE_UP, KEYCODE_PAGE_DOWN, KEYCODE_F1 to KEYCODE_F12` **Dune HD :** `YOUTUBE, NETFLIX, PRIMEVIDEO, FACTORYTEST, DISNEY` From 62927a8d18f9f6efec695b6fa4ffadf7a6f2ac58 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 6 Sep 2025 18:07:31 +0200 Subject: [PATCH 20/32] Added buttons and UI mapping for remote entity --- src/const.py | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/remote.py | 6 +-- 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 src/const.py diff --git a/src/const.py b/src/const.py new file mode 100644 index 0000000..13e88e9 --- /dev/null +++ b/src/const.py @@ -0,0 +1,103 @@ +from ucapi.ui import DeviceButtonMapping, Buttons, EntityCommand, UiPage +from ucapi.media_player import Commands + +REMOTE_BUTTONS_MAPPING: list[DeviceButtonMapping] = [ + DeviceButtonMapping(button=Buttons.BACK, short_press=EntityCommand(**{"cmd_id": Commands.BACK})), + DeviceButtonMapping(button=Buttons.HOME, short_press=EntityCommand(**{"cmd_id": Commands.HOME})), + DeviceButtonMapping(button=Buttons.CHANNEL_DOWN, short_press=EntityCommand(**{"cmd_id": Commands.CHANNEL_DOWN})), + DeviceButtonMapping(button=Buttons.CHANNEL_UP, short_press=EntityCommand(**{"cmd_id": Commands.CHANNEL_UP})), + DeviceButtonMapping(button=Buttons.DPAD_UP, short_press=EntityCommand(**{"cmd_id": Commands.CURSOR_UP})), + DeviceButtonMapping(button=Buttons.DPAD_DOWN, short_press=EntityCommand(**{"cmd_id": Commands.CURSOR_DOWN})), + DeviceButtonMapping(button=Buttons.DPAD_LEFT, short_press=EntityCommand(**{"cmd_id": Commands.CURSOR_LEFT})), + DeviceButtonMapping(button=Buttons.DPAD_RIGHT, short_press=EntityCommand(**{"cmd_id": Commands.CURSOR_RIGHT})), + DeviceButtonMapping(button=Buttons.DPAD_MIDDLE, short_press=EntityCommand(**{"cmd_id": Commands.CURSOR_ENTER})), + DeviceButtonMapping(button=Buttons.PLAY, short_press=EntityCommand(**{"cmd_id": Commands.PLAY_PAUSE})), + DeviceButtonMapping(button=Buttons.PREV, short_press=EntityCommand(**{"cmd_id": Commands.PREVIOUS})), + DeviceButtonMapping(button=Buttons.NEXT, short_press=EntityCommand(**{"cmd_id": Commands.NEXT})), + DeviceButtonMapping(button=Buttons.VOLUME_UP, short_press=EntityCommand(**{"cmd_id": Commands.VOLUME_UP})), + DeviceButtonMapping(button=Buttons.VOLUME_DOWN, short_press=EntityCommand(**{"cmd_id": Commands.VOLUME_DOWN})), + DeviceButtonMapping(button=Buttons.MUTE, short_press=EntityCommand(**{"cmd_id": Commands.MUTE_TOGGLE})), + DeviceButtonMapping(button="STOP", short_press=EntityCommand(**{"cmd_id": Commands.STOP})), # TODO missing R3 buttons in UCAPI + DeviceButtonMapping(button="MENU", short_press=EntityCommand(**{"cmd_id": Commands.CONTEXT_MENU})), # TODO missing R3 buttons in UCAPI +] + + +REMOTE_UI_PAGES: list[UiPage] = [ + UiPage(**{ + "page_id": "Android TV numbers", + "name": "Android TV numbers", + "grid": {"height": 4, "width": 3}, + "items": [ + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_1, "repeat": 1}}, + "location": {"x": 0, "y": 0}, + "size": {"height": 1, "width": 1}, + "text": "1", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2, "repeat": 1}}, + "location": {"x": 1, "y": 0}, + "size": {"height": 1, "width": 1}, + "text": "2", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3, "repeat": 1}}, + "location": {"x": 2, "y": 0}, + "size": {"height": 1, "width": 1}, + "text": "3", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4, "repeat": 1}}, + "location": {"x": 0, "y": 1}, + "size": {"height": 1, "width": 1}, + "text": "4", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5, "repeat": 1}}, + "location": {"x": 1, "y": 1}, + "size": {"height": 1, "width": 1}, + "text": "5", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6, "repeat": 1}}, + "location": {"x": 2, "y": 1}, + "size": {"height": 1, "width": 1}, + "text": "6", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7, "repeat": 1}}, + "location": {"x": 0, "y": 2}, + "size": {"height": 1, "width": 1}, + "text": "7", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8, "repeat": 1}}, + "location": {"x": 1, "y": 2}, + "size": {"height": 1, "width": 1}, + "text": "8", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9, "repeat": 1}}, + "location": {"x": 2, "y": 2}, + "size": {"height": 1, "width": 1}, + "text": "9", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0, "repeat": 1}}, + "location": {"x": 1, "y": 3}, + "size": {"height": 1, "width": 1}, + "text": "0", + "type": "text", + }, + ], + }), +] \ No newline at end of file diff --git a/src/remote.py b/src/remote.py index a778d51..fa6d1b4 100644 --- a/src/remote.py +++ b/src/remote.py @@ -16,6 +16,7 @@ import tv from config import AtvDevice, create_entity_id +from const import REMOTE_BUTTONS_MAPPING, REMOTE_UI_PAGES from profiles import Profile _LOG = logging.getLogger(__name__) @@ -31,7 +32,6 @@ MediaStates.UNKNOWN: RemoteStates.UNKNOWN, } - class AndroidTVRemote(Remote): """Representation of a AndroidTV Remote entity.""" @@ -55,8 +55,8 @@ def __init__(self, device_config: AtvDevice, device: tv.AndroidTv, profile: Prof features, attributes, simple_commands=profile.simple_commands if profile.simple_commands else [], - button_mapping=[], # TODO - ui_pages=[], # TODO + button_mapping=REMOTE_BUTTONS_MAPPING, + ui_pages=REMOTE_UI_PAGES, ) def get_int_param(self, param: str, params: dict[str, Any], default: int): From 1b6263ea75f5a411e43600cf2482ca97320d4091 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 6 Sep 2025 18:12:48 +0200 Subject: [PATCH 21/32] Linting --- src/const.py | 177 +++++++++++++++++++++++++++----------------------- src/remote.py | 1 + 2 files changed, 97 insertions(+), 81 deletions(-) diff --git a/src/const.py b/src/const.py index 13e88e9..17780c0 100644 --- a/src/const.py +++ b/src/const.py @@ -1,6 +1,15 @@ -from ucapi.ui import DeviceButtonMapping, Buttons, EntityCommand, UiPage +""" +Android TV constants. + +:copyright: (c) 2023-2025 by Unfolded Circle ApS. +:license: MPL-2.0, see LICENSE for more details. +""" + from ucapi.media_player import Commands +# pylint: disable=C0301 +from ucapi.ui import Buttons, DeviceButtonMapping, EntityCommand, UiPage + REMOTE_BUTTONS_MAPPING: list[DeviceButtonMapping] = [ DeviceButtonMapping(button=Buttons.BACK, short_press=EntityCommand(**{"cmd_id": Commands.BACK})), DeviceButtonMapping(button=Buttons.HOME, short_press=EntityCommand(**{"cmd_id": Commands.HOME})), @@ -17,87 +26,93 @@ DeviceButtonMapping(button=Buttons.VOLUME_UP, short_press=EntityCommand(**{"cmd_id": Commands.VOLUME_UP})), DeviceButtonMapping(button=Buttons.VOLUME_DOWN, short_press=EntityCommand(**{"cmd_id": Commands.VOLUME_DOWN})), DeviceButtonMapping(button=Buttons.MUTE, short_press=EntityCommand(**{"cmd_id": Commands.MUTE_TOGGLE})), - DeviceButtonMapping(button="STOP", short_press=EntityCommand(**{"cmd_id": Commands.STOP})), # TODO missing R3 buttons in UCAPI - DeviceButtonMapping(button="MENU", short_press=EntityCommand(**{"cmd_id": Commands.CONTEXT_MENU})), # TODO missing R3 buttons in UCAPI + DeviceButtonMapping( + button="STOP", short_press=EntityCommand(**{"cmd_id": Commands.STOP}) + ), # TODO missing R3 buttons in UCAPI + DeviceButtonMapping( + button="MENU", short_press=EntityCommand(**{"cmd_id": Commands.CONTEXT_MENU}) + ), # TODO missing R3 buttons in UCAPI ] REMOTE_UI_PAGES: list[UiPage] = [ - UiPage(**{ - "page_id": "Android TV numbers", - "name": "Android TV numbers", - "grid": {"height": 4, "width": 3}, - "items": [ - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_1, "repeat": 1}}, - "location": {"x": 0, "y": 0}, - "size": {"height": 1, "width": 1}, - "text": "1", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2, "repeat": 1}}, - "location": {"x": 1, "y": 0}, - "size": {"height": 1, "width": 1}, - "text": "2", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3, "repeat": 1}}, - "location": {"x": 2, "y": 0}, - "size": {"height": 1, "width": 1}, - "text": "3", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4, "repeat": 1}}, - "location": {"x": 0, "y": 1}, - "size": {"height": 1, "width": 1}, - "text": "4", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5, "repeat": 1}}, - "location": {"x": 1, "y": 1}, - "size": {"height": 1, "width": 1}, - "text": "5", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6, "repeat": 1}}, - "location": {"x": 2, "y": 1}, - "size": {"height": 1, "width": 1}, - "text": "6", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7, "repeat": 1}}, - "location": {"x": 0, "y": 2}, - "size": {"height": 1, "width": 1}, - "text": "7", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8, "repeat": 1}}, - "location": {"x": 1, "y": 2}, - "size": {"height": 1, "width": 1}, - "text": "8", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9, "repeat": 1}}, - "location": {"x": 2, "y": 2}, - "size": {"height": 1, "width": 1}, - "text": "9", - "type": "text", - }, - { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0, "repeat": 1}}, - "location": {"x": 1, "y": 3}, - "size": {"height": 1, "width": 1}, - "text": "0", - "type": "text", - }, - ], - }), -] \ No newline at end of file + UiPage( + **{ + "page_id": "Android TV numbers", + "name": "Android TV numbers", + "grid": {"height": 4, "width": 3}, + "items": [ + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_1, "repeat": 1}}, + "location": {"x": 0, "y": 0}, + "size": {"height": 1, "width": 1}, + "text": "1", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2, "repeat": 1}}, + "location": {"x": 1, "y": 0}, + "size": {"height": 1, "width": 1}, + "text": "2", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3, "repeat": 1}}, + "location": {"x": 2, "y": 0}, + "size": {"height": 1, "width": 1}, + "text": "3", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4, "repeat": 1}}, + "location": {"x": 0, "y": 1}, + "size": {"height": 1, "width": 1}, + "text": "4", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5, "repeat": 1}}, + "location": {"x": 1, "y": 1}, + "size": {"height": 1, "width": 1}, + "text": "5", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6, "repeat": 1}}, + "location": {"x": 2, "y": 1}, + "size": {"height": 1, "width": 1}, + "text": "6", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7, "repeat": 1}}, + "location": {"x": 0, "y": 2}, + "size": {"height": 1, "width": 1}, + "text": "7", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8, "repeat": 1}}, + "location": {"x": 1, "y": 2}, + "size": {"height": 1, "width": 1}, + "text": "8", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9, "repeat": 1}}, + "location": {"x": 2, "y": 2}, + "size": {"height": 1, "width": 1}, + "text": "9", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0, "repeat": 1}}, + "location": {"x": 1, "y": 3}, + "size": {"height": 1, "width": 1}, + "text": "0", + "type": "text", + }, + ], + } + ), +] diff --git a/src/remote.py b/src/remote.py index fa6d1b4..b0897dc 100644 --- a/src/remote.py +++ b/src/remote.py @@ -32,6 +32,7 @@ MediaStates.UNKNOWN: RemoteStates.UNKNOWN, } + class AndroidTVRemote(Remote): """Representation of a AndroidTV Remote entity.""" From bbc8a2997174501cc46ea6d7126714caedbd0f76 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:15:45 +0200 Subject: [PATCH 22/32] Requested changes --- README.md | 2 +- requirements.txt | 2 +- src/config.py | 8 ++++---- src/const.py | 38 ++++++++++++------------------------ src/driver.py | 6 +++++- src/remote.py | 51 +++++++++++++++++++++++------------------------- 6 files changed, 47 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 76a763d..455eaee 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ In addition these specific commands are also available depending on the device c **Shield TV :** `SCREENSAVER` -**onn. Streaming Device 4K pro:** `TELETEXT` +**Philips Android TV:** `TELETEXT` ## Standalone Usage diff --git a/requirements.txt b/requirements.txt index 6b008a8..0be9ebc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ androidtvremote2==0.2.1 -ucapi==0.3.1 +ucapi==0.3.2 pyee~=13.0.0 google_play_scraper==1.2.7 pillow>=11.2.1 diff --git a/src/config.py b/src/config.py index 5cd3706..7b6a720 100644 --- a/src/config.py +++ b/src/config.py @@ -20,16 +20,16 @@ _CFG_FILENAME = "config.json" -def create_entity_id(avr_id: str, entity_type: EntityTypes) -> str: +def create_entity_id(device_id: str, entity_type: EntityTypes) -> str: """Create a unique entity identifier for the given receiver and entity type.""" - return f"{entity_type.value}.{avr_id}" + return f"{entity_type.value}.{device_id}" def device_from_entity_id(entity_id: str) -> str | None: """ - Return the avr_id prefix of an entity_id. + Return the device id prefix of an entity_id. - The prefix is the part before the first dot in the name and refers to the AVR device identifier. + The prefix is the part before the first dot in the name and refers to the device identifier. :param entity_id: the entity identifier :return: the device prefix, or None if entity_id doesn't contain a dot diff --git a/src/const.py b/src/const.py index 17780c0..22bfb08 100644 --- a/src/const.py +++ b/src/const.py @@ -26,12 +26,8 @@ DeviceButtonMapping(button=Buttons.VOLUME_UP, short_press=EntityCommand(**{"cmd_id": Commands.VOLUME_UP})), DeviceButtonMapping(button=Buttons.VOLUME_DOWN, short_press=EntityCommand(**{"cmd_id": Commands.VOLUME_DOWN})), DeviceButtonMapping(button=Buttons.MUTE, short_press=EntityCommand(**{"cmd_id": Commands.MUTE_TOGGLE})), - DeviceButtonMapping( - button="STOP", short_press=EntityCommand(**{"cmd_id": Commands.STOP}) - ), # TODO missing R3 buttons in UCAPI - DeviceButtonMapping( - button="MENU", short_press=EntityCommand(**{"cmd_id": Commands.CONTEXT_MENU}) - ), # TODO missing R3 buttons in UCAPI + DeviceButtonMapping(button=Buttons.STOP, short_press=EntityCommand(**{"cmd_id": Commands.STOP})), + DeviceButtonMapping(button=Buttons.MENU, short_press=EntityCommand(**{"cmd_id": Commands.CONTEXT_MENU})), ] @@ -43,72 +39,62 @@ "grid": {"height": 4, "width": 3}, "items": [ { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_1, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_1}}, "location": {"x": 0, "y": 0}, - "size": {"height": 1, "width": 1}, "text": "1", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2}}, "location": {"x": 1, "y": 0}, - "size": {"height": 1, "width": 1}, "text": "2", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3}}, "location": {"x": 2, "y": 0}, - "size": {"height": 1, "width": 1}, "text": "3", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4}}, "location": {"x": 0, "y": 1}, - "size": {"height": 1, "width": 1}, "text": "4", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5}}, "location": {"x": 1, "y": 1}, - "size": {"height": 1, "width": 1}, "text": "5", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6}}, "location": {"x": 2, "y": 1}, - "size": {"height": 1, "width": 1}, "text": "6", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7}}, "location": {"x": 0, "y": 2}, - "size": {"height": 1, "width": 1}, "text": "7", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8}}, "location": {"x": 1, "y": 2}, - "size": {"height": 1, "width": 1}, "text": "8", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9}}, "location": {"x": 2, "y": 2}, - "size": {"height": 1, "width": 1}, "text": "9", "type": "text", }, { - "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0, "repeat": 1}}, + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0}}, "location": {"x": 1, "y": 3}, - "size": {"height": 1, "width": 1}, "text": "0", "type": "text", }, diff --git a/src/driver.py b/src/driver.py index adcfb70..27ee108 100644 --- a/src/driver.py +++ b/src/driver.py @@ -80,7 +80,11 @@ async def connect_device(device: tv.AndroidTv): continue # Return all attributes according to entity type if isinstance(entity, media_player.AndroidTVMediaPlayer): - _LOG.debug("Sending attributes %s : %s", entity_id, device.attributes) + if _LOG.level <= logging.DEBUG: + attributes = { + k: v for k, v in device.attributes.items() if k != MediaAttr.MEDIA_IMAGE_URL or len(v) < 64 + } + _LOG.debug("Sending attributes %s : %s", entity_id, attributes) api.configured_entities.update_attributes(entity_id, device.attributes) if isinstance(entity, remote.AndroidTVRemote): api.configured_entities.update_attributes( diff --git a/src/remote.py b/src/remote.py index b0897dc..6f475cf 100644 --- a/src/remote.py +++ b/src/remote.py @@ -79,49 +79,46 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St :return: status code of the command request """ _LOG.info("[%s] Got command request: %s %s", self.id, cmd_id, params) - if self._device is None: _LOG.warning("[%s] No AndroidTV instance for this remote entity", self.id) return StatusCodes.NOT_FOUND - - repeat = self.get_int_param("repeat", params, 1) - res = StatusCodes.OK - for _i in range(0, repeat): - res = await self.handle_command(cmd_id, params) - return res - - async def handle_command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: - """Handle command.""" - # hold = self.get_int_param("hold", params, 0) - delay = self.get_int_param("delay", params, 0) command = params.get("command", "") - - if command == Commands.ON: + res = StatusCodes.OK + if cmd_id == Commands.ON: res = await self._device.turn_on() - elif command == Commands.OFF: + elif cmd_id == Commands.OFF: res = await self._device.turn_off() - elif command == Commands.TOGGLE: + elif cmd_id == Commands.TOGGLE: if self._device.is_on: res = await self._device.turn_off() else: res = await self._device.turn_on() elif command in self.options.get(Options.SIMPLE_COMMANDS, {}): res = await self._device.send_media_player_command(command) - elif cmd_id == Commands.SEND_CMD: - res = await self._device.send_media_player_command(command) - elif cmd_id == Commands.SEND_CMD_SEQUENCE: - commands = params.get("sequence", []) # .split(",") - res = StatusCodes.OK - for command in commands: - res = await self.handle_command(Commands.SEND_CMD, {"command": command, "params": params}) - if delay > 0: - await asyncio.sleep(delay) + elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: + _ = asyncio.get_event_loop().create_task(self.send_commands(cmd_id, params)) else: return StatusCodes.NOT_IMPLEMENTED - if delay > 0 and cmd_id != Commands.SEND_CMD_SEQUENCE: - await asyncio.sleep(delay) return res + async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None): + """Handle custom command or commands sequence.""" + # hold = self.get_int_param("hold", params, 0) + delay = self.get_int_param("delay", params, 0) + repeat = self.get_int_param("repeat", params, 1) + command = params.get("command", "") + for _i in range(0, repeat): + if cmd_id == Commands.SEND_CMD: + await self._device.send_media_player_command(command) + if delay > 0: + await asyncio.sleep(delay) + else: + commands = params.get("sequence", []) + for command in commands: + await self._device.send_media_player_command(command) + if delay > 0: + await asyncio.sleep(delay) + def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: """ Filter the given attributes and return only the changed values. From 4d30bd5f1942d9639d0abe73d3ff7c023a7fe32e Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 17 Sep 2025 19:43:48 +0200 Subject: [PATCH 23/32] Better async handling of commands/command sequenc --- src/remote.py | 51 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/src/remote.py b/src/remote.py index 6f475cf..df1ca40 100644 --- a/src/remote.py +++ b/src/remote.py @@ -32,6 +32,27 @@ MediaStates.UNKNOWN: RemoteStates.UNKNOWN, } +COMMAND_DURATION_MS = 250 +COMMAND_TIMEOUT = 5000 + +def calculate_duration(cmd_id: str, params: dict[str, Any] | None = None) -> int: + """Calculate and return the expected duration of command or command sequence.""" + delay = get_int_param("delay", params, 0) + repeat = get_int_param("repeat", params, 1) + commands_count = 1 + if cmd_id == Commands.SEND_CMD_SEQUENCE: + commands_count = len(params.get("sequence", [])) + return commands_count*(delay+COMMAND_DURATION_MS)*repeat + + +def get_int_param(param: str, params: dict[str, Any], default: int): + """Get parameter in integer format.""" + # TODO bug to be fixed on UC Core : some params are sent as (empty) strings by remote (hold == "") + value = params.get(param, default) + if isinstance(value, str) and len(value) > 0: + return int(float(value)) + return default + class AndroidTVRemote(Remote): """Representation of a AndroidTV Remote entity.""" @@ -60,14 +81,6 @@ def __init__(self, device_config: AtvDevice, device: tv.AndroidTv, profile: Prof ui_pages=REMOTE_UI_PAGES, ) - def get_int_param(self, param: str, params: dict[str, Any], default: int): - """Get parameter in integer format.""" - # TODO bug to be fixed on UC Core : some params are sent as (empty) strings by remote (hold == "") - value = params.get(param, default) - if isinstance(value, str) and len(value) > 0: - return int(float(value)) - return default - async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: """ Media-player entity command handler. @@ -96,28 +109,38 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St elif command in self.options.get(Options.SIMPLE_COMMANDS, {}): res = await self._device.send_media_player_command(command) elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: - _ = asyncio.get_event_loop().create_task(self.send_commands(cmd_id, params)) + # If the expected duration exceeds the remote timeout, execute it in async mode + if calculate_duration(cmd_id, params) > COMMAND_TIMEOUT: + _ = asyncio.get_event_loop().create_task(self.send_commands(cmd_id, params)) + else: + res = self.send_commands(cmd_id, params) else: return StatusCodes.NOT_IMPLEMENTED return res - async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None): + async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: """Handle custom command or commands sequence.""" # hold = self.get_int_param("hold", params, 0) - delay = self.get_int_param("delay", params, 0) - repeat = self.get_int_param("repeat", params, 1) + delay = get_int_param("delay", params, 0) + repeat = get_int_param("repeat", params, 1) command = params.get("command", "") + res = StatusCodes.OK for _i in range(0, repeat): if cmd_id == Commands.SEND_CMD: - await self._device.send_media_player_command(command) + result = await self._device.send_media_player_command(command) + if result != StatusCodes.OK: + res = result if delay > 0: await asyncio.sleep(delay) else: commands = params.get("sequence", []) for command in commands: - await self._device.send_media_player_command(command) + result = await self._device.send_media_player_command(command) + if result != StatusCodes.OK: + res = result if delay > 0: await asyncio.sleep(delay) + return res def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: """ From 0786a3fa9a5c151eaa30cfb2926c7f82696cbe42 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 17 Sep 2025 19:51:01 +0200 Subject: [PATCH 24/32] Linting --- src/remote.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/remote.py b/src/remote.py index df1ca40..f1303fe 100644 --- a/src/remote.py +++ b/src/remote.py @@ -35,6 +35,7 @@ COMMAND_DURATION_MS = 250 COMMAND_TIMEOUT = 5000 + def calculate_duration(cmd_id: str, params: dict[str, Any] | None = None) -> int: """Calculate and return the expected duration of command or command sequence.""" delay = get_int_param("delay", params, 0) @@ -42,7 +43,7 @@ def calculate_duration(cmd_id: str, params: dict[str, Any] | None = None) -> int commands_count = 1 if cmd_id == Commands.SEND_CMD_SEQUENCE: commands_count = len(params.get("sequence", [])) - return commands_count*(delay+COMMAND_DURATION_MS)*repeat + return commands_count * (delay + COMMAND_DURATION_MS) * repeat def get_int_param(param: str, params: dict[str, Any], default: int): From fa8bf444656b369525dde46abfff86f4319d09f5 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:19:38 +0200 Subject: [PATCH 25/32] Better way to handle long commands --- src/remote.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/remote.py b/src/remote.py index f1303fe..3b899f0 100644 --- a/src/remote.py +++ b/src/remote.py @@ -7,6 +7,7 @@ import asyncio import logging +from asyncio import shield from typing import Any from ucapi import EntityTypes, Remote, StatusCodes @@ -32,19 +33,9 @@ MediaStates.UNKNOWN: RemoteStates.UNKNOWN, } -COMMAND_DURATION_MS = 250 -COMMAND_TIMEOUT = 5000 +COMMAND_TIMEOUT = 4500 -def calculate_duration(cmd_id: str, params: dict[str, Any] | None = None) -> int: - """Calculate and return the expected duration of command or command sequence.""" - delay = get_int_param("delay", params, 0) - repeat = get_int_param("repeat", params, 1) - commands_count = 1 - if cmd_id == Commands.SEND_CMD_SEQUENCE: - commands_count = len(params.get("sequence", [])) - return commands_count * (delay + COMMAND_DURATION_MS) * repeat - def get_int_param(param: str, params: dict[str, Any], default: int): """Get parameter in integer format.""" @@ -110,11 +101,12 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St elif command in self.options.get(Options.SIMPLE_COMMANDS, {}): res = await self._device.send_media_player_command(command) elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: - # If the expected duration exceeds the remote timeout, execute it in async mode - if calculate_duration(cmd_id, params) > COMMAND_TIMEOUT: - _ = asyncio.get_event_loop().create_task(self.send_commands(cmd_id, params)) - else: - res = self.send_commands(cmd_id, params) + # If the expected duration exceeds the remote timeout, keep it running and return + try: + async with asyncio.timeout(COMMAND_TIMEOUT): + res = await shield(self.send_commands(cmd_id, params)) + except asyncio.TimeoutError: + _LOG.info("[%s] Command request timeout, keep running: %s %s", self.id, cmd_id, params) else: return StatusCodes.NOT_IMPLEMENTED return res From fbb9678c0b395d4b9d11c990be6f1f99adc2fc5b Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:19:59 +0200 Subject: [PATCH 26/32] Linting --- src/remote.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/remote.py b/src/remote.py index 3b899f0..b4c0c69 100644 --- a/src/remote.py +++ b/src/remote.py @@ -36,7 +36,6 @@ COMMAND_TIMEOUT = 4500 - def get_int_param(param: str, params: dict[str, Any], default: int): """Get parameter in integer format.""" # TODO bug to be fixed on UC Core : some params are sent as (empty) strings by remote (hold == "") From db2a1ed9df4d82e0f8443ea27491af81b241d1df Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:24:34 +0200 Subject: [PATCH 27/32] Fixed wrong timeout unit --- src/remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/remote.py b/src/remote.py index b4c0c69..ba212c4 100644 --- a/src/remote.py +++ b/src/remote.py @@ -33,7 +33,7 @@ MediaStates.UNKNOWN: RemoteStates.UNKNOWN, } -COMMAND_TIMEOUT = 4500 +COMMAND_TIMEOUT = 4.5 def get_int_param(param: str, params: dict[str, Any], default: int): From c54fcfec6f4fc6b38a8f9952909397fa56902063 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:22:19 +0200 Subject: [PATCH 28/32] Removed simple commands block because simple commands should be handled as others send_cmd commands --- src/remote.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/remote.py b/src/remote.py index ba212c4..114616b 100644 --- a/src/remote.py +++ b/src/remote.py @@ -97,8 +97,6 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St res = await self._device.turn_off() else: res = await self._device.turn_on() - elif command in self.options.get(Options.SIMPLE_COMMANDS, {}): - res = await self._device.send_media_player_command(command) elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: # If the expected duration exceeds the remote timeout, keep it running and return try: From c525d1328f537eea00065b3b95268f0dac5cdf24 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:23:35 +0200 Subject: [PATCH 29/32] Cleaning --- src/remote.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/remote.py b/src/remote.py index 114616b..5597e27 100644 --- a/src/remote.py +++ b/src/remote.py @@ -86,7 +86,6 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St if self._device is None: _LOG.warning("[%s] No AndroidTV instance for this remote entity", self.id) return StatusCodes.NOT_FOUND - command = params.get("command", "") res = StatusCodes.OK if cmd_id == Commands.ON: res = await self._device.turn_on() @@ -98,7 +97,7 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St else: res = await self._device.turn_on() elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: - # If the expected duration exceeds the remote timeout, keep it running and return + # If the duration exceeds the remote timeout, keep it running and return immediately try: async with asyncio.timeout(COMMAND_TIMEOUT): res = await shield(self.send_commands(cmd_id, params)) From cec539cabdb202dda996a8cdb223357088e86388 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:38:33 +0200 Subject: [PATCH 30/32] Linting --- src/remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/remote.py b/src/remote.py index 5597e27..9522c22 100644 --- a/src/remote.py +++ b/src/remote.py @@ -12,7 +12,7 @@ from ucapi import EntityTypes, Remote, StatusCodes from ucapi.media_player import States as MediaStates -from ucapi.remote import Attributes, Commands, Features, Options +from ucapi.remote import Attributes, Commands, Features from ucapi.remote import States as RemoteStates import tv From 3021b28b4d1ca721122417b39a4a19003d87d550 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:37:04 +0200 Subject: [PATCH 31/32] Fixes after testing --- requirements.txt | 2 +- src/driver.py | 2 ++ src/remote.py | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0be9ebc..da2dffd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -androidtvremote2==0.2.1 +androidtvremote2==0.2.3 ucapi==0.3.2 pyee~=13.0.0 google_play_scraper==1.2.7 diff --git a/src/driver.py b/src/driver.py index 27ee108..608eaf9 100644 --- a/src/driver.py +++ b/src/driver.py @@ -454,6 +454,8 @@ async def main(): logging.getLogger("discover").setLevel(level) logging.getLogger("profiles").setLevel(level) logging.getLogger("setup_flow").setLevel(level) + logging.getLogger("media_player").setLevel(level) + logging.getLogger("remote").setLevel(level) logging.getLogger("androidtvremote2").setLevel(level) logging.getLogger("external_metadata").setLevel(level) # logging.getLogger("pychromecast").setLevel(level) diff --git a/src/remote.py b/src/remote.py index 9522c22..24daffa 100644 --- a/src/remote.py +++ b/src/remote.py @@ -41,8 +41,8 @@ def get_int_param(param: str, params: dict[str, Any], default: int): # TODO bug to be fixed on UC Core : some params are sent as (empty) strings by remote (hold == "") value = params.get(param, default) if isinstance(value, str) and len(value) > 0: - return int(float(value)) - return default + return int(value) + return value class AndroidTVRemote(Remote): @@ -120,7 +120,7 @@ async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None) if result != StatusCodes.OK: res = result if delay > 0: - await asyncio.sleep(delay) + await asyncio.sleep(delay/1000) else: commands = params.get("sequence", []) for command in commands: @@ -128,7 +128,7 @@ async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None) if result != StatusCodes.OK: res = result if delay > 0: - await asyncio.sleep(delay) + await asyncio.sleep(delay/1000) return res def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: From e02a9334659219334c8f9ff42f5f51309c31270b Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 1 Oct 2025 11:29:49 +0200 Subject: [PATCH 32/32] remote.py linting --- src/remote.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/remote.py b/src/remote.py index 24daffa..798f270 100644 --- a/src/remote.py +++ b/src/remote.py @@ -120,7 +120,7 @@ async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None) if result != StatusCodes.OK: res = result if delay > 0: - await asyncio.sleep(delay/1000) + await asyncio.sleep(delay / 1000) else: commands = params.get("sequence", []) for command in commands: @@ -128,7 +128,7 @@ async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None) if result != StatusCodes.OK: res = result if delay > 0: - await asyncio.sleep(delay/1000) + await asyncio.sleep(delay / 1000) return res def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: