Repository: postlund/pyatv
Pull request #1151 (2021-06-16 17:40:28)

Various updates in preparation for 0.8.0
Some minor things:
* Properly TEARDOWN RTSP session when done
* Fix RAOP service in fake_device
* Minor bug fixes and documentation updates
* Add a tutorial, explaining how to build a simple web client!

<a href="https://gitpod.io/#https://github.com/postlund/pyatv/pull/1151"><img src="https://gitpod.io/button/open-in-gitpod.svg"/></a>



codecov[bot]: # [Codecov](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) Report
> Merging [#1151](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) (a17e820) into [master](https://codecov.io/gh/postlund/pyatv/commit/116d29d9039e213c0822129abe93789579d0fdb2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) (116d29d) will **decrease** coverage by `0.42%`.
> The diff coverage is `72.72%`.

[![Impacted file tree graph](https://codecov.io/gh/postlund/pyatv/pull/1151/graphs/tree.svg?width=650&height=150&src=pr&token=wb6azJjRSY&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)

```diff
@@            Coverage Diff             @@
##           master    #1151      +/-   ##
==========================================
- Coverage   95.24%   94.81%   -0.43%     
==========================================
  Files          58       58              
  Lines        5930     5902      -28     
==========================================
- Hits         5648     5596      -52     
- Misses        282      306      +24     
```


| [Impacted Files](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) | Coverage Δ | |
|---|---|---|
| [pyatv/raop/raop.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcmFvcC9yYW9wLnB5) | `85.42% <50.00%> (-3.84%)` | :arrow_down: |
| [pyatv/support/scan.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9zY2FuLnB5) | `94.64% <60.00%> (-1.69%)` | :arrow_down: |
| [pyatv/helpers.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvaGVscGVycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [pyatv/raop/rtsp.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcmFvcC9ydHNwLnB5) | `97.27% <100.00%> (+0.05%)` | :arrow_up: |
| [pyatv/mrp/protocol.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvbXJwL3Byb3RvY29sLnB5) | `92.30% <0.00%> (-2.80%)` | :arrow_down: |
| [pyatv/mrp/connection.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvbXJwL2Nvbm5lY3Rpb24ucHk=) | `81.05% <0.00%> (-2.29%)` | :arrow_down: |
| [pyatv/support/net.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9uZXQucHk=) | `91.66% <0.00%> (-1.67%)` | :arrow_down: |
| [pyatv/support/http.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9odHRwLnB5) | `92.83% <0.00%> (-0.71%)` | :arrow_down: |
| [pyatv/mrp/pairing.py](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvbXJwL3BhaXJpbmcucHk=) | `97.56% <0.00%> (-0.12%)` | :arrow_down: |
| ... and [13 more](https://codecov.io/gh/postlund/pyatv/pull/1151/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl). Last update [116d29d...a17e820](https://codecov.io/gh/postlund/pyatv/pull/1151?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5f1ddcaf..5021ed94 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -45,3 +45,6 @@ updates:
     - "2.1"
     - "2.2"
     - "2.3"
+  - dependency-name: bitarray
+    versions:
+    - "> 2.1.2"
\ No newline at end of file
diff --git a/base_versions.txt b/base_versions.txt
index ae3c94d2..ac9b368f 100644
--- a/base_versions.txt
+++ b/base_versions.txt
@@ -1,6 +1,6 @@
 aiohttp==3.1.0,<5
 audio-metadata==0.11.1
-bitarray==2.1.3
+bitarray==2.1.2
 cryptography==2.6
 miniaudio==1.44
 netifaces==0.10.0
diff --git a/docs/_config.yml b/docs/_config.yml
index e6e0de20..2b67c396 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -39,6 +39,8 @@ page_links:
       title: Start
     - link: /documentation/getting-started/
       title: Getting Started
+    - link: /documentation/tutorial/
+      title: Tutorial
     - link: /documentation/supported_features/
       title: Supported Features
     - link: /documentation/concepts/
diff --git a/docs/_includes/pypi b/docs/_includes/pypi
new file mode 100644
index 00000000..12835106
--- /dev/null
+++ b/docs/_includes/pypi
@@ -0,0 +1,5 @@
+{%- assign package = include.package -%}
+{%- capture link -%}
+  <a href="https://pypi.org/project/{{ package }}">{{ package }}</a>
+{%- endcapture -%}
+{{ link }}
\ No newline at end of file
diff --git a/docs/api/pyatv/helpers.html b/docs/api/pyatv/helpers.html
index fa62951d..d21e951a 100644
--- a/docs/api/pyatv/helpers.html
+++ b/docs/api/pyatv/helpers.html
@@ -93,16 +93,14 @@ def get_unique_id(
 
     The unique identifier is returned if available, otherwise `None` is returned.
     &#34;&#34;&#34;
-    if service_type == HOMESHARING_SERVICE:
-        return properties.get(&#34;hG&#34;)
-    if service_type == DEVICE_SERVICE:
+    if service_type in [DEVICE_SERVICE, HOMESHARING_SERVICE]:
         return service_name.split(&#34;_&#34;)[0]
     if service_type == MEDIAREMOTE_SERVICE:
         return properties.get(&#34;UniqueIdentifier&#34;)
     if service_type == AIRPLAY_SERVICE:
         return properties.get(&#34;deviceid&#34;)
     if service_type == RAOP_SERVICE:
-        return service_name.split(&#34;@&#34;)[0]
+        return service_name.split(&#34;@&#34;, maxsplit=1)[0]
     return None</code></pre>
 </details>
 </section>
@@ -186,16 +184,14 @@ Very inflexible in many cases, but can be handys sometimes when trying things.</
 
     The unique identifier is returned if available, otherwise `None` is returned.
     &#34;&#34;&#34;
-    if service_type == HOMESHARING_SERVICE:
-        return properties.get(&#34;hG&#34;)
-    if service_type == DEVICE_SERVICE:
+    if service_type in [DEVICE_SERVICE, HOMESHARING_SERVICE]:
         return service_name.split(&#34;_&#34;)[0]
     if service_type == MEDIAREMOTE_SERVICE:
         return properties.get(&#34;UniqueIdentifier&#34;)
     if service_type == AIRPLAY_SERVICE:
         return properties.get(&#34;deviceid&#34;)
     if service_type == RAOP_SERVICE:
-        return service_name.split(&#34;@&#34;)[0]
+        return service_name.split(&#34;@&#34;, maxsplit=1)[0]
     return None</code></pre>
 </details>
 </dd>
diff --git a/docs/documentation/documentation.md b/docs/documentation/documentation.md
index b63c0151..844bfd4e 100644
--- a/docs/documentation/documentation.md
+++ b/docs/documentation/documentation.md
@@ -90,7 +90,8 @@ are needed as well. An updated list is available
 [here](https://github.com/postlund/pyatv/blob/master/base_versions.txt).
 
 You also need to have OpenSSL compiled with support for ed25519 in order
-to connect to MRP devices.
+to connect to MRP devices. More details is
+[here](../support/faq/#i-get-an-error-about-ed25519-is-not-supported-how-can-i-fix-that).
 
 # Milestones
 
diff --git a/docs/documentation/getting_started.md b/docs/documentation/getting_started.md
index 4ed69c9e..12e683b3 100644
--- a/docs/documentation/getting_started.md
+++ b/docs/documentation/getting_started.md
@@ -149,4 +149,5 @@ You can find this example under `examples/scan_and_connect.py`
 
 It is recommended that you read the [Documentation](../documentation/) section to get
 a better grasp of how pyatv works. Then continue with [Development](../development)
-once you are ready to write some code.
+once you are ready to write some code. Next is however a tutorial
+you can follow if you want to try out creating a simple application!
diff --git a/docs/documentation/supported_features.md b/docs/documentation/supported_features.md
index d7550aca..ed8dfe1d 100644
--- a/docs/documentation/supported_features.md
+++ b/docs/documentation/supported_features.md
@@ -108,6 +108,8 @@ screen mirroring and image sharing.
 ### Limitations and notes
 
 * Very limited support for this protocol so far. More features will be added in the future.
+* Does not implement support for {% include api i="interface.DeviceListener" %} and will
+  *not* trigger `connection_lost` or `connection_closed` when used stand-alone.
 
 ## Companion
 
@@ -125,7 +127,8 @@ app and power related functions.
 
 * Early stage of development - not many features supported
 * No persistent connection at the moment, so events are not supported
-* Will support power commands (turn on/off) and remote control commands in the future
+* Does not implement support for {% include api i="interface.DeviceListener" %} and will
+  *not* trigger `connection_lost` or `connection_closed` when used stand-alone.
 
 ## DMAP
 
@@ -203,6 +206,8 @@ AirTunes).
 * Devices requiring password are not supported
 * Remote control commands does not work (except for volume_up and volume_down),
   e.g. play or pause {% include issue no="1068" %}
+  * Does not implement support for {% include api i="interface.DeviceListener" %} and will
+  *not* trigger `connection_lost` or `connection_closed` when used stand-alone.
 
 ### Verified Devices
 
diff --git a/docs/documentation/tutorial.md b/docs/documentation/tutorial.md
new file mode 100644
index 00000000..436d44c7
--- /dev/null
+++ b/docs/documentation/tutorial.md
@@ -0,0 +1,1037 @@
+---
+layout: template
+title: Tutorial
+permalink: /documentation/tutorial/
+link_group: documentation
+---
+# :green_book: Table of Contents
+{:.no_toc}
+* TOC
+{:toc}
+
+# Tutorial
+Looking at simple example is one thing, building an application is something else. This
+tutorial will guide you through building an application that is simple to understand,
+but still more complex than the bundled examples. So, grab your favorite beverage and
+lets get going! :coffee: :tea:
+
+# The goal: simple web based client
+The goal of this tutorial is to build an application that starts a web server and
+accepts commands for scanning, connecting to a device, sending remote control commands
+and fetching what is currently playing. There might be a bonus at the end, demonstrating
+live push updates via websockets if you are lucky... Pairing is left out from the
+tutorial as an exercise to you. So you will need to obtain credentials (if
+needed) via some other method, e.g. [atvremote](../atvremote#pairing-with-a-device).
+
+Small steps is key, so the tutorial will be divded into the following sections:
+
+1. Basic web server
+2. Add scan support
+3. Connect to a device
+4. Remote control commands
+5. Retrieve current play state
+6. Closing c connection
+7. Some bonuses...
+
+The complete source code will be listed several times along the way. If you're unsure
+if you did it right, just scroll down and hopefully you can compare with the expected
+result. The final result is [here](#the-complete-example).
+
+# Tutorial steps
+
+## 1. Basic web server
+Let's get going! First, we're gonna create a web server that will handle the requests
+for us. We'll use {% include pypi package="aiohttp" %} for that since it's already a
+dependency of pyatv and fairly easy to use. Here's a simple skeleton, save it
+as `tutorial.py`:
+
+```python
+import asyncio
+from aiohttp import web
+import pyatv
+
+routes = web.RouteTableDef()
+
+@routes.get('/')
+async def scan(request):
+    return web.Response(text="Hello world!")
+
+app = web.Application()
+app.add_routes(routes)
+web.run_app(app)
+```
+
+Run the script with `python tutorial.py`:
+
+```shell
+$ python tutorial.py 
+======== Running on http://0.0.0.0:8080 ========
+(Press CTRL+C to quit)
+```
+
+Open your web browser, navigate to
+<a href="http://127.0.0.1:8080">http://127.0.0.1:8080</a> and you should be
+greeted by `Hello world!`.
+
+The `scan` method is simply called whenever the index page
+is requested (because of `@routes.get('/')`). We'll change the endpoint to `/scan`
+in the next section.
+
+## 2. Add scan support
+Implementing scanning is rather straight-forward using {% include api i="pyatv.scan" %}.
+The tricky part is: what output format should we use? In this example we'll just stick
+to a simple human-readble format, but changing to something else like JSON would be
+pretty simple as well.
+
+Change the `scan` method into something like this:
+
+```python
+@routes.get('/scan')
+async def scan(request):
+    results = await pyatv.scan(loop=asyncio.get_event_loop())
+    output = "\n\n".join(str(result) for result in results)
+    return web.Response(text=output)
+```
+
+A break-down:
+1. First we scan for all devices on the network with {% include api i="pyatv.scan" %},
+   passing current event loop as `loop`. This will return a list of
+   {% include api i="interface.AppleTV" %} objects.
+2. Results are converted into a readable string with two newlines between each device.
+3. Content is returned as a text
+
+The output is pretty close to what `atvremote scan` would give. To give an idea of
+what it would take to return JSON output instead, here's an example of that (containing
+only address and name for each device):
+
+```python
+@routes.get('/scan')
+async def scan(request):
+    devices = []
+    for result in await pyatv.scan(loop=asyncio.get_event_loop()):
+        devices.append({"name": result.name, "address": str(result.address)})
+    return web.json_response(devices)
+```
+
+There's an important thing to not here. By default, scanning will take around three
+seconds. That means it will take roughly three seconds until the page is rendered.
+That might be ok, or it might not be depending on usecase. A potential improvement
+is to periodically scan for devices and keep a cache that is immediately returned.
+Alternatively, provide another endpoint (e.g. `/trigger_scan`) that performs scanning
+in the background and saves the result. Then `/scan` can return that result.
+
+*Tip: {% include code file="scripts/atvscript.py" %} is a good reference if you
+need help with converting output to JSON.*
+
+## 3. Connect to a device
+Now we can find devices, next step is to connect to one. We'll support doing that
+by ID. We will also support passing in credentials. A typical call to connect will
+look like this:
+
+```raw
+http://127.0.0.1:8080/connect/aabbccddee?mrp=1234&dmap=5678
+```
+
+The ID in this case is `aabbccddee` and credentials are passed to MRP as 1234
+and 5678 for DMAP. Argument names for credentials will be the same as in
+{% include api i="const.Protocol" %} but converted to lower-case.
+
+Let's ignore credentials for now though, focusing on just connecting to the device:
+
+```python
+@routes.get('/connect/{id}')
+async def connect(request):
+    loop = asyncio.get_event_loop()
+    device_id = request.match_info["id"]
+
+    results = await pyatv.scan(identifier=device_id, loop=loop)
+    if not results:
+        return web.Response(text="Device not found", status=500)
+
+    try:
+        atv = await pyatv.connect(results[0], loop=loop)
+    except Exception as ex:
+        return web.Response(text=f"Failed to connect to device: {ex}", status=500)
+
+    return web.Response(text=f"Connected to device {device_id}")
+```
+
+So, there's a bunch of code here:
+
+1. There's built-in support for matchers in {% include pypi package="aiohttp" %},
+   so the `<id>` is easily extracted with `request.match_info["id"]`.
+2. Next, scan for device with the requested device id by passing it via
+   `identifier`. If no device is not found, return an error message and error code.
+3. Try to connect, making sure that we catch any error and return another error
+   message in case connect failed.
+4. At the end, return a message stating we are connected.
+
+Assuming everything went OK, we have a handle to our device via `atv`. We need
+to save that somewhere (and make sure we close the connection properly when
+exiting the script), since we need it in other request handlers.
+
+The `web.Application` instance can store global variables for us, so lets use
+that. Before returning in `connect`, make this change:
+
+```python
+    ...
+    except Exception as ex:
+        return web.Response(text=f"Failed to connect to device: {ex}", status=500)
+
+    request.app["atv"][device_id] = atv  # <-- Add this
+    return web.Response(text=f"Connected to device {device_id}")
+```
+
+We can then access our device from other handlers via `request.app[<id>]` later.
+But we should also close it when exiting the script. We can do that by modifying
+the startup code like this:
+
+```python
+async def on_shutdown(app: web.Application) -> None:
+    for atv in app["atv"].values():
+        atv.close()
+
+app = web.Application()
+app["atv"] = {}
+app.add_routes(routes)
+app.on_shutdown.append(on_shutdown)
+web.run_app(app)
+```
+
+The `on_shutdown` method will be called when the script is exitted, e.g. by
+pressing Ctrl+C. There's one more guard we should add, making sure we don't try
+to connect if we are already connected. A simple check at the top will fix that:
+
+```python
+@routes.get('/connect/{id}')
+async def connect(request):
+    loop = asyncio.get_event_loop()
+    device_id = request.match_info["id"]
+    if device_id in request.app["atv"]:
+        return web.Response(text=f"Already connected to {device_id}")
+```
+
+
+Next part is to add parsing of credentials. We'll create a
+helper method for that, which will iterate all services and look for credentials
+in the GET-parameters:
+
+```python
+def add_credentials(config, query):
+    for service in config.services:
+        proto_name = service.protocol.name.lower()  # E.g. Protocol.MRP -> "mrp"
+        if proto_name in query:
+            config.set_credentials(service.protocol, query[proto_name])
+```
+
+Here, `query` is a map with all values passed via the URL, e.g.
+`xxx?mrp=1234&dmap=5678` => `{"mrp": "1234", "dmap": "5678"}`. Add `add_credentials`
+above the `scan` method and call it before connecting:
+
+```python
+    ...
+    if not results:
+        return web.Response(text="Device not found", status=500)
+
+    add_credentials(results[0], request.query)
+
+    try:
+        atv = await pyatv.connect(results[0], loop=loop)
+    ...
+```
+
+For the sake of completeness, here is the final script:
+
+<details>
+
+```python
+import asyncio
+from aiohttp import web
+import pyatv
+
+routes = web.RouteTableDef()
+
+
+def add_credentials(config, query):
+    for service in config.services:
+        proto_name = service.protocol.name.lower()
+        if proto_name in query:
+            config.set_credentials(service.protocol, query[proto_name])
+
+
+@routes.get("/scan")
+async def scan(request):
+    results = await pyatv.scan(loop=asyncio.get_event_loop())
+    output = "\n\n".join(str(result) for result in results)
+    return web.Response(text=output)
+
+
+@routes.get("/connect/{id}")
+async def connect(request):
+    loop = asyncio.get_event_loop()
+    device_id = request.match_info["id"]
+    if device_id in request.app["atv"]:
+        return web.Response(text=f"Already connected to {device_id}")
+
+    results = await pyatv.scan(identifier=device_id, loop=loop)
+    if not results:
+        return web.Response(text="Device not found", status=500)
+
+    add_credentials(results[0], request.query)
+
+    try:
+        atv = await pyatv.connect(results[0], loop=loop)
+    except Exception as ex:
+        return web.Response(text=f"Failed to connect to device: {ex}", status=500)
+
+    request.app["atv"][device_id] = atv
+    return web.Response(text=f"Connected to device {device_id}")
+
+
+async def on_shutdown(app: web.Application) -> None:
+    for atv in app["atv"].values():
+        atv.close()
+
+
+app = web.Application()
+app["atv"] = {}
+app.add_routes(routes)
+app.on_shutdown.append(on_shutdown)
+web.run_app(app)
+```
+
+</details>
+
+## 4. Remote control commands
+
+Nice, we are connected! Now, continue with remote control commands. We'll
+stick with single tap actions for now. If you want support for other actions, e.g.
+double tap, pass the action as an argument and access it via `request.query`.
+
+Here's a basic handler for the remote control:
+
+```python
+@routes.get("/remote_control/{id}/{command}")
+async def remote_control(request):
+    device_id = request.match_info["id"]
+    atv = request.app["atv"][device_id]
+
+    try:
+        await getattr(atv.remote_control, request.match_info["command"])()
+    except Exception as ex:
+        return web.Response(text=f"Remote control command failed: {ex}")
+
+    return web.Response(text="OK")
+```
+
+By using `getattr`, we can look up commands dynamically without having to
+write them in code. Use the same names as methods
+in {% include api i="interface.RemoteControl" %}. We should do one more thing:
+check that we are connected, e.g. like this:
+
+```python
+@routes.get("/remote_control/{id}/{command}")
+async def remote_control(request):
+    device_id = request.match_info["id"]
+    atv = request.app["atv"].get(device_id)
+    if not atv:
+        return web.Response(text=f"Not connected to {device_id}", code=500)
+
+    ...
+```
+
+To trigger a command, use a URL like this:
+
+```raw
+http://127.0.0.1:8080/remote_control/aabbccddee/menu
+```
+
+You might have noticed that the device id is passed here as well. By doing that,
+multiple devices can be controlled at the same time. Pretty cool, huh?
+
+## 4.5. Some refactoring
+
+This is a pattern we will see a lot:
+
+```python
+@routes.get("/something/{id}/{command}")
+async def something(request):
+    device_id = request.match_info["id"]
+    atv = request.app["atv"].get(device_id)
+    if not atv:
+        return web.Response(text=f"Not connected to {device_id}", status=500)
+
+    ...
+```
+
+To reduce code, we can create a decorator taking care of this for us. Here's
+one way:
+
+```python
+def web_command(method):
+    async def _handler(request):
+        device_id = request.match_info["id"]
+        atv = request.app["atv"].get(device_id)
+        if not atv:
+            return web.Response(text=f"Not connected to {device_id}", status=500)
+        return await method(request, atv)
+    return _handler
+```
+
+This decorator will verify that a device handler exists for the given id, returning
+an error otherwise. It will also pass the device handler (`atv`) as a second
+argument to the handler method so it is conveniently available. Re-writing original
+`remote_control` method using the decorator, it will now look like this:
+
+```python
+@routes.get("/remote_control/{id}/{command}")
+@web_command
+async def remote_control(request, atv):
+    try:
+        await getattr(atv.remote_control, request.match_info["command"])()
+    except Exception as ex:
+        return web.Response(text=f"Remote control command failed: {ex}")
+    return web.Response(text="OK")
+```
+
+Again, for completeness, here is the code so far:
+
+<details>
+
+```python
+import asyncio
+from aiohttp import web
+import pyatv
+
+
+routes = web.RouteTableDef()
+
+
+def web_command(method):
+    async def _handler(request):
+        device_id = request.match_info["id"]
+        atv = request.app["atv"].get(device_id)
+        if not atv:
+            return web.Response(text=f"Not connected to {device_id}", status=500)
+        return await method(request, atv)
+    return _handler
+
+
+def add_credentials(config, query):
+    for service in config.services:
+        proto_name = service.protocol.name.lower()
+        if proto_name in query:
+            config.set_credentials(service.protocol, query[proto_name])
+
+
+@routes.get("/scan")
+async def scan(request):
+    results = await pyatv.scan(loop=asyncio.get_event_loop())
+    output = "\n\n".join(str(result) for result in results)
+    return web.Response(text=output)
+
+
+@routes.get("/connect/{id}")
+async def connect(request):
+    loop = asyncio.get_event_loop()
+    device_id = request.match_info["id"]
+    if device_id in request.app["atv"]:
+        return web.Response(text=f"Already connected to {device_id}")
+
+    results = await pyatv.scan(identifier=device_id, loop=loop)
+    if not results:
+        return web.Response(text="Device not found", status=500)
+
+    add_credentials(results[0], request.query)
+
+    try:
+        atv = await pyatv.connect(results[0], loop=loop)
+    except Exception as ex:
+        return web.Response(text=f"Failed to connect to device: {ex}", status=500)
+
+    request.app["atv"][device_id] = atv
+    return web.Response(text=f"Connected to device {device_id}")
+
+
+@routes.get("/remote_control/{id}/{command}")
+@web_command
+async def remote_control(request, atv):
+    try:
+        await getattr(atv.remote_control, request.match_info["command"])()
+    except Exception as ex:
+        return web.Response(text=f"Remote control command failed: {ex}")
+    return web.Response(text="OK")
+
+
+async def on_shutdown(app: web.Application) -> None:
+    for atv in app["atv"].values():
+        atv.close()
+
+
+app = web.Application()
+app["atv"] = {}
+app.add_routes(routes)
+app.on_shutdown.append(on_shutdown)
+web.run_app(app)
+```
+
+</details>
+
+## 5. Retrieve current play state
+
+With the new decorator, exposing play status is a breeze:
+
+```python
+@routes.get("/playing/{id}")
+@web_command
+async def playing(request, atv):
+    try:
+        status = await atv.metadata.playing()
+    except Exception as ex:
+        return web.Response(text=f"Remote control command failed: {ex}")
+    return web.Response(text=str(status))
+```
+
+Current play status is retrieved via {% include api i="interface.Metadata.playing" %},
+converted to a string and returned.
+
+## 6. Closing a connection
+
+It might be convenient to have a function that can close a connection, so lets
+add that:
+
+```python
+@routes.get("/close/{id}")
+@web_command
+async def close_connection(request, atv):
+    atv.close()
+    request.app["atv"].pop(request.match_info["id"])
+    return web.Response(text="OK")
+```
+
+We basically just call close on the device handler and remove our internally
+stored reference. This will allow us to re-connect again when needed.
+
+*Note: No error handling here if device is not connected.*
+
+## 7. Bonus: Handle device disconnects
+
+There's currently a problem: if the connection for some reason is lost, the
+device handler will still be in `request.app["pyatv"]` (looking like
+it is connected) allowing new commands to be issued. These will obviously fail
+however and it will not be possible to call connect again until close has
+been called. It would be nice to clean up the handler in case the connection is
+lost, so connect can be called directly.
+
+We can do this by setting up a listener for device updates, just removing the
+handler when the connection is lost. We start by declaring a device listener,
+which is an implementation of {% include api i="interface.DeviceListener" %}:
+
+```python
+class DeviceListener(pyatv.interface.DeviceListener):
+    def __init__(self, app, identifier):
+        self.app = app
+        self.identifier = identifier
+
+    def connection_lost(self, exception: Exception) -> None:
+        self._remove()
+
+    def connection_closed(self) -> None:
+        self._remove()
+
+    def _remove(self):
+        self.app["atv"].pop(self.identifier)
+        self.app["listeners"].remove(self)
+```
+
+It will keep track of `app` and the device identifier as we will create one
+listener per device. When a connection is either lost (unknown reason) or
+intentionally closed (e.g. via the close command), remove the handler and
+current listener from internal list. More on `"listeners"` next.
+
+So, we need to make a few adjustments. First and foremost, we need somewhere
+to store the listener objects. It's very tempting to do somethinglike this:
+
+```python
+atv.listener = DeviceListener(request.app, device_id)
+```
+
+The problem however is that pyatv uses *weak references* to listener objects.
+In practice, that means as soon as the variable holding a reference to the
+object goes out of scope, the object (i.e. the `DeviceListener` instance) will
+be taken care of by the garbage collector. Unless someone else has a reference
+to it of course. We are gonna put listeners in a list and remove them once a
+connection is lost. That's what the last line in `DeviceListener` does for us.
+This way there will be a reference to the listener instance and we don't risk
+it getting garbage collected. Add the list in the setup code:
+
+```python
+...
+app = web.Application()
+app["atv"] = {}
+app["listeners"] = []  # <-- add this
+app.add_routes(routes)
+...
+```
+
+Now we need to create the actual listener, make sure it receives updates and
+also add it to the `listeners` list (in the `connect` method):
+
+```python
+    ...
+    except Exception as ex:
+        return web.Response(text=f"Failed to connect to device: {ex}", status=500)
+
+    listener = DeviceListener(request.app, device_id)
+    atv.listener = listener
+    request.app["listeners"].append(listener)
+
+    request.app["atv"][device_id] = atv
+    ...
+```
+
+You can read more about device listeners [here](../../development/listeners/#device-updates),
+if you want some additional context.
+
+There's one more thing to do: get rid of the line that removes the device handler
+in the close command:
+
+```python
+@routes.get("/close/{id}")
+@web_command
+async def close_connection(request, atv):
+    atv.close()
+    return web.Response(text="OK")
+```
+
+Calling `atv.close()` will trigger `connection_closed` in the device listener,
+which in turn will remove the device handler and pop the listener for us.
+
+## 8. Bonus: Push updates
+
+If you made it this far: good job! Adding support for live push updates is a bit
+tricky, but not that hard. There are three steps to this:
+
+1. Add a websocket request handler where clients can subscribe to updates
+2. Create and set up a {% include api i="interface.PushListener" %} that
+   receives updates and forward them over websockets
+3. Serve a small web page with some javascript that connects to the websocket
+   endpoint and updates an element when status change
+
+Let's take it one step at the time.
+
+### Websocket request handler
+
+A websocket request handler works similarly to other request handlers (e.g. GET),
+but since a websocket is generally open over longer period of time, it doesn't
+return until the client disconnects. We will not be handling any commands from
+the client, just let the connection remain open and save a handler to it internally,
+so the push listener can send updates later. We'll start by adding somewhere to
+store these handlers (in the setup code):
+
+```python
+...
+app = web.Application()
+app["atv"] = {}
+app["listeners"] = []
+app["clients"] = {}  # <--- add this
+...
+```
+
+We will map the device id to a list of clients, so that multiple clients can
+connect and receive updates concurrently (that's why a dict is used). Now,
+let's define the websocket handler:
+
+```python
+@routes.get("/ws/{id}")
+@web_command
+async def websocket_handler(request, pyatv):
+    device_id = request.match_info["id"]
+
+    ws = web.WebSocketResponse()
+    await ws.prepare(request)
+    request.app["clients"].setdefault(device_id, []).append(ws)
+
+    playstatus = await pyatv.metadata.playing()
+    await ws.send_str(str(playstatus))
+
+    async for msg in ws:
+        if msg.type == WSMsgType.TEXT:
+            # Handle custom commands from client here
+            if msg.data == "close":
+                await ws.close()
+        elif msg.type == WSMsgType.ERROR:
+            print(f"Connection closed with exception: {ws.exception()}")
+
+    request.app["clients"][device_id].remove(ws)
+
+    return ws
+```
+
+Some code is new, some we have already seen and some is boiler plate. A break-down:
+
+1. Endpoint `/ws/{id}` is used to map which device to receive updates from.
+2. `ws` is the "response" we use to send and receive messages (this is straight
+   from the [aiohttp documentation](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets)).
+   Notice that this handler is saved in `app["clients"]` that we previously added.
+3. When a new client connects, we want to send an initial update with what is
+   currently playing. So current state is fetched, converted to a string and sent.
+4. Loop (from documentation) waiting for incoming data. We don't do much here,
+   but an example handling a `close` command from the client is left for inspiration.
+5. When the connection is closed, remove the handle so we don't try to send updates
+   on a closed connection later.
+
+Make sure to import `WSMsgType` at the top as well:
+
+```python
+from aiohttp import WSMsgType, web
+```
+
+That's it for the websocket handler. You can add additional websocket commands
+if you like in the loop, but it's not used here.
+
+### Handling push updates
+
+At this stage, websocket clients can connect and we store handlers to them in
+`app["clients"][<id>]` per device. Now we need to subscribe to push updates from
+the device and forward them to all websocket connections for a particular
+device. The natural way would be to add a new class, implement
+{% include api i="interface.PushListener" %} and add logic there. An easier way
+however, is to use the fact that we have a device listener already. We can just
+implement the relevant methods there and use that as a push listener as well.
+By doing so, we don't have to handle a new listener (weak reference problem exists
+here as well) and it requires a bit less code.
+
+Start by inheriting from {% include api i="interface.PushListener" %}:
+
+```python
+class DeviceListener(pyatv.interface.DeviceListener, pyatv.interface.PushListener):
+```
+
+Now, add these methods to `DeviceListener`:
+
+```python
+    def playstatus_update(self, updater, playstatus: pyatv.interface.Playing) -> None:
+        clients = self.app["clients"].get(self.identifier, [])
+        for client in clients:
+            asyncio.ensure_future(client.send_str(str(playstatus)))
+
+    def playstatus_error(self, updater, exception: Exception) -> None:
+        pass
+```
+
+When an update is received in `playstatus_update`, look up all client handlers for
+the device and send a string version of it. Note that `send_str` is a coroutine and
+`playstatus_update` is a plain callback function, so `asyncio.ensure_future` is
+used to schedule a call on the event loop. We ignore any error updates for now by
+leaving that method empty.
+
+The final piece is to subscribe to push updates, so our new methods are actually
+called at all. We do this in `connect`:
+
+```python
+    ...
+    listener = DeviceListener(request.app, device_id)
+    atv.listener = listener
+    atv.push_updater.listener = listener  # <-- set the listener
+    atv.push_updater.start()              # <-- start subscribing to updates
+    request.app["listeners"].append(listener)
+    ...
+```
+
+We are basically done with the websocket implementation now and you can try it out
+with a third-part client if you like. But it's convenient if we provide a simple
+web page that updates for us. So let's finalize the script with that.
+
+### Websocket client page
+This can't be stressed enough: the solution implemented here is *not* a good
+solution. It is only meant to be simple, keeping everything in the same file.
+Preferably the client page would be stored as a separate file and served as a
+static file. We will however bundle a basic page in the script, so add this at the
+top (below the imports):
+
+```python
+PAGE = """
+<script>
+let socket = new WebSocket('ws://' + location.host + '/ws/DEVICE_ID');
+
+socket.onopen = function(e) {
+  document.getElementById('status').innerText = 'Connected!';
+};
+
+socket.onmessage = function(event) {
+  document.getElementById('state').innerText = event.data;
+};
+
+socket.onclose = function(event) {
+  if (event.wasClean) {
+    document.getElementById('status').innerText = 'Connection closed cleanly!';
+  } else {
+    document.getElementById('status').innerText = 'Disconnected due to error!';
+  }
+  document.getElementById('state').innerText = "";
+};
+
+socket.onerror = function(error) {
+  document.getElementById('status').innerText = 'Failed to connect!';
+};
+</script>
+<div id="status">Connecting...</div>
+<div id="state"></div>
+"""
+```
+
+This page has two `<div>` elements: one for connection status and one for play state.
+A websocket connection is set up, connecting to `ws://<server address>/ws/DEVICE_ID`
+(we will replace `DEVICE_ID` with the correct id). The various callback
+functions then just update what's shown in the div elements.
+
+Now we need a handler to serve the page. Here's what that looks like:
+
+```python
+@routes.get("/state/{id}")
+async def state(request):
+    return web.Response(
+        text=PAGE.replace("DEVICE_ID", request.match_info["id"]),
+        content_type="text/html",
+    )
+```
+
+The `PAGE` is just returned, but `DEVICE_ID` is replaced with the correct id.
+
+To test this out, start by opening
+
+```raw
+http://127.0.0.1:8080/connect/aabbccddee
+```
+
+Once connected, navigate to:
+
+```raw
+http://127.0.0.1:8080/state/aabbccddee
+```
+
+You should hopefully see the current state immediately. If you start playing
+something on the device, it should hopefully update instantaneously!
+
+# The complete example
+
+Here is the final code for the application:
+
+<details>
+
+```python
+import asyncio
+from aiohttp import WSMsgType, web
+import pyatv
+
+PAGE = """
+<script>
+let socket = new WebSocket('ws://' + location.host + '/ws/DEVICE_ID');
+
+socket.onopen = function(e) {
+  document.getElementById('status').innerText = 'Connected!';
+};
+
+socket.onmessage = function(event) {
+  document.getElementById('state').innerText = event.data;
+};
+
+socket.onclose = function(event) {
+  if (event.wasClean) {
+    document.getElementById('status').innerText = 'Connection closed cleanly!';
+  } else {
+    document.getElementById('status').innerText = 'Disconnected due to error!';
+  }
+  document.getElementById('state').innerText = "";
+};
+
+socket.onerror = function(error) {
+  document.getElementById('status').innerText = 'Failed to connect!';
+};
+</script>
+<div id="status">Connecting...</div>
+<div id="state"></div>
+"""
+
+routes = web.RouteTableDef()
+
+
+class DeviceListener(pyatv.interface.DeviceListener, pyatv.interface.PushListener):
+    def __init__(self, app, identifier):
+        self.app = app
+        self.identifier = identifier
+
+    def connection_lost(self, exception: Exception) -> None:
+        self._remove()
+
+    def connection_closed(self) -> None:
+        self._remove()
+
+    def _remove(self):
+        self.app["atv"].pop(self.identifier)
+        self.app["listeners"].remove(self)
+
+    def playstatus_update(self, updater, playstatus: pyatv.interface.Playing) -> None:
+        clients = self.app["clients"].get(self.identifier, [])
+        for client in clients:
+            asyncio.ensure_future(client.send_str(str(playstatus)))
+
+    def playstatus_error(self, updater, exception: Exception) -> None:
+        pass
+
+
+def web_command(method):
+    async def _handler(request):
+        device_id = request.match_info["id"]
+        atv = request.app["atv"].get(device_id)
+        if not atv:
+            return web.Response(text=f"Not connected to {device_id}", status=500)
+        return await method(request, atv)
+
+    return _handler
+
+
+def add_credentials(config, query):
+    for service in config.services:
+        proto_name = service.protocol.name.lower()
+        if proto_name in query:
+            config.set_credentials(service.protocol, query[proto_name])
+
+
+@routes.get("/state/{id}")
+async def state(request):
+    return web.Response(
+        text=PAGE.replace("DEVICE_ID", request.match_info["id"]),
+        content_type="text/html",
+    )
+
+
+@routes.get("/scan")
+async def scan(request):
+    results = await pyatv.scan(loop=asyncio.get_event_loop())
+    output = "\n\n".join(str(result) for result in results)
+    return web.Response(text=output)
+
+
+@routes.get("/connect/{id}")
+async def connect(request):
+    loop = asyncio.get_event_loop()
+    device_id = request.match_info["id"]
+    if device_id in request.app["atv"]:
+        return web.Response(text=f"Already connected to {device_id}")
+
+    results = await pyatv.scan(identifier=device_id, loop=loop)
+    if not results:
+        return web.Response(text="Device not found", status=500)
+
+    add_credentials(results[0], request.query)
+
+    try:
+        atv = await pyatv.connect(results[0], loop=loop)
+    except Exception as ex:
+        return web.Response(text=f"Failed to connect to device: {ex}", status=500)
+
+    listener = DeviceListener(request.app, device_id)
+    atv.listener = listener
+    atv.push_updater.listener = listener
+    atv.push_updater.start()
+    request.app["listeners"].append(listener)
+
+    request.app["atv"][device_id] = atv
+    return web.Response(text=f"Connected to device {device_id}")
+
+
+@routes.get("/remote_control/{id}/{command}")
+@web_command
+async def remote_control(request, atv):
+    try:
+        await getattr(atv.remote_control, request.match_info["command"])()
+    except Exception as ex:
+        return web.Response(text=f"Remote control command failed: {ex}")
+    return web.Response(text="OK")
+
+
+@routes.get("/playing/{id}")
+@web_command
+async def playing(request, atv):
+    try:
+        status = await atv.metadata.playing()
+    except Exception as ex:
+        return web.Response(text=f"Remote control command failed: {ex}")
+    return web.Response(text=str(status))
+
+
+@routes.get("/close/{id}")
+@web_command
+async def close_connection(request, atv):
+    atv.close()
+    return web.Response(text="OK")
+
+
+@routes.get("/ws/{id}")
+@web_command
+async def websocket_handler(request, pyatv):
+    device_id = request.match_info["id"]
+
+    ws = web.WebSocketResponse()
+    await ws.prepare(request)
+    request.app["clients"].setdefault(device_id, []).append(ws)
+
+    playstatus = await pyatv.metadata.playing()
+    await ws.send_str(str(playstatus))
+
+    async for msg in ws:
+        if msg.type == WSMsgType.TEXT:
+            # Handle custom commands from client here
+            if msg.data == "close":
+                await ws.close()
+        elif msg.type == WSMsgType.ERROR:
+            print(f"Connection closed with exception: {ws.exception()}")
+
+    request.app["clients"][device_id].remove(ws)
+
+    return ws
+
+
+async def on_shutdown(app: web.Application) -> None:
+    for atv in app["atv"].values():
+        atv.close()
+
+
+app = web.Application()
+app["atv"] = {}
+app["listeners"] = []
+app["clients"] = {}
+app.add_routes(routes)
+app.on_shutdown.append(on_shutdown)
+web.run_app(app)
+```
+
+</details>
+
+# Some final notes
+This is the end of the tutorial, have some :cake:! Feel free to use this code
+in any way you like (there's no copyright attached to it), but remember that
+it's more for inspiration than complete project. There are pitfalls,
+especially with regards to error handling.
+
+If you want some inspiration for additional thins to do, here are few:
+
+* Implement additional commands, e.g. volume control, app launching, artwork
+  or streaming
+* Support commands over websocket instead of GET requests
+* Serve interface via static files (and improve it!)
+* Implement pairing support
+* Allow triggering of a scan and return results via websocket
+* Add a command showing all connected devices
+* Create a container class, eliminating the need for three variables in `app`
+* Combine `web_command` and `routes.get` into a single decorator, e.g.
+  `@web_command("/ws/{id}")`
+* Allow some way to enable debugging, either via CLI flags, a new endpoint
+  or websockets
+* Do all of the above and build a simple remote control!
+
+Regarding websockets... currently only the play state is sent over websockets.
+Some means of multiplexing needs to be added to support additional commands,
+e.g. by sending JSON (a dict) instead.
diff --git a/docs/index.md b/docs/index.md
index 144d0b45..be432bde 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -45,7 +45,8 @@ To get going, install with `pip`:
   <p style="margin: 0px">:tada: <a href="https://pypi.org/project/pyatv">pip install pyatv :tada:</a></p>
 </div>
 
-Head over to [Getting started](documentation/getting-started) to see what you can do!
+Head over to [Getting started](documentation/getting-started) to see what you can do! There's
+also a [Tutorial](documentation/tutorial) if you want to get going faster!
 
 As pyatv is a library, it is mainly aimed for developers creating applications that can interact
 with Apple TVs. However, pyatv ships with a few powerful command lines tools you can use to
diff --git a/docs/internals/tools.md b/docs/internals/tools.md
index ead48669..2578d428 100644
--- a/docs/internals/tools.md
+++ b/docs/internals/tools.md
@@ -279,6 +279,10 @@ Pairing seems to have succeeded, yey!
 You may now use these credentials: 75FBEEC773CFC563:8F06696F2542D70DF59286C761695C485F815BE3D152849E1361282D46AB1493
 ```
 
+### RAOP
+
+Streaming files and most other feature should work to some extent. Encryption is not supported.
+
 ## State Support
 
 Partial support for maintaining device state is implemented, but not for everything. Here is a simple example:
diff --git a/docs/support/migration.md b/docs/support/migration.md
index 17f9ba67..ffb30641 100644
--- a/docs/support/migration.md
+++ b/docs/support/migration.md
@@ -17,13 +17,29 @@ and might be incomplete or missing some details. If you find something
 to be unclear, please help out by writing an issue or creating a pull
 request
 
+# From 0.7.1-... to 0.8.0
+
+## General Changes
+
+* The push updater interface is now considered a feature and availability
+  (via {% include api i="interface.Features.get_feature" %} and
+  {% include api i="const.FeatureName.PushUpdates" %}) should now be performed
+  before using it.
+* {% include pypi package="audio-metadata" %}, {% include pypi package="bitarray" %}
+  and {% include pypi package="miniaudio" %} are new dependencies in this
+  release (used by RAOP).
+## Deprecations
+
+* Passing `protocol` to {% include api i="pyatv.connect" %} is no longer
+  needed and its value will be ignored. Scheduled for removal in version 1.0.0.
+
 # From 0.7.0 to 0.7.1
 
 ## General Changes
 
-* Unicast scanning (i.e. passing `hosts` to `pyatv.scan`) will not verify
-  if hosts are on the same network anymore (`NonLocalSubnetError` will not
-  be thrown). Requests will just time out. See {% include issue no="775" %}
+* Unicast scanning (i.e. passing `hosts` to {% include api i="pyatv.scan" %}) will
+  not verify if hosts are on the same network anymore (`NonLocalSubnetError` will
+  not be thrown). Requests will just time out. See {% include issue no="775" %}
   for more details.
 
 ## Deprecations
@@ -71,19 +87,22 @@ request
 
 ## General Changes
 
-* Device configuration has moved from `AppleTVDevice` to `conf.AppleTV`
-* `pyatv.connect_to_apple_tv` has been renamed to `pyatv.connect`
-* `pyatv.scan_for_apple_tvs` has been renamed to `pyatv.scan`
-* Pairing has been made generic and is done via `pyatv.pair`
+* Device configuration has moved from `AppleTVDevice` to {% include api i="conf.AppleTV" %}
+* `pyatv.connect_to_apple_tv` has been renamed to {% include api i="pyatv.connect" %}
+* `pyatv.scan_for_apple_tvs` has been renamed to {% include api i="pyatv.scan" %}
+* Pairing has been made generic and is done via {% include api i="pyatv.pair" %}
 * Constants, e.g. media type, has been changed into enums
 * `play_state` has been renamed to `device_state`
-* AirPlay interface has been renamed from `AppleTV.airplay` to `AppleTV.stream`
+* AirPlay interface has been renamed from `AppleTV.airplay` to
+  {% include api i="interface.AppleTV.stream" %}
 
 ## Deprecations
 
-* Arguments `abort_on_found` and `only_home_sharing` have been removed from `pyatv.scan`
+* Arguments `abort_on_found` and `only_home_sharing` have been removed from
+  {% include api i="pyatv.scan" %}
 * The `Metadata.artwork_url` method has been deprecated and has no
   replacement
 * All methods in the AirPlay interface has been deprecated in favor
   of the generic pairing interface (except for `play_url`)
-* `pyatv.pair_with_apple_tv` has been replaced by `pyatv.pair` which can pair all protocols
+* `pyatv.pair_with_apple_tv` has been replaced by {% include api i="pyatv.pair" %}
+  which can pair all protocols
diff --git a/pyatv/helpers.py b/pyatv/helpers.py
index 3c904520..11118510 100644
--- a/pyatv/helpers.py
+++ b/pyatv/helpers.py
@@ -58,14 +58,12 @@ def get_unique_id(
 
     The unique identifier is returned if available, otherwise `None` is returned.
     """
-    if service_type == HOMESHARING_SERVICE:
-        return properties.get("hG")
-    if service_type == DEVICE_SERVICE:
+    if service_type in [DEVICE_SERVICE, HOMESHARING_SERVICE]:
         return service_name.split("_")[0]
     if service_type == MEDIAREMOTE_SERVICE:
         return properties.get("UniqueIdentifier")
     if service_type == AIRPLAY_SERVICE:
         return properties.get("deviceid")
     if service_type == RAOP_SERVICE:
-        return service_name.split("@")[0]
+        return service_name.split("@", maxsplit=1)[0]
     return None
diff --git a/pyatv/raop/raop.py b/pyatv/raop/raop.py
index 7c58340e..d08c201f 100644
--- a/pyatv/raop/raop.py
+++ b/pyatv/raop/raop.py
@@ -367,9 +367,7 @@ class RaopClient:
         # Misplaced check that unencrypted data is supported
         intersection = self._encryption_types & SUPPORTED_ENCRYPTIONS
         if not intersection or intersection == EncryptionType.Unknown:
-            raise exceptions.NotSupportedError(
-                f"no supported encryption types in {str(self._encryption_types)}"
-            )
+            _LOGGER.debug("No supported encryption type, continuing anyway")
 
         self._update_output_properties(properties)
 
@@ -517,6 +515,10 @@ class RaopClient:
         finally:
             self._packet_backlog.clear()  # Don't keep old packets around (big!)
             if transport:
+                # TODO: Teardown should not be done here. In fact, nothing should be
+                # closed here since the connection should be re-usable for streaming
+                # more audio files. Refactor when support for that is added.
+                await self.rtsp.teardown()
                 transport.close()
             if self._keep_alive_task:
                 self._keep_alive_task.cancel()
diff --git a/pyatv/raop/rtsp.py b/pyatv/raop/rtsp.py
index 26f9c5da..07e95d99 100644
--- a/pyatv/raop/rtsp.py
+++ b/pyatv/raop/rtsp.py
@@ -217,6 +217,12 @@ class RtspSession:
         """Send SET_PARAMETER message."""
         return await self.exchange("POST", uri="/feedback", allow_error=allow_error)
 
+    async def teardown(self, allow_error=False) -> HttpResponse:
+        """Send TEARDOWN message."""
+        return await self.exchange(
+            "TEARDOWN", headers={"Session": self.context.rtsp_session}
+        )
+
     async def exchange(
         self,
         method: str,
diff --git a/pyatv/support/scan.py b/pyatv/support/scan.py
index 9caad4bc..4b8ed160 100644
--- a/pyatv/support/scan.py
+++ b/pyatv/support/scan.py
@@ -75,7 +75,10 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
         """Call when an MDNS response was received."""
         for service in response.services:
             if service.address and service.port != 0:
-                self._service_discovered(service, response)
+                try:
+                    self._service_discovered(service, response)
+                except Exception:
+                    _LOGGER.warning("Failed to parse service: %s", service)
 
     def _service_discovered(
         self, service: mdns.Service, response: mdns.Response
@@ -93,7 +96,9 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
         """Add a new device to discovered list."""
         name = mdns_service.properties.get("Name", "Unknown")
         service = conf.DmapService(
-            mdns_service.name,
+            get_unique_id(
+                mdns_service.type, mdns_service.name, mdns_service.properties
+            ),
             mdns_service.properties.get("hG"),
             port=mdns_service.port,
             properties=mdns_service.properties,
@@ -106,7 +111,9 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
         """Add a new device without Home Sharing to discovered list."""
         name = mdns_service.properties.get("CtlN", "Unknown")
         service = conf.DmapService(
-            mdns_service.name,
+            get_unique_id(
+                mdns_service.type, mdns_service.name, mdns_service.properties
+            ),
             None,
             port=mdns_service.port,
             properties=mdns_service.properties,
@@ -117,7 +124,9 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
         """Add a new MediaRemoteProtocol device to discovered list."""
         name = mdns_service.properties.get("Name", "Unknown")
         service = conf.MrpService(
-            mdns_service.properties.get("UniqueIdentifier"),
+            get_unique_id(
+                mdns_service.type, mdns_service.name, mdns_service.properties
+            ),
             mdns_service.port,
             properties=mdns_service.properties,
         )
@@ -128,7 +137,9 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
     ) -> None:
         """Add a new AirPlay device to discovered list."""
         service = conf.AirPlayService(
-            mdns_service.properties.get("deviceid"),
+            get_unique_id(
+                mdns_service.type, mdns_service.name, mdns_service.properties
+            ),
             mdns_service.port,
             properties=mdns_service.properties,
         )
@@ -144,9 +155,11 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
 
     def _raop_service(self, mdns_service: mdns.Service, response: mdns.Response):
         """Add a new RAOP device to discovered list."""
-        identifier, name = mdns_service.name.split("@", maxsplit=1)
+        _, name = mdns_service.name.split("@", maxsplit=1)
         service = conf.RaopService(
-            identifier,
+            get_unique_id(
+                mdns_service.type, mdns_service.name, mdns_service.properties
+            ),
             mdns_service.port,
             properties=mdns_service.properties,
         )
diff --git a/scripts/fake_device.py b/scripts/fake_device.py
index f1f03848..a3b32314 100755
--- a/scripts/fake_device.py
+++ b/scripts/fake_device.py
@@ -139,7 +139,7 @@ async def publish_raop_zeroconf(loop, zconf, address, port):
         "et": "0",
         "ss": "16",
         "am": "AppleTV6,2",
-        "md": "0,1,2",
+        "md": "0",
         "ch": "2",
         "sr": "44100",
         "cn": "1",


Test patch:
diff --git a/tests/fake_device/raop.py b/tests/fake_device/raop.py
index b671f0ba..32de8205 100644
--- a/tests/fake_device/raop.py
+++ b/tests/fake_device/raop.py
@@ -65,6 +65,7 @@ class FakeRaopState:
         self.volume: float = INITIAL_VOLUME
         self.initial_audio_level_supported: bool = False
         self.info_supported: bool = True
+        self.teardown_called: bool = False
 
     @property
     def raw_audio(self) -> bytes:
@@ -248,12 +249,15 @@ class FakeRaopService(HttpSimpleRouter):
         self.add_route("RECORD", "rtsp://*", self.handle_record)
         self.add_route("POST", "/auth-setup", self.handle_auth_setup)
         self.add_route("GET", "/info", self.handle_info)
+        self.add_route("TEARDOWN", "rtsp://*", self.handle_teardown)
 
     async def start(self, start_web_server: bool):
         """Start the fake RAOP service."""
-        self.server, self.port = await http_server(lambda: BasicHttpServer(self))
+        self.server, self.port = await http_server(
+            lambda: BasicHttpServer(self), address="0.0.0.0"
+        )
 
-        local_addr = ("127.0.0.1", 0)
+        local_addr = ("0.0.0.0", 0)
         (_, audio_receiver) = await self.loop.create_datagram_endpoint(
             lambda: AudioReceiver(self.state),
             local_addr=local_addr,
@@ -384,7 +388,7 @@ class FakeRaopService(HttpSimpleRouter):
             "RTSP", "1.0", 403, "Forbidden", {"CSeq": request.headers["CSeq"]}, b""
         )
 
-    def handle_info(self, request: HttpRequest) -> Optional[HttpRequest]:
+    def handle_info(self, request: HttpRequest) -> Optional[HttpResponse]:
         """Handle incoming info request."""
         if not self.state.info_supported:
             return HttpResponse(
@@ -412,6 +416,13 @@ class FakeRaopService(HttpSimpleRouter):
             plistlib.dumps(info),
         )
 
+    def handle_teardown(self, request: HttpRequest) -> Optional[HttpResponse]:
+        """Handle incoming TEARDOWN request."""
+        self.state.teardown_called = True
+        return HttpResponse(
+            "RTSP", "1.0", 200, "OK", {"CSeq": request.headers["CSeq"]}, b""
+        )
+
 
 class FakeRaopUseCases:
     """Wrapper for altering behavior of a FakeRaopService instance."""
diff --git a/tests/raop/test_raop_functional.py b/tests/raop/test_raop_functional.py
index eaad1124..3e3cce37 100644
--- a/tests/raop/test_raop_functional.py
+++ b/tests/raop/test_raop_functional.py
@@ -405,3 +405,9 @@ async def test_device_not_supporting_info_requests(raop_client, raop_usecase):
 
     # Should just not crash with an error if endpoint is not supported
     await raop_client.stream.stream_file(data_path("only_metadata.wav"))
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_teardown_called_after_playback(raop_client, raop_state):
+    await raop_client.stream.stream_file(data_path("only_metadata.wav"))
+    assert raop_state.teardown_called
diff --git a/tests/support/test_scan.py b/tests/support/test_scan.py
index 78f8c339..393f9ebe 100644
--- a/tests/support/test_scan.py
+++ b/tests/support/test_scan.py
@@ -13,7 +13,7 @@ from pyatv.support.scan import (
     get_unique_identifiers,
 )
 
-HS = Service(HOMESHARING_SERVICE, "name", None, 0, {"hG": "hs_id"})
+HS = Service(HOMESHARING_SERVICE, "hsname", None, 0, {"hG": "hs_id"})
 DEVICE = Service(DEVICE_SERVICE, "devid", None, 0, {})
 MRP = Service(MEDIAREMOTE_SERVICE, "name", None, 0, {"UniqueIdentifier": "mrp_id"})
 AIRPLAY = Service(AIRPLAY_SERVICE, "name", None, 0, {"deviceid": "airplay_id"})
@@ -35,7 +35,7 @@ def test_unique_identifier_home_sharing(response):
 
     identifiers = list(get_unique_identifiers(response))
     assert len(identifiers) == 1
-    assert "hs_id" in identifiers
+    assert "hsname" in identifiers
 
 
 def test_unique_identifier_device(response):
@@ -80,7 +80,7 @@ def test_unique_identifier_multiple(response):
 
     identifiers = list(get_unique_identifiers(response))
     assert len(identifiers) == 5
-    assert "hs_id" in identifiers
+    assert "hsname" in identifiers
     assert "devid" in identifiers
     assert "mrp_id" in identifiers
     assert "airplay_id" in identifiers
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 12a25981..13164772 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -73,12 +73,14 @@ async def test_auto_connect_with_device(mock_scan, mock_connect):
     "service_type,service_name,properties,expected_id",
     [
         ("_unknown._tcp.local", "name", {}, None),
-        ("_appletv-v2._tcp.local", "name", {"hG": "test"}, "test"),
+        ("_appletv-v2._tcp.local", "name", {}, "name"),
+        ("_appletv-v2._tcp.local", "name_duplicate", {}, "name"),
         ("_touch-able._tcp.local", "name", {}, "name"),
         ("_touch-able._tcp.local", "name_duplicate", {}, "name"),
         ("_mediaremotetv._tcp.local", "name", {"UniqueIdentifier": "test"}, "test"),
         ("_airplay._tcp.local", "name", {"deviceid": "test"}, "test"),
         ("_raop._tcp.local", "abcd@name", {}, "abcd"),
+        ("_raop._tcp.local", "abcd@name", {}, "abcd"),
     ],
 )
 def test_get_unique_id_(service_type, service_name, properties, expected_id):