diff --git a/.pylintrc b/.pylintrc index fd6c627..a2999cf 100644 --- a/.pylintrc +++ b/.pylintrc @@ -19,6 +19,7 @@ disable= global-statement, too-many-instance-attributes, too-many-arguments, + too-many-public-methods, fixme # temporary [STRING] diff --git a/intg-appletv/driver.py b/intg-appletv/driver.py index 8112c11..6ea590d 100644 --- a/intg-appletv/driver.py +++ b/intg-appletv/driver.py @@ -14,30 +14,30 @@ import config import pyatv import pyatv.const +import setup_flow import tv import ucapi import ucapi.api as uc from ucapi import MediaPlayer, media_player -LOG = logging.getLogger(__name__) -LOOP = asyncio.get_event_loop() +_LOG = logging.getLogger("driver") # avoid having __main__ in log messages +_LOOP = asyncio.get_event_loop() # Global variables -api = uc.IntegrationAPI(LOOP) -configuredAppleTvs = {} -pairing_apple_tv = None +api = uc.IntegrationAPI(_LOOP) +_configured_atvs: dict[str, tv.AppleTv] = {} # DRIVER SETUP # @api.events.on(uc.uc.EVENTS.SETUP_DRIVER) # async def on_setup_driver(websocket, req_id, _data): -# LOG.debug("Starting driver setup") +# _LOG.debug("Starting driver setup") # config.devices.clear() # await api.acknowledge_command(websocket, req_id) # await api.driver_setup_progress(websocket) # -# LOG.debug("Starting Apple TV discovery") -# tvs = await discover.apple_tvs(LOOP) +# _LOG.debug("Starting Apple TV discovery") +# tvs = await discover.apple_tvs(_LOOP) # dropdown_items = [] # # for device in tvs: @@ -46,7 +46,7 @@ # dropdown_items.append(tv_data) # # if not dropdown_items: -# LOG.warning("No Apple TVs found") +# _LOG.warning("No Apple TVs found") # await api.driver_setup_error(websocket) # return # @@ -72,7 +72,7 @@ # # # We pair with companion second # if "pin_companion" in data: -# LOG.debug("User has entered the Companion PIN") +# _LOG.debug("User has entered the Companion PIN") # await pairing_apple_tv.enter_pin(data["pin_companion"]) # # res = await pairing_apple_tv.finish_pairing() @@ -99,7 +99,7 @@ # # # We pair with airplay first # elif "pin_airplay" in data: -# LOG.debug("User has entered the Airplay PIN") +# _LOG.debug("User has entered the Airplay PIN") # await pairing_apple_tv.enter_pin(data["pin_airplay"]) # # res = await pairing_apple_tv.finish_pairing() @@ -114,7 +114,7 @@ # res = await pairing_apple_tv.start_pairing(pyatv.const.Protocol.Companion, "Remote Two Companion") # # if res == 0: -# LOG.debug("Device provides PIN") +# _LOG.debug("Device provides PIN") # await api.request_driver_setup_user_input( # websocket, # "Please enter the PIN from your Apple TV", @@ -128,7 +128,7 @@ # ) # # else: -# LOG.debug("We provide PIN") +# _LOG.debug("We provide PIN") # await api.request_driver_setup_user_confirmation( # websocket, "Please enter the following PIN on your Apple TV:" + res # ) @@ -136,25 +136,25 @@ # # elif "choice" in data: # choice = data["choice"] -# LOG.debug("Chosen Apple TV: %s", choice) +# _LOG.debug("Chosen Apple TV: %s", choice) # # # Create a new AppleTv object -# pairing_apple_tv = tv.AppleTv(LOOP) +# pairing_apple_tv = tv.AppleTv(_LOOP) # pairing_apple_tv.pairing_atv = await pairing_apple_tv.find_atv(choice) # # if pairing_apple_tv.pairing_atv is None: -# LOG.error("Cannot find the chosen AppleTV") +# _LOG.error("Cannot find the chosen AppleTV") # await api.driver_setup_error(websocket) # return # # await pairing_apple_tv.init(choice, name=pairing_apple_tv.pairing_atv.name) # -# LOG.debug("Pairing process begin") +# _LOG.debug("Pairing process begin") # # Hook up to signals # res = await pairing_apple_tv.start_pairing(pyatv.const.Protocol.AirPlay, "Remote Two Airplay") # # if res == 0: -# LOG.debug("Device provides PIN") +# _LOG.debug("Device provides PIN") # await api.request_driver_setup_user_input( # websocket, # "Please enter the PIN from your Apple TV", @@ -168,14 +168,14 @@ # ) # # else: -# LOG.debug("We provide PIN") +# _LOG.debug("We provide PIN") # await api.request_driver_setup_user_confirmation( # websocket, "Please enter the following PIN on your Apple TV:" + res # ) # await pairing_apple_tv.finish_pairing() # # else: -# LOG.error("No choice was received") +# _LOG.error("No choice was received") # await api.driver_setup_error(websocket) @@ -189,9 +189,10 @@ async def on_r2_connect_cmd() -> None: @api.listens_to(ucapi.Events.DISCONNECT) async def on_r2_disconnect_cmd(): """Disconnect all configured ATVs when the Remote Two sends the disconnect command.""" - for device in configuredAppleTvs.values(): - LOG.debug("Client disconnected, disconnecting all Apple TVs") + _LOG.debug("Client disconnected, disconnecting all Apple TVs") + for device in _configured_atvs.values(): await device.disconnect() + # TODO still required? device.events.remove_all_listeners() await api.set_device_state(ucapi.DeviceStates.DISCONNECTED) @@ -204,7 +205,9 @@ async def on_r2_enter_standby() -> None: Disconnect every ATV instances. """ - for device in configuredAppleTvs.values(): + _LOG.debug("Enter standby event: disconnecting device(s)") + + for device in _configured_atvs.values(): await device.disconnect() @@ -215,7 +218,9 @@ async def on_r2_exit_standby() -> None: Connect all ATV instances. """ - for device in configuredAppleTvs.values(): + _LOG.debug("Exit standby event: connecting device(s)") + + for device in _configured_atvs.values(): await device.connect() @@ -226,42 +231,36 @@ async def on_subscribe_entities(entity_ids: list[str]) -> None: :param entity_ids: entity identifiers. """ + _LOG.debug("Subscribe entities event: %s", entity_ids) for entity_id in entity_ids: - if entity_id in configuredAppleTvs: - LOG.debug("We have a match, start listening to events") - - api.configured_entities.update_attributes( - entity_id, {media_player.Attributes.STATE: media_player.States.UNAVAILABLE} - ) - - device = configuredAppleTvs[entity_id] - - @device.events.on(tv.EVENTS.CONNECTED) - async def _on_connected(identifier): - await on_atv_connected(identifier) - - @device.events.on(tv.EVENTS.DISCONNECTED) - async def _on_disconnected(identifier): - await on_atv_disconnected(identifier) - - @device.events.on(tv.EVENTS.ERROR) - async def _on_disconnected(identifier, message): - await on_atv_connection_error(identifier, message) - - @device.events.on(tv.EVENTS.UPDATE) - async def on_update(update): - await on_atv_update(entity_id, update) - - await device.connect() + # TODO add atv_id -> list(entities_id) mapping. Right now the atv_id == entity_id! + atv_id = entity_id + if atv_id in _configured_atvs: + _LOG.debug("We have a match, start listening to events") + atv = _configured_atvs[atv_id] + if atv.is_on is None: + state = 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}) + continue + + device = config.devices.get(atv_id) + if device: + _add_configured_atv(device) + else: + _LOG.error("Failed to subscribe entity %s: no Apple TV instance found", entity_id) @api.listens_to(ucapi.Events.UNSUBSCRIBE_ENTITIES) async def on_unsubscribe_entities(entity_ids: list[str]) -> None: """On unsubscribe, we disconnect the objects and remove listeners for events.""" + _LOG.debug("Unsubscribe entities event: %s", entity_ids) + # TODO add entity_id --> atv_id mapping. Right now the atv_id == entity_id! for entity_id in entity_ids: - if entity_id in configuredAppleTvs: - LOG.debug("We have a match, stop listening to events") - device = configuredAppleTvs[entity_id] + if entity_id in _configured_atvs: + _LOG.debug("We have a match, stop listening to events") + device = _configured_atvs[entity_id] await device.disconnect() device.events.remove_all_listeners() @@ -279,7 +278,7 @@ async def media_player_cmd_handler( :param params: optional command parameters :return: """ - LOG.info("Got %s command request: %s %s", entity.id, cmd_id, params) + _LOG.info("Got %s command request: %s %s", entity.id, cmd_id, params) # TODO map from device id to entities (see Denon integration) # atv_id = _tv_from_entity_id(entity.id) @@ -287,7 +286,7 @@ async def media_player_cmd_handler( # return ucapi.StatusCodes.NOT_FOUND atv_id = entity.id - device = configuredAppleTvs[atv_id] + device = _configured_atvs[atv_id] # If the device is not on we send SERVICE_UNAVAILABLE if device.is_on is False: @@ -296,64 +295,65 @@ async def media_player_cmd_handler( configured_entity = api.configured_entities.get(entity.id) if configured_entity is None: - LOG.warning("No Apple TV device found for entity: %s", entity.id) + _LOG.warning("No Apple TV device found for entity: %s", entity.id) return ucapi.StatusCodes.SERVICE_UNAVAILABLE # If the entity is OFF, we send the turnOn command regardless of the actual command if configured_entity.attributes[media_player.Attributes.STATE] == media_player.States.OFF: - LOG.debug("Apple TV is off, sending turn on command") + _LOG.debug("Apple TV is off, sending turn on command") return await device.turn_on() res = ucapi.StatusCodes.NOT_IMPLEMENTED - if cmd_id == media_player.Commands.PLAY_PAUSE: - res = await device.play_pause() - elif cmd_id == media_player.Commands.NEXT: - res = await device.next() - elif cmd_id == media_player.Commands.PREVIOUS: - res = await device.previous() - elif cmd_id == media_player.Commands.VOLUME_UP: - res = await device.volume_up() - elif cmd_id == media_player.Commands.VOLUME_DOWN: - res = await device.volume_down() - elif cmd_id == media_player.Commands.ON: - res = await device.turn_on() - elif cmd_id == media_player.Commands.OFF: - res = await device.turn_off() - elif cmd_id == media_player.Commands.CURSOR_UP: - res = await device.cursor_up() - elif cmd_id == media_player.Commands.CURSOR_DOWN: - res = await device.cursor_down() - elif cmd_id == media_player.Commands.CURSOR_LEFT: - res = await device.cursor_left() - elif cmd_id == media_player.Commands.CURSOR_RIGHT: - res = await device.cursor_right() - elif cmd_id == media_player.Commands.CURSOR_ENTER: - res = await device.cursor_enter() - elif cmd_id == media_player.Commands.HOME: - res = await device.home() - - # we wait a bit to get a push update, because music can play in the background - await asyncio.sleep(1) - if configured_entity.attributes[media_player.Attributes.STATE] != media_player.States.PLAYING: - # if nothing is playing we clear the playing information - attributes = {} - attributes[media_player.Attributes.MEDIA_IMAGE_URL] = "" - attributes[media_player.Attributes.MEDIA_ALBUM] = "" - attributes[media_player.Attributes.MEDIA_ARTIST] = "" - attributes[media_player.Attributes.MEDIA_TITLE] = "" - attributes[media_player.Attributes.MEDIA_TYPE] = "" - attributes[media_player.Attributes.SOURCE] = "" - attributes[media_player.Attributes.MEDIA_DURATION] = 0 - api.configured_entities.update_attributes(entity.id, attributes) - elif cmd_id == media_player.Commands.BACK: - res = await device.menu() - elif cmd_id == media_player.Commands.CHANNEL_DOWN: - res = await device.channel_down() - elif cmd_id == media_player.Commands.CHANNEL_UP: - res = await device.channel_up() - elif cmd_id == media_player.Commands.SELECT_SOURCE: - res = await device.launch_app(params["source"]) + match cmd_id: + case media_player.Commands.PLAY_PAUSE: + res = await device.play_pause() + case media_player.Commands.NEXT: + res = await device.next() + case media_player.Commands.PREVIOUS: + res = await device.previous() + case media_player.Commands.VOLUME_UP: + res = await device.volume_up() + case media_player.Commands.VOLUME_DOWN: + res = await device.volume_down() + case media_player.Commands.ON: + res = await device.turn_on() + case media_player.Commands.OFF: + res = await device.turn_off() + case media_player.Commands.CURSOR_UP: + res = await device.cursor_up() + case media_player.Commands.CURSOR_DOWN: + res = await device.cursor_down() + case media_player.Commands.CURSOR_LEFT: + res = await device.cursor_left() + case media_player.Commands.CURSOR_RIGHT: + res = await device.cursor_right() + case media_player.Commands.CURSOR_ENTER: + res = await device.cursor_enter() + case media_player.Commands.HOME: + res = await device.home() + + # we wait a bit to get a push update, because music can play in the background + await asyncio.sleep(1) + if configured_entity.attributes[media_player.Attributes.STATE] != media_player.States.PLAYING: + # if nothing is playing we clear the playing information + attributes = {} + attributes[media_player.Attributes.MEDIA_IMAGE_URL] = "" + attributes[media_player.Attributes.MEDIA_ALBUM] = "" + attributes[media_player.Attributes.MEDIA_ARTIST] = "" + attributes[media_player.Attributes.MEDIA_TITLE] = "" + attributes[media_player.Attributes.MEDIA_TYPE] = "" + attributes[media_player.Attributes.SOURCE] = "" + attributes[media_player.Attributes.MEDIA_DURATION] = 0 + api.configured_entities.update_attributes(entity.id, attributes) + case media_player.Commands.BACK: + res = await device.menu() + case media_player.Commands.CHANNEL_DOWN: + res = await device.channel_down() + case media_player.Commands.CHANNEL_UP: + res = await device.channel_up() + case media_player.Commands.SELECT_SOURCE: + res = await device.launch_app(params["source"]) return res @@ -373,7 +373,7 @@ def _key_update_helper(key, value, attributes, configured_entity): async def on_atv_connected(identifier: str) -> None: """Handle ATV connection.""" - LOG.debug("Apple TV connected: %s", identifier) + _LOG.debug("Apple TV connected: %s", identifier) configured_entity = api.configured_entities.get(identifier) if configured_entity.attributes[media_player.Attributes.STATE] == media_player.States.UNAVAILABLE: @@ -384,7 +384,7 @@ async def on_atv_connected(identifier: str) -> None: async def on_atv_disconnected(identifier: str) -> None: """Handle ATV disconnection.""" - LOG.debug("Apple TV disconnected: %s", identifier) + _LOG.debug("Apple TV disconnected: %s", identifier) api.configured_entities.update_attributes( identifier, {media_player.Attributes.STATE: media_player.States.UNAVAILABLE} ) @@ -392,13 +392,15 @@ async def on_atv_disconnected(identifier: str) -> None: async def on_atv_connection_error(identifier: str, message) -> None: """Set entities of ATV to state UNAVAILABLE if ATV connection error occurred.""" - LOG.error(message) + _LOG.error(message) api.configured_entities.update_attributes( identifier, {media_player.Attributes.STATE: media_player.States.UNAVAILABLE} ) await api.set_device_state(ucapi.DeviceStates.ERROR) +# TODO refactor & simply on_atv_update, then remove pylint exceptions +# pylint: disable=too-many-branches,too-many-statements async def on_atv_update(entity_id: str, update: dict[str, Any] | None) -> None: """ Update attributes of configured media-player entity if ATV properties changed. @@ -413,20 +415,21 @@ async def on_atv_update(entity_id: str, update: dict[str, Any] | None) -> None: return if "state" in update: - state = media_player.States.UNKNOWN - - if update["state"] == pyatv.const.PowerState.On: - state = media_player.States.ON - elif update["state"] == pyatv.const.DeviceState.Playing: - state = media_player.States.PLAYING - elif update["state"] == pyatv.const.DeviceState.Playing: - state = media_player.States.PLAYING - elif update["state"] == pyatv.const.DeviceState.Paused: - state = media_player.States.PAUSED - elif update["state"] == pyatv.const.DeviceState.Idle: - state = media_player.States.PAUSED - elif update["state"] == pyatv.const.PowerState.Off: - state = media_player.States.OFF + match update["state"]: + case pyatv.const.PowerState.On: + state = media_player.States.ON + case pyatv.const.DeviceState.Playing: + state = media_player.States.PLAYING + case pyatv.const.DeviceState.Playing: + state = media_player.States.PLAYING + case pyatv.const.DeviceState.Paused: + state = media_player.States.PAUSED + case pyatv.const.DeviceState.Idle: + state = media_player.States.PAUSED + case pyatv.const.PowerState.Off: + state = media_player.States.OFF + case _: + state = media_player.States.UNKNOWN attributes = _key_update_helper(media_player.Attributes.STATE, state, attributes, configured_entity) @@ -491,7 +494,35 @@ async def on_atv_update(entity_id: str, update: dict[str, Any] | None) -> None: api.configured_entities.update_attributes(entity_id, attributes) -def add_available_apple_tv(identifier: str, name: str) -> bool: +def _add_configured_atv(device: config.AtvDevice, connect: bool = True) -> None: + # the device should not yet be configured, but better be safe + if device.identifier in _configured_atvs: + atv = _configured_atvs[device.identifier] + atv.disconnect() + else: + _LOG.debug("Adding new ATV device: %s (%s)", device.identifier, device.name) + atv = tv.AppleTv(_LOOP) + atv.init(device.identifier, device.credentials, device.name) + atv.events.on(tv.EVENTS.CONNECTED, on_atv_connected) + atv.events.on(tv.EVENTS.DISCONNECTED, on_atv_disconnected) + atv.events.on(tv.EVENTS.ERROR, on_atv_connection_error) + atv.events.on(tv.EVENTS.UPDATE, on_atv_update) + + _configured_atvs[device.identifier] = atv + + # await atv.connect() + + async def start_connection(): + await atv.connect() + + if connect: + # start background task + _LOOP.create_task(start_connection()) + + _register_available_entities(device.identifier, device.name) + + +def _register_available_entities(identifier: str, name: str) -> bool: """ Add a new ATV device to the available entities. @@ -499,8 +530,11 @@ def add_available_apple_tv(identifier: str, name: str) -> bool: :param name: Friendly name :return: True if added, False if the device was already in storage. """ + # TODO map entity IDs from device identifier + entity_id = identifier + # plain and simple for now: only one media_player per ATV device entity = MediaPlayer( - identifier, + entity_id, name, [ media_player.Features.ON_OFF, @@ -537,22 +571,24 @@ def add_available_apple_tv(identifier: str, name: str) -> bool: cmd_handler=media_player_cmd_handler, ) + if api.available_entities.contains(entity.id): + api.available_entities.remove(entity.id) return api.available_entities.add(entity) def on_device_added(device: config.AtvDevice) -> None: """Handle a newly added device in the configuration.""" - LOG.debug("New device added: %s", device) - # TODO + _LOG.debug("New device added: %s", device) + _add_configured_atv(device, connect=False) def on_device_removed(device: config.AtvDevice | None) -> None: """Handle a removed device in the configuration.""" if device is None: - LOG.debug("Configuration cleared, disconnecting & removing all configured ATV instances") + _LOG.debug("Configuration cleared, disconnecting & removing all configured ATV instances") # TODO else: - LOG.debug("Device removed: %s", device) + _LOG.debug("Device removed: %s", device) # TODO @@ -562,21 +598,17 @@ async def main(): level = os.getenv("UC_LOG_LEVEL", "DEBUG").upper() logging.getLogger("tv").setLevel(level) - logging.getLogger("discover").setLevel(level) logging.getLogger("driver").setLevel(level) + logging.getLogger("discover").setLevel(level) + logging.getLogger("setup_flow").setLevel(level) config.devices = config.Devices(api.config_dir_path, on_device_added, on_device_removed) for device in config.devices.all(): - # _add_configured_apple_tv(device, connect=False) - apple_tv = tv.AppleTv(LOOP) - await apple_tv.init(device.identifier, device.credentials, device.name) - configuredAppleTvs[apple_tv.identifier] = apple_tv - - add_available_apple_tv(device.identifier, device.name) + _add_configured_atv(device, connect=False) - await api.init("driver.json") + await api.init("driver.json", setup_flow.driver_setup_handler) if __name__ == "__main__": - LOOP.run_until_complete(main()) - LOOP.run_forever() + _LOOP.run_until_complete(main()) + _LOOP.run_forever() diff --git a/intg-appletv/setup_flow.py b/intg-appletv/setup_flow.py index 4684dcb..e7d2007 100644 --- a/intg-appletv/setup_flow.py +++ b/intg-appletv/setup_flow.py @@ -13,6 +13,7 @@ import discover import pyatv import tv +from config import AtvDevice from ucapi import ( AbortDriverSetup, DriverSetupRequest, @@ -39,9 +40,8 @@ class SetupSteps(IntEnum): _setup_step = SetupSteps.INIT -# _discovered_android_tvs: list[dict[str, str]] = [] -# _pairing_android_tv: tv.AndroidTv | None = None -pairing_apple_tv = None +# _discovered_atvs: list[dict[str, str]] = [] +_pairing_apple_tv: tv.AppleTv | None = None async def driver_setup_handler(msg: SetupDriver) -> SetupAction: @@ -54,7 +54,7 @@ async def driver_setup_handler(msg: SetupDriver) -> SetupAction: :return: the setup action on how to continue """ global _setup_step - global _pairing_android_tv + global _pairing_apple_tv if isinstance(msg, DriverSetupRequest): _setup_step = SetupSteps.INIT @@ -70,9 +70,9 @@ async def driver_setup_handler(msg: SetupDriver) -> SetupAction: _LOG.error("No or invalid user response was received: %s", msg) elif isinstance(msg, AbortDriverSetup): _LOG.info("Setup was aborted with code: %s", msg.error) - if _pairing_android_tv is not None: - _pairing_android_tv.disconnect() - _pairing_android_tv = None + if _pairing_apple_tv is not None: + _pairing_apple_tv.disconnect() + _pairing_apple_tv = None _setup_step = SetupSteps.INIT # user confirmation not used in setup process @@ -96,9 +96,9 @@ async def handle_driver_setup(_msg: DriverSetupRequest) -> RequestUserInput | Se _LOG.debug("Starting driver setup with Apple TV discovery") # clear all configured devices and any previous pairing attempt - # if _pairing_android_tv: - # _pairing_android_tv.disconnect() - # _pairing_android_tv = None + # if _pairing_apple_tv: + # _pairing_apple_tv.disconnect() + # _pairing_apple_tv = None config.devices.clear() tvs = await discover.apple_tvs(asyncio.get_event_loop()) @@ -135,26 +135,26 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu :param msg: response data from the requested user data :return: the setup action on how to continue. """ - global pairing_apple_tv + global _pairing_apple_tv choice = msg.input_values["choice"] # name = "" _LOG.debug("Chosen Apple TV: %s", choice) # Create a new AppleTv object - pairing_apple_tv = tv.AppleTv(asyncio.get_event_loop()) - pairing_apple_tv.pairing_atv = await pairing_apple_tv.find_atv(choice) + _pairing_apple_tv = tv.AppleTv(asyncio.get_event_loop()) + _pairing_apple_tv.pairing_atv = await _pairing_apple_tv.find_atv(choice) - if pairing_apple_tv.pairing_atv is None: + if _pairing_apple_tv.pairing_atv is None: _LOG.error("Cannot find the chosen AppleTV") return SetupError(error_type=IntegrationSetupError.NOT_FOUND) - await pairing_apple_tv.init(choice, name=pairing_apple_tv.pairing_atv.name) + await _pairing_apple_tv.init(choice, name=_pairing_apple_tv.pairing_atv.name) _LOG.debug("Pairing process begin") # Hook up to signals # TODO error conditions in start_pairing? - res = await pairing_apple_tv.start_pairing(pyatv.const.Protocol.AirPlay, "Remote Two Airplay") + res = await _pairing_apple_tv.start_pairing(pyatv.const.Protocol.AirPlay, "Remote Two Airplay") if res == 0: _LOG.debug("Device provides PIN") @@ -171,7 +171,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu _LOG.debug("We provide PIN") # FIXME handle finish_pairing() in next step! - await pairing_apple_tv.finish_pairing() + await _pairing_apple_tv.finish_pairing() return RequestUserConfirmation("Please enter the following PIN on your Apple TV:" + res) # # no better error code right now @@ -188,18 +188,18 @@ async def handle_user_data_airplay_pin(msg: UserDataResponse) -> RequestUserInpu :return: the setup action on how to continue """ _LOG.debug("User has entered the Airplay PIN") - await pairing_apple_tv.enter_pin(msg.input_values["pin"]) + await _pairing_apple_tv.enter_pin(msg.input_values["pin"]) - res = await pairing_apple_tv.finish_pairing() + res = await _pairing_apple_tv.finish_pairing() if res is None: return SetupError() # Store credentials c = {"protocol": res.protocol.name.lower(), "credentials": res.credentials} - pairing_apple_tv.add_credentials(c) + _pairing_apple_tv.add_credentials(c) # Start new pairing process - res = await pairing_apple_tv.start_pairing(pyatv.const.Protocol.Companion, "Remote Two Companion") + res = await _pairing_apple_tv.start_pairing(pyatv.const.Protocol.Companion, "Remote Two Companion") if res == 0: _LOG.debug("Device provides PIN") @@ -216,34 +216,81 @@ async def handle_user_data_airplay_pin(msg: UserDataResponse) -> RequestUserInpu _LOG.debug("We provide PIN") # FIXME handle finish_pairing() in next step! - await pairing_apple_tv.finish_pairing() + await _pairing_apple_tv.finish_pairing() return RequestUserConfirmation("Please enter the following PIN on your Apple TV:" + res) - # global _pairing_android_tv + # global _pairing_apple_tv # # _LOG.info("User has entered the PIN") # - # if _pairing_android_tv is None: + # if _pairing_apple_tv is None: # _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") # return SetupError() # - # res = await _pairing_android_tv.finish_pairing(msg.input_values["pin"]) - # _pairing_android_tv.disconnect() + # res = await _pairing_apple_tv.finish_pairing(msg.input_values["pin"]) + # _pairing_apple_tv.disconnect() # # if res != ucapi.StatusCodes.OK: - # _pairing_android_tv = None + # _pairing_apple_tv = None # if res == ucapi.StatusCodes.UNAUTHORIZED: # return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) # return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED) # - # device = AtvDevice(_pairing_android_tv.identifier, _pairing_android_tv.name, _pairing_android_tv.address) + # device = AtvDevice(_pairing_apple_tv.identifier, _pairing_apple_tv.name, _pairing_apple_tv.address) # config.devices.add(device) # triggers AndroidTv instance creation # config.devices.store() # # # ATV device connection will be triggered with subscribe_entities request # - # _pairing_android_tv = None + # _pairing_apple_tv = None # await asyncio.sleep(1) # # _LOG.info("Setup successfully completed for %s", device.name) # return SetupComplete() + + +async def handle_user_data_companion_pin(msg: UserDataResponse) -> SetupComplete | SetupError: + """ + Process user data companion pairing pin response in a setup process. + + Driver setup callback to provide requested user data during the setup process. + + :param msg: response data from the requested user data + :return: the setup action on how to continue: SetupComplete if a valid Apple TV device was chosen. + """ + global _pairing_apple_tv + + _LOG.debug("User has entered the Companion PIN") + + if _pairing_apple_tv is None: + _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") + return SetupError() + + await _pairing_apple_tv.enter_pin(msg.input_values["pin_companion"]) + + res = await _pairing_apple_tv.finish_pairing() + _pairing_apple_tv.disconnect() + + if res is None: + _pairing_apple_tv = None + return SetupError() + + c = {"protocol": res.protocol.name.lower(), "credentials": res.credentials} + _pairing_apple_tv.add_credentials(c) + + device = AtvDevice( + identifier=_pairing_apple_tv.identifier, + name=_pairing_apple_tv.name, + credentials=_pairing_apple_tv.get_credentials(), + ) + config.devices.add(device) # triggers ATV instance creation + config.devices.store() + + # ATV device connection will be triggered with subscribe_entities request + + _pairing_apple_tv = None + await asyncio.sleep(1) + + _LOG.info("Setup successfully completed for %s", device.name) + + return SetupComplete() diff --git a/intg-appletv/tv.py b/intg-appletv/tv.py index e547c2f..264417a 100644 --- a/intg-appletv/tv.py +++ b/intg-appletv/tv.py @@ -92,7 +92,7 @@ async def wrapper(self: _AppleTvT, *args: _P.args, **kwargs: _P.kwargs) -> ucapi self._receiver.host, err, ) - except Exception as err: + except Exception as err: # pylint: disable=broad-exception-caught LOG.exception("Error %s occurred in method %s%s for ATV %s", err, func.__name__, args, self._receiver.host) return result @@ -114,7 +114,7 @@ def __init__( """Create instance.""" self._loop = loop or asyncio.get_running_loop() self.events = AsyncIOEventEmitter(self._loop) - self.is_on = False + self._is_on = False self._atv = None self.name = "" self.identifier = None @@ -128,6 +128,13 @@ def __init__( self._state = None self._app_list = {} + @property + def is_on(self) -> bool | None: + """Whether the Apple TV is on or off. Returns None if not connected.""" + if self._atv is None: + return None + return self._is_on + def _backoff(self) -> float: if self._connection_attempts * BACKOFF_SEC >= BACKOFF_MAX: return BACKOFF_MAX @@ -238,21 +245,21 @@ async def finish_pairing(self) -> pyatv.interface.BaseService | None: async def connect(self) -> None: """Establish connection to ATV.""" - if self.is_on is True: + if self._is_on is True: return - self.is_on = True + self._is_on = True self.events.emit(EVENTS.CONNECTING, self.identifier) self._start_connect_loop() def _start_connect_loop(self) -> None: - if not self._connect_task and self._atv is None and self.is_on: + if not self._connect_task and self._atv is None and self._is_on: self._connect_task = asyncio.create_task(self._connect_loop()) else: - LOG.debug("Not starting connect loop (Atv: %s, isOn: %s)", self._atv is None, self.is_on) + LOG.debug("Not starting connect loop (Atv: %s, isOn: %s)", self._atv is None, self._is_on) async def _connect_loop(self) -> None: LOG.debug("Starting connect loop") - while self.is_on and self._atv is None: + while self._is_on and self._atv is None: await self._connect_once() if self._atv is not None: break @@ -292,7 +299,7 @@ async def _connect_once(self) -> None: return except asyncio.CancelledError: pass - except Exception: + except Exception: # pylint: disable=broad-exception-caught LOG.warning("Could not connect") self._atv = None @@ -324,9 +331,10 @@ async def _connect(self, conf) -> None: async def disconnect(self) -> None: """Disconnect from ATV.""" LOG.debug("Disconnecting from device") - self.is_on = False + self._is_on = False await self._stop_polling() + # FIXME error handling try: if self._atv: self._atv.close() @@ -335,8 +343,8 @@ async def disconnect(self) -> None: self._connect_task.cancel() self._connect_task = None self.events.emit(EVENTS.DISCONNECTED, self.identifier) - except Exception: - LOG.exception("An error occured while disconnecting") + except Exception: # pylint: disable=broad-exception-caught + LOG.exception("An error occurred while disconnecting") async def _start_polling(self) -> None: if self._atv is None: @@ -377,7 +385,7 @@ async def _process_update(self, data) -> None: artwork = await self._atv.metadata.artwork(width=ARTWORK_WIDTH, height=ARTWORK_HEIGHT) artwork_encoded = "data:image/png;base64," + base64.b64encode(artwork.bytes).decode("utf-8") update["artwork"] = artwork_encoded - except Exception as err: + except Exception as err: # pylint: disable=broad-exception-caught LOG.warning("Error while updating the artwork: %s", err) update["total_time"] = data.total_time