diff --git a/README.md b/README.md index 6c3e67e..aebd1af 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ development. - [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. @@ -30,6 +30,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 these specific commands are also available depending on the device capabilities : + +**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` + +**Shield TV :** `SCREENSAVER` + +**Philips Android TV:** `TELETEXT` + + ## Standalone Usage ### Setup diff --git a/driver.json b/driver.json index 80c5473..700cb8d 100644 --- a/driver.json +++ b/driver.json @@ -41,5 +41,5 @@ } ] }, - "release_date": "2025-09-18" + "release_date": "2025-09-06" } diff --git a/src/config.py b/src/config.py index b5515b4..3611bfa 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(device_id: str, entity_type: EntityTypes) -> str: + """Create a unique entity identifier for the given receiver and entity type.""" + return f"{entity_type.value}.{device_id}" + + +def device_from_entity_id(entity_id: str) -> str | None: + """ + 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 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/const.py b/src/const.py new file mode 100644 index 0000000..22bfb08 --- /dev/null +++ b/src/const.py @@ -0,0 +1,104 @@ +""" +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})), + 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=Buttons.STOP, short_press=EntityCommand(**{"cmd_id": Commands.STOP})), + DeviceButtonMapping(button=Buttons.MENU, short_press=EntityCommand(**{"cmd_id": Commands.CONTEXT_MENU})), +] + + +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}}, + "location": {"x": 0, "y": 0}, + "text": "1", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2}}, + "location": {"x": 1, "y": 0}, + "text": "2", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3}}, + "location": {"x": 2, "y": 0}, + "text": "3", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4}}, + "location": {"x": 0, "y": 1}, + "text": "4", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5}}, + "location": {"x": 1, "y": 1}, + "text": "5", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6}}, + "location": {"x": 2, "y": 1}, + "text": "6", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7}}, + "location": {"x": 0, "y": 2}, + "text": "7", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8}}, + "location": {"x": 1, "y": 2}, + "text": "8", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9}}, + "location": {"x": 2, "y": 2}, + "text": "9", + "type": "text", + }, + { + "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0}}, + "location": {"x": 1, "y": 3}, + "text": "0", + "type": "text", + }, + ], + } + ), +] diff --git a/src/driver.py b/src/driver.py index 50ee0e4..608eaf9 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,34 @@ 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): + 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( + 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 +116,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 +144,93 @@ 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() + device_id = config.device_from_entity_id(entity_id) + if device_id is None: + continue + devices_to_remove.add(device_id) + # 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) -# 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. - - Called by the integration-API if a command is sent to a configured media-player entity. - - :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 +239,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 +271,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 +280,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 +376,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 +394,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 +435,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(): @@ -407,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/media_player.py b/src/media_player.py new file mode 100644 index 0000000..f2d9bb5 --- /dev/null +++ b/src/media_player.py @@ -0,0 +1,81 @@ +""" +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..798f270 --- /dev/null +++ b/src/remote.py @@ -0,0 +1,162 @@ +""" +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 asyncio import shield +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 +from ucapi.remote import States as RemoteStates + +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__) + +# 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, +} + +COMMAND_TIMEOUT = 4.5 + + +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(value) + return value + + +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=REMOTE_BUTTONS_MAPPING, + ui_pages=REMOTE_UI_PAGES, + ) + + 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 + res = StatusCodes.OK + if cmd_id == Commands.ON: + res = await self._device.turn_on() + elif cmd_id == Commands.OFF: + res = await self._device.turn_off() + elif cmd_id == Commands.TOGGLE: + if self._device.is_on: + res = await self._device.turn_off() + else: + res = await self._device.turn_on() + elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: + # 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)) + 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 + + 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 = 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: + result = await self._device.send_media_player_command(command) + if result != StatusCodes.OK: + res = result + if delay > 0: + await asyncio.sleep(delay / 1000) + else: + commands = params.get("sequence", []) + for command in commands: + result = await self._device.send_media_player_command(command) + if result != StatusCodes.OK: + res = result + if delay > 0: + await asyncio.sleep(delay / 1000) + 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 8d99c0e..89ee7d6 100644 --- a/src/tv.py +++ b/src/tv.py @@ -361,6 +361,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: