@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.
Files changed (283) hide show
  1. package/dist/index.d.ts +15 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +89 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/mdns.d.ts.map +1 -1
  6. package/dist/mdns.js +96 -11
  7. package/dist/mdns.js.map +1 -1
  8. package/examples/print-device-json.js +22 -0
  9. package/package.json +2 -3
  10. package/pyatv/.codecov.yml +38 -0
  11. package/pyatv/.github/FUNDING.yml +3 -0
  12. package/pyatv/.github/ISSUE_TEMPLATE/bug_report.yml +80 -0
  13. package/pyatv/.github/ISSUE_TEMPLATE/config.yml +1 -0
  14. package/pyatv/.github/ISSUE_TEMPLATE/feature_request.yml +22 -0
  15. package/pyatv/.github/ISSUE_TEMPLATE/implementation-proposal.yml +29 -0
  16. package/pyatv/.github/ISSUE_TEMPLATE/investigation.yml +16 -0
  17. package/pyatv/.github/ISSUE_TEMPLATE/minor-change.yml +10 -0
  18. package/pyatv/.github/ISSUE_TEMPLATE/question-or-idea.yml +11 -0
  19. package/pyatv/.github/dependabot.yml +26 -0
  20. package/pyatv/.github/workflows/codeql-analysis.yml +71 -0
  21. package/pyatv/.github/workflows/release.yml +160 -0
  22. package/pyatv/.github/workflows/tests.yml +104 -0
  23. package/pyatv/.gitpod.yml +23 -0
  24. package/pyatv/CHANGES.md +3708 -0
  25. package/pyatv/CODE_OF_CONDUCT.md +76 -0
  26. package/pyatv/CONTRIBUTING.md +72 -0
  27. package/pyatv/CONTRIBUTORS.md +3 -0
  28. package/pyatv/Dockerfile +15 -0
  29. package/pyatv/LICENSE.md +9 -0
  30. package/pyatv/MANIFEST.in +14 -0
  31. package/pyatv/README.md +111 -0
  32. package/pyatv/base_versions.txt +13 -0
  33. package/pyatv/chickn.yaml +75 -0
  34. package/pyatv/docs/404.html +24 -0
  35. package/pyatv/docs/CNAME +1 -0
  36. package/pyatv/docs/Gemfile +31 -0
  37. package/pyatv/docs/_config.yml +121 -0
  38. package/pyatv/docs/_includes/api +10 -0
  39. package/pyatv/docs/_includes/atvremote_scan +32 -0
  40. package/pyatv/docs/_includes/code +6 -0
  41. package/pyatv/docs/_includes/issue +14 -0
  42. package/pyatv/docs/_includes/pypi +5 -0
  43. package/pyatv/docs/_layouts/template.html +109 -0
  44. package/pyatv/docs/api/pyatv.conf.html +312 -0
  45. package/pyatv/docs/api/pyatv.const.html +974 -0
  46. package/pyatv/docs/api/pyatv.convert.html +106 -0
  47. package/pyatv/docs/api/pyatv.exceptions.html +489 -0
  48. package/pyatv/docs/api/pyatv.helpers.html +102 -0
  49. package/pyatv/docs/api/pyatv.html +120 -0
  50. package/pyatv/docs/api/pyatv.interface.html +2369 -0
  51. package/pyatv/docs/api/pyatv.settings.html +484 -0
  52. package/pyatv/docs/api/pyatv.storage.file_storage.html +102 -0
  53. package/pyatv/docs/api/pyatv.storage.html +186 -0
  54. package/pyatv/docs/api/pyatv.storage.memory_storage.html +83 -0
  55. package/pyatv/docs/assets/css/custom.css +19 -0
  56. package/pyatv/docs/assets/css/hljs.css +1 -0
  57. package/pyatv/docs/assets/css/normalize.css +349 -0
  58. package/pyatv/docs/assets/css/pdoc.css +287 -0
  59. package/pyatv/docs/assets/css/sanitize.css +566 -0
  60. package/pyatv/docs/assets/css/style.scss +9 -0
  61. package/pyatv/docs/assets/img/logo.svg +63 -0
  62. package/pyatv/docs/assets/js/highlight.9.12.0.min.js +3 -0
  63. package/pyatv/docs/assets/js/mermaid.8.9.2.min.js +32 -0
  64. package/pyatv/docs/assets/js/mermaid.min.js.map +1 -0
  65. package/pyatv/docs/development/apps.md +81 -0
  66. package/pyatv/docs/development/audio.md +42 -0
  67. package/pyatv/docs/development/control.md +56 -0
  68. package/pyatv/docs/development/development.md +15 -0
  69. package/pyatv/docs/development/device_info.md +36 -0
  70. package/pyatv/docs/development/examples.md +44 -0
  71. package/pyatv/docs/development/features.md +70 -0
  72. package/pyatv/docs/development/keyboard.md +51 -0
  73. package/pyatv/docs/development/listeners.md +144 -0
  74. package/pyatv/docs/development/logging.md +55 -0
  75. package/pyatv/docs/development/metadata.md +115 -0
  76. package/pyatv/docs/development/power_management.md +53 -0
  77. package/pyatv/docs/development/scan_pair_and_connect.md +331 -0
  78. package/pyatv/docs/development/services.md +9 -0
  79. package/pyatv/docs/development/storage.md +259 -0
  80. package/pyatv/docs/development/stream.md +241 -0
  81. package/pyatv/docs/development/testing.md +9 -0
  82. package/pyatv/docs/documentation/atvlog.md +64 -0
  83. package/pyatv/docs/documentation/atvproxy.md +244 -0
  84. package/pyatv/docs/documentation/atvremote.md +639 -0
  85. package/pyatv/docs/documentation/atvscript.md +275 -0
  86. package/pyatv/docs/documentation/concepts.md +168 -0
  87. package/pyatv/docs/documentation/documentation.md +130 -0
  88. package/pyatv/docs/documentation/getting_started.md +248 -0
  89. package/pyatv/docs/documentation/protocols.md +1959 -0
  90. package/pyatv/docs/documentation/supported_features.md +246 -0
  91. package/pyatv/docs/documentation/tutorial.md +1062 -0
  92. package/pyatv/docs/documentation/workspace.code-workspace +7 -0
  93. package/pyatv/docs/favicon.ico +0 -0
  94. package/pyatv/docs/index.md +109 -0
  95. package/pyatv/docs/internals/design.md +354 -0
  96. package/pyatv/docs/internals/documentation.md +84 -0
  97. package/pyatv/docs/internals/interfaces.md +95 -0
  98. package/pyatv/docs/internals/internals.md +157 -0
  99. package/pyatv/docs/internals/submit_pr.md +56 -0
  100. package/pyatv/docs/internals/testing.md +176 -0
  101. package/pyatv/docs/internals/tools.md +574 -0
  102. package/pyatv/docs/pdoc_templates/config.mako +46 -0
  103. package/pyatv/docs/pdoc_templates/html.mako +454 -0
  104. package/pyatv/docs/support/acknowledgements.md +87 -0
  105. package/pyatv/docs/support/faq.md +214 -0
  106. package/pyatv/docs/support/migration.md +138 -0
  107. package/pyatv/docs/support/scanning_issues.md +110 -0
  108. package/pyatv/docs/support/support.md +18 -0
  109. package/pyatv/docs/support/troubleshooting.md +83 -0
  110. package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeMessage.proto +13 -0
  111. package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeMessage_pb2.pyi +37 -0
  112. package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeResponseMessage.proto +11 -0
  113. package/pyatv/pyatv/protocols/mrp/protobuf/AudioFadeResponseMessage_pb2.pyi +32 -0
  114. package/pyatv/pyatv/protocols/mrp/protobuf/AudioFormatSettingsMessage.proto +5 -0
  115. package/pyatv/pyatv/protocols/mrp/protobuf/AudioFormatSettingsMessage_pb2.pyi +27 -0
  116. package/pyatv/pyatv/protocols/mrp/protobuf/ClientUpdatesConfigMessage.proto +16 -0
  117. package/pyatv/pyatv/protocols/mrp/protobuf/ClientUpdatesConfigMessage_pb2.pyi +44 -0
  118. package/pyatv/pyatv/protocols/mrp/protobuf/CommandInfo.proto +117 -0
  119. package/pyatv/pyatv/protocols/mrp/protobuf/CommandInfo_pb2.pyi +325 -0
  120. package/pyatv/pyatv/protocols/mrp/protobuf/CommandOptions.proto +36 -0
  121. package/pyatv/pyatv/protocols/mrp/protobuf/CommandOptions_pb2.pyi +115 -0
  122. package/pyatv/pyatv/protocols/mrp/protobuf/Common.proto +79 -0
  123. package/pyatv/pyatv/protocols/mrp/protobuf/Common_pb2.pyi +228 -0
  124. package/pyatv/pyatv/protocols/mrp/protobuf/ConfigureConnectionMessage.proto +11 -0
  125. package/pyatv/pyatv/protocols/mrp/protobuf/ConfigureConnectionMessage_pb2.pyi +32 -0
  126. package/pyatv/pyatv/protocols/mrp/protobuf/ContentItem.proto +27 -0
  127. package/pyatv/pyatv/protocols/mrp/protobuf/ContentItemMetadata.proto +213 -0
  128. package/pyatv/pyatv/protocols/mrp/protobuf/ContentItemMetadata_pb2.pyi +630 -0
  129. package/pyatv/pyatv/protocols/mrp/protobuf/ContentItem_pb2.pyi +94 -0
  130. package/pyatv/pyatv/protocols/mrp/protobuf/CryptoPairingMessage.proto +15 -0
  131. package/pyatv/pyatv/protocols/mrp/protobuf/CryptoPairingMessage_pb2.pyi +46 -0
  132. package/pyatv/pyatv/protocols/mrp/protobuf/DeviceInfoMessage.proto +69 -0
  133. package/pyatv/pyatv/protocols/mrp/protobuf/DeviceInfoMessage_pb2.pyi +226 -0
  134. package/pyatv/pyatv/protocols/mrp/protobuf/GenericMessage.proto +12 -0
  135. package/pyatv/pyatv/protocols/mrp/protobuf/GenericMessage_pb2.pyi +35 -0
  136. package/pyatv/pyatv/protocols/mrp/protobuf/GetKeyboardSessionMessage.proto +11 -0
  137. package/pyatv/pyatv/protocols/mrp/protobuf/GetKeyboardSessionMessage_pb2.pyi +26 -0
  138. package/pyatv/pyatv/protocols/mrp/protobuf/GetRemoteTextInputSessionMessage.proto +10 -0
  139. package/pyatv/pyatv/protocols/mrp/protobuf/GetRemoteTextInputSessionMessage_pb2.pyi +26 -0
  140. package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeMessage.proto +11 -0
  141. package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeMessage_pb2.pyi +32 -0
  142. package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeResultMessage.proto +11 -0
  143. package/pyatv/pyatv/protocols/mrp/protobuf/GetVolumeResultMessage_pb2.pyi +32 -0
  144. package/pyatv/pyatv/protocols/mrp/protobuf/KeyboardMessage.proto +88 -0
  145. package/pyatv/pyatv/protocols/mrp/protobuf/KeyboardMessage_pb2.pyi +261 -0
  146. package/pyatv/pyatv/protocols/mrp/protobuf/LanguageOption.proto +9 -0
  147. package/pyatv/pyatv/protocols/mrp/protobuf/LanguageOption_pb2.pyi +42 -0
  148. package/pyatv/pyatv/protocols/mrp/protobuf/ModifyOutputContextRequestMessage.proto +23 -0
  149. package/pyatv/pyatv/protocols/mrp/protobuf/ModifyOutputContextRequestMessage_pb2.pyi +86 -0
  150. package/pyatv/pyatv/protocols/mrp/protobuf/NotificationMessage.proto +12 -0
  151. package/pyatv/pyatv/protocols/mrp/protobuf/NotificationMessage_pb2.pyi +38 -0
  152. package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingClient.proto +12 -0
  153. package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingClient_pb2.pyi +49 -0
  154. package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingInfo.proto +24 -0
  155. package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingInfo_pb2.pyi +79 -0
  156. package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingPlayer.proto +11 -0
  157. package/pyatv/pyatv/protocols/mrp/protobuf/NowPlayingPlayer_pb2.pyi +45 -0
  158. package/pyatv/pyatv/protocols/mrp/protobuf/Origin.proto +17 -0
  159. package/pyatv/pyatv/protocols/mrp/protobuf/OriginClientPropertiesMessage.proto +11 -0
  160. package/pyatv/pyatv/protocols/mrp/protobuf/OriginClientPropertiesMessage_pb2.pyi +32 -0
  161. package/pyatv/pyatv/protocols/mrp/protobuf/Origin_pb2.pyi +63 -0
  162. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueue.proto +15 -0
  163. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueCapabilities.proto +7 -0
  164. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueCapabilities_pb2.pyi +33 -0
  165. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueContext.proto +5 -0
  166. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueContext_pb2.pyi +27 -0
  167. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueRequestMessage.proto +29 -0
  168. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueueRequestMessage_pb2.pyi +87 -0
  169. package/pyatv/pyatv/protocols/mrp/protobuf/PlaybackQueue_pb2.pyi +53 -0
  170. package/pyatv/pyatv/protocols/mrp/protobuf/PlayerClientPropertiesMessage.proto +13 -0
  171. package/pyatv/pyatv/protocols/mrp/protobuf/PlayerClientPropertiesMessage_pb2.pyi +37 -0
  172. package/pyatv/pyatv/protocols/mrp/protobuf/PlayerPath.proto +11 -0
  173. package/pyatv/pyatv/protocols/mrp/protobuf/PlayerPath_pb2.pyi +39 -0
  174. package/pyatv/pyatv/protocols/mrp/protobuf/ProtocolMessage.proto +171 -0
  175. package/pyatv/pyatv/protocols/mrp/protobuf/ProtocolMessage_pb2.pyi +377 -0
  176. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterForGameControllerEventsMessage.proto +18 -0
  177. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterForGameControllerEventsMessage_pb2.pyi +54 -0
  178. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceMessage.proto +12 -0
  179. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceMessage_pb2.pyi +34 -0
  180. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceResultMessage.proto +12 -0
  181. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterHIDDeviceResultMessage_pb2.pyi +35 -0
  182. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceMessage.proto +12 -0
  183. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceMessage_pb2.pyi +34 -0
  184. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceResponseMessage.proto +12 -0
  185. package/pyatv/pyatv/protocols/mrp/protobuf/RegisterVoiceInputDeviceResponseMessage_pb2.pyi +35 -0
  186. package/pyatv/pyatv/protocols/mrp/protobuf/RemoteTextInputMessage.proto +13 -0
  187. package/pyatv/pyatv/protocols/mrp/protobuf/RemoteTextInputMessage_pb2.pyi +38 -0
  188. package/pyatv/pyatv/protocols/mrp/protobuf/RemoveClientMessage.proto +12 -0
  189. package/pyatv/pyatv/protocols/mrp/protobuf/RemoveClientMessage_pb2.pyi +34 -0
  190. package/pyatv/pyatv/protocols/mrp/protobuf/RemoveEndpointsMessage.proto +11 -0
  191. package/pyatv/pyatv/protocols/mrp/protobuf/RemoveEndpointsMessage_pb2.pyi +34 -0
  192. package/pyatv/pyatv/protocols/mrp/protobuf/RemoveOutputDevicesMessage.proto +12 -0
  193. package/pyatv/pyatv/protocols/mrp/protobuf/RemoveOutputDevicesMessage_pb2.pyi +38 -0
  194. package/pyatv/pyatv/protocols/mrp/protobuf/RemovePlayerMessage.proto +12 -0
  195. package/pyatv/pyatv/protocols/mrp/protobuf/RemovePlayerMessage_pb2.pyi +34 -0
  196. package/pyatv/pyatv/protocols/mrp/protobuf/SendButtonEventMessage.proto +13 -0
  197. package/pyatv/pyatv/protocols/mrp/protobuf/SendButtonEventMessage_pb2.pyi +38 -0
  198. package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandMessage.proto +16 -0
  199. package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandMessage_pb2.pyi +43 -0
  200. package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandResultMessage.proto +100 -0
  201. package/pyatv/pyatv/protocols/mrp/protobuf/SendCommandResultMessage_pb2.pyi +286 -0
  202. package/pyatv/pyatv/protocols/mrp/protobuf/SendHIDEventMessage.proto +41 -0
  203. package/pyatv/pyatv/protocols/mrp/protobuf/SendHIDEventMessage_pb2.pyi +63 -0
  204. package/pyatv/pyatv/protocols/mrp/protobuf/SendPackedVirtualTouchEventMessage.proto +24 -0
  205. package/pyatv/pyatv/protocols/mrp/protobuf/SendPackedVirtualTouchEventMessage_pb2.pyi +64 -0
  206. package/pyatv/pyatv/protocols/mrp/protobuf/SendVoiceInputMessage.proto +38 -0
  207. package/pyatv/pyatv/protocols/mrp/protobuf/SendVoiceInputMessage_pb2.pyi +134 -0
  208. package/pyatv/pyatv/protocols/mrp/protobuf/SetArtworkMessage.proto +11 -0
  209. package/pyatv/pyatv/protocols/mrp/protobuf/SetArtworkMessage_pb2.pyi +32 -0
  210. package/pyatv/pyatv/protocols/mrp/protobuf/SetConnectionStateMessage.proto +18 -0
  211. package/pyatv/pyatv/protocols/mrp/protobuf/SetConnectionStateMessage_pb2.pyi +54 -0
  212. package/pyatv/pyatv/protocols/mrp/protobuf/SetDefaultSupportedCommandsMessage.proto +28 -0
  213. package/pyatv/pyatv/protocols/mrp/protobuf/SetDefaultSupportedCommandsMessage_pb2.pyi +74 -0
  214. package/pyatv/pyatv/protocols/mrp/protobuf/SetDiscoveryModeMessage.proto +12 -0
  215. package/pyatv/pyatv/protocols/mrp/protobuf/SetDiscoveryModeMessage_pb2.pyi +35 -0
  216. package/pyatv/pyatv/protocols/mrp/protobuf/SetHiliteModeMessage.proto +11 -0
  217. package/pyatv/pyatv/protocols/mrp/protobuf/SetHiliteModeMessage_pb2.pyi +32 -0
  218. package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingClientMessage.proto +12 -0
  219. package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingClientMessage_pb2.pyi +34 -0
  220. package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingPlayerMessage.proto +12 -0
  221. package/pyatv/pyatv/protocols/mrp/protobuf/SetNowPlayingPlayerMessage_pb2.pyi +34 -0
  222. package/pyatv/pyatv/protocols/mrp/protobuf/SetRecordingStateMessage.proto +17 -0
  223. package/pyatv/pyatv/protocols/mrp/protobuf/SetRecordingStateMessage_pb2.pyi +54 -0
  224. package/pyatv/pyatv/protocols/mrp/protobuf/SetStateMessage.proto +27 -0
  225. package/pyatv/pyatv/protocols/mrp/protobuf/SetStateMessage_pb2.pyi +72 -0
  226. package/pyatv/pyatv/protocols/mrp/protobuf/SetVolumeMessage.proto +12 -0
  227. package/pyatv/pyatv/protocols/mrp/protobuf/SetVolumeMessage_pb2.pyi +35 -0
  228. package/pyatv/pyatv/protocols/mrp/protobuf/SupportedCommands.proto +7 -0
  229. package/pyatv/pyatv/protocols/mrp/protobuf/SupportedCommands_pb2.pyi +30 -0
  230. package/pyatv/pyatv/protocols/mrp/protobuf/TextInputMessage.proto +23 -0
  231. package/pyatv/pyatv/protocols/mrp/protobuf/TextInputMessage_pb2.pyi +76 -0
  232. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionKey.proto +6 -0
  233. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionKey_pb2.pyi +30 -0
  234. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionMessage.proto +15 -0
  235. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionMessage_pb2.pyi +42 -0
  236. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPacket.proto +11 -0
  237. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPacket_pb2.pyi +41 -0
  238. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPackets.proto +7 -0
  239. package/pyatv/pyatv/protocols/mrp/protobuf/TransactionPackets_pb2.pyi +30 -0
  240. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateClientMessage.proto +12 -0
  241. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateClientMessage_pb2.pyi +34 -0
  242. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemArtworkMessage.proto +14 -0
  243. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemArtworkMessage_pb2.pyi +41 -0
  244. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemMessage.proto +14 -0
  245. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateContentItemMessage_pb2.pyi +41 -0
  246. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateEndPointsMessage.proto +25 -0
  247. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateEndPointsMessage_pb2.pyi +74 -0
  248. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateOutputDeviceMessage.proto +88 -0
  249. package/pyatv/pyatv/protocols/mrp/protobuf/UpdateOutputDeviceMessage_pb2.pyi +277 -0
  250. package/pyatv/pyatv/protocols/mrp/protobuf/UpdatePlayerPath.proto +12 -0
  251. package/pyatv/pyatv/protocols/mrp/protobuf/UpdatePlayerPath_pb2.pyi +34 -0
  252. package/pyatv/pyatv/protocols/mrp/protobuf/VirtualTouchDeviceDescriptorMessage.proto +8 -0
  253. package/pyatv/pyatv/protocols/mrp/protobuf/VirtualTouchDeviceDescriptorMessage_pb2.pyi +36 -0
  254. package/pyatv/pyatv/protocols/mrp/protobuf/VoiceInputDeviceDescriptorMessage.proto +8 -0
  255. package/pyatv/pyatv/protocols/mrp/protobuf/VoiceInputDeviceDescriptorMessage_pb2.pyi +35 -0
  256. package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlAvailabilityMessage.proto +23 -0
  257. package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlAvailabilityMessage_pb2.pyi +71 -0
  258. package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlCapabilitiesDidChangeMessage.proto +14 -0
  259. package/pyatv/pyatv/protocols/mrp/protobuf/VolumeControlCapabilitiesDidChangeMessage_pb2.pyi +40 -0
  260. package/pyatv/pyatv/protocols/mrp/protobuf/VolumeDidChangeMessage.proto +13 -0
  261. package/pyatv/pyatv/protocols/mrp/protobuf/VolumeDidChangeMessage_pb2.pyi +38 -0
  262. package/pyatv/pyatv/protocols/mrp/protobuf/WakeDeviceMessage.proto +11 -0
  263. package/pyatv/pyatv/protocols/mrp/protobuf/WakeDeviceMessage_pb2.pyi +26 -0
  264. package/pyatv/pyatv/py.typed +0 -0
  265. package/pyatv/pylintrc +49 -0
  266. package/pyatv/pyproject.toml +74 -0
  267. package/pyatv/requirements/requirements.txt +14 -0
  268. package/pyatv/requirements/requirements_docs.txt +2 -0
  269. package/pyatv/requirements/requirements_test.txt +20 -0
  270. package/pyatv/scripts/build_docs.sh +17 -0
  271. package/pyatv/scripts/setup_dev_env.sh +83 -0
  272. package/pyatv/setup.cfg +14 -0
  273. package/pyatv/tests/data/README +23 -0
  274. package/pyatv/tests/data/audio_10_frames.wav +0 -0
  275. package/pyatv/tests/data/audio_1_packet_metadata.wav +0 -0
  276. package/pyatv/tests/data/audio_3_packets.wav +0 -0
  277. package/pyatv/tests/data/only_metadata.wav +0 -0
  278. package/pyatv/tests/data/only_title.wav +0 -0
  279. package/pyatv/tests/data/static_3sec.ogg +0 -0
  280. package/pyatv/tests/data/testfile.txt +1 -0
  281. package/pyatv/tests/support/pyatv.code-workspace +14 -0
  282. package/src/index.ts +122 -8
  283. 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.