Repository: postlund/pyatv
Pull request #899 (2020-12-19 22:43:46)

Improve DNS message parsing for mDNS
I also encountered the issues in #878/#765, so decided to take a crack at it. To make it easier to keep track of the return types and arguments, I replaced the data+ptr arguments and tuple return types with giving each function a BytesIO stream and using the position in that stream as the pointer.

As far as standards compliance goes, TXT record parsing was improved so that records longer than 63 bytes could be handled (I think that might've been contributing to #878) and the property keys are now compared case-insensitively to match the RFC better. I also changed some of the encode/decode calls with IDNA (for domain names) and ASCII (as DNS-SD keys are explicitly ASCII only).

I changed `Dns[Header|Question|Resource]` from `collections.namedtuple` to `typing.NamedTuple`, but I'm not completely sure I'm satisfied with them (maybe dataclasses? but that would need the dataclass backport for 3.6. Or an actual class hierarchy for different resource record types?)

With these changes, I'm now able to scan and discover my Apple TV that is on a different subnet (through avahi-reflector), as well as pair with the Apple TV. Sorry about the noise in other files from changing the query type constants into an `IntEnum`.

codecov[bot]: # [Codecov](https://codecov.io/gh/postlund/pyatv/pull/899?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@03e4438`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/postlund/pyatv/pull/899/graphs/tree.svg?width=650&height=150&src=pr&token=wb6azJjRSY)](https://codecov.io/gh/postlund/pyatv/pull/899?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##             master     #899   +/-   ##
=========================================
  Coverage          ?   95.31%           
=========================================
  Files             ?       36           
  Lines             ?     3202           
  Branches          ?        0           
=========================================
  Hits              ?     3052           
  Misses            ?      150           
  Partials          ?        0           
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `95.31% <0.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.


------

[Continue to review full report at Codecov](https://codecov.io/gh/postlund/pyatv/pull/899?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/postlund/pyatv/pull/899?src=pr&el=footer). Last update [03e4438...ff653aa](https://codecov.io/gh/postlund/pyatv/pull/899?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

postlund: I have honestly not looked at this yet, I will try to take time later tonight to do so (and test it). But I'm *very* grateful that you have taken time and tried to fix this mess. Truly appreciated!
lgtm-com[bot]: This pull request **introduces 4 alerts** when merging dcf5ad94cf886b26b2d2549ac917974cb098e899 into b2e80b31c326b20048c87e0aba5b52082b39e65b - [view on LGTM.com](https://lgtm.com/projects/g/postlund/pyatv/rev/pr-87cb176b2bf5a324d43933757c39afcc549109ed)

**new alerts:**

* 4 for Comparison using is when operands support \_\_eq\_\_
postlund: You seem to be on a roll. Just ping me when you are done 😅
paxswill: Ugh, those macOS failures. It looks like it's a known issue (pytest-dev/pytest-timeout#8), with a [behavior change in 3.8](https://bugs.python.org/issue38904) making it more visible. I can at least occasionally reproduce it locally, so I'll be poking at this for a bit.

Besides a fix for that (which I suspect is going to be to the testing setup, and probably not in this PR), I'm not planning on making other changes. I tried to keep the commits self-contained, but it does lead to some places being changed a few times (ex: parsing changes, with a later split into different modules).
postlund: I'm gonna try to review this ASAP, but since it's huge and I only have my phone right now, it will probably take a while 😅
paxswill: > Doing such a big review thoroughly is really hard, but I've done my best to grasp what you have done and it's a big lift of my mess. So big thank you for that! Just noted two very minor things otherwise ready to go.

Yeah, sorry not splitting it up a bit more :sweat_smile:

 In addition to those changes, I also made a small change to `DnsMessage.pack()` to use the `DnsHeader` and `DnsQuestion` `pack()` methods (I thought I'd made that change already :/ ).

> Just gonna throw it out there since you obviously have some domain knowledge... There's another bug (# 765) where multicast requests are not reaching other networks when using Protocol-Independent Multicast. Or responses are not revived at least. If that's something you might have any ideas for troubleshooting, I would be very very interested...

I haven't used Protocol-Independent Multicast before, but I might be able to get a simple setup going to reproduce it after another project I'm poking at.

postlund: Looks good to me! 👍

Patch:
diff --git a/docs/api/pyatv/conf.html b/docs/api/pyatv/conf.html
index 8b28d191..3d04b70e 100644
--- a/docs/api/pyatv/conf.html
+++ b/docs/api/pyatv/conf.html
@@ -72,7 +72,7 @@ For a configuration to be usable (&#34;ready&#34;) it must have either a `DMAP`
 configuration (or both), as connecting to plain `AirPlay` devices it not supported.
 &#34;&#34;&#34;
 from ipaddress import IPv4Address
-from typing import Dict, List, Optional
+from typing import Dict, Mapping, List, Optional
 
 from pyatv import exceptions
 from pyatv.const import Protocol, OperatingSystem, DeviceModel
@@ -210,7 +210,7 @@ class AppleTV:
 
         return DeviceInfo(os_type, version, build, model, mac)
 
-    def _all_properties(self) -&gt; Dict[str, str]:
+    def _all_properties(self) -&gt; Mapping[str, str]:
         properties: Dict[str, str] = {}
         for service in self.services:
             properties.update(service.properties)
@@ -249,7 +249,7 @@ class DmapService(BaseService):
         identifier: Optional[str],
         credentials: Optional[str],
         port: int = 3689,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new DmapService.&#34;&#34;&#34;
         super().__init__(
@@ -270,7 +270,7 @@ class MrpService(BaseService):
         identifier: Optional[str],
         port: int,
         credentials: Optional[str] = None,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new MrpService.&#34;&#34;&#34;
         super().__init__(identifier, Protocol.MRP, port, properties)
@@ -286,7 +286,7 @@ class AirPlayService(BaseService):
         identifier: Optional[str],
         port: int = 7000,
         credentials: Optional[str] = None,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new AirPlayService.&#34;&#34;&#34;
         super().__init__(identifier, Protocol.AirPlay, port, properties)
@@ -304,7 +304,7 @@ class AirPlayService(BaseService):
 <dl>
 <dt id="pyatv.conf.AirPlayService"><code class="flex name class">
 <span>class <span class="ident">AirPlayService</span></span>
-<span>(</span><span>identifier: Optional[str], port: int = 7000, credentials: Optional[str] = None, properties: Optional[Dict[str, str]] = None)</span>
+<span>(</span><span>identifier: Optional[str], port: int = 7000, credentials: Optional[str] = None, properties: Optional[Mapping[str, str]] = None)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Representation of an AirPlay service.</p>
@@ -321,7 +321,7 @@ class AirPlayService(BaseService):
         identifier: Optional[str],
         port: int = 7000,
         credentials: Optional[str] = None,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new AirPlayService.&#34;&#34;&#34;
         super().__init__(identifier, Protocol.AirPlay, port, properties)
@@ -485,7 +485,7 @@ AirPlay.</p>
 
         return DeviceInfo(os_type, version, build, model, mac)
 
-    def _all_properties(self) -&gt; Dict[str, str]:
+    def _all_properties(self) -&gt; Mapping[str, str]:
         properties: Dict[str, str] = {}
         for service in self.services:
             properties.update(service.properties)
@@ -738,7 +738,7 @@ returned.</p></section>
 </dd>
 <dt id="pyatv.conf.DmapService"><code class="flex name class">
 <span>class <span class="ident">DmapService</span></span>
-<span>(</span><span>identifier: Optional[str], credentials: Optional[str], port: int = 3689, properties: Optional[Dict[str, str]] = None)</span>
+<span>(</span><span>identifier: Optional[str], credentials: Optional[str], port: int = 3689, properties: Optional[Mapping[str, str]] = None)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Representation of a DMAP service.</p>
@@ -755,7 +755,7 @@ returned.</p></section>
         identifier: Optional[str],
         credentials: Optional[str],
         port: int = 3689,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new DmapService.&#34;&#34;&#34;
         super().__init__(
@@ -782,7 +782,7 @@ returned.</p></section>
 </dd>
 <dt id="pyatv.conf.MrpService"><code class="flex name class">
 <span>class <span class="ident">MrpService</span></span>
-<span>(</span><span>identifier: Optional[str], port: int, credentials: Optional[str] = None, properties: Optional[Dict[str, str]] = None)</span>
+<span>(</span><span>identifier: Optional[str], port: int, credentials: Optional[str] = None, properties: Optional[Mapping[str, str]] = None)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Representation of a MediaRemote Protocol (MRP) service.</p>
@@ -799,7 +799,7 @@ returned.</p></section>
         identifier: Optional[str],
         port: int,
         credentials: Optional[str] = None,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new MrpService.&#34;&#34;&#34;
         super().__init__(identifier, Protocol.MRP, port, properties)
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index 599f9907..1d5bfa04 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -229,6 +229,8 @@ import hashlib
 from typing import (
     Any,
     Dict,
+    Mapping,
+    MutableMapping,
     Optional,
     NamedTuple,
     Callable,
@@ -379,14 +381,14 @@ class BaseService:
         identifier: Optional[str],
         protocol: Protocol,
         port: int,
-        properties: Optional[Dict[str, str]],
+        properties: Optional[Mapping[str, str]],
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new BaseService.&#34;&#34;&#34;
         self.__identifier = identifier
         self.protocol = protocol
         self.port = port
         self.credentials: Optional[str] = None
-        self.properties = properties or {}
+        self.properties: MutableMapping[str, str] = dict(properties or {})
 
     @property
     def identifier(self) -&gt; Optional[str]:
@@ -1481,7 +1483,7 @@ async def connect(self) -&gt; None:
 </dd>
 <dt id="pyatv.interface.BaseService"><code class="flex name class">
 <span>class <span class="ident">BaseService</span></span>
-<span>(</span><span>identifier: Optional[str], protocol: <a title="pyatv.const.Protocol" href="const#pyatv.const.Protocol">Protocol</a>, port: int, properties: Optional[Dict[str, str]])</span>
+<span>(</span><span>identifier: Optional[str], protocol: <a title="pyatv.const.Protocol" href="const#pyatv.const.Protocol">Protocol</a>, port: int, properties: Optional[Mapping[str, str]])</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Base class for protocol services.</p>
@@ -1498,14 +1500,14 @@ async def connect(self) -&gt; None:
         identifier: Optional[str],
         protocol: Protocol,
         port: int,
-        properties: Optional[Dict[str, str]],
+        properties: Optional[Mapping[str, str]],
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new BaseService.&#34;&#34;&#34;
         self.__identifier = identifier
         self.protocol = protocol
         self.port = port
         self.credentials: Optional[str] = None
-        self.properties = properties or {}
+        self.properties: MutableMapping[str, str] = dict(properties or {})
 
     @property
     def identifier(self) -&gt; Optional[str]:
diff --git a/pyatv/conf.py b/pyatv/conf.py
index b1d034df..952c535f 100644
--- a/pyatv/conf.py
+++ b/pyatv/conf.py
@@ -8,7 +8,7 @@ For a configuration to be usable ("ready") it must have either a `DMAP` or `MRP`
 configuration (or both), as connecting to plain `AirPlay` devices it not supported.
 """
 from ipaddress import IPv4Address
-from typing import Dict, List, Optional
+from typing import Dict, Mapping, List, Optional
 
 from pyatv import exceptions
 from pyatv.const import Protocol, OperatingSystem, DeviceModel
@@ -146,7 +146,7 @@ class AppleTV:
 
         return DeviceInfo(os_type, version, build, model, mac)
 
-    def _all_properties(self) -> Dict[str, str]:
+    def _all_properties(self) -> Mapping[str, str]:
         properties: Dict[str, str] = {}
         for service in self.services:
             properties.update(service.properties)
@@ -185,7 +185,7 @@ class DmapService(BaseService):
         identifier: Optional[str],
         credentials: Optional[str],
         port: int = 3689,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -> None:
         """Initialize a new DmapService."""
         super().__init__(
@@ -206,7 +206,7 @@ class MrpService(BaseService):
         identifier: Optional[str],
         port: int,
         credentials: Optional[str] = None,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -> None:
         """Initialize a new MrpService."""
         super().__init__(identifier, Protocol.MRP, port, properties)
@@ -222,7 +222,7 @@ class AirPlayService(BaseService):
         identifier: Optional[str],
         port: int = 7000,
         credentials: Optional[str] = None,
-        properties: Optional[Dict[str, str]] = None,
+        properties: Optional[Mapping[str, str]] = None,
     ) -> None:
         """Initialize a new AirPlayService."""
         super().__init__(identifier, Protocol.AirPlay, port, properties)
diff --git a/pyatv/interface.py b/pyatv/interface.py
index bcf982d7..8ddc601d 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -10,6 +10,8 @@ import hashlib
 from typing import (
     Any,
     Dict,
+    Mapping,
+    MutableMapping,
     Optional,
     NamedTuple,
     Callable,
@@ -160,14 +162,14 @@ class BaseService:
         identifier: Optional[str],
         protocol: Protocol,
         port: int,
-        properties: Optional[Dict[str, str]],
+        properties: Optional[Mapping[str, str]],
     ) -> None:
         """Initialize a new BaseService."""
         self.__identifier = identifier
         self.protocol = protocol
         self.port = port
         self.credentials: Optional[str] = None
-        self.properties = properties or {}
+        self.properties: MutableMapping[str, str] = dict(properties or {})
 
     @property
     def identifier(self) -> Optional[str]:
diff --git a/pyatv/support/collections.py b/pyatv/support/collections.py
new file mode 100644
index 00000000..875b4b53
--- /dev/null
+++ b/pyatv/support/collections.py
@@ -0,0 +1,95 @@
+"""Collections for pyatv."""
+import collections.abc
+import typing
+
+
+T = typing.TypeVar("T")
+
+
+class CaseInsensitiveDict(typing.MutableMapping[str, T]):
+    """A mapping where the keys are compared case-insensitively.
+
+    As a consequence of this, the keys *must* be strings.
+    """
+
+    _data: typing.Dict[str, T]
+
+    @staticmethod
+    def _lower_key(key_value: typing.Tuple[str, T]) -> typing.Tuple[str, T]:
+        key, value = key_value
+        return key.lower(), value
+
+    def __init__(
+        self,
+        mapping_or_iterable: typing.Union[
+            typing.Mapping[str, T], typing.Iterable[typing.Tuple[str, T]], None
+        ] = None,
+        **kwargs
+    ):
+        """Create a `CaseInensitiveDict`. It takes the same arguments as `dict`."""
+        self._data = dict()
+        if isinstance(mapping_or_iterable, collections.abc.Mapping):
+            self._data.update(map(self._lower_key, mapping_or_iterable.items()))
+        elif isinstance(mapping_or_iterable, collections.abc.Iterable):
+            self._data.update(
+                # It doesn't look like mypy goes deep enough to check this properly, (it
+                # thinks the argument to the callable should be a sequence of objects,
+                # not an iterable of tuples) so casting it is.
+                map(
+                    typing.cast(
+                        typing.Callable[
+                            [typing.Sequence[object]], typing.Tuple[str, T]
+                        ],
+                        self._lower_key,
+                    ),
+                    mapping_or_iterable,
+                )
+            )
+        if kwargs:
+            self._data.update(map(self._lower_key, kwargs.items()))
+
+    def __getitem__(self, key: str) -> T:
+        """Get a value referenced by a string key, compared case-insensitively."""
+        return self._data[key.lower()]
+
+    def __setitem__(self, key: str, value: T):
+        """Set a value referenced by a string key, compared case-insensitively."""
+        self._data[key.lower()] = value
+
+    def __delitem__(self, key: str):
+        """Delete a value referenced by a string key, compared case-insensitively."""
+        del self._data[key.lower()]
+
+    def __contains__(self, key) -> bool:
+        """Check if a key is present in the dictionary, compared case-insensitively."""
+        try:
+            return super().__contains__(key.lower())
+        except AttributeError:
+            return NotImplemented
+
+    def __len__(self) -> int:
+        """Get the number of keys (and values) present in the mapping."""
+        return len(self._data)
+
+    def __iter__(self) -> typing.Iterator[str]:
+        """Return an iterator over the mapping keys."""
+        return iter(self._data)
+
+    def __eq__(self, other):
+        """Comparetwo dictionaries, with keys compared case-insensitively."""
+        if isinstance(other, CaseInsensitiveDict):
+            # If it's another CaseInsensitiveDict, super easy
+            return self._data == other._data
+        elif isinstance(other, collections.abc.Mapping):
+            # if it's not, but it is a dict, attempt to lower the keys of the other dict
+            for other_key, other_value in other.items():
+                try:
+                    lowered_key = other_key.lower()
+                except AttributeError:
+                    # If the other keys aren't strings, these dicts aren't equal
+                    return False
+                if other_value != self[lowered_key]:
+                    return False
+            return True
+        else:
+            return NotImplemented
diff --git a/pyatv/support/dns.py b/pyatv/support/dns.py
new file mode 100644
index 00000000..a256fab2
--- /dev/null
+++ b/pyatv/support/dns.py
@@ -0,0 +1,357 @@
+"""Processing functions for raw DNS messages."""
+import enum
+import io
+import logging
+import struct
+import typing
+from ipaddress import IPv4Address
+
+from pyatv.support.collections import CaseInsensitiveDict
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def unpack_stream(fmt: str, buffer: typing.BinaryIO) -> typing.Tuple:
+    """Unpack data from a binary stream according to the given format string.
+
+    This is basically `struct.unpack`, but for binary streams.
+    """
+    size = struct.calcsize(fmt)
+    return struct.unpack(fmt, buffer.read(size))
+
+
+def qname_encode(name: str) -> bytes:
+    """Encode QNAME without using name compression."""
+    encoded = bytearray()
+    labels = name.split(".")
+    # Ensure there's an empty label for the root domain
+    if labels[-1] != "":
+        labels.append("")
+    for label in labels:
+        encoded_label = label.encode("idna")
+        encoded_length = len(encoded_label)
+        # Length of the encoded label, in bytes, but a maximum of 63
+        # The maximum is 63 as the upper two bits are used as a flag for name
+        # compression.
+        encoded.append(min(encoded_length, 63))
+        if encoded_length == 0:
+            # If we've reached an empty label, assume this is the last component.
+            # Empty labels (two periods right after each other) isn't legal anyways.
+            break
+        if encoded_length > 63:
+            _LOGGER.warning(
+                (
+                    "A label (%s) isn being truncated (to %s) in the DNS name '%s' "
+                    "as it is over 63 bytes long."
+                ),
+                label,
+                label[:64],
+                name,
+            )
+            encoded.extend(encoded_label[:64])
+        else:
+            encoded.extend(encoded_label)
+    return encoded
+
+
+def parse_string(buffer: typing.BinaryIO) -> bytes:
+    """Unpack a DNS character string.
+
+    These are simply a single length byte, followed by up to that many bytes of data.
+
+    This is distinct from "domain-name" encoding; use `parse_domain_name` for that.
+    """
+    chunk_length = unpack_stream(">B", buffer)[0]
+    return buffer.read(chunk_length)
+
+
+def parse_domain_name(buffer: typing.BinaryIO) -> str:
+    """Unpack a domain name, handling any name compression encountered.
+
+    Basically, each component of a domain name (called a "label") is prefixed with a
+    length followed by the IDNA encoded label. The final component has a zero length
+    with a null label for the DNS root. The tricky part is that labels are limited to 63
+    bytes, and the upper two bits of the length are used as a flag for "name
+    compression". For full details, see RFC1035, sections 3.1 and 4.1.4.
+
+    This is distinct from "character-string" encoding; use `parse_string` for that.
+    """
+    labels = []
+    compression_offset = None
+    while buffer:
+        length = unpack_stream(">B", buffer)[0]
+        if length == 0:
+            break
+        # The two high bits of the length are a flag for DNS name compression
+        length_flags = (length & 0xC0) >> 6
+        # The 10 and 01 flags are reserved
+        assert length_flags in (0, 0b11)
+        if length_flags == 0b11:
+            # Mask off the upper two bits, then concatenate the next byte from the
+            # stream to get the offset.
+            high_bits: int = length & 0x3F
+            new_offset_data = bytearray(buffer.read(1))
+            new_offset_data.insert(0, high_bits)
+            new_offset = struct.unpack(">H", new_offset_data)[0]
+            # I think it's technically possible to have multiple levels of name
+            # compression, so make sure we don't lose the original place we need to go
+            # back to.
+            if compression_offset is None:
+                compression_offset = buffer.tell()
+            buffer.seek(new_offset)
+        elif length_flags == 0:
+            label = buffer.read(length)
+            labels.append(label)
+    if compression_offset is not None:
+        buffer.seek(compression_offset)
+    return ".".join(label.decode("idna") for label in labels)
+
+
+def parse_txt_dict(buffer: typing.BinaryIO, length: int) -> CaseInsensitiveDict[bytes]:
+    """Parse DNS-SD TXT records into a `dict`."""
+    output: CaseInsensitiveDict[bytes] = CaseInsensitiveDict()
+    stop_position = buffer.tell() + length
+    while buffer.tell() < stop_position:
+        chunk = parse_string(buffer)
+        if b"=" not in chunk:
+            decoded_chunk = chunk.decode("ascii")
+            # missing "=" means it's just present with no value.
+            output[decoded_chunk] = b""
+        else:
+            key, value = chunk.split(b"=", 1)
+            if not key:
+                # Missing keys are skipped
+                continue
+            try:
+                # Keys are explicitly ASCII only
+                decoded_key = key.decode("ascii")
+            except UnicodeDecodeError:
+                _LOGGER.debug("Non-ASCII DNS-SD key encountered: %s", key)
+                continue
+            # Compared to the keys (ASCII strings), values are opaque binary blobs
+            output[decoded_key] = value
+    return output
+
+
+def parse_srv_dict(buffer: typing.BinaryIO):
+    """Parse DNS SRV record."""
+    priority, weight, port = unpack_stream(">3H", buffer)
+    # Name compression isn't allowed by the RFC, but let's accept it anyways
+    target = parse_domain_name(buffer)
+    # TODO: Should there be a check for target == ".", for marking services that
+    # (according to the RFC), "the service is decidedly not available at this domain."
+    return {
+        "priority": priority,
+        "weight": weight,
+        "port": port,
+        "target": target,
+    }
+
+
+class QueryType(enum.IntEnum):
+    """A DNS type ID."""
+
+    A = 0x01
+    PTR = 0x0C
+    TXT = 0x10
+    SRV = 0x21
+    ANY = 0xFF
+
+    def parse_rdata(self, buffer: typing.BinaryIO, length: int) -> typing.Any:
+        """Parse the RDATA from DNS resource record according to the type of the record.
+
+        If the record type isn't specifically handled, the raw binary data is returned.
+        """
+        if self is self.A:
+            if length != 4:
+                raise ValueError(
+                    f"An A record must have exactly 4 bytes of data (not {length})"
+                )
+            return str(IPv4Address(buffer.read(length)))
+        elif self is self.PTR:
+            return parse_domain_name(buffer)
+        elif self is self.TXT:
+            return parse_txt_dict(buffer, length)
+        elif self is self.SRV:
+            return parse_srv_dict(buffer)
+        else:
+            return buffer.read(length)
+
+
+class DnsHeader(typing.NamedTuple):
+    """Represents the header to a DNS message."""
+
+    id: int
+    flags: int
+    qdcount: int
+    ancount: int
+    nscount: int
+    arcount: int
+
+    @classmethod
+    # Py3.7 allows the quotes to be removes as long as `annotations` is imported from
+    # __future__. Py3.10 makes the behavior default.
+    def unpack_read(cls, buffer: typing.BinaryIO) -> "DnsHeader":
+        """Create a `DnsHeader` from a data stream.
+
+        Only as much data as is needed to create the header is read from the stream,
+        leaving it prepped to start parsing questions or resource records immediately
+        afterwards.
+        """
+        return cls._make(unpack_stream(">6H", buffer))
+
+    def pack(self) -> bytes:
+        """Generate the packed DNS header data."""
+        return struct.pack(">6H", *self)  # pylint: disable=not-an-iterable
+
+
+class DnsQuestion(typing.NamedTuple):
+    """Represents a DNS query."""
+
+    qname: str
+    qtype: QueryType
+    qclass: int
+
+    @classmethod
+    def unpack_read(cls, buffer: typing.BinaryIO) -> "DnsQuestion":
+        """Create a `DnsQuestion` from a data stream.
+
+        The entire question data is read from the stream, leaving it ready to parse
+        further questions form it, or to parse resource records out of it (depending on
+        the DNS message structure).
+        """
+        qname = parse_domain_name(buffer)
+        return cls(qname, *unpack_stream(">2H", buffer))
+
+    def pack(self) -> bytes:
+        """Encode the question data as needed for a DNS query or response."""
+        data = bytearray(qname_encode(self.qname))
+        data.extend(
+            struct.pack(">2H", *self[1:])
+        )  # pylint: disable=unsubscriptable-object
+        return data
+
+
+class DnsResource(typing.NamedTuple):
+    """Represents a DNS resource record."""
+
+    qname: str
+    qtype: QueryType
+    qclass: int
+    ttl: int
+    rd_length: int
+    rd: typing.Any
+
+    @classmethod
+    def unpack_read(cls, buffer: typing.BinaryIO) -> "DnsResource":
+        """Create a `DnsResource` from data in a data stream.
+
+        All data from a resource record is consumed, leaving the stream ready to be used
+        in another call to this method (as needed).
+        """
+        qname = parse_domain_name(buffer)
+        qtype, qclass, ttl, rd_length = unpack_stream(">2HIH", buffer)
+        before_rd = buffer.tell()
+        if qtype in QueryType.__members__.values():
+            qtype = QueryType(qtype)
+            rd = qtype.parse_rdata(buffer, rd_length)
+        else:
+            rd = buffer.read(rd_length)
+        assert buffer.tell() == before_rd + rd_length
+        return cls(qname, qtype, qclass, ttl, rd_length, rd)
+
+
+class DnsMessage:
+    """Represent a DNS message."""
+
+    def __init__(self, msg_id=0, flags=0x0120):
+        """Initialize a new DnsMessage."""
+        self.msg_id = msg_id
+        self.flags = flags
+        self.questions: typing.List[DnsQuestion] = []
+        self.answers: typing.List[DnsResource] = []
+        self.authorities: typing.List[DnsResource] = []
+        self.resources: typing.List[DnsResource] = []
+
+    def unpack(self, msg: bytes):
+        """Unpack bytes into a DnsMessage."""
+        buffer = io.BytesIO(msg)
+
+        header = DnsHeader.unpack_read(buffer)
+        self.msg_id = header.id
+        self.flags = header.flags
+
+        # Unpack questions
+        self.questions.extend(
+            DnsQuestion.unpack_read(buffer) for _ in range((header.qdcount))
+        )
+
+        # Unpack answers
+        self.answers.extend(
+            DnsResource.unpack_read(buffer) for _ in range((header.ancount))
+        )
+
+        # Unpack authorities
+        self.authorities.extend(
+            DnsResource.unpack_read(buffer) for _ in range((header.nscount))
+        )
+
+        # Unpack additional resources
+        self.resources.extend(
+            DnsResource.unpack_read(buffer) for _ in range((header.arcount))
+        )
+
+        return self
+
+    def pack(self):
+        """Pack message into bytes."""
+        header = DnsHeader(
+            self.msg_id,
+            self.flags,
+            len(self.questions),
+            len(self.answers),
+            len(self.authorities),
+            len(self.resources),
+        )
+
+        buf = bytearray()
+
+        buf.extend(header.pack())
+
+        for question in self.questions:
+            buf.extend(question.pack())
+
+        for answer in self.answers:
+            data = qname_encode(answer.rd)
+            buf += qname_encode(answer.qname)
+            buf += struct.pack(">H", answer.qtype)
+            buf += struct.pack(">H", answer.qclass)
+            buf += struct.pack(">I", answer.ttl)
+            buf += struct.pack(">H", len(data))
+            buf += data
+
+        for section in [self.authorities, self.resources]:
+            for resource in section:
+                buf += qname_encode(resource.qname)
+                buf += struct.pack(">H", resource.qtype)
+                buf += struct.pack(">H", resource.qclass)
+                buf += struct.pack(">I", resource.ttl)
+                buf += struct.pack(">H", len(resource.rd))
+                buf += resource.rd
+
+        return buf
+
+    def __str__(self):
+        """Return string representation of DnsMessage."""
+        return (
+            "MsgId=0x{0:04X}\nFlags=0x{1:04X}\nQuestions={2}\n"
+            "Answers={3}\nAuthorities={4}\nResources={5}".format(
+                self.msg_id,
+                self.flags,
+                self.questions,
+                self.answers,
+                self.authorities,
+                self.resources,
+            )
+        )
diff --git a/pyatv/support/mdns.py b/pyatv/support/mdns.py
index 6c05f1cb..66dadf17 100644
--- a/pyatv/support/mdns.py
+++ b/pyatv/support/mdns.py
@@ -1,17 +1,17 @@
 """Minimalistic DNS-SD implementation."""
-import math
-import socket
 import asyncio
-import struct
 import logging
+import math
+import socket
 import weakref
 from ipaddress import IPv4Address, ip_address
-from collections import namedtuple
-from typing import Optional, Dict, List, cast, NamedTuple, Callable
+import typing
 
 from zeroconf import Zeroconf, ServiceInfo
 
 from pyatv.support import log_binary, net
+from pyatv.support.collections import CaseInsensitiveDict
+from pyatv.support.dns import DnsMessage, DnsQuestion, DnsResource, QueryType
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -21,39 +21,31 @@ TRAFFIC_LEVEL = logging.DEBUG - 5
 setattr(logging, "TRAFFIC", TRAFFIC_LEVEL)
 logging.addLevelName(TRAFFIC_LEVEL, "Traffic")
 
-DnsHeader = namedtuple("DnsHeader", "id flags qdcount ancount nscount arcount")
-DnsQuestion = namedtuple("DnsQuestion", "qname qtype qclass")
-DnsResource = namedtuple("DnsResource", "qname qtype qclass ttl rd_length rd")
-
 
-class Service(NamedTuple):
+class Service(typing.NamedTuple):
     """Represent an MDNS service."""
 
     type: str
     name: str
-    address: Optional[IPv4Address]
+    address: typing.Optional[IPv4Address]
     port: int
-    properties: Dict[str, str]
+    properties: typing.Mapping[str, str]
 
 
-class Response(NamedTuple):
+class Response(typing.NamedTuple):
     """Represent response to an MDNS request."""
 
-    services: List[Service]
+    services: typing.List[Service]
     deep_sleep: bool
-    model: Optional[str]  # Comes from _device-info._tcp.local
-
+    model: typing.Optional[str]  # Comes from _device-info._tcp.local
 
-QTYPE_A: int = 0x0001
-QTYPE_PTR: int = 0x000C
-QTYPE_TXT: int = 0x0010
-QTYPE_SRV: int = 0x0021
-QTYPE_ANY: int = 0x00FF
 
 DEVICE_INFO_SERVICE = "_device-info._tcp.local"
 
 
-def _decode_properties(properties: Dict[bytes, bytes]) -> Dict[str, str]:
+def _decode_properties(
+    properties: typing.Mapping[str, bytes],
+) -> CaseInsensitiveDict[str]:
     def _decode(value: bytes):
         try:
             # Remove non-breaking-spaces (0xA2A0, 0x00A0) before decoding
@@ -65,215 +57,34 @@ def _decode_properties(properties: Dict[bytes, bytes]) -> Dict[str, str]:
         except Exception:  # pylint: disable=broad-except
             return str(value)
 
-    return {k.decode("utf-8"): _decode(v) for k, v in properties.items()}
-
-
-def qname_encode(name: str) -> bytes:
-    """Encode QNAME without using labels."""
-
-    def _enc_word(word):
-        encoded = word.encode("utf-8")
-        return bytes([len(encoded) & 0xFF]) + encoded
-
-    return b"".join([_enc_word(x) for x in name.split(".")]) + b"\x00"
-
+    return CaseInsensitiveDict({k: _decode(v) for k, v in properties.items()})
 
-def qname_decode(ptr, message, raw=False):
-    """Read a QNAME from pointer and respect labels."""
 
-    def _rec(name):
-        ret = []
-        while name and name[0] > 0:
-            length = int(name[0])
-            if (length & 0xC0) == 0xC0:
-                offset = (length & 0x03) << 8 | int(name[1])
-                comps, _ = _rec(message[offset:])
-                ret += comps
-                name = name[1:]
-                break
-
-            ret.append(name[1 : 1 + length])
-            name = name[length + 1 :]
-
-        return ret, name
-
-    name_components, rest = _rec(ptr)
-    if raw:
-        return name_components, rest[1:]
-    return ".".join([x.decode("utf-8") for x in name_components]), rest[1:]
-
-
-def parse_txt_dict(data, msg):
-    """Parse DNS TXT record containing a dict."""
-    output = {}
-    txt, _ = qname_decode(data, msg, raw=True)
-    for prop in txt:
-        key, value = prop.split(b"=", 1)
-        output[key] = value
-    return output
-
-
-def parse_srv_dict(data, msg):
-    """Parse DNS SRV record."""
-    priority, weight, port = struct.unpack(">HHH", data[0:6])
-    return {
-        "priority": priority,
-        "weight": weight,
-        "port": port,
-        "target": qname_decode(data[6:], msg)[0],
-    }
-
-
-def subunpack(ptr, fmt):
-    """Unpack raw data from pointer and move pointer forward."""
-    unpack_size = struct.calcsize(fmt)
-    data = struct.unpack(fmt, ptr[0:unpack_size])
-    return ptr[unpack_size:], data
-
-
-def dns_unpack(ptr, msg, fmt):
-    """Unoack a generic DNS record."""
-    qname, ptr = qname_decode(ptr, msg)
-    ptr, data = subunpack(ptr, fmt)
-    return ptr, (qname,) + data
-
-
-def unpack_rr(ptr, msg):
-    """Unpack DNS resource record."""
-    ptr, data = dns_unpack(ptr, msg, ">2HIH")
-    _, qtype, _, _, rd_length = data
-    rd_data = ptr[0:rd_length]
-    ptr = ptr[rd_length:]
-
-    if qtype == QTYPE_PTR:
-        rd_data, _ = qname_decode(rd_data, msg)
-    elif qtype == QTYPE_TXT:
-        rd_data = parse_txt_dict(rd_data, msg)
-    elif qtype == QTYPE_SRV:
-        rd_data = parse_srv_dict(rd_data, msg)
-    elif qtype == QTYPE_A:
-        rd_data = str(IPv4Address(rd_data))
-
-    return ptr, data + (rd_data,)
-
-
-class DnsMessage:
-    """Represent a DNS message."""
-
-    def __init__(self, msg_id=0, flags=0x0120):
-        """Initialize a new DnsMessage."""
-        self.msg_id = msg_id
-        self.flags = flags
-        self.questions: List[DnsQuestion] = []
-        self.answers: List[DnsResource] = []
-        self.authorities: List[DnsResource] = []
-        self.resources: List[DnsResource] = []
-
-    def unpack(self, msg):
-        """Unpack bytes into a DnsMessage."""
-        ptr, data = subunpack(msg, ">6H")
-        header = DnsHeader._make(data)
-        self.msg_id = header.id
-        self.flags = header.flags
-
-        # Unpack questions
-        for _ in range(header.qdcount):
-            ptr, data = dns_unpack(ptr, msg, ">2H")
-            self.questions.append(DnsQuestion(*data))
-
-        # Unpack answers
-        for _ in range(header.ancount):
-            ptr, data = unpack_rr(ptr, msg)
-            self.answers.append(DnsResource(*data))
-
-        # Unpack authorities
-        for _ in range(header.nscount):
-            ptr, data = unpack_rr(ptr, msg)
-            self.authorities.append(DnsResource(*data))
-
-        # Unpack additional resources
-        for _ in range(header.arcount):
-            ptr, data = unpack_rr(ptr, msg)
-            self.resources.append(DnsResource(*data))
-
-        return self
-
-    def pack(self):
-        """Pack message into bytes."""
-        header = DnsHeader(
-            self.msg_id,
-            self.flags,
-            len(self.questions),
-            len(self.answers),
-            len(self.authorities),
-            len(self.resources),
-        )
-
-        buf = struct.pack(">6H", *header)
-
-        for question in self.questions:
-            buf += qname_encode(question.qname)
-            buf += struct.pack(">H", question.qtype)
-            buf += struct.pack(">H", question.qclass)
-
-        for answer in self.answers:
-            data = qname_encode(answer.rd)
-            buf += qname_encode(answer.qname)
-            buf += struct.pack(">H", answer.qtype)
-            buf += struct.pack(">H", answer.qclass)
-            buf += struct.pack(">I", answer.ttl)
-            buf += struct.pack(">H", len(data))
-            buf += data
-
-        for section in [self.authorities, self.resources]:
-            for resource in section:
-                buf += qname_encode(resource.qname)
-                buf += struct.pack(">H", resource.qtype)
-                buf += struct.pack(">H", resource.qclass)
-                buf += struct.pack(">I", resource.ttl)
-                buf += struct.pack(">H", len(resource.rd))
-                buf += resource.rd
-
-        return buf
-
-    def __str__(self):
-        """Return string representation of DnsMessage."""
-        return (
-            "MsgId=0x{0:04X}\nFlags=0x{1:04X}\nQuestions={2}\n"
-            "Answers={3}\nAuthorities={4}\nResources={5}".format(
-                self.msg_id,
-                self.flags,
-                self.questions,
-                self.answers,
-                self.authorities,
-                self.resources,
-            )
-        )
-
-
-def create_request(services: List[str], qtype: int = QTYPE_PTR) -> bytes:
+def create_request(
+    services: typing.List[str], qtype: QueryType = QueryType.PTR
+) -> bytes:
     """Create a new DnsMessage requesting specified services."""
     msg = DnsMessage(0x35FF)
     msg.questions += [DnsQuestion(s, qtype, 0x8001) for s in services]
     return msg.pack()
 
 
-def _get_model(services: List[Service]) -> Optional[str]:
+def _get_model(services: typing.List[Service]) -> typing.Optional[str]:
     for service in services:
         if service.type == DEVICE_INFO_SERVICE:
             return service.properties.get("model")
     return None
 
 
-def parse_services(message: DnsMessage) -> List[Service]:
+def parse_services(message: DnsMessage) -> typing.List[Service]:
     """Parse DNS response into Service objects."""
-    table: Dict[str, Dict[int, DnsResource]] = {}
-    ptrs: Dict[str, str] = {}  # qname -> real name
-    results: Dict[str, Service] = {}
+    table: typing.Dict[str, typing.Dict[int, DnsResource]] = {}
+    ptrs: typing.Dict[str, str] = {}  # qname -> real name
+    results: typing.Dict[str, Service] = {}
 
     # Create a global table with all records
     for record in message.answers + message.resources:
-        if record.qtype == QTYPE_PTR and record.qname.startswith("_"):
+        if record.qtype == QueryType.PTR and record.qname.startswith("_"):
             ptrs[record.qname] = record.rd
         else:
             table.setdefault(record.qname, {})[record.qtype] = record
@@ -285,11 +96,13 @@ def parse_services(message: DnsMessage) -> List[Service]:
         if not service_type.endswith("_tcp.local"):
             continue
 
-        port = (QTYPE_SRV in device and device[QTYPE_SRV].rd["port"]) or 0
-        target = (QTYPE_SRV in device and device[QTYPE_SRV].rd["target"]) or None
-        properties = (QTYPE_TXT in device and device[QTYPE_TXT].rd) or {}
+        port = (QueryType.SRV in device and device[QueryType.SRV].rd["port"]) or 0
+        target = (
+            QueryType.SRV in device and device[QueryType.SRV].rd["target"]
+        ) or None
+        properties = (QueryType.TXT in device and device[QueryType.TXT].rd) or {}
 
-        target_record = table.get(cast(str, target), {}).get(QTYPE_A)
+        target_record = table.get(typing.cast(str, target), {}).get(QueryType.A)
         address = IPv4Address(target_record.rd) if target_record else None
 
         results[service] = Service(
@@ -311,7 +124,7 @@ def parse_services(message: DnsMessage) -> List[Service]:
 class UnicastDnsSdClientProtocol(asyncio.Protocol):
     """Protocol to make unicast requests."""
 
-    def __init__(self, services: List[str], host: str, timeout: int):
+    def __init__(self, services: typing.List[str], host: str, timeout: int):
         """Initialize a new UnicastDnsSdClientProtocol."""
         self.message = create_request(services)
         self.host = host
@@ -319,7 +132,7 @@ class UnicastDnsSdClientProtocol(asyncio.Protocol):
         self.transport = None
         self.semaphore: asyncio.Semaphore = asyncio.Semaphore(value=0)
         self.result: DnsMessage = DnsMessage()
-        self._task: Optional[asyncio.Future] = None
+        self._task: typing.Optional[asyncio.Future] = None
 
     async def get_response(self) -> Response:
         """Get respoonse with a maximum timeout."""
@@ -417,7 +230,7 @@ class ReceiveDelegate(asyncio.Protocol):
         if delegate is not None:
             try:
                 delegate.datagram_received(data, addr)
-            except Exception:  # pylint: disable=no-bare
+            except Exception:  # pylint: disable=broad-except
                 _LOGGER.exception("exception during data handling")
 
     def error_received(self, exc) -> None:
@@ -426,7 +239,7 @@ class ReceiveDelegate(asyncio.Protocol):
         if delegate is not None:
             try:
                 delegate.error_received(exc)
-            except Exception:  # pylint: disable=no-bare
+            except Exception:  # pylint: disable=broad-except
                 _LOGGER.exception("connection error")
 
     def __str__(self):
@@ -440,10 +253,10 @@ class MulticastDnsSdClientProtocol:
     def __init__(
         self,
         loop: asyncio.AbstractEventLoop,
-        services: List[str],
+        services: typing.List[str],
         address: str,
         port: int,
-        end_condition: Optional[Callable[[Response], bool]],
+        end_condition: typing.Optional[typing.Callable[[Response], bool]],
     ) -> None:
         """Initialize a new MulticastDnsSdClientProtocol."""
         self.loop = loop
@@ -453,10 +266,10 @@ class MulticastDnsSdClientProtocol:
         self.port = port
         self.end_condition = end_condition or (lambda _: False)
         self.semaphore: asyncio.Semaphore = asyncio.Semaphore(value=0)
-        self.responses: Dict[IPv4Address, Response] = {}
-        self._unicasts: Dict[IPv4Address, bytes] = {}
-        self._task: Optional[asyncio.Future] = None
-        self._receivers: List[asyncio.BaseProtocol] = []
+        self.responses: typing.Dict[IPv4Address, Response] = {}
+        self._unicasts: typing.Dict[IPv4Address, bytes] = {}
+        self._task: typing.Optional[asyncio.Future] = None
+        self._receivers: typing.List[asyncio.BaseProtocol] = []
 
     async def add_socket(self, sock: socket.socket):
         """Add a new multicast socket."""
@@ -467,7 +280,7 @@ class MulticastDnsSdClientProtocol:
 
         self._receivers.append(protocol)
 
-    async def get_response(self, timeout: int) -> Dict[IPv4Address, Response]:
+    async def get_response(self, timeout: int) -> typing.Dict[IPv4Address, Response]:
         """Get respoonse with a maximum timeout."""
         # Semaphore used here as a quick-bailout when testing
         try:
@@ -507,7 +320,7 @@ class MulticastDnsSdClientProtocol:
         for receiver in self._receivers:
             try:
                 receiver.sendto(message, target)
-            except Exception:  # pylint: disable=bare-except
+            except Exception:  # pylint: disable=broad-except
                 _LOGGER.exception("fail to send to %r", receiver)
 
     def datagram_received(self, data, addr) -> None:
@@ -538,7 +351,7 @@ class MulticastDnsSdClientProtocol:
         if is_sleep_proxy:
             self._unicasts[addr[0]] = create_request(
                 [service.name + "." + service.type for service in services],
-                qtype=QTYPE_ANY,
+                qtype=QueryType.ANY,
             )
         else:
             response = Response(
@@ -572,7 +385,7 @@ class MulticastDnsSdClientProtocol:
 async def unicast(
     loop: asyncio.AbstractEventLoop,
     address: str,
-    services: List[str],
+    services: typing.List[str],
     port: int = 5353,
     timeout: int = 4,
 ) -> Response:
@@ -583,19 +396,19 @@ async def unicast(
     )
 
     try:
-        return await cast(UnicastDnsSdClientProtocol, protocol).get_response()
+        return await typing.cast(UnicastDnsSdClientProtocol, protocol).get_response()
     finally:
         transport.close()
 
 
 async def multicast(
     loop: asyncio.AbstractEventLoop,
-    services: List[str],
+    services: typing.List[str],
     address: str = "224.0.0.251",
     port: int = 5353,
     timeout: int = 4,
-    end_condition: Optional[Callable[[Response], bool]] = None,
-) -> Dict[IPv4Address, Response]:
+    end_condition: typing.Optional[typing.Callable[[Response], bool]] = None,
+) -> typing.Dict[IPv4Address, Response]:
     """Send multicast request for services."""
     protocol = MulticastDnsSdClientProtocol(
         loop, services, address, port, end_condition
@@ -608,10 +421,12 @@ async def multicast(
     for addr in net.get_private_addresses():
         try:
             await protocol.add_socket(net.mcast_socket(str(addr)))
-        except Exception:
+        except Exception:  # pylint: disable=broad-except
             _LOGGER.exception(f"failed to add listener for {addr}")
 
-    return await cast(MulticastDnsSdClientProtocol, protocol).get_response(timeout)
+    return await typing.cast(MulticastDnsSdClientProtocol, protocol).get_response(
+        timeout
+    )
 
 
 async def publish(loop: asyncio.AbstractEventLoop, service: Service, zconf: Zeroconf):


Test patch:
diff --git a/tests/fake_udns.py b/tests/fake_udns.py
index 01c57bd5..0a9855f0 100644
--- a/tests/fake_udns.py
+++ b/tests/fake_udns.py
@@ -5,9 +5,9 @@ from unittest.mock import patch
 from contextlib import contextmanager
 from ipaddress import IPv4Address
 from collections import namedtuple
-from typing import List, Dict, Optional, Tuple
+from typing import List, Dict, Optional, Tuple, Union, cast
 
-from pyatv.support import mdns
+from pyatv.support import dns, mdns
 
 from tests.support import dns_utils
 
@@ -18,38 +18,55 @@ FakeDnsService = namedtuple("FakeDnsService", "name address port properties mode
 
 
 def mrp_service(
-    service_name, atv_name, identifier, address="127.0.0.1", port=49152, model=None
-):
+    service_name: str,
+    atv_name: str,
+    identifier: str,
+    address="127.0.0.1",
+    port: int = 49152,
+    model: Optional[str] = None,
+) -> Tuple[str, FakeDnsService]:
     service = FakeDnsService(
         name=service_name,
         address=address,
         port=port,
         properties={
-            b"Name": atv_name.encode("utf-8"),
-            b"UniqueIdentifier": identifier.encode("utf-8"),
+            "Name": atv_name.encode("utf-8"),
+            "UniqueIdentifier": identifier.encode("utf-8"),
         },
         model=model,
     )
     return ("_mediaremotetv._tcp.local", service)
 
 
-def airplay_service(atv_name, deviceid, address="127.0.0.1", port=7000, model=None):
+def airplay_service(
+    atv_name: str,
+    deviceid: str,
+    address="127.0.0.1",
+    port: int = 7000,
+    model: Optional[str] = None,
+) -> Tuple[str, FakeDnsService]:
     service = FakeDnsService(
         name=atv_name,
         address=address,
         port=port,
-        properties={b"deviceid": deviceid.encode("utf-8")},
+        properties={"deviceid": deviceid.encode("utf-8")},
         model=model,
     )
     return ("_airplay._tcp.local", service)
 
 
-def homesharing_service(service_name, atv_name, hsgid, address="127.0.0.1", model=None):
+def homesharing_service(
+    service_name: str,
+    atv_name: str,
+    hsgid: str,
+    address="127.0.0.1",
+    model: Optional[str] = None,
+) -> Tuple[str, FakeDnsService]:
     service = FakeDnsService(
         name=service_name,
         address=address,
         port=3689,
-        properties={b"hG": hsgid.encode("utf-8"), b"Name": atv_name.encode("utf-8")},
+        properties={"hG": hsgid.encode("utf-8"), "Name": atv_name.encode("utf-8")},
         model=model,
     )
     return ("_appletv-v2._tcp.local", service)
@@ -60,18 +77,23 @@ def device_service(service_name, atv_name, address="127.0.0.1", model=None):
         name=service_name,
         address=address,
         port=3689,
-        properties={b"CtlN": atv_name.encode("utf-8")},
+        properties={"CtlN": atv_name.encode("utf-8")},
         model=model,
     )
     return ("_touch-able._tcp.local", service)
 
 
 def _lookup_service(
-    question: mdns.DnsQuestion, services: Dict[str, FakeDnsService]
-) -> Tuple[Optional[FakeDnsService], Optional[str]]:
+    question: dns.DnsQuestion,
+    services: Dict[str, FakeDnsService],
+) -> Union[Tuple[None, None], Tuple[FakeDnsService, str]]:
+    """Given a DNS query and the registered fake services, find a matching service."""
     if question.qname.startswith("_"):
         service = services.get(question.qname)
-        return service, service.name + "." + question.qname if service else None
+        if service is not None:
+            return service, service.name + "." + question.qname
+        else:
+            return None, None
 
     service_name, _, service_type = question.qname.partition(".")
     for name, service in services.items():
@@ -87,9 +109,9 @@ def create_response(
     ip_filter: Optional[str] = None,
     sleep_proxy: bool = False,
 ):
-    msg = mdns.DnsMessage().unpack(request)
+    msg = dns.DnsMessage().unpack(request)
 
-    resp = mdns.DnsMessage()
+    resp = dns.DnsMessage()
     resp.flags = 0x0840
     resp.questions = msg.questions
 
@@ -97,6 +119,9 @@ def create_response(
         service, full_name = _lookup_service(question, services)
         if service is None or (ip_filter and service.address != ip_filter):
             continue
+        # For typing purposes, because service is not None, then full_name is not None
+        # either.
+        full_name = cast(str, full_name)
 
         # Add answer
         if full_name:
@@ -108,28 +133,28 @@ def create_response(
 
         # Add service (SRV) resource
         if service.port:
-            local_name = mdns.qname_encode(service.name + ".local")
+            local_name = dns.qname_encode(service.name + ".local")
             rd = struct.pack(">3H", 0, 0, service.port) + local_name
-            resp.resources.append(dns_utils.resource(full_name, mdns.QTYPE_SRV, rd))
+            resp.resources.append(dns_utils.resource(full_name, dns.QueryType.SRV, rd))
 
         # Add IP address
         if service.address:
             ipaddr = IPv4Address(service.address).packed
             resp.resources.append(
-                dns_utils.resource(service.name + ".local", mdns.QTYPE_A, ipaddr)
+                dns_utils.resource(service.name + ".local", dns.QueryType.A, ipaddr)
             )
 
         # Add properties
         if service.properties:
             rd = dns_utils.properties(service.properties)
-            resp.resources.append(dns_utils.resource(full_name, mdns.QTYPE_TXT, rd))
+            resp.resources.append(dns_utils.resource(full_name, dns.QueryType.TXT, rd))
 
         # Add model if present
         if service.model:
-            rd = dns_utils.properties({b"model": service.model.encode("utf-8")})
+            rd = dns_utils.properties({"model": service.model.encode("utf-8")})
             resp.resources.append(
                 dns_utils.resource(
-                    service.name + "._device-info._tcp.local", mdns.QTYPE_TXT, rd
+                    service.name + "._device-info._tcp.local", dns.QueryType.TXT, rd
                 )
             )
 
@@ -155,7 +180,7 @@ class FakeUdns(asyncio.Protocol):
     def close(self):
         self.server.close()
 
-    def add_service(self, service):
+    def add_service(self, service: Tuple[str, FakeDnsService]):
         self.services[service[0]] = service[1]
 
     @property
@@ -166,7 +191,7 @@ class FakeUdns(asyncio.Protocol):
         self.transport = transport
 
     def datagram_received(self, data: bytes, addr):
-        msg = mdns.DnsMessage().unpack(data)
+        msg = dns.DnsMessage().unpack(data)
         _LOGGER.debug("Received DNS request %s: %s", addr, msg)
 
         if self.skip_count > 0:
diff --git a/tests/support/dns_utils.py b/tests/support/dns_utils.py
index ab6b87a2..de561f52 100644
--- a/tests/support/dns_utils.py
+++ b/tests/support/dns_utils.py
@@ -2,8 +2,8 @@
 
 import struct
 from ipaddress import IPv4Address
-from typing import Optional, Dict
-from pyatv.support import mdns
+from typing import Optional, Dict, List
+from pyatv.support import dns, mdns
 
 DEFAULT_QCLASS = 1
 DEFAULT_TTL = 10
@@ -11,23 +11,25 @@ DEFAULT_TTL = 10
 
 def answer(qname: str, full_name: str) -> mdns.DnsResource:
     return mdns.DnsResource(
-        qname, mdns.QTYPE_PTR, DEFAULT_QCLASS, DEFAULT_TTL, 0, full_name
+        qname, mdns.QueryType.PTR, DEFAULT_QCLASS, DEFAULT_TTL, 0, full_name
     )
 
 
-def resource(qname: str, qtype: int, rd) -> mdns.DnsResource:
+def resource(qname: str, qtype: mdns.QueryType, rd) -> mdns.DnsResource:
     return mdns.DnsResource(qname, qtype, DEFAULT_QCLASS, DEFAULT_TTL, len(rd), rd)
 
 
-def properties(properties: Dict[bytes, bytes]) -> bytes:
+def properties(properties: Dict[str, bytes]) -> bytes:
     rd = b""
     for k, v in properties.items():
-        encoded = k + b"=" + v
+        encoded = k.encode("ascii") + b"=" + v
         rd += bytes([len(encoded)]) + encoded
     return rd
 
 
-def get_qtype(messages: mdns.DnsMessage, qtype: int) -> Optional[mdns.DnsResource]:
+def get_qtype(
+    messages: List[mdns.DnsResource], qtype: int
+) -> Optional[mdns.DnsResource]:
     for message in messages:
         if message.qtype == qtype:
             return message
@@ -40,14 +42,14 @@ def add_service(
     service_name: Optional[str],
     address: Optional[str],
     port: int,
-    properties: dict,
+    properties: Dict[str, bytes],
 ) -> None:
     if service_name is None:
         return message
 
     if address:
         message.resources.append(
-            resource(service_name + ".local", mdns.QTYPE_A, address)
+            resource(service_name + ".local", mdns.QueryType.A, address)
         )
 
     # Remaining depends on service type
@@ -59,7 +61,7 @@ def add_service(
     message.resources.append(
         resource(
             service_name + "." + service_type,
-            mdns.QTYPE_SRV,
+            mdns.QueryType.SRV,
             {
                 "priority": 0,
                 "weight": 0,
@@ -73,8 +75,8 @@ def add_service(
         message.resources.append(
             resource(
                 service_name + "." + service_type,
-                mdns.QTYPE_TXT,
-                {k.encode("utf-8"): v.encode("utf-8") for k, v in properties.items()},
+                mdns.QueryType.TXT,
+                {k: v.encode("utf-8") for k, v in properties.items()},
             )
         )
 
@@ -82,15 +84,15 @@ def add_service(
 
 
 def assert_service(
-    message: mdns.DnsMessage,
+    message: mdns.Service,
     service_type: str,
     service_name: str,
     address: str,
     port: int,
-    properties: dict,
+    known_properties: Dict[str, bytes],
 ) -> None:
     assert message.type == service_type
     assert message.name == service_name
     assert message.address == (IPv4Address(address) if address else None)
     assert message.port == port
-    assert message.properties == properties
+    assert message.properties == known_properties
diff --git a/tests/support/test_collections.py b/tests/support/test_collections.py
new file mode 100644
index 00000000..6b17bd62
--- /dev/null
+++ b/tests/support/test_collections.py
@@ -0,0 +1,82 @@
+"""Unit tests for pyatv.support.collections"""
+import typing
+from unittest.mock import sentinel
+
+import pytest
+
+from pyatv.support.collections import CaseInsensitiveDict
+
+
+@pytest.fixture
+def fixture_dict():
+    ret = CaseInsensitiveDict()
+    ret["foo"] = sentinel.foo
+    return ret
+
+
+@pytest.fixture(params=["foo", "FOO", "Foo", "fOO"])
+def key(request):
+    return request.param
+
+
+@pytest.fixture(params=["bar", "BAR", "Bar", "bAR"])
+def new_key(request):
+    return request.param
+
+
+def test_getitem(fixture_dict: CaseInsensitiveDict, key: str):
+    assert fixture_dict[key] == sentinel.foo
+
+
+def test_setitem(fixture_dict: CaseInsensitiveDict, key: str, new_key: str):
+    fixture_dict[key] = sentinel.Foo
+    assert fixture_dict[key] != sentinel.foo
+    assert fixture_dict[key] == sentinel.Foo
+    assert len(fixture_dict) == 1
+    fixture_dict[new_key] = getattr(sentinel, new_key)
+    fixture_dict["bar"] = getattr(sentinel, new_key)
+    assert len(fixture_dict) == 2
+
+
+def test_delitem(fixture_dict: CaseInsensitiveDict, key: str):
+    del fixture_dict[key]
+    assert len(fixture_dict) == 0
+    with pytest.raises(KeyError):
+        fixture_dict["foo"]
+
+
+def test_contains(fixture_dict: CaseInsensitiveDict, key: str):
+    assert key in fixture_dict
+
+
+def test_equals_case_insensitive(fixture_dict: CaseInsensitiveDict):
+    other = CaseInsensitiveDict()
+    other["FOO"] = sentinel.foo
+    assert fixture_dict == other
+
+
+def test_equals_plain_dict(fixture_dict: CaseInsensitiveDict):
+    other = {"Foo": sentinel.foo}
+    assert fixture_dict == other
+
+
+def test_init_mapping():
+    cid = CaseInsensitiveDict({"FOO": sentinel.FOO, "bAr": sentinel.bAr})
+    assert cid["foo"] == sentinel.FOO
+    assert cid["bar"] == sentinel.bAr
+    assert len(cid) == 2
+
+
+def test_init_iterable():
+    cid = CaseInsensitiveDict([("fOO", sentinel.fOO), ("BaR", sentinel.BaR)])
+    assert cid["foo"] == sentinel.fOO
+    assert cid["bar"] == sentinel.BaR
+    assert len(cid) == 2
+
+
+def test_init_kwargs():
+    cid = CaseInsensitiveDict({"FOO": sentinel.FOO, "bAr": sentinel.bAr})
+    cid = CaseInsensitiveDict(foO=sentinel.foO, BAR=sentinel.BAR)
+    assert cid["foo"] == sentinel.foO
+    assert cid["bar"] == sentinel.BAR
+    assert len(cid) == 2
diff --git a/tests/support/test_dns.py b/tests/support/test_dns.py
new file mode 100644
index 00000000..213578b9
--- /dev/null
+++ b/tests/support/test_dns.py
@@ -0,0 +1,190 @@
+"""Unit tests for pyatv.support.dns"""
+import io
+import typing
+
+import pytest
+
+from pyatv.support import dns
+
+
+# mapping is test_id: tuple(name, expected_raw)
+encode_domain_names = {
+    "root": (".", b"\x00"),
+    "example.com": (".", b"\x00"),
+    # Taken straight from Wikipedia's IDNA page
+    "idna": ("Bücher.example", b"\x0Dxn--bcher-kva\x07example\x00"),
+}
+
+
+@pytest.mark.parametrize(
+    "name,expected_raw",
+    [pytest.param(*value, id=key) for key, value in encode_domain_names.items()],
+)
+def test_qname_encode(name, expected_raw):
+    assert dns.qname_encode(name) == expected_raw
+
+
+# mapping is test_id: tuple(raw_name, offset, expected_name, expected_offset)
+# If expected offset is None, it means len(raw_name), otherwise it's like an array index
+# (positive is from the beginning, negative from the end)
+decode_domain_names = {
+    "simple": (b"\x03foo\x07example\x03com\x00", 0, "foo.example.com", None),
+    "null": (b"\00", 0, "", None),
+    "compressed": (b"aaaa\x04test\x00\x05label\xC0\x04\xAB\xCD", 10, "label.test", -2),
+    # This case has two levels of compression
+    "multi_compressed": (
+        b"aaaa\x04test\x00\x05label\xC0\x04\x03foo\xC0\x0A\xAB\xCD",
+        18,
+        "foo.label.test",
+        -2,
+    ),
+    # Again, take straight from the Internationalized Domain name Wikipedia page
+    "idna": (b"\x0Dxn--bcher-kva\x07example\x00", 0, "bücher.example", None),
+}
+
+
+@pytest.mark.parametrize(
+    "raw_name,offset,expected_name,expected_offset",
+    [pytest.param(*value, id=key) for key, value in decode_domain_names.items()],
+)
+def test_domain_name_parsing(
+    raw_name: bytes,
+    offset: int,
+    expected_name: str,
+    expected_offset: typing.Optional[int],
+):
+    with io.BytesIO(raw_name) as buffer:
+        buffer.seek(offset)
+        name = dns.parse_domain_name(buffer)
+        assert name == expected_name
+        if expected_offset is None:
+            assert buffer.tell() == len(raw_name)
+        else:
+            # if expected_offset is positive, this will wrap around to the beginning, if
+            # it's negative it won't.
+            raw_len = len(raw_name)
+            assert buffer.tell() == (raw_len + expected_offset) % raw_len
+
+
+# mapping is test_id: tuple(encoded_data, expected_data, expected_offset)
+# If expected offset is None, it means len(raw_name), otherwise it's like an array index
+# (positive is from the beginning, negative from the end)
+decode_strings = {
+    "null": (b"\x00", b"", None),
+    # 63 is significant because that's the max length for a domain label, but not a
+    # character-string (they have similar encodings).
+    "len_63": (b"\x3F" + (63 * b"0"), (63 * b"0"), None),
+    # For similar reasons as 63, 64 is significant because it would set only one of the
+    # flag bits for name compression if domain-name encoding is assumed.
+    "len_64": (b"\x40" + (64 * b"0"), (64 * b"0"), None),
+    # Ditto for 128, but the other flag
+    "len_128": (b"\x80" + (128 * b"0"), (128 * b"0"), None),
+    # ...and 192 is both flags
+    "len_192": (b"\xC0" + (192 * b"0"), (192 * b"0"), None),
+    # 255 is the max length a character-string can be
+    "len_255": (b"\xFF" + (255 * b"0"), (255 * b"0"), None),
+    "trailing": (b"\x0A" + (10 * b"2") + (17 * b"9"), (10 * b"2"), -17),
+}
+
+
+@pytest.mark.parametrize(
+    "encoded_data,expected_data,expected_offset",
+    [pytest.param(*value, id=key) for key, value in decode_strings.items()],
+)
+def test_string_parsing(
+    encoded_data: bytes,
+    expected_data: bytes,
+    expected_offset: typing.Optional[int],
+):
+    with io.BytesIO(encoded_data) as buffer:
+        name = dns.parse_string(buffer)
+        assert name == expected_data
+        if expected_offset is None:
+            assert buffer.tell() == len(encoded_data)
+        else:
+            # if expected_offset is positive, this will wrap around to the beginning, if
+            # it's negative it won't.
+            data_len = len(encoded_data)
+            assert buffer.tell() == (data_len + expected_offset) % data_len
+
+
+def test_dns_sd_txt_parse_single():
+    """Test that a TXT RDATA section with one key can be parsed properly."""
+    data = b"\x07foo=bar"
+    extra_data = data + b"\xDE\xAD\xBE\xEF" * 3
+    with io.BytesIO(extra_data) as buffer:
+        txt_dict = dns.parse_txt_dict(buffer, len(data))
+        assert buffer.tell() == len(data)
+        assert txt_dict == {"foo": b"bar"}
+
+
+def test_dns_sd_txt_parse_multiple():
+    """Test that a TXT RDATA section with multiple keys can be parsed properly."""
+    data = b"\x07foo=bar\x09spam=eggs"
+    extra_data = data + b"\xDE\xAD\xBE\xEF" * 2
+    with io.BytesIO(extra_data) as buffer:
+        txt_dict = dns.parse_txt_dict(buffer, len(data))
+        assert buffer.tell() == len(data)
+        assert txt_dict == {"foo": b"bar", "spam": b"eggs"}
+
+
+def test_dns_sd_txt_parse_binary():
+    """Test that a TXT RDATA section with a binary value can be parsed properly."""
+    # 0xfeed can't be decoded as UTF-8 or ASCII, so it'll thrown an error if it's not
+    # being treated as binary data.
+    data = b"\x06foo=\xFE\xED"
+    extra_data = data + b"\xDE\xAD\xBE\xEF" * 3
+    with io.BytesIO(extra_data) as buffer:
+        txt_dict = dns.parse_txt_dict(buffer, len(data))
+        assert buffer.tell() == len(data)
+        assert txt_dict == {"foo": b"\xFE\xED"}
+
+
+def test_dns_sd_txt_parse_long():
+    """Test that a TXT RDATA section with a long value can be parsed properly."""
+    # If TXT records are being parsed the same way domain names are, this won't work as
+    # the data is too long to fit in a label.
+    data = b"\xCCfoo=" + b"\xCA\xFE" * 100
+    extra_data = data + b"\xDE\xAD\xBE\xEF" * 3
+    with io.BytesIO(extra_data) as buffer:
+        txt_dict = dns.parse_txt_dict(buffer, len(data))
+        assert buffer.tell() == len(data)
+        assert txt_dict == {"foo": b"\xCA\xFE" * 100}
+
+
+@pytest.mark.parametrize(
+    "record_type,data,expected",
+    [
+        (dns.QueryType.A, b"\x0A\x00\x00\x2A", "10.0.0.42"),
+        (dns.QueryType.PTR, b"\x03foo\x07example\x03com\x00", "foo.example.com"),
+        (dns.QueryType.TXT, b"\x07foo=bar", {"foo": b"bar"}),
+        (
+            dns.QueryType.SRV,
+            b"\x00\x0A\x00\x00\x00\x50\x03foo\x07example\x03com\x00",
+            {
+                "priority": 10,
+                "weight": 0,
+                "port": 80,
+                "target": "foo.example.com",
+            },
+        ),
+    ],
+    # Use the name of the record type as the test id
+    ids=(
+        t.name
+        for t in (
+            dns.QueryType.A,
+            dns.QueryType.PTR,
+            dns.QueryType.TXT,
+            dns.QueryType.SRV,
+        )
+    ),
+)
+def test_parse_rdata(
+    record_type: dns.QueryType,
+    data: bytes,
+    expected: typing.Any,
+):
+    with io.BytesIO(data) as buffer:
+        assert record_type.parse_rdata(buffer, len(data)) == expected
+        assert buffer.tell() == len(data)
diff --git a/tests/support/test_mdns.py b/tests/support/test_mdns.py
index 031da8b5..7596be78 100644
--- a/tests/support/test_mdns.py
+++ b/tests/support/test_mdns.py
@@ -1,13 +1,14 @@
 """Unit tests pyatv.support.mdns."""
 
 import asyncio
+import io
 import logging
-from typing import Tuple
+from typing import Tuple, Optional
 from ipaddress import IPv4Address
 
 import pytest
 
-from pyatv.support import mdns, net
+from pyatv.support import dns, mdns, net
 from tests import fake_udns
 from tests.support import dns_utils
 
@@ -28,19 +29,10 @@ TEST_SERVICES = dict(
 
 def get_response_for_service(
     service: str,
-) -> Tuple[mdns.DnsMessage, fake_udns.FakeDnsService]:
+) -> Tuple[dns.DnsMessage, Optional[fake_udns.FakeDnsService]]:
     req = mdns.create_request([service])
     resp = fake_udns.create_response(req, TEST_SERVICES)
-    return mdns.DnsMessage().unpack(resp.pack()), TEST_SERVICES.get(service)
-
-
-def test_qname_with_label():
-    # This should resolve to "label.test" when reading from \x05
-    message = b"aaaa" + b"\x04test\x00" + b"\x05label\xC0\x04\xAB\xCD"
-    ptr = message[10:]
-    ret, rest = mdns.qname_decode(ptr, message)
-    assert ret == "label.test"
-    assert rest == b"\xAB\xCD"
+    return dns.DnsMessage().unpack(resp.pack()), TEST_SERVICES.get(service)
 
 
 def test_non_existing_service():
@@ -61,7 +53,7 @@ def test_service_has_valid_question():
     resp, _ = get_response_for_service(MEDIAREMOTE_SERVICE)
     question = resp.questions[0]
     assert question.qname == MEDIAREMOTE_SERVICE
-    assert question.qtype == mdns.QTYPE_PTR
+    assert question.qtype == dns.QueryType.PTR
     assert question.qclass == 0x8001
 
 
@@ -69,7 +61,7 @@ def test_service_has_valid_answer():
     resp, data = get_response_for_service(MEDIAREMOTE_SERVICE)
     answer = resp.answers[0]
     assert answer.qname == MEDIAREMOTE_SERVICE
-    assert answer.qtype == mdns.QTYPE_PTR
+    assert answer.qtype == dns.QueryType.PTR
     assert answer.qclass == dns_utils.DEFAULT_QCLASS
     assert answer.ttl == dns_utils.DEFAULT_TTL
     assert answer.rd == data.name + "." + MEDIAREMOTE_SERVICE
@@ -78,9 +70,9 @@ def test_service_has_valid_answer():
 def test_service_has_valid_srv_resource():
     resp, data = get_response_for_service(MEDIAREMOTE_SERVICE)
 
-    srv = dns_utils.get_qtype(resp.resources, mdns.QTYPE_SRV)
+    srv = dns_utils.get_qtype(resp.resources, dns.QueryType.SRV)
     assert srv.qname == data.name + "." + MEDIAREMOTE_SERVICE
-    assert srv.qtype == mdns.QTYPE_SRV
+    assert srv.qtype == dns.QueryType.SRV
     assert srv.qclass == dns_utils.DEFAULT_QCLASS
     assert srv.ttl == dns_utils.DEFAULT_TTL
 
@@ -94,9 +86,9 @@ def test_service_has_valid_srv_resource():
 def test_service_has_valid_txt_resource():
     resp, data = get_response_for_service(MEDIAREMOTE_SERVICE)
 
-    srv = dns_utils.get_qtype(resp.resources, mdns.QTYPE_TXT)
+    srv = dns_utils.get_qtype(resp.resources, dns.QueryType.TXT)
     assert srv.qname == data.name + "." + MEDIAREMOTE_SERVICE
-    assert srv.qtype == mdns.QTYPE_TXT
+    assert srv.qtype == dns.QueryType.TXT
     assert srv.qclass == dns_utils.DEFAULT_QCLASS
     assert srv.ttl == dns_utils.DEFAULT_TTL
 
@@ -109,38 +101,38 @@ def test_service_has_valid_txt_resource():
 def test_service_has_valid_a_resource():
     resp, data = get_response_for_service(MEDIAREMOTE_SERVICE)
 
-    srv = dns_utils.get_qtype(resp.resources, mdns.QTYPE_A)
+    srv = dns_utils.get_qtype(resp.resources, dns.QueryType.A)
     assert srv.qname == data.name + ".local"
-    assert srv.qtype == mdns.QTYPE_A
+    assert srv.qtype == dns.QueryType.A
     assert srv.qclass == dns_utils.DEFAULT_QCLASS
     assert srv.ttl == dns_utils.DEFAULT_TTL
     assert srv.rd == "127.0.0.1"
 
 
 def test_authority():
-    msg = mdns.DnsMessage()
+    msg = dns.DnsMessage()
     msg.authorities.append(
-        dns_utils.resource("test.local", mdns.QTYPE_A, b"\x01\x02\x03\x04")
+        dns_utils.resource("test.local", dns.QueryType.A, b"\x01\x02\x03\x04")
     )
 
-    unpacked = mdns.DnsMessage().unpack(msg.pack())
+    unpacked = dns.DnsMessage().unpack(msg.pack())
     assert len(unpacked.authorities) == 1
 
     record = unpacked.authorities[0]
     assert record.qname == "test.local"
-    assert record.qtype == mdns.QTYPE_A
+    assert record.qtype == dns.QueryType.A
     assert record.qclass == dns_utils.DEFAULT_QCLASS
     assert record.ttl == dns_utils.DEFAULT_TTL
     assert record.rd == "1.2.3.4"
 
 
 def test_parse_empty_service():
-    assert mdns.parse_services(mdns.DnsMessage()) == []
+    assert mdns.parse_services(dns.DnsMessage()) == []
 
 
 def test_parse_no_service_type():
     service_params = (None, "service", None, 0, {})
-    message = dns_utils.add_service(mdns.DnsMessage(), *service_params)
+    message = dns_utils.add_service(dns.DnsMessage(), *service_params)
 
     parsed = mdns.parse_services(message)
     assert len(parsed) == 0
@@ -148,7 +140,7 @@ def test_parse_no_service_type():
 
 def test_parse_no_service_name():
     service_params = ("_abc._tcp.local", None, None, 0, {})
-    message = dns_utils.add_service(mdns.DnsMessage(), *service_params)
+    message = dns_utils.add_service(dns.DnsMessage(), *service_params)
 
     parsed = mdns.parse_services(message)
     assert len(parsed) == 0
@@ -156,7 +148,7 @@ def test_parse_no_service_name():
 
 def test_parse_with_name_and_type():
     service_params = ("_abc._tcp.local", "service", None, 0, {})
-    message = dns_utils.add_service(mdns.DnsMessage(), *service_params)
+    message = dns_utils.add_service(dns.DnsMessage(), *service_params)
 
     parsed = mdns.parse_services(message)
     assert len(parsed) == 1
@@ -165,7 +157,7 @@ def test_parse_with_name_and_type():
 
 def test_parse_with_port_and_address():
     service_params = ("_abc._tcp.local", "service", "10.0.0.1", 123, {})
-    message = dns_utils.add_service(mdns.DnsMessage(), *service_params)
+    message = dns_utils.add_service(dns.DnsMessage(), *service_params)
 
     parsed = mdns.parse_services(message)
     assert len(parsed) == 1
@@ -174,7 +166,7 @@ def test_parse_with_port_and_address():
 
 def test_parse_single_service():
     service_params = ("_abc._tcp.local", "service", "10.0.10.1", 123, {"foo": "bar"})
-    message = dns_utils.add_service(mdns.DnsMessage(), *service_params)
+    message = dns_utils.add_service(dns.DnsMessage(), *service_params)
 
     parsed = mdns.parse_services(message)
     assert len(parsed) == 1
@@ -190,7 +182,7 @@ def test_parse_double_service():
         456,
         {"fizz": "buzz"},
     )
-    message = dns_utils.add_service(mdns.DnsMessage(), *service1_params)
+    message = dns_utils.add_service(dns.DnsMessage(), *service1_params)
     message = dns_utils.add_service(message, *service2_params)
 
     parsed = mdns.parse_services(message)
diff --git a/tests/support/test_mdns_functional.py b/tests/support/test_mdns_functional.py
index d66f3838..39b04a5e 100644
--- a/tests/support/test_mdns_functional.py
+++ b/tests/support/test_mdns_functional.py
@@ -2,6 +2,7 @@
 
 import asyncio
 import logging
+import typing
 from ipaddress import IPv4Address
 from unittest.mock import MagicMock, patch
 
@@ -74,10 +75,10 @@ def redirect_mcast(udns_server):
 # of requests have been received. Mainly this is for not slowing down tests.
 @pytest.fixture
 async def multicast_fastexit(event_loop, monkeypatch, udns_server):
-    clients: List[asyncio.Future] = []
+    clients: typing.List[asyncio.Future] = []
 
     # Interface used to set number of expected responses (1 by default)
-    conditions: Dict[str, object] = {"responses": 1, "requests": 0}
+    conditions: typing.Dict[str, object] = {"responses": 1, "requests": 0}
 
     # Checks if either response or request count has been fulfilled
     def _check_cond(protocol: mdns.MulticastDnsSdClientProtocol) -> bool:
@@ -97,7 +98,7 @@ async def multicast_fastexit(event_loop, monkeypatch, udns_server):
             clients.append(asyncio.ensure_future(_poll_responses()))
         return val
 
-    monkeypatch.setattr(mdns, "cast", _cast)
+    monkeypatch.setattr(typing, "cast", _cast)
 
     yield lambda **kwargs: conditions.update(**kwargs)
     await asyncio.gather(*clients)
diff --git a/tests/test_scan_functional.py b/tests/test_scan_functional.py
index ed4b8236..97d00964 100644
--- a/tests/test_scan_functional.py
+++ b/tests/test_scan_functional.py
@@ -1,5 +1,6 @@
 """Scanning tests with fake mDNS responder.."""
 
+import typing
 import pyatv
 from ipaddress import ip_address
 
@@ -51,6 +52,11 @@ async def udns_server(event_loop, stub_knock_server):
     server.close()
 
 
+#: A type alias for the [multi,uni]cast_scan fixtures.
+# There isn't a way to type-hint optional arguments, so instead we leave it unspecified.
+Scanner = typing.Callable[..., typing.Awaitable[typing.List[pyatv.conf.AppleTV]]]
+
+
 @pytest.fixture
 async def multicast_scan(event_loop, udns_server):
     async def _scan(timeout=1, identifier=None, protocol=None):
@@ -73,13 +79,16 @@ async def unicast_scan(event_loop, udns_server):
 
 
 @pytest.mark.asyncio
-async def test_multicast_scan_no_device_found(multicast_scan):
+async def test_multicast_scan_no_device_found(multicast_scan: Scanner):
     atvs = await multicast_scan()
     assert len(atvs) == 0
 
 
 @pytest.mark.asyncio
-async def test_multicast_scan(udns_server, multicast_scan):
+async def test_multicast_scan(
+    udns_server: fake_udns.FakeUdns,
+    multicast_scan: Scanner,
+):
     udns_server.add_service(
         fake_udns.mrp_service("Apple TV", "Apple TV MRP", MRP_ID_1, address=IP_1)
     )