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" } diff --git a/src/config.py b/src/config.py index b37b66f..0397091 100644 --- a/src/config.py +++ b/src/config.py @@ -38,6 +38,10 @@ class AtvDevice: """Enable External Metadata.""" use_chromecast: bool = False """Enable Chromecast features.""" + use_chromecast_volume: bool = False + """Enable volume driven by Chromecast protocol.""" + volume_step: float = 10 + """Volume step (1 to 100).""" class _EnhancedJSONEncoder(json.JSONEncoder): @@ -133,6 +137,8 @@ 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.use_chromecast_volume = atv.use_chromecast_volume + item.volume_step = atv.volume_step if atv.volume_step else 10 return self.store() return False @@ -236,6 +242,8 @@ def load(self) -> bool: item.get("auth_error", False), item.get("use_external_metadata", False), item.get("use_chromecast", False), + item.get("use_chromecast_volume", False), + item.get("volume_step", 10), ) self._config.append(atv) return True 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, ], [], {}, diff --git a/src/setup_flow.py b/src/setup_flow.py index 4fff16f..e59fb26 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -48,6 +48,8 @@ class SetupSteps(IntEnum): _use_external_metadata: bool = False _reconfigured_device: AtvDevice | None = None _use_chromecast: bool = False +_use_chromecast_volume: bool = False +_volume_step: float = 10 # TODO #9 externalize language texts _user_input_discovery = RequestUserInput( @@ -297,9 +299,13 @@ async def handle_configuration_mode( _setup_step = SetupSteps.RECONFIGURE _reconfigured_device = selected_device use_chromecast = selected_device.use_chromecast if selected_device.use_chromecast else False + use_chromecast_volume = ( + selected_device.use_chromecast_volume if selected_device.use_chromecast_volume else False + ) 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( { @@ -316,6 +322,14 @@ async def handle_configuration_mode( }, "field": {"checkbox": {"value": use_chromecast}}, }, + { + "id": "chromecast_volume", + "label": { + "en": "Set volume through Chromecast", + "fr": "Régler le volume par Chromecast", + }, + "field": {"checkbox": {"value": use_chromecast_volume}}, + }, { "id": "external_metadata", "label": { @@ -325,6 +339,14 @@ async def handle_configuration_mode( }, "field": {"checkbox": {"value": use_external_metadata}}, }, + { + "id": "volume_step", + "label": { + "en": "Volume step in percent (Chromecast only)", + "fr": "Pallier de volume en pourcentage (Chromecast uniquement)", + }, + "field": {"number": {"value": volume_step, "min": 1, "max": 50, "steps": 1, "decimals": 0}}, + }, ], ) case "reset": @@ -437,6 +459,14 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr }, "field": {"checkbox": {"value": False}}, }, + { + "id": "chromecast_volume", + "label": { + "en": "Set volume through Chromecast", + "fr": "Régler le volume par Chromecast", + }, + "field": {"checkbox": {"value": False}}, + }, { "id": "external_metadata", "label": { @@ -446,6 +476,14 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr }, "field": {"checkbox": {"value": False}}, }, + { + "id": "volume_step", + "label": { + "en": "Volume step in percent (Chromecast only)", + "fr": "Pallier de volume en pourcent (Chromecast uniquement)", + }, + "field": {"number": {"value": 10, "min": 1, "max": 50, "steps": 1, "decimals": 0}}, + }, ], ) @@ -461,12 +499,16 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu """ global _pairing_android_tv global _use_chromecast + global _use_chromecast_volume 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" + _use_chromecast_volume = msg.input_values.get("chromecast_volume", "false") == "true" + _volume_step = float(msg.input_values.get("volume_step", 10)) name = "" for discovered_tv in _discovered_android_tvs: @@ -484,6 +526,8 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu id="", use_external_metadata=False, use_chromecast=False, + use_chromecast_volume=_use_chromecast_volume, + volume_step=_volume_step, ), ) _LOG.info("Chosen Android TV: %s. Start pairing process...", choice) @@ -564,8 +608,10 @@ async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupEr address=_pairing_android_tv.address, use_external_metadata=_use_external_metadata, use_chromecast=_use_chromecast, + use_chromecast_volume=_use_chromecast_volume, manufacturer=device_info.get("manufacturer", ""), model=device_info.get("model", ""), + volume_step=_volume_step, ) config.devices.add_or_update(device) # triggers AndroidTv instance creation @@ -595,15 +641,25 @@ async def _handle_device_reconfigure( return SetupError() use_chromecast = msg.input_values.get("chromecast", "false") == "true" + use_chromecast_volume = msg.input_values.get("chromecast_volume", "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_chromecast_volume = use_chromecast_volume _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.use_external_metadata, + _reconfigured_device.volume_step, + ) return SetupComplete() diff --git a/src/tv.py b/src/tv.py index fa3051d..af46c7b 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.""" @@ -961,46 +966,66 @@ async def media_seek(self, position: float) -> ucapi.StatusCodes: async def volume_up(self) -> ucapi.StatusCodes: """Change volume up.""" - if self._chromecast is None: - return ucapi.StatusCodes.NOT_IMPLEMENTED - try: - self._chromecast.volume_up() - return ucapi.StatusCodes.OK - except PyChromecastError as ex: - _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) - return ucapi.StatusCodes.BAD_REQUEST + if self.device_config.use_chromecast and self.device_config.use_chromecast_volume: + if self._chromecast is None: + return ucapi.StatusCodes.NOT_IMPLEMENTED + try: + _LOG.debug( + "[%s] Volume up : current %.2f + 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: + _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) + return ucapi.StatusCodes.BAD_REQUEST + return await self.send_media_player_command(media_player.Commands.VOLUME_UP) async def volume_down(self) -> ucapi.StatusCodes: """Change volume down.""" - if self._chromecast is None: - return ucapi.StatusCodes.NOT_IMPLEMENTED - try: - self._chromecast.volume_down() - return ucapi.StatusCodes.OK - except PyChromecastError as ex: - _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) - return ucapi.StatusCodes.BAD_REQUEST + if self.device_config.use_chromecast and self.device_config.use_chromecast_volume: + if self._chromecast is None: + return ucapi.StatusCodes.NOT_IMPLEMENTED + try: + _LOG.debug( + "[%s] Volume down : current %.2f - 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: + _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) + return ucapi.StatusCodes.BAD_REQUEST + return await self.send_media_player_command(media_player.Commands.VOLUME_DOWN) async def volume_mute_toggle(self) -> ucapi.StatusCodes: """Mute toggle.""" - if self._chromecast is None: - return ucapi.StatusCodes.NOT_IMPLEMENTED - try: - self._muted = not self._muted - self._chromecast.set_volume_muted(self._muted) - return ucapi.StatusCodes.OK - except PyChromecastError as ex: - _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) - return ucapi.StatusCodes.BAD_REQUEST + if self.device_config.use_chromecast and self.device_config.use_chromecast_volume: + if self._chromecast is None: + 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: + _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) + return ucapi.StatusCodes.BAD_REQUEST + return await self.send_media_player_command(media_player.Commands.MUTE_TOGGLE) - 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) + _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: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex)