@bharper/atv-js 0.2.6 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +89 -9
- package/dist/index.js.map +1 -1
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +96 -11
- package/dist/mdns.js.map +1 -1
- package/examples/print-device-json.js +22 -0
- package/package.json +2 -3
- package/pyatv/.codecov.yml +38 -0
- package/pyatv/.github/FUNDING.yml +3 -0
- package/pyatv/.github/ISSUE_TEMPLATE/bug_report.yml +80 -0
- package/pyatv/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/pyatv/.github/ISSUE_TEMPLATE/feature_request.yml +22 -0
- package/pyatv/.github/ISSUE_TEMPLATE/implementation-proposal.yml +29 -0
- package/pyatv/.github/ISSUE_TEMPLATE/investigation.yml +16 -0
- package/pyatv/.github/ISSUE_TEMPLATE/minor-change.yml +10 -0
- package/pyatv/.github/ISSUE_TEMPLATE/question-or-idea.yml +11 -0
- package/pyatv/.github/dependabot.yml +26 -0
- package/pyatv/.github/workflows/codeql-analysis.yml +71 -0
- package/pyatv/.github/workflows/release.yml +160 -0
- package/pyatv/.github/workflows/tests.yml +104 -0
- package/pyatv/.gitpod.yml +23 -0
- package/pyatv/CHANGES.md +3708 -0
- package/pyatv/CODE_OF_CONDUCT.md +76 -0
- package/pyatv/CONTRIBUTING.md +72 -0
- package/pyatv/CONTRIBUTORS.md +3 -0
- package/pyatv/Dockerfile +15 -0
- package/pyatv/LICENSE.md +9 -0
- package/pyatv/MANIFEST.in +14 -0
- package/pyatv/README.md +111 -0
- package/pyatv/base_versions.txt +13 -0
- package/pyatv/chickn.yaml +75 -0
- package/pyatv/docs/404.html +24 -0
- package/pyatv/docs/CNAME +1 -0
- package/pyatv/docs/Gemfile +31 -0
- package/pyatv/docs/_config.yml +121 -0
- package/pyatv/docs/_includes/api +10 -0
- package/pyatv/docs/_includes/atvremote_scan +32 -0
- package/pyatv/docs/_includes/code +6 -0
- package/pyatv/docs/_includes/issue +14 -0
- package/pyatv/docs/_includes/pypi +5 -0
- package/pyatv/docs/_layouts/template.html +109 -0
- package/pyatv/docs/api/pyatv.conf.html +312 -0
- package/pyatv/docs/api/pyatv.const.html +974 -0
- package/pyatv/docs/api/pyatv.convert.html +106 -0
- package/pyatv/docs/api/pyatv.exceptions.html +489 -0
- package/pyatv/docs/api/pyatv.helpers.html +102 -0
- package/pyatv/docs/api/pyatv.html +120 -0
- package/pyatv/docs/api/pyatv.interface.html +2369 -0
- package/pyatv/docs/api/pyatv.settings.html +484 -0
- package/pyatv/docs/api/pyatv.storage.file_storage.html +102 -0
- package/pyatv/docs/api/pyatv.storage.html +186 -0
- package/pyatv/docs/api/pyatv.storage.memory_storage.html +83 -0
- package/pyatv/docs/assets/css/custom.css +19 -0
- package/pyatv/docs/assets/css/hljs.css +1 -0
- package/pyatv/docs/assets/css/normalize.css +349 -0
- package/pyatv/docs/assets/css/pdoc.css +287 -0
- package/pyatv/docs/assets/css/sanitize.css +566 -0
- package/pyatv/docs/assets/css/style.scss +9 -0
- package/pyatv/docs/assets/img/logo.svg +63 -0
- package/pyatv/docs/assets/js/highlight.9.12.0.min.js +3 -0
- package/pyatv/docs/assets/js/mermaid.8.9.2.min.js +32 -0
- package/pyatv/docs/assets/js/mermaid.min.js.map +1 -0
- package/pyatv/docs/development/apps.md +81 -0
- package/pyatv/docs/development/audio.md +42 -0
- package/pyatv/docs/development/control.md +56 -0
- package/pyatv/docs/development/development.md +15 -0
- package/pyatv/docs/development/device_info.md +36 -0
- package/pyatv/docs/development/examples.md +44 -0
- package/pyatv/docs/development/features.md +70 -0
- package/pyatv/docs/development/keyboard.md +51 -0
- package/pyatv/docs/development/listeners.md +144 -0
- package/pyatv/docs/development/logging.md +55 -0
- package/pyatv/docs/development/metadata.md +115 -0
- package/pyatv/docs/development/power_management.md +53 -0
- package/pyatv/docs/development/scan_pair_and_connect.md +331 -0
- package/pyatv/docs/development/services.md +9 -0
- package/pyatv/docs/development/storage.md +259 -0
- package/pyatv/docs/development/stream.md +241 -0
- package/pyatv/docs/development/testing.md +9 -0
- package/pyatv/docs/documentation/atvlog.md +64 -0
- package/pyatv/docs/documentation/atvproxy.md +244 -0
- package/pyatv/docs/documentation/atvremote.md +639 -0
- package/pyatv/docs/documentation/atvscript.md +275 -0
- package/pyatv/docs/documentation/concepts.md +168 -0
- package/pyatv/docs/documentation/documentation.md +130 -0
- package/pyatv/docs/documentation/getting_started.md +248 -0
- package/pyatv/docs/documentation/protocols.md +1959 -0
- package/pyatv/docs/documentation/supported_features.md +246 -0
- package/pyatv/docs/documentation/tutorial.md +1062 -0
- package/pyatv/docs/documentation/workspace.code-workspace +7 -0
- package/pyatv/docs/favicon.ico +0 -0
- package/pyatv/docs/index.md +109 -0
- package/pyatv/docs/internals/design.md +354 -0
- package/pyatv/docs/internals/documentation.md +84 -0
- package/pyatv/docs/internals/interfaces.md +95 -0
- package/pyatv/docs/internals/internals.md +157 -0
- package/pyatv/docs/internals/submit_pr.md +56 -0
- package/pyatv/docs/internals/testing.md +176 -0
- package/pyatv/docs/internals/tools.md +574 -0
- package/pyatv/docs/pdoc_templates/config.mako +46 -0
- package/pyatv/docs/pdoc_templates/html.mako +454 -0
- package/pyatv/docs/support/acknowledgements.md +87 -0
- package/pyatv/docs/support/faq.md +214 -0
- package/pyatv/docs/support/migration.md +138 -0
- package/pyatv/docs/support/scanning_issues.md +110 -0
- package/pyatv/docs/support/support.md +18 -0
- package/pyatv/docs/support/troubleshooting.md +83 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeMessage.proto +13 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeMessage_pb2.pyi +37 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeResponseMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeResponseMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/AudioFormatSettingsMessage.proto +5 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/AudioFormatSettingsMessage_pb2.pyi +27 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ClientUpdatesConfigMessage.proto +16 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ClientUpdatesConfigMessage_pb2.pyi +44 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/CommandInfo.proto +117 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/CommandInfo_pb2.pyi +325 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/CommandOptions.proto +36 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/CommandOptions_pb2.pyi +115 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/Common.proto +79 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/Common_pb2.pyi +228 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ConfigureConnectionMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ConfigureConnectionMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ContentItem.proto +27 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ContentItemMetadata.proto +213 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ContentItemMetadata_pb2.pyi +630 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ContentItem_pb2.pyi +94 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/CryptoPairingMessage.proto +15 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/CryptoPairingMessage_pb2.pyi +46 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/DeviceInfoMessage.proto +69 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/DeviceInfoMessage_pb2.pyi +226 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GenericMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GenericMessage_pb2.pyi +35 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetKeyboardSessionMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetKeyboardSessionMessage_pb2.pyi +26 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetRemoteTextInputSessionMessage.proto +10 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetRemoteTextInputSessionMessage_pb2.pyi +26 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeResultMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeResultMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/KeyboardMessage.proto +88 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/KeyboardMessage_pb2.pyi +261 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/LanguageOption.proto +9 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/LanguageOption_pb2.pyi +42 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ModifyOutputContextRequestMessage.proto +23 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ModifyOutputContextRequestMessage_pb2.pyi +86 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NotificationMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NotificationMessage_pb2.pyi +38 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingClient.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingClient_pb2.pyi +49 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingInfo.proto +24 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingInfo_pb2.pyi +79 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingPlayer.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingPlayer_pb2.pyi +45 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/Origin.proto +17 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/OriginClientPropertiesMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/OriginClientPropertiesMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/Origin_pb2.pyi +63 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueue.proto +15 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueCapabilities.proto +7 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueCapabilities_pb2.pyi +33 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueContext.proto +5 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueContext_pb2.pyi +27 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueRequestMessage.proto +29 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueRequestMessage_pb2.pyi +87 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueue_pb2.pyi +53 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlayerClientPropertiesMessage.proto +13 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlayerClientPropertiesMessage_pb2.pyi +37 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlayerPath.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/PlayerPath_pb2.pyi +39 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ProtocolMessage.proto +171 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/ProtocolMessage_pb2.pyi +377 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterForGameControllerEventsMessage.proto +18 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterForGameControllerEventsMessage_pb2.pyi +54 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceResultMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceResultMessage_pb2.pyi +35 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceResponseMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceResponseMessage_pb2.pyi +35 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoteTextInputMessage.proto +13 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoteTextInputMessage_pb2.pyi +38 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoveClientMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoveClientMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoveEndpointsMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoveEndpointsMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoveOutputDevicesMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemoveOutputDevicesMessage_pb2.pyi +38 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemovePlayerMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/RemovePlayerMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendButtonEventMessage.proto +13 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendButtonEventMessage_pb2.pyi +38 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandMessage.proto +16 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandMessage_pb2.pyi +43 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandResultMessage.proto +100 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandResultMessage_pb2.pyi +286 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendHIDEventMessage.proto +41 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendHIDEventMessage_pb2.pyi +63 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendPackedVirtualTouchEventMessage.proto +24 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendPackedVirtualTouchEventMessage_pb2.pyi +64 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendVoiceInputMessage.proto +38 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SendVoiceInputMessage_pb2.pyi +134 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetArtworkMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetArtworkMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetConnectionStateMessage.proto +18 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetConnectionStateMessage_pb2.pyi +54 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetDefaultSupportedCommandsMessage.proto +28 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetDefaultSupportedCommandsMessage_pb2.pyi +74 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetDiscoveryModeMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetDiscoveryModeMessage_pb2.pyi +35 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetHiliteModeMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetHiliteModeMessage_pb2.pyi +32 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingClientMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingClientMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingPlayerMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingPlayerMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetRecordingStateMessage.proto +17 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetRecordingStateMessage_pb2.pyi +54 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetStateMessage.proto +27 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetStateMessage_pb2.pyi +72 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetVolumeMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SetVolumeMessage_pb2.pyi +35 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SupportedCommands.proto +7 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/SupportedCommands_pb2.pyi +30 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TextInputMessage.proto +23 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TextInputMessage_pb2.pyi +76 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionKey.proto +6 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionKey_pb2.pyi +30 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionMessage.proto +15 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionMessage_pb2.pyi +42 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPacket.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPacket_pb2.pyi +41 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPackets.proto +7 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPackets_pb2.pyi +30 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateClientMessage.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateClientMessage_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemArtworkMessage.proto +14 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemArtworkMessage_pb2.pyi +41 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemMessage.proto +14 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemMessage_pb2.pyi +41 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateEndPointsMessage.proto +25 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateEndPointsMessage_pb2.pyi +74 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateOutputDeviceMessage.proto +88 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdateOutputDeviceMessage_pb2.pyi +277 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdatePlayerPath.proto +12 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/UpdatePlayerPath_pb2.pyi +34 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VirtualTouchDeviceDescriptorMessage.proto +8 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VirtualTouchDeviceDescriptorMessage_pb2.pyi +36 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VoiceInputDeviceDescriptorMessage.proto +8 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VoiceInputDeviceDescriptorMessage_pb2.pyi +35 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlAvailabilityMessage.proto +23 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlAvailabilityMessage_pb2.pyi +71 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlCapabilitiesDidChangeMessage.proto +14 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlCapabilitiesDidChangeMessage_pb2.pyi +40 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VolumeDidChangeMessage.proto +13 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/VolumeDidChangeMessage_pb2.pyi +38 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/WakeDeviceMessage.proto +11 -0
- package/pyatv/pyatv/protocols/mrp/protobuf/WakeDeviceMessage_pb2.pyi +26 -0
- package/pyatv/pyatv/py.typed +0 -0
- package/pyatv/pylintrc +49 -0
- package/pyatv/pyproject.toml +74 -0
- package/pyatv/requirements/requirements.txt +14 -0
- package/pyatv/requirements/requirements_docs.txt +2 -0
- package/pyatv/requirements/requirements_test.txt +20 -0
- package/pyatv/scripts/build_docs.sh +17 -0
- package/pyatv/scripts/setup_dev_env.sh +83 -0
- package/pyatv/setup.cfg +14 -0
- package/pyatv/tests/data/README +23 -0
- package/pyatv/tests/data/audio_10_frames.wav +0 -0
- package/pyatv/tests/data/audio_1_packet_metadata.wav +0 -0
- package/pyatv/tests/data/audio_3_packets.wav +0 -0
- package/pyatv/tests/data/only_metadata.wav +0 -0
- package/pyatv/tests/data/only_title.wav +0 -0
- package/pyatv/tests/data/static_3sec.ogg +0 -0
- package/pyatv/tests/data/testfile.txt +1 -0
- package/pyatv/tests/support/pyatv.code-workspace +14 -0
- package/src/index.ts +122 -8
- package/src/mdns.ts +64 -11
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: template
|
|
3
|
+
title: Tutorial
|
|
4
|
+
permalink: /documentation/tutorial/
|
|
5
|
+
link_group: documentation
|
|
6
|
+
---
|
|
7
|
+
# :green_book: Table of Contents
|
|
8
|
+
{:.no_toc}
|
|
9
|
+
* TOC
|
|
10
|
+
{:toc}
|
|
11
|
+
|
|
12
|
+
# Tutorial
|
|
13
|
+
Looking at simple example is one thing, building an application is something else. This
|
|
14
|
+
tutorial will guide you through building an application that is simple to understand,
|
|
15
|
+
but still more complex than the bundled examples. So, grab your favorite beverage and
|
|
16
|
+
lets get going! :coffee: :tea:
|
|
17
|
+
|
|
18
|
+
# The goal: simple web based client
|
|
19
|
+
The goal of this tutorial is to build an application that starts a web server and
|
|
20
|
+
accepts commands for scanning, connecting to a device, sending remote control commands
|
|
21
|
+
and fetching what is currently playing. There might be a bonus at the end, demonstrating
|
|
22
|
+
live push updates via websockets if you are lucky... Pairing is left out from the
|
|
23
|
+
tutorial as an exercise to you. So you will need to obtain credentials (if
|
|
24
|
+
needed) via some other method, e.g. [atvremote](../atvremote#pairing-with-a-device).
|
|
25
|
+
|
|
26
|
+
Small steps is key, so the tutorial will be divided into the following sections:
|
|
27
|
+
|
|
28
|
+
1. Basic web server
|
|
29
|
+
2. Add scan support
|
|
30
|
+
3. Connect to a device
|
|
31
|
+
4. Remote control commands
|
|
32
|
+
5. Retrieve current play state
|
|
33
|
+
6. Closing a connection
|
|
34
|
+
7. Some bonuses...
|
|
35
|
+
|
|
36
|
+
The complete source code will be listed several times along the way. If you're unsure
|
|
37
|
+
if you did it right, just scroll down and hopefully you can compare with the expected
|
|
38
|
+
result. The final result is [here](#the-complete-example). It is also available as an
|
|
39
|
+
example at {% include code file="../examples/tutorial.py" %}.
|
|
40
|
+
|
|
41
|
+
# Tutorial steps
|
|
42
|
+
|
|
43
|
+
## 1. Basic web server
|
|
44
|
+
Let's get going! First, we're gonna create a web server that will handle the requests
|
|
45
|
+
for us. We'll use {% include pypi package="aiohttp" %} for that since it's already a
|
|
46
|
+
dependency of pyatv and fairly easy to use. Here's a simple skeleton, save it
|
|
47
|
+
as `tutorial.py`:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from aiohttp import web
|
|
52
|
+
import pyatv
|
|
53
|
+
|
|
54
|
+
routes = web.RouteTableDef()
|
|
55
|
+
|
|
56
|
+
@routes.get('/')
|
|
57
|
+
async def scan(request):
|
|
58
|
+
return web.Response(text="Hello world!")
|
|
59
|
+
|
|
60
|
+
def main():
|
|
61
|
+
app = web.Application()
|
|
62
|
+
app.add_routes(routes)
|
|
63
|
+
web.run_app(app)
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run the script with `python tutorial.py`:
|
|
70
|
+
|
|
71
|
+
```shell
|
|
72
|
+
$ python tutorial.py
|
|
73
|
+
======== Running on http://0.0.0.0:8080 ========
|
|
74
|
+
(Press CTRL+C to quit)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Open your web browser, navigate to
|
|
78
|
+
<a href="http://127.0.0.1:8080">http://127.0.0.1:8080</a> and you should be
|
|
79
|
+
greeted by `Hello world!`.
|
|
80
|
+
|
|
81
|
+
The `scan` method is simply called whenever the index page
|
|
82
|
+
is requested (because of `@routes.get('/')`). We'll change the endpoint to `/scan`
|
|
83
|
+
in the next section.
|
|
84
|
+
|
|
85
|
+
## 2. Add scan support
|
|
86
|
+
Implementing scanning is rather straight-forward using {% include api i="pyatv.scan" %}.
|
|
87
|
+
The tricky part is: what output format should we use? In this example we'll just stick
|
|
88
|
+
to a simple human-readble format, but changing to something else like JSON would be
|
|
89
|
+
pretty simple as well.
|
|
90
|
+
|
|
91
|
+
Change the `scan` method into something like this:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
@routes.get('/scan')
|
|
95
|
+
async def scan(request):
|
|
96
|
+
results = await pyatv.scan(loop=asyncio.get_event_loop())
|
|
97
|
+
output = "\n\n".join(str(result) for result in results)
|
|
98
|
+
return web.Response(text=output)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A break-down:
|
|
102
|
+
1. First we scan for all devices on the network with {% include api i="pyatv.scan" %},
|
|
103
|
+
passing current event loop as `loop`. This will return a list of
|
|
104
|
+
{% include api i="interface.AppleTV" %} objects.
|
|
105
|
+
2. Results are converted into a readable string with two newlines between each device.
|
|
106
|
+
3. Content is returned as a text
|
|
107
|
+
|
|
108
|
+
The output is pretty close to what `atvremote scan` would give. To give an idea of
|
|
109
|
+
what it would take to return JSON output instead, here's an example of that (containing
|
|
110
|
+
only address and name for each device):
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
@routes.get('/scan')
|
|
114
|
+
async def scan(request):
|
|
115
|
+
devices = []
|
|
116
|
+
for result in await pyatv.scan(loop=asyncio.get_event_loop()):
|
|
117
|
+
devices.append({"name": result.name, "address": str(result.address)})
|
|
118
|
+
return web.json_response(devices)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
There's an important thing to not here. By default, scanning will take around three
|
|
122
|
+
seconds. That means it will take roughly three seconds until the page is rendered.
|
|
123
|
+
That might be ok, or it might not be depending on usecase. A potential improvement
|
|
124
|
+
is to periodically scan for devices and keep a cache that is immediately returned.
|
|
125
|
+
Alternatively, provide another endpoint (e.g. `/trigger_scan`) that performs scanning
|
|
126
|
+
in the background and saves the result. Then `/scan` can return that result.
|
|
127
|
+
|
|
128
|
+
*Tip: {% include code file="scripts/atvscript.py" %} is a good reference if you
|
|
129
|
+
need help with converting output to JSON.*
|
|
130
|
+
|
|
131
|
+
## 3. Connect to a device
|
|
132
|
+
Now we can find devices, next step is to connect to one. We'll support doing that
|
|
133
|
+
by ID. We will also support passing in credentials. A typical call to connect will
|
|
134
|
+
look like this:
|
|
135
|
+
|
|
136
|
+
```raw
|
|
137
|
+
http://127.0.0.1:8080/connect/aabbccddee?mrp=1234&dmap=5678
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The ID in this case is `aabbccddee` and credentials are passed to MRP as 1234
|
|
141
|
+
and 5678 for DMAP. Argument names for credentials will be the same as in
|
|
142
|
+
{% include api i="const.Protocol" %} but converted to lower-case.
|
|
143
|
+
|
|
144
|
+
Let's ignore credentials for now though, focusing on just connecting to the device:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
@routes.get('/connect/{id}')
|
|
148
|
+
async def connect(request):
|
|
149
|
+
loop = asyncio.get_event_loop()
|
|
150
|
+
device_id = request.match_info["id"]
|
|
151
|
+
|
|
152
|
+
results = await pyatv.scan(identifier=device_id, loop=loop)
|
|
153
|
+
if not results:
|
|
154
|
+
return web.Response(text="Device not found", status=500)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
atv = await pyatv.connect(results[0], loop=loop)
|
|
158
|
+
except Exception as ex:
|
|
159
|
+
return web.Response(text=f"Failed to connect to device: {ex}", status=500)
|
|
160
|
+
|
|
161
|
+
return web.Response(text=f"Connected to device {device_id}")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
So, there's a bunch of code here:
|
|
165
|
+
|
|
166
|
+
1. There's built-in support for matchers in {% include pypi package="aiohttp" %},
|
|
167
|
+
so the `<id>` is easily extracted with `request.match_info["id"]`.
|
|
168
|
+
2. Next, scan for device with the requested device id by passing it via
|
|
169
|
+
`identifier`. If no device is not found, return an error message and error code.
|
|
170
|
+
3. Try to connect, making sure that we catch any error and return another error
|
|
171
|
+
message in case connect failed.
|
|
172
|
+
4. At the end, return a message stating we are connected.
|
|
173
|
+
|
|
174
|
+
Assuming everything went OK, we have a handle to our device via `atv`. We need
|
|
175
|
+
to save that somewhere (and make sure we close the connection properly when
|
|
176
|
+
exiting the script), since we need it in other request handlers.
|
|
177
|
+
|
|
178
|
+
The `web.Application` instance can store global variables for us, so lets use
|
|
179
|
+
that. Before returning in `connect`, make this change:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
...
|
|
183
|
+
except Exception as ex:
|
|
184
|
+
return web.Response(text=f"Failed to connect to device: {ex}", status=500)
|
|
185
|
+
|
|
186
|
+
request.app["atv"][device_id] = atv # <-- Add this
|
|
187
|
+
return web.Response(text=f"Connected to device {device_id}")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
We can then access our device from other handlers via `request.app[<id>]` later.
|
|
191
|
+
But we should also close it when exiting the script. We can do that by modifying
|
|
192
|
+
the startup code like this:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
async def on_shutdown(app: web.Application) -> None:
|
|
196
|
+
for atv in app["atv"].values():
|
|
197
|
+
atv.close()
|
|
198
|
+
|
|
199
|
+
def main():
|
|
200
|
+
app = web.Application()
|
|
201
|
+
app["atv"] = {}
|
|
202
|
+
app.add_routes(routes)
|
|
203
|
+
app.on_shutdown.append(on_shutdown)
|
|
204
|
+
web.run_app(app)
|
|
205
|
+
|
|
206
|
+
...
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The `on_shutdown` method will be called when the script is exited, e.g. by
|
|
210
|
+
pressing Ctrl+C. There's one more guard we should add, making sure we don't try
|
|
211
|
+
to connect if we are already connected. A simple check at the top will fix that:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
@routes.get('/connect/{id}')
|
|
215
|
+
async def connect(request):
|
|
216
|
+
loop = asyncio.get_event_loop()
|
|
217
|
+
device_id = request.match_info["id"]
|
|
218
|
+
if device_id in request.app["atv"]:
|
|
219
|
+
return web.Response(text=f"Already connected to {device_id}")
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
Next part is to add parsing of credentials. We'll create a
|
|
224
|
+
helper method for that, which will iterate all services and look for credentials
|
|
225
|
+
in the GET-parameters:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
def add_credentials(config, query):
|
|
229
|
+
for service in config.services:
|
|
230
|
+
proto_name = service.protocol.name.lower() # E.g. Protocol.MRP -> "mrp"
|
|
231
|
+
if proto_name in query:
|
|
232
|
+
config.set_credentials(service.protocol, query[proto_name])
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Here, `query` is a map with all values passed via the URL, e.g.
|
|
236
|
+
`xxx?mrp=1234&dmap=5678` => `{"mrp": "1234", "dmap": "5678"}`. Add `add_credentials`
|
|
237
|
+
above the `scan` method and call it before connecting:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
...
|
|
241
|
+
if not results:
|
|
242
|
+
return web.Response(text="Device not found", status=500)
|
|
243
|
+
|
|
244
|
+
add_credentials(results[0], request.query)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
atv = await pyatv.connect(results[0], loop=loop)
|
|
248
|
+
...
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
For the sake of completeness, here is the final script:
|
|
252
|
+
|
|
253
|
+
<details>
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import asyncio
|
|
257
|
+
from aiohttp import web
|
|
258
|
+
import pyatv
|
|
259
|
+
|
|
260
|
+
routes = web.RouteTableDef()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def add_credentials(config, query):
|
|
264
|
+
for service in config.services:
|
|
265
|
+
proto_name = service.protocol.name.lower()
|
|
266
|
+
if proto_name in query:
|
|
267
|
+
config.set_credentials(service.protocol, query[proto_name])
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@routes.get("/scan")
|
|
271
|
+
async def scan(request):
|
|
272
|
+
results = await pyatv.scan(loop=asyncio.get_event_loop())
|
|
273
|
+
output = "\n\n".join(str(result) for result in results)
|
|
274
|
+
return web.Response(text=output)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@routes.get("/connect/{id}")
|
|
278
|
+
async def connect(request):
|
|
279
|
+
loop = asyncio.get_event_loop()
|
|
280
|
+
device_id = request.match_info["id"]
|
|
281
|
+
if device_id in request.app["atv"]:
|
|
282
|
+
return web.Response(text=f"Already connected to {device_id}")
|
|
283
|
+
|
|
284
|
+
results = await pyatv.scan(identifier=device_id, loop=loop)
|
|
285
|
+
if not results:
|
|
286
|
+
return web.Response(text="Device not found", status=500)
|
|
287
|
+
|
|
288
|
+
add_credentials(results[0], request.query)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
atv = await pyatv.connect(results[0], loop=loop)
|
|
292
|
+
except Exception as ex:
|
|
293
|
+
return web.Response(text=f"Failed to connect to device: {ex}", status=500)
|
|
294
|
+
|
|
295
|
+
request.app["atv"][device_id] = atv
|
|
296
|
+
return web.Response(text=f"Connected to device {device_id}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def on_shutdown(app: web.Application) -> None:
|
|
300
|
+
for atv in app["atv"].values():
|
|
301
|
+
atv.close()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def main():
|
|
305
|
+
app = web.Application()
|
|
306
|
+
app["atv"] = {}
|
|
307
|
+
app.add_routes(routes)
|
|
308
|
+
app.on_shutdown.append(on_shutdown)
|
|
309
|
+
web.run_app(app)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
main()
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
</details>
|
|
317
|
+
|
|
318
|
+
## 4. Remote control commands
|
|
319
|
+
|
|
320
|
+
Nice, we are connected! Now, continue with remote control commands. We'll
|
|
321
|
+
stick with single tap actions for now. If you want support for other actions, e.g.
|
|
322
|
+
double tap, pass the action as an argument and access it via `request.query`.
|
|
323
|
+
|
|
324
|
+
Here's a basic handler for the remote control:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
@routes.get("/remote_control/{id}/{command}")
|
|
328
|
+
async def remote_control(request):
|
|
329
|
+
device_id = request.match_info["id"]
|
|
330
|
+
atv = request.app["atv"][device_id]
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
await getattr(atv.remote_control, request.match_info["command"])()
|
|
334
|
+
except Exception as ex:
|
|
335
|
+
return web.Response(text=f"Remote control command failed: {ex}")
|
|
336
|
+
|
|
337
|
+
return web.Response(text="OK")
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
By using `getattr`, we can look up commands dynamically without having to
|
|
341
|
+
write them in code. Use the same names as methods
|
|
342
|
+
in {% include api i="interface.RemoteControl" %}. We should do one more thing:
|
|
343
|
+
check that we are connected, e.g. like this:
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
@routes.get("/remote_control/{id}/{command}")
|
|
347
|
+
async def remote_control(request):
|
|
348
|
+
device_id = request.match_info["id"]
|
|
349
|
+
atv = request.app["atv"].get(device_id)
|
|
350
|
+
if not atv:
|
|
351
|
+
return web.Response(text=f"Not connected to {device_id}", status=500)
|
|
352
|
+
|
|
353
|
+
...
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
To trigger a command, use a URL like this:
|
|
357
|
+
|
|
358
|
+
```raw
|
|
359
|
+
http://127.0.0.1:8080/remote_control/aabbccddee/menu
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
You might have noticed that the device id is passed here as well. By doing that,
|
|
363
|
+
multiple devices can be controlled at the same time. Pretty cool, huh?
|
|
364
|
+
|
|
365
|
+
## 4.5. Some refactoring
|
|
366
|
+
|
|
367
|
+
This is a pattern we will see a lot:
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
@routes.get("/something/{id}/{command}")
|
|
371
|
+
async def something(request):
|
|
372
|
+
device_id = request.match_info["id"]
|
|
373
|
+
atv = request.app["atv"].get(device_id)
|
|
374
|
+
if not atv:
|
|
375
|
+
return web.Response(text=f"Not connected to {device_id}", status=500)
|
|
376
|
+
|
|
377
|
+
...
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
To reduce code, we can create a decorator taking care of this for us. Here's
|
|
381
|
+
one way:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
def web_command(method):
|
|
385
|
+
async def _handler(request):
|
|
386
|
+
device_id = request.match_info["id"]
|
|
387
|
+
atv = request.app["atv"].get(device_id)
|
|
388
|
+
if not atv:
|
|
389
|
+
return web.Response(text=f"Not connected to {device_id}", status=500)
|
|
390
|
+
return await method(request, atv)
|
|
391
|
+
return _handler
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
This decorator will verify that a device handler exists for the given id, returning
|
|
395
|
+
an error otherwise. It will also pass the device handler (`atv`) as a second
|
|
396
|
+
argument to the handler method so it is conveniently available. Re-writing original
|
|
397
|
+
`remote_control` method using the decorator, it will now look like this:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
@routes.get("/remote_control/{id}/{command}")
|
|
401
|
+
@web_command
|
|
402
|
+
async def remote_control(request, atv):
|
|
403
|
+
try:
|
|
404
|
+
await getattr(atv.remote_control, request.match_info["command"])()
|
|
405
|
+
except Exception as ex:
|
|
406
|
+
return web.Response(text=f"Remote control command failed: {ex}")
|
|
407
|
+
return web.Response(text="OK")
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Again, for completeness, here is the code so far:
|
|
411
|
+
|
|
412
|
+
<details>
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
import asyncio
|
|
416
|
+
from aiohttp import web
|
|
417
|
+
import pyatv
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
routes = web.RouteTableDef()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def web_command(method):
|
|
424
|
+
async def _handler(request):
|
|
425
|
+
device_id = request.match_info["id"]
|
|
426
|
+
atv = request.app["atv"].get(device_id)
|
|
427
|
+
if not atv:
|
|
428
|
+
return web.Response(text=f"Not connected to {device_id}", status=500)
|
|
429
|
+
return await method(request, atv)
|
|
430
|
+
return _handler
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def add_credentials(config, query):
|
|
434
|
+
for service in config.services:
|
|
435
|
+
proto_name = service.protocol.name.lower()
|
|
436
|
+
if proto_name in query:
|
|
437
|
+
config.set_credentials(service.protocol, query[proto_name])
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@routes.get("/scan")
|
|
441
|
+
async def scan(request):
|
|
442
|
+
results = await pyatv.scan(loop=asyncio.get_event_loop())
|
|
443
|
+
output = "\n\n".join(str(result) for result in results)
|
|
444
|
+
return web.Response(text=output)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@routes.get("/connect/{id}")
|
|
448
|
+
async def connect(request):
|
|
449
|
+
loop = asyncio.get_event_loop()
|
|
450
|
+
device_id = request.match_info["id"]
|
|
451
|
+
if device_id in request.app["atv"]:
|
|
452
|
+
return web.Response(text=f"Already connected to {device_id}")
|
|
453
|
+
|
|
454
|
+
results = await pyatv.scan(identifier=device_id, loop=loop)
|
|
455
|
+
if not results:
|
|
456
|
+
return web.Response(text="Device not found", status=500)
|
|
457
|
+
|
|
458
|
+
add_credentials(results[0], request.query)
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
atv = await pyatv.connect(results[0], loop=loop)
|
|
462
|
+
except Exception as ex:
|
|
463
|
+
return web.Response(text=f"Failed to connect to device: {ex}", status=500)
|
|
464
|
+
|
|
465
|
+
request.app["atv"][device_id] = atv
|
|
466
|
+
return web.Response(text=f"Connected to device {device_id}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@routes.get("/remote_control/{id}/{command}")
|
|
470
|
+
@web_command
|
|
471
|
+
async def remote_control(request, atv):
|
|
472
|
+
try:
|
|
473
|
+
await getattr(atv.remote_control, request.match_info["command"])()
|
|
474
|
+
except Exception as ex:
|
|
475
|
+
return web.Response(text=f"Remote control command failed: {ex}")
|
|
476
|
+
return web.Response(text="OK")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def on_shutdown(app: web.Application) -> None:
|
|
480
|
+
for atv in app["atv"].values():
|
|
481
|
+
atv.close()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def main():
|
|
485
|
+
app = web.Application()
|
|
486
|
+
app["atv"] = {}
|
|
487
|
+
app.add_routes(routes)
|
|
488
|
+
app.on_shutdown.append(on_shutdown)
|
|
489
|
+
web.run_app(app)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
if __name__ == "__main__":
|
|
493
|
+
main()
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
</details>
|
|
497
|
+
|
|
498
|
+
## 5. Retrieve current play state
|
|
499
|
+
|
|
500
|
+
With the new decorator, exposing play status is a breeze:
|
|
501
|
+
|
|
502
|
+
```python
|
|
503
|
+
@routes.get("/playing/{id}")
|
|
504
|
+
@web_command
|
|
505
|
+
async def playing(request, atv):
|
|
506
|
+
try:
|
|
507
|
+
status = await atv.metadata.playing()
|
|
508
|
+
except Exception as ex:
|
|
509
|
+
return web.Response(text=f"Remote control command failed: {ex}")
|
|
510
|
+
return web.Response(text=str(status))
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Current play status is retrieved via {% include api i="interface.Metadata.playing" %},
|
|
514
|
+
converted to a string and returned.
|
|
515
|
+
|
|
516
|
+
## 6. Closing a connection
|
|
517
|
+
|
|
518
|
+
It might be convenient to have a function that can close a connection, so lets
|
|
519
|
+
add that:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
@routes.get("/close/{id}")
|
|
523
|
+
@web_command
|
|
524
|
+
async def close_connection(request, atv):
|
|
525
|
+
atv.close()
|
|
526
|
+
request.app["atv"].pop(request.match_info["id"])
|
|
527
|
+
return web.Response(text="OK")
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
We basically just call close on the device handler and remove our internally
|
|
531
|
+
stored reference. This will allow us to re-connect again when needed.
|
|
532
|
+
|
|
533
|
+
*Note: No error handling here if device is not connected.*
|
|
534
|
+
|
|
535
|
+
## 7. Bonus: Handle device disconnects
|
|
536
|
+
|
|
537
|
+
There's currently a problem: if the connection for some reason is lost, the
|
|
538
|
+
device handler will still be in `request.app["pyatv"]` (looking like
|
|
539
|
+
it is connected) allowing new commands to be issued. These will obviously fail
|
|
540
|
+
however and it will not be possible to call connect again until close has
|
|
541
|
+
been called. It would be nice to clean up the handler in case the connection is
|
|
542
|
+
lost, so connect can be called directly.
|
|
543
|
+
|
|
544
|
+
We can do this by setting up a listener for device updates, just removing the
|
|
545
|
+
handler when the connection is lost. We start by declaring a device listener,
|
|
546
|
+
which is an implementation of {% include api i="interface.DeviceListener" %}:
|
|
547
|
+
|
|
548
|
+
```python
|
|
549
|
+
class DeviceListener(pyatv.interface.DeviceListener):
|
|
550
|
+
def __init__(self, app, identifier):
|
|
551
|
+
self.app = app
|
|
552
|
+
self.identifier = identifier
|
|
553
|
+
|
|
554
|
+
def connection_lost(self, exception: Exception) -> None:
|
|
555
|
+
self._remove()
|
|
556
|
+
|
|
557
|
+
def connection_closed(self) -> None:
|
|
558
|
+
self._remove()
|
|
559
|
+
|
|
560
|
+
def _remove(self):
|
|
561
|
+
self.app["atv"].pop(self.identifier)
|
|
562
|
+
self.app["listeners"].remove(self)
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
It will keep track of `app` and the device identifier as we will create one
|
|
566
|
+
listener per device. When a connection is either lost (unknown reason) or
|
|
567
|
+
intentionally closed (e.g. via the close command), remove the handler and
|
|
568
|
+
current listener from internal list. More on `"listeners"` next.
|
|
569
|
+
|
|
570
|
+
So, we need to make a few adjustments. First and foremost, we need somewhere
|
|
571
|
+
to store the listener objects. It's very tempting to do somethinglike this:
|
|
572
|
+
|
|
573
|
+
```python
|
|
574
|
+
atv.listener = DeviceListener(request.app, device_id)
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
The problem however is that pyatv uses *weak references* to listener objects.
|
|
578
|
+
In practice, that means as soon as the variable holding a reference to the
|
|
579
|
+
object goes out of scope, the object (i.e. the `DeviceListener` instance) will
|
|
580
|
+
be taken care of by the garbage collector. Unless someone else has a reference
|
|
581
|
+
to it of course. We are gonna put listeners in a list and remove them once a
|
|
582
|
+
connection is lost. That's what the last line in `DeviceListener` does for us.
|
|
583
|
+
This way there will be a reference to the listener instance and we don't risk
|
|
584
|
+
it getting garbage collected. Add the list in the setup code:
|
|
585
|
+
|
|
586
|
+
```python
|
|
587
|
+
...
|
|
588
|
+
def main():
|
|
589
|
+
app = web.Application()
|
|
590
|
+
app["atv"] = {}
|
|
591
|
+
app["listeners"] = [] # <-- add this
|
|
592
|
+
app.add_routes(routes)
|
|
593
|
+
...
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Now we need to create the actual listener, make sure it receives updates and
|
|
597
|
+
also add it to the `listeners` list (in the `connect` method):
|
|
598
|
+
|
|
599
|
+
```python
|
|
600
|
+
...
|
|
601
|
+
except Exception as ex:
|
|
602
|
+
return web.Response(text=f"Failed to connect to device: {ex}", status=500)
|
|
603
|
+
|
|
604
|
+
listener = DeviceListener(request.app, device_id)
|
|
605
|
+
atv.listener = listener
|
|
606
|
+
request.app["listeners"].append(listener)
|
|
607
|
+
|
|
608
|
+
request.app["atv"][device_id] = atv
|
|
609
|
+
...
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
You can read more about device listeners [here](../../development/listeners/#device-updates),
|
|
613
|
+
if you want some additional context.
|
|
614
|
+
|
|
615
|
+
There's one more thing to do: get rid of the line that removes the device handler
|
|
616
|
+
in the close command:
|
|
617
|
+
|
|
618
|
+
```python
|
|
619
|
+
@routes.get("/close/{id}")
|
|
620
|
+
@web_command
|
|
621
|
+
async def close_connection(request, atv):
|
|
622
|
+
atv.close()
|
|
623
|
+
return web.Response(text="OK")
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Calling `atv.close()` will trigger `connection_closed` in the device listener,
|
|
627
|
+
which in turn will remove the device handler and pop the listener for us.
|
|
628
|
+
|
|
629
|
+
## 8. Bonus: Push updates
|
|
630
|
+
|
|
631
|
+
If you made it this far: good job! Adding support for live push updates is a bit
|
|
632
|
+
tricky, but not that hard. There are three steps to this:
|
|
633
|
+
|
|
634
|
+
1. Add a websocket request handler where clients can subscribe to updates
|
|
635
|
+
2. Create and set up a {% include api i="interface.PushListener" %} that
|
|
636
|
+
receives updates and forward them over websockets
|
|
637
|
+
3. Serve a small web page with some javascript that connects to the websocket
|
|
638
|
+
endpoint and updates an element when status change
|
|
639
|
+
|
|
640
|
+
Let's take it one step at the time.
|
|
641
|
+
|
|
642
|
+
### Websocket request handler
|
|
643
|
+
|
|
644
|
+
A websocket request handler works similarly to other request handlers (e.g. GET),
|
|
645
|
+
but since a websocket is generally open over longer period of time, it doesn't
|
|
646
|
+
return until the client disconnects. We will not be handling any commands from
|
|
647
|
+
the client, just let the connection remain open and save a handler to it internally,
|
|
648
|
+
so the push listener can send updates later. We'll start by adding somewhere to
|
|
649
|
+
store these handlers (in the setup code):
|
|
650
|
+
|
|
651
|
+
```python
|
|
652
|
+
def main():
|
|
653
|
+
app = web.Application()
|
|
654
|
+
app["atv"] = {}
|
|
655
|
+
app["listeners"] = []
|
|
656
|
+
app["clients"] = {} # <--- add this
|
|
657
|
+
...
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
We will map the device id to a list of clients, so that multiple clients can
|
|
661
|
+
connect and receive updates concurrently (that's why a dict is used). Now,
|
|
662
|
+
let's define the websocket handler:
|
|
663
|
+
|
|
664
|
+
```python
|
|
665
|
+
@routes.get("/ws/{id}")
|
|
666
|
+
@web_command
|
|
667
|
+
async def websocket_handler(request, atv):
|
|
668
|
+
device_id = request.match_info["id"]
|
|
669
|
+
|
|
670
|
+
ws = web.WebSocketResponse()
|
|
671
|
+
await ws.prepare(request)
|
|
672
|
+
request.app["clients"].setdefault(device_id, []).append(ws)
|
|
673
|
+
|
|
674
|
+
playstatus = await atv.metadata.playing()
|
|
675
|
+
await ws.send_str(str(playstatus))
|
|
676
|
+
|
|
677
|
+
async for msg in ws:
|
|
678
|
+
if msg.type == WSMsgType.TEXT:
|
|
679
|
+
# Handle custom commands from client here
|
|
680
|
+
if msg.data == "close":
|
|
681
|
+
await ws.close()
|
|
682
|
+
elif msg.type == WSMsgType.ERROR:
|
|
683
|
+
print(f"Connection closed with exception: {ws.exception()}")
|
|
684
|
+
|
|
685
|
+
request.app["clients"][device_id].remove(ws)
|
|
686
|
+
|
|
687
|
+
return ws
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Some code is new, some we have already seen and some is boiler plate. A break-down:
|
|
691
|
+
|
|
692
|
+
1. Endpoint `/ws/{id}` is used to map which device to receive updates from.
|
|
693
|
+
2. `ws` is the "response" we use to send and receive messages (this is straight
|
|
694
|
+
from the [aiohttp documentation](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets)).
|
|
695
|
+
Notice that this handler is saved in `app["clients"]` that we previously added.
|
|
696
|
+
3. When a new client connects, we want to send an initial update with what is
|
|
697
|
+
currently playing. So current state is fetched, converted to a string and sent.
|
|
698
|
+
4. Loop (from documentation) waiting for incoming data. We don't do much here,
|
|
699
|
+
but an example handling a `close` command from the client is left for inspiration.
|
|
700
|
+
5. When the connection is closed, remove the handle so we don't try to send updates
|
|
701
|
+
on a closed connection later.
|
|
702
|
+
|
|
703
|
+
Make sure to import `WSMsgType` at the top as well:
|
|
704
|
+
|
|
705
|
+
```python
|
|
706
|
+
from aiohttp import WSMsgType, web
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
That's it for the websocket handler. You can add additional websocket commands
|
|
710
|
+
if you like in the loop, but it's not used here.
|
|
711
|
+
|
|
712
|
+
### Handling push updates
|
|
713
|
+
|
|
714
|
+
At this stage, websocket clients can connect and we store handlers to them in
|
|
715
|
+
`app["clients"][<id>]` per device. Now we need to subscribe to push updates from
|
|
716
|
+
the device and forward them to all websocket connections for a particular
|
|
717
|
+
device. The natural way would be to add a new class, implement
|
|
718
|
+
{% include api i="interface.PushListener" %} and add logic there. An easier way
|
|
719
|
+
however, is to use the fact that we have a device listener already. We can just
|
|
720
|
+
implement the relevant methods there and use that as a push listener as well.
|
|
721
|
+
By doing so, we don't have to handle a new listener (weak reference problem exists
|
|
722
|
+
here as well) and it requires a bit less code.
|
|
723
|
+
|
|
724
|
+
Start by inheriting from {% include api i="interface.PushListener" %}:
|
|
725
|
+
|
|
726
|
+
```python
|
|
727
|
+
class DeviceListener(pyatv.interface.DeviceListener, pyatv.interface.PushListener):
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Now, add these methods to `DeviceListener`:
|
|
731
|
+
|
|
732
|
+
```python
|
|
733
|
+
def playstatus_update(self, updater, playstatus: pyatv.interface.Playing) -> None:
|
|
734
|
+
clients = self.app["clients"].get(self.identifier, [])
|
|
735
|
+
for client in clients:
|
|
736
|
+
asyncio.ensure_future(client.send_str(str(playstatus)))
|
|
737
|
+
|
|
738
|
+
def playstatus_error(self, updater, exception: Exception) -> None:
|
|
739
|
+
pass
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
When an update is received in `playstatus_update`, look up all client handlers for
|
|
743
|
+
the device and send a string version of it. Note that `send_str` is a coroutine and
|
|
744
|
+
`playstatus_update` is a plain callback function, so `asyncio.ensure_future` is
|
|
745
|
+
used to schedule a call on the event loop. We ignore any error updates for now by
|
|
746
|
+
leaving that method empty.
|
|
747
|
+
|
|
748
|
+
The final piece is to subscribe to push updates, so our new methods are actually
|
|
749
|
+
called at all. We do this in `connect`:
|
|
750
|
+
|
|
751
|
+
```python
|
|
752
|
+
...
|
|
753
|
+
listener = DeviceListener(request.app, device_id)
|
|
754
|
+
atv.listener = listener
|
|
755
|
+
atv.push_updater.listener = listener # <-- set the listener
|
|
756
|
+
atv.push_updater.start() # <-- start subscribing to updates
|
|
757
|
+
request.app["listeners"].append(listener)
|
|
758
|
+
...
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
We are basically done with the websocket implementation now and you can try it out
|
|
762
|
+
with a third-part client if you like. But it's convenient if we provide a simple
|
|
763
|
+
web page that updates for us. So let's finalize the script with that.
|
|
764
|
+
|
|
765
|
+
### Websocket client page
|
|
766
|
+
This can't be stressed enough: the solution implemented here is *not* a good
|
|
767
|
+
solution. It is only meant to be simple, keeping everything in the same file.
|
|
768
|
+
Preferably the client page would be stored as a separate file and served as a
|
|
769
|
+
static file. We will however bundle a basic page in the script, so add this at the
|
|
770
|
+
top (below the imports):
|
|
771
|
+
|
|
772
|
+
```python
|
|
773
|
+
PAGE = """
|
|
774
|
+
<script>
|
|
775
|
+
let socket = new WebSocket('ws://' + location.host + '/ws/DEVICE_ID');
|
|
776
|
+
|
|
777
|
+
socket.onopen = function(e) {
|
|
778
|
+
document.getElementById('status').innerText = 'Connected!';
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
socket.onmessage = function(event) {
|
|
782
|
+
document.getElementById('state').innerText = event.data;
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
socket.onclose = function(event) {
|
|
786
|
+
if (event.wasClean) {
|
|
787
|
+
document.getElementById('status').innerText = 'Connection closed cleanly!';
|
|
788
|
+
} else {
|
|
789
|
+
document.getElementById('status').innerText = 'Disconnected due to error!';
|
|
790
|
+
}
|
|
791
|
+
document.getElementById('state').innerText = "";
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
socket.onerror = function(error) {
|
|
795
|
+
document.getElementById('status').innerText = 'Failed to connect!';
|
|
796
|
+
};
|
|
797
|
+
</script>
|
|
798
|
+
<div id="status">Connecting...</div>
|
|
799
|
+
<div id="state"></div>
|
|
800
|
+
"""
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
This page has two `<div>` elements: one for connection status and one for play state.
|
|
804
|
+
A websocket connection is set up, connecting to `ws://<server address>/ws/DEVICE_ID`
|
|
805
|
+
(we will replace `DEVICE_ID` with the correct id). The various callback
|
|
806
|
+
functions then just update what's shown in the div elements.
|
|
807
|
+
|
|
808
|
+
Now we need a handler to serve the page. Here's what that looks like:
|
|
809
|
+
|
|
810
|
+
```python
|
|
811
|
+
@routes.get("/state/{id}")
|
|
812
|
+
async def state(request):
|
|
813
|
+
return web.Response(
|
|
814
|
+
text=PAGE.replace("DEVICE_ID", request.match_info["id"]),
|
|
815
|
+
content_type="text/html",
|
|
816
|
+
)
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
The `PAGE` is just returned, but `DEVICE_ID` is replaced with the correct id.
|
|
820
|
+
|
|
821
|
+
To test this out, start by opening
|
|
822
|
+
|
|
823
|
+
```raw
|
|
824
|
+
http://127.0.0.1:8080/connect/aabbccddee
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Once connected, navigate to:
|
|
828
|
+
|
|
829
|
+
```raw
|
|
830
|
+
http://127.0.0.1:8080/state/aabbccddee
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
You should hopefully see the current state immediately. If you start playing
|
|
834
|
+
something on the device, it should hopefully update instantaneously!
|
|
835
|
+
|
|
836
|
+
# The complete example
|
|
837
|
+
|
|
838
|
+
Here is the final code for the application (or here:
|
|
839
|
+
{% include code file="../examples/tutorial.py" %}):
|
|
840
|
+
|
|
841
|
+
<details>
|
|
842
|
+
|
|
843
|
+
```python
|
|
844
|
+
import asyncio
|
|
845
|
+
from aiohttp import WSMsgType, web
|
|
846
|
+
import pyatv
|
|
847
|
+
|
|
848
|
+
PAGE = """
|
|
849
|
+
<script>
|
|
850
|
+
let socket = new WebSocket('ws://' + location.host + '/ws/DEVICE_ID');
|
|
851
|
+
|
|
852
|
+
socket.onopen = function(e) {
|
|
853
|
+
document.getElementById('status').innerText = 'Connected!';
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
socket.onmessage = function(event) {
|
|
857
|
+
document.getElementById('state').innerText = event.data;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
socket.onclose = function(event) {
|
|
861
|
+
if (event.wasClean) {
|
|
862
|
+
document.getElementById('status').innerText = 'Connection closed cleanly!';
|
|
863
|
+
} else {
|
|
864
|
+
document.getElementById('status').innerText = 'Disconnected due to error!';
|
|
865
|
+
}
|
|
866
|
+
document.getElementById('state').innerText = "";
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
socket.onerror = function(error) {
|
|
870
|
+
document.getElementById('status').innerText = 'Failed to connect!';
|
|
871
|
+
};
|
|
872
|
+
</script>
|
|
873
|
+
<div id="status">Connecting...</div>
|
|
874
|
+
<div id="state"></div>
|
|
875
|
+
"""
|
|
876
|
+
|
|
877
|
+
routes = web.RouteTableDef()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
class DeviceListener(pyatv.interface.DeviceListener, pyatv.interface.PushListener):
|
|
881
|
+
def __init__(self, app, identifier):
|
|
882
|
+
self.app = app
|
|
883
|
+
self.identifier = identifier
|
|
884
|
+
|
|
885
|
+
def connection_lost(self, exception: Exception) -> None:
|
|
886
|
+
self._remove()
|
|
887
|
+
|
|
888
|
+
def connection_closed(self) -> None:
|
|
889
|
+
self._remove()
|
|
890
|
+
|
|
891
|
+
def _remove(self):
|
|
892
|
+
self.app["atv"].pop(self.identifier)
|
|
893
|
+
self.app["listeners"].remove(self)
|
|
894
|
+
|
|
895
|
+
def playstatus_update(self, updater, playstatus: pyatv.interface.Playing) -> None:
|
|
896
|
+
clients = self.app["clients"].get(self.identifier, [])
|
|
897
|
+
for client in clients:
|
|
898
|
+
asyncio.ensure_future(client.send_str(str(playstatus)))
|
|
899
|
+
|
|
900
|
+
def playstatus_error(self, updater, exception: Exception) -> None:
|
|
901
|
+
pass
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def web_command(method):
|
|
905
|
+
async def _handler(request):
|
|
906
|
+
device_id = request.match_info["id"]
|
|
907
|
+
atv = request.app["atv"].get(device_id)
|
|
908
|
+
if not atv:
|
|
909
|
+
return web.Response(text=f"Not connected to {device_id}", status=500)
|
|
910
|
+
return await method(request, atv)
|
|
911
|
+
|
|
912
|
+
return _handler
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def add_credentials(config, query):
|
|
916
|
+
for service in config.services:
|
|
917
|
+
proto_name = service.protocol.name.lower()
|
|
918
|
+
if proto_name in query:
|
|
919
|
+
config.set_credentials(service.protocol, query[proto_name])
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
@routes.get("/state/{id}")
|
|
923
|
+
async def state(request):
|
|
924
|
+
return web.Response(
|
|
925
|
+
text=PAGE.replace("DEVICE_ID", request.match_info["id"]),
|
|
926
|
+
content_type="text/html",
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
@routes.get("/scan")
|
|
931
|
+
async def scan(request):
|
|
932
|
+
results = await pyatv.scan(loop=asyncio.get_event_loop())
|
|
933
|
+
output = "\n\n".join(str(result) for result in results)
|
|
934
|
+
return web.Response(text=output)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@routes.get("/connect/{id}")
|
|
938
|
+
async def connect(request):
|
|
939
|
+
loop = asyncio.get_event_loop()
|
|
940
|
+
device_id = request.match_info["id"]
|
|
941
|
+
if device_id in request.app["atv"]:
|
|
942
|
+
return web.Response(text=f"Already connected to {device_id}")
|
|
943
|
+
|
|
944
|
+
results = await pyatv.scan(identifier=device_id, loop=loop)
|
|
945
|
+
if not results:
|
|
946
|
+
return web.Response(text="Device not found", status=500)
|
|
947
|
+
|
|
948
|
+
add_credentials(results[0], request.query)
|
|
949
|
+
|
|
950
|
+
try:
|
|
951
|
+
atv = await pyatv.connect(results[0], loop=loop)
|
|
952
|
+
except Exception as ex:
|
|
953
|
+
return web.Response(text=f"Failed to connect to device: {ex}", status=500)
|
|
954
|
+
|
|
955
|
+
listener = DeviceListener(request.app, device_id)
|
|
956
|
+
atv.listener = listener
|
|
957
|
+
atv.push_updater.listener = listener
|
|
958
|
+
atv.push_updater.start()
|
|
959
|
+
request.app["listeners"].append(listener)
|
|
960
|
+
|
|
961
|
+
request.app["atv"][device_id] = atv
|
|
962
|
+
return web.Response(text=f"Connected to device {device_id}")
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
@routes.get("/remote_control/{id}/{command}")
|
|
966
|
+
@web_command
|
|
967
|
+
async def remote_control(request, atv):
|
|
968
|
+
try:
|
|
969
|
+
await getattr(atv.remote_control, request.match_info["command"])()
|
|
970
|
+
except Exception as ex:
|
|
971
|
+
return web.Response(text=f"Remote control command failed: {ex}")
|
|
972
|
+
return web.Response(text="OK")
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
@routes.get("/playing/{id}")
|
|
976
|
+
@web_command
|
|
977
|
+
async def playing(request, atv):
|
|
978
|
+
try:
|
|
979
|
+
status = await atv.metadata.playing()
|
|
980
|
+
except Exception as ex:
|
|
981
|
+
return web.Response(text=f"Remote control command failed: {ex}")
|
|
982
|
+
return web.Response(text=str(status))
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@routes.get("/close/{id}")
|
|
986
|
+
@web_command
|
|
987
|
+
async def close_connection(request, atv):
|
|
988
|
+
atv.close()
|
|
989
|
+
return web.Response(text="OK")
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@routes.get("/ws/{id}")
|
|
993
|
+
@web_command
|
|
994
|
+
async def websocket_handler(request, pyatv):
|
|
995
|
+
device_id = request.match_info["id"]
|
|
996
|
+
|
|
997
|
+
ws = web.WebSocketResponse()
|
|
998
|
+
await ws.prepare(request)
|
|
999
|
+
request.app["clients"].setdefault(device_id, []).append(ws)
|
|
1000
|
+
|
|
1001
|
+
playstatus = await pyatv.metadata.playing()
|
|
1002
|
+
await ws.send_str(str(playstatus))
|
|
1003
|
+
|
|
1004
|
+
async for msg in ws:
|
|
1005
|
+
if msg.type == WSMsgType.TEXT:
|
|
1006
|
+
# Handle custom commands from client here
|
|
1007
|
+
if msg.data == "close":
|
|
1008
|
+
await ws.close()
|
|
1009
|
+
elif msg.type == WSMsgType.ERROR:
|
|
1010
|
+
print(f"Connection closed with exception: {ws.exception()}")
|
|
1011
|
+
|
|
1012
|
+
request.app["clients"][device_id].remove(ws)
|
|
1013
|
+
|
|
1014
|
+
return ws
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
async def on_shutdown(app: web.Application) -> None:
|
|
1018
|
+
for atv in app["atv"].values():
|
|
1019
|
+
atv.close()
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def main():
|
|
1023
|
+
app = web.Application()
|
|
1024
|
+
app["atv"] = {}
|
|
1025
|
+
app["listeners"] = []
|
|
1026
|
+
app["clients"] = {}
|
|
1027
|
+
app.add_routes(routes)
|
|
1028
|
+
app.on_shutdown.append(on_shutdown)
|
|
1029
|
+
web.run_app(app)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
if __name__ == "__main__":
|
|
1033
|
+
main()
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
</details>
|
|
1037
|
+
|
|
1038
|
+
# Some final notes
|
|
1039
|
+
This is the end of the tutorial, have some :cake:! Feel free to use this code
|
|
1040
|
+
in any way you like (there's no copyright attached to it), but remember that
|
|
1041
|
+
it's more for inspiration than complete project. There are pitfalls,
|
|
1042
|
+
especially with regards to error handling.
|
|
1043
|
+
|
|
1044
|
+
If you want some inspiration for additional things to do, here are few:
|
|
1045
|
+
|
|
1046
|
+
* Implement additional commands, e.g. volume control, app launching, artwork
|
|
1047
|
+
or streaming
|
|
1048
|
+
* Support commands over websocket instead of GET requests
|
|
1049
|
+
* Serve interface via static files (and improve it!)
|
|
1050
|
+
* Implement pairing support
|
|
1051
|
+
* Allow triggering of a scan and return results via websocket
|
|
1052
|
+
* Add a command showing all connected devices
|
|
1053
|
+
* Create a container class, eliminating the need for three variables in `app`
|
|
1054
|
+
* Combine `web_command` and `routes.get` into a single decorator, e.g.
|
|
1055
|
+
`@web_command("/ws/{id}")`
|
|
1056
|
+
* Allow some way to enable debugging, either via CLI flags, a new endpoint
|
|
1057
|
+
or websockets
|
|
1058
|
+
* Do all of the above and build a simple remote control!
|
|
1059
|
+
|
|
1060
|
+
Regarding websockets... currently only the play state is sent over websockets.
|
|
1061
|
+
Some means of multiplexing needs to be added to support additional commands,
|
|
1062
|
+
e.g. by sending JSON (a dict) instead.
|