Repository: postlund/pyatv
Pull request #1143 (2021-06-14 14:10:52)

helpers: Add get_unique_id
This helper can extract a unique identifier from a Zerconf service in
the same way as pyatv does internally. It is meant to be used when
Zeroconf service data comes from elsewhere than when scanning in pyatv,
e.g. when using python-zeroconf manually.

Relates to #1127

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



codecov[bot]: # [Codecov](https://codecov.io/gh/postlund/pyatv/pull/1143?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 [#1143](https://codecov.io/gh/postlund/pyatv/pull/1143?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) (3126273) into [master](https://codecov.io/gh/postlund/pyatv/commit/1c3adcfbe07cd78efd1441b47738f858b93d3978?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) (1c3adcf) will **decrease** coverage by `0.40%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/postlund/pyatv/pull/1143/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/1143?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    #1143      +/-   ##
==========================================
- Coverage   95.27%   94.86%   -0.41%     
==========================================
  Files          58       58              
  Lines        5905     5884      -21     
==========================================
- Hits         5626     5582      -44     
- Misses        279      302      +23     
```


| [Impacted Files](https://codecov.io/gh/postlund/pyatv/pull/1143?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/conf.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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-cHlhdHYvY29uZi5weQ==) | `99.20% <ø> (ø)` | |
| [pyatv/helpers.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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/support/scan.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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) | `96.33% <100.00%> (-0.20%)` | :arrow_down: |
| [pyatv/raop/raop.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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.13% <0.00%> (-3.95%)` | :arrow_down: |
| [pyatv/mrp/protocol.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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/1143/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/1143/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/1143/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.49% <0.00%> (-1.05%)` | :arrow_down: |
| [pyatv/mrp/pairing.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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: |
| [pyatv/companion/connection.py](https://codecov.io/gh/postlund/pyatv/pull/1143/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-cHlhdHYvY29tcGFuaW9uL2Nvbm5lY3Rpb24ucHk=) | `89.09% <0.00%> (-0.10%)` | :arrow_down: |
| ... and [12 more](https://codecov.io/gh/postlund/pyatv/pull/1143/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/1143?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/1143?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 [1c3adcf...3126273](https://codecov.io/gh/postlund/pyatv/pull/1143?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/docs/api/pyatv/conf.html b/docs/api/pyatv/conf.html
index be1d16ff..28182f54 100644
--- a/docs/api/pyatv/conf.html
+++ b/docs/api/pyatv/conf.html
@@ -293,7 +293,7 @@ class DmapService(BaseService):
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new DmapService.&#34;&#34;&#34;
         super().__init__(
-            identifier.split(&#34;_&#34;)[0] if identifier else None,
+            identifier,
             Protocol.DMAP,
             port,
             properties,
@@ -931,7 +931,7 @@ returned.</p></section>
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new DmapService.&#34;&#34;&#34;
         super().__init__(
-            identifier.split(&#34;_&#34;)[0] if identifier else None,
+            identifier,
             Protocol.DMAP,
             port,
             properties,
diff --git a/docs/api/pyatv/helpers.html b/docs/api/pyatv/helpers.html
index 8d5fe114..fa62951d 100644
--- a/docs/api/pyatv/helpers.html
+++ b/docs/api/pyatv/helpers.html
@@ -18,6 +18,7 @@ link_group: api
 <li><h3><a href="#header-functions">Functions</a></h3>
 <ul class="">
 <li><code><a title="pyatv.helpers.auto_connect" href="#pyatv.helpers.auto_connect">auto_connect</a></code></li>
+<li><code><a title="pyatv.helpers.get_unique_id" href="#pyatv.helpers.get_unique_id">get_unique_id</a></code></li>
 </ul>
 </li>
 </ul>
@@ -35,10 +36,16 @@ link_group: api
 <pre><code class="python">&#34;&#34;&#34;Various helper methods.&#34;&#34;&#34;
 
 import asyncio
-from typing import Callable, Optional
+from typing import Callable, Mapping, Optional
 
 import pyatv
 
+HOMESHARING_SERVICE: str = &#34;_appletv-v2._tcp.local&#34;
+DEVICE_SERVICE: str = &#34;_touch-able._tcp.local&#34;
+MEDIAREMOTE_SERVICE: str = &#34;_mediaremotetv._tcp.local&#34;
+AIRPLAY_SERVICE: str = &#34;_airplay._tcp.local&#34;
+RAOP_SERVICE: str = &#34;_raop._tcp.local&#34;
+
 
 async def auto_connect(
     handler: Callable[[pyatv.interface.AppleTV], None],
@@ -72,7 +79,31 @@ async def auto_connect(
                 await not_found()
 
     loop = loop or asyncio.get_event_loop()
-    await _handle(loop)</code></pre>
+    await _handle(loop)
+
+
+def get_unique_id(
+    service_type: str, service_name: str, properties: Mapping[str, str]
+) -&gt; Optional[str]:
+    &#34;&#34;&#34;Return unique identifier from a Zeroconf service.
+
+    `service_type` is the Zeroconf service type (e.g. *_mediaremotetv._tcp.local*),
+    `service_name` name of the service (e.g. *Office* or *Living Room*) and
+    `properties` all key-value properties belonging to the service.
+
+    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:
+        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 None</code></pre>
 </details>
 </section>
 <section>
@@ -131,6 +162,43 @@ Very inflexible in many cases, but can be handys sometimes when trying things.</
     await _handle(loop)</code></pre>
 </details>
 </dd>
+<dt id="pyatv.helpers.get_unique_id"><code class="name flex">
+<span>def <span class="ident">get_unique_id</span></span>(<span>service_type: str, service_name: str, properties: Mapping[str, str]) -> Optional[str]</span>
+</code></dt>
+<dd>
+<section class="desc"><p>Return unique identifier from a Zeroconf service.</p>
+<p><code>service_type</code> is the Zeroconf service type (e.g. <em>_mediaremotetv._tcp.local</em>),
+<code>service_name</code> name of the service (e.g. <em>Office</em> or <em>Living Room</em>) and
+<code>properties</code> all key-value properties belonging to the service.</p>
+<p>The unique identifier is returned if available, otherwise <code>None</code> is returned.</p></section>
+<details class="source">
+<summary>
+<span>Expand source code</span>
+</summary>
+<pre><code class="python">def get_unique_id(
+    service_type: str, service_name: str, properties: Mapping[str, str]
+) -&gt; Optional[str]:
+    &#34;&#34;&#34;Return unique identifier from a Zeroconf service.
+
+    `service_type` is the Zeroconf service type (e.g. *_mediaremotetv._tcp.local*),
+    `service_name` name of the service (e.g. *Office* or *Living Room*) and
+    `properties` all key-value properties belonging to the service.
+
+    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:
+        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 None</code></pre>
+</details>
+</dd>
 </dl>
 </section>
 <section>
diff --git a/pyatv/conf.py b/pyatv/conf.py
index 1a460f5a..91d9aafd 100644
--- a/pyatv/conf.py
+++ b/pyatv/conf.py
@@ -223,7 +223,7 @@ class DmapService(BaseService):
     ) -> None:
         """Initialize a new DmapService."""
         super().__init__(
-            identifier.split("_")[0] if identifier else None,
+            identifier,
             Protocol.DMAP,
             port,
             properties,
diff --git a/pyatv/helpers.py b/pyatv/helpers.py
index 316c794f..3c904520 100644
--- a/pyatv/helpers.py
+++ b/pyatv/helpers.py
@@ -1,10 +1,16 @@
 """Various helper methods."""
 
 import asyncio
-from typing import Callable, Optional
+from typing import Callable, Mapping, Optional
 
 import pyatv
 
+HOMESHARING_SERVICE: str = "_appletv-v2._tcp.local"
+DEVICE_SERVICE: str = "_touch-able._tcp.local"
+MEDIAREMOTE_SERVICE: str = "_mediaremotetv._tcp.local"
+AIRPLAY_SERVICE: str = "_airplay._tcp.local"
+RAOP_SERVICE: str = "_raop._tcp.local"
+
 
 async def auto_connect(
     handler: Callable[[pyatv.interface.AppleTV], None],
@@ -39,3 +45,27 @@ async def auto_connect(
 
     loop = loop or asyncio.get_event_loop()
     await _handle(loop)
+
+
+def get_unique_id(
+    service_type: str, service_name: str, properties: Mapping[str, str]
+) -> Optional[str]:
+    """Return unique identifier from a Zeroconf service.
+
+    `service_type` is the Zeroconf service type (e.g. *_mediaremotetv._tcp.local*),
+    `service_name` name of the service (e.g. *Office* or *Living Room*) and
+    `properties` all key-value properties belonging to the service.
+
+    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:
+        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 None
diff --git a/pyatv/support/scan.py b/pyatv/support/scan.py
index f81b54d0..9caad4bc 100644
--- a/pyatv/support/scan.py
+++ b/pyatv/support/scan.py
@@ -8,6 +8,7 @@ import os
 from typing import Dict, Generator, List, Mapping, Optional
 
 from pyatv import conf, interface
+from pyatv.helpers import get_unique_id
 from pyatv.support import knock, mdns
 from pyatv.support.device_info import lookup_internal_name
 
@@ -54,16 +55,9 @@ def get_unique_identifiers(
 ) -> Generator[Optional[str], None, None]:
     """Return (yield) all unique identifiers for a response."""
     for service in response.services:
-        if service.type == HOMESHARING_SERVICE:
-            yield service.properties.get("hG")
-        elif service.type == DEVICE_SERVICE:
-            yield service.name
-        elif service.type == MEDIAREMOTE_SERVICE:
-            yield service.properties.get("UniqueIdentifier")
-        elif service.type == AIRPLAY_SERVICE:
-            yield service.properties.get("deviceid")
-        elif service.type == RAOP_SERVICE:
-            yield service.name.split("@")[0]
+        unique_id = get_unique_id(service.type, service.name, service.properties)
+        if unique_id:
+            yield unique_id
 
 
 class BaseScanner(ABC):  # pylint: disable=too-few-public-methods


Test patch:
diff --git a/tests/support/test_scan.py b/tests/support/test_scan.py
index 36014a9f..78f8c339 100644
--- a/tests/support/test_scan.py
+++ b/tests/support/test_scan.py
@@ -14,7 +14,7 @@ from pyatv.support.scan import (
 )
 
 HS = Service(HOMESHARING_SERVICE, "name", None, 0, {"hG": "hs_id"})
-DEVICE = Service(DEVICE_SERVICE, "dev_id", None, 0, {})
+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"})
 COMPANION = Service(COMPANION_SERVICE, "name", None, 0, {})
@@ -43,7 +43,7 @@ def test_unique_identifier_device(response):
 
     identifiers = list(get_unique_identifiers(response))
     assert len(identifiers) == 1
-    assert "dev_id" in identifiers
+    assert "devid" in identifiers
 
 
 def test_unique_identifier_mrp(response):
@@ -81,7 +81,7 @@ def test_unique_identifier_multiple(response):
     identifiers = list(get_unique_identifiers(response))
     assert len(identifiers) == 5
     assert "hs_id" in identifiers
-    assert "dev_id" in identifiers
+    assert "devid" in identifiers
     assert "mrp_id" in identifiers
     assert "airplay_id" in identifiers
     assert "raop_id" in identifiers
diff --git a/tests/test_conf.py b/tests/test_conf.py
index 5be446d2..b8f2c3ae 100644
--- a/tests/test_conf.py
+++ b/tests/test_conf.py
@@ -257,13 +257,6 @@ def test_ready(config, service, expected):
     assert config.ready == expected
 
 
-# Name collisions on the network results in _X being added to the identifier,
-# which should be stripped
-def test_dmap_identifier_strip():
-    service = conf.DmapService("abcd_2", "dummy")
-    assert service.identifier == "abcd"
-
-
 # This test is a bit strange and couples to protocol specific services,
 # but it's mainly to exercise string as that is important. Might refactor
 # this in the future.
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 87036c01..12a25981 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -67,3 +67,19 @@ async def test_auto_connect_with_device(mock_scan, mock_connect):
 
     assert obj.found == mock_device
     mock_device.close.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    "service_type,service_name,properties,expected_id",
+    [
+        ("_unknown._tcp.local", "name", {}, None),
+        ("_appletv-v2._tcp.local", "name", {"hG": "test"}, "test"),
+        ("_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"),
+    ],
+)
+def test_get_unique_id_(service_type, service_name, properties, expected_id):
+    assert helpers.get_unique_id(service_type, service_name, properties) == expected_id