Repository: postlund/pyatv

Issue:
Add device info support for AirPort Express
**Short feature/function description**
Currently no device information is extracted for the AirPort Express:

```raw
$ atvremote scan
Scan Results
========================================
       Name: Pierre's AirPort Express
   Model/SW: Unknown Model Unknown OS
    Address: 169.254.81.198
        MAC: 48:D7:XX:XX:XX:XX
 Deep Sleep: False
Identifiers:
 - 48:D7:XX:XX:XX:XX
 - 48D7XXXXXXXX
Services:
 - Protocol: AirPlay, Port: 7000, Credentials: None
 - Protocol: RAOP, Port: 7000, Credentials: None
```

Correct model, operating system and version should be set.

**What needs to be done?**

* Add model to model list, e.g. `AirPort10,115` in my case (probably more that are relevant)
* Not sure what operating system it runs (some Apple internal I believe), I have not found any name for it
* Extract operating system/software version somehow

**Is this a breaking change?**
No

**Anything else worth knowing?**
It seems like there's no easy way of extracting the software version. There's a firmware version field in zeroconf, but in my case it has the value `p20.78100.3` and the installed version is 7.8.1. There's something resembling the version in there, but can't be sure.

The `_airport.tcp.local` service has a property called `waMA` containing a lot of things, one of them being the software version (`syVs=7.8.1`). Question is if it's worth the cost of extracting that. 

Discussion:
gitpod-io[bot]: 

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


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

[![Impacted file tree graph](https://codecov.io/gh/postlund/pyatv/pull/1096/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/1096?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    #1096      +/-   ##
==========================================
- Coverage   88.83%   88.76%   -0.08%     
==========================================
  Files          57       57              
  Lines        5392     5411      +19     
==========================================
+ Hits         4790     4803      +13     
- Misses        602      608       +6     
```


| [Impacted Files](https://codecov.io/gh/postlund/pyatv/pull/1096?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/support/device\_info.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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-cHlhdHYvc3VwcG9ydC9kZXZpY2VfaW5mby5weQ==) | `100.00% <ø> (ø)` | |
| [pyatv/support/scan.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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.52% <81.25%> (-1.52%)` | :arrow_down: |
| [pyatv/conf.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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% <100.00%> (+0.06%)` | :arrow_up: |
| [pyatv/const.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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-cHlhdHYvY29uc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [pyatv/interface.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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-cHlhdHYvaW50ZXJmYWNlLnB5) | `99.29% <100.00%> (-0.01%)` | :arrow_down: |
| [pyatv/mrp/protocol.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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) | `94.81% <0.00%> (-2.98%)` | :arrow_down: |
| [pyatv/mrp/connection.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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.25% <0.00%> (-2.09%)` | :arrow_down: |
| [pyatv/support/net.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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=) | `88.79% <0.00%> (-0.87%)` | :arrow_down: |
| [pyatv/dmap/\_\_init\_\_.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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-cHlhdHYvZG1hcC9fX2luaXRfXy5weQ==) | `97.54% <0.00%> (-0.01%)` | :arrow_down: |
| [pyatv/airplay/player.py](https://codecov.io/gh/postlund/pyatv/pull/1096/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-cHlhdHYvYWlycGxheS9wbGF5ZXIucHk=) | `100.00% <0.00%> (ø)` | |
| ... and [1 more](https://codecov.io/gh/postlund/pyatv/pull/1096/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/1096?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/1096?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 [75d31f4...a098e4e](https://codecov.io/gh/postlund/pyatv/pull/1096?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 0d90a1aa..be1d16ff 100644
--- a/docs/api/pyatv/conf.html
+++ b/docs/api/pyatv/conf.html
@@ -78,7 +78,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, Mapping, Optional
+from typing import Dict, List, Mapping, Optional, Tuple, cast
 
 from pyatv import exceptions
 from pyatv.const import DeviceModel, OperatingSystem, Protocol
@@ -100,6 +100,7 @@ class AppleTV:
         name: str,
         deep_sleep: bool = False,
         model: DeviceModel = DeviceModel.Unknown,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new AppleTV.&#34;&#34;&#34;
         self._address = address
@@ -107,6 +108,7 @@ class AppleTV:
         self._deep_sleep = deep_sleep
         self._model = model
         self._services: Dict[Protocol, BaseService] = {}
+        self._properties: Mapping[str, str] = properties or {}
 
     @property
     def address(self) -&gt; IPv4Address:
@@ -196,12 +198,14 @@ class AppleTV:
             return True
         return False
 
+    # TODO: The extraction should be generialized and moved somewhere else. It is
+    # very hard to test rght now.
     @property
     def device_info(self) -&gt; DeviceInfo:
         &#34;&#34;&#34;Return general device information.&#34;&#34;&#34;
         properties = self._all_properties()
 
-        build: Optional[str] = properties.get(&#34;SystemBuildVersion&#34;)
+        build: Optional[str] = properties.get(&#34;systembuildversion&#34;)
         version = properties.get(&#34;ov&#34;)
         if not version:
             version = properties.get(&#34;osvers&#34;, lookup_version(build))
@@ -221,17 +225,33 @@ class AppleTV:
             os_type = OperatingSystem.TvOS
         elif Protocol.DMAP in self._services:
             os_type = OperatingSystem.Legacy
+        elif model in [DeviceModel.AirPortExpress, DeviceModel.AirPortExpressGen2]:
+            os_type = OperatingSystem.AirPortOS
         else:
             os_type = OperatingSystem.Unknown
 
-        mac = properties.get(&#34;macAddress&#34;, properties.get(&#34;deviceid&#34;))
+        mac = properties.get(&#34;macaddress&#34;, properties.get(&#34;deviceid&#34;))
         if mac:
             mac = mac.upper()
 
+        # The waMA property comes from the _airport._tcp.local service, announced by
+        # AirPort Expresses (used by the admin tool). It contains various information,
+        # for instance MAC address and software version.
+        wama = properties.get(&#34;wama&#34;)
+        if wama:
+            props: Mapping[str, str] = dict(
+                cast(Tuple[str, str], prop.split(&#34;=&#34;, maxsplit=1))
+                for prop in (&#34;macaddress=&#34; + wama).split(&#34;,&#34;)
+            )
+            if not mac:
+                mac = props[&#34;macaddress&#34;].replace(&#34;-&#34;, &#34;:&#34;).upper()
+            version = props.get(&#34;syVs&#34;)
+
         return DeviceInfo(os_type, version, build, model, mac)
 
     def _all_properties(self) -&gt; Mapping[str, str]:
         properties: Dict[str, str] = {}
+        properties.update(self._properties)
         for service in self.services:
             properties.update(service.properties)
         return properties
@@ -394,7 +414,7 @@ class RaopService(BaseService):
 </dd>
 <dt id="pyatv.conf.AppleTV"><code class="flex name class">
 <span>class <span class="ident">AppleTV</span></span>
-<span>(</span><span>address: ipaddress.IPv4Address, name: str, deep_sleep: bool = False, model: <a title="pyatv.const.DeviceModel" href="const#pyatv.const.DeviceModel">DeviceModel</a> = DeviceModel.Unknown)</span>
+<span>(</span><span>address: ipaddress.IPv4Address, name: str, deep_sleep: bool = False, model: <a title="pyatv.const.DeviceModel" href="const#pyatv.const.DeviceModel">DeviceModel</a> = DeviceModel.Unknown, properties: Optional[Mapping[str, str]] = None)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Representation of an Apple TV configuration.</p>
@@ -420,6 +440,7 @@ AirPlay.</p>
         name: str,
         deep_sleep: bool = False,
         model: DeviceModel = DeviceModel.Unknown,
+        properties: Optional[Mapping[str, str]] = None,
     ) -&gt; None:
         &#34;&#34;&#34;Initialize a new AppleTV.&#34;&#34;&#34;
         self._address = address
@@ -427,6 +448,7 @@ AirPlay.</p>
         self._deep_sleep = deep_sleep
         self._model = model
         self._services: Dict[Protocol, BaseService] = {}
+        self._properties: Mapping[str, str] = properties or {}
 
     @property
     def address(self) -&gt; IPv4Address:
@@ -516,12 +538,14 @@ AirPlay.</p>
             return True
         return False
 
+    # TODO: The extraction should be generialized and moved somewhere else. It is
+    # very hard to test rght now.
     @property
     def device_info(self) -&gt; DeviceInfo:
         &#34;&#34;&#34;Return general device information.&#34;&#34;&#34;
         properties = self._all_properties()
 
-        build: Optional[str] = properties.get(&#34;SystemBuildVersion&#34;)
+        build: Optional[str] = properties.get(&#34;systembuildversion&#34;)
         version = properties.get(&#34;ov&#34;)
         if not version:
             version = properties.get(&#34;osvers&#34;, lookup_version(build))
@@ -541,17 +565,33 @@ AirPlay.</p>
             os_type = OperatingSystem.TvOS
         elif Protocol.DMAP in self._services:
             os_type = OperatingSystem.Legacy
+        elif model in [DeviceModel.AirPortExpress, DeviceModel.AirPortExpressGen2]:
+            os_type = OperatingSystem.AirPortOS
         else:
             os_type = OperatingSystem.Unknown
 
-        mac = properties.get(&#34;macAddress&#34;, properties.get(&#34;deviceid&#34;))
+        mac = properties.get(&#34;macaddress&#34;, properties.get(&#34;deviceid&#34;))
         if mac:
             mac = mac.upper()
 
+        # The waMA property comes from the _airport._tcp.local service, announced by
+        # AirPort Expresses (used by the admin tool). It contains various information,
+        # for instance MAC address and software version.
+        wama = properties.get(&#34;wama&#34;)
+        if wama:
+            props: Mapping[str, str] = dict(
+                cast(Tuple[str, str], prop.split(&#34;=&#34;, maxsplit=1))
+                for prop in (&#34;macaddress=&#34; + wama).split(&#34;,&#34;)
+            )
+            if not mac:
+                mac = props[&#34;macaddress&#34;].replace(&#34;-&#34;, &#34;:&#34;).upper()
+            version = props.get(&#34;syVs&#34;)
+
         return DeviceInfo(os_type, version, build, model, mac)
 
     def _all_properties(self) -&gt; Mapping[str, str]:
         properties: Dict[str, str] = {}
+        properties.update(self._properties)
         for service in self.services:
             properties.update(service.properties)
         return properties
@@ -632,7 +672,7 @@ def device_info(self) -&gt; DeviceInfo:
     &#34;&#34;&#34;Return general device information.&#34;&#34;&#34;
     properties = self._all_properties()
 
-    build: Optional[str] = properties.get(&#34;SystemBuildVersion&#34;)
+    build: Optional[str] = properties.get(&#34;systembuildversion&#34;)
     version = properties.get(&#34;ov&#34;)
     if not version:
         version = properties.get(&#34;osvers&#34;, lookup_version(build))
@@ -652,13 +692,28 @@ def device_info(self) -&gt; DeviceInfo:
         os_type = OperatingSystem.TvOS
     elif Protocol.DMAP in self._services:
         os_type = OperatingSystem.Legacy
+    elif model in [DeviceModel.AirPortExpress, DeviceModel.AirPortExpressGen2]:
+        os_type = OperatingSystem.AirPortOS
     else:
         os_type = OperatingSystem.Unknown
 
-    mac = properties.get(&#34;macAddress&#34;, properties.get(&#34;deviceid&#34;))
+    mac = properties.get(&#34;macaddress&#34;, properties.get(&#34;deviceid&#34;))
     if mac:
         mac = mac.upper()
 
+    # The waMA property comes from the _airport._tcp.local service, announced by
+    # AirPort Expresses (used by the admin tool). It contains various information,
+    # for instance MAC address and software version.
+    wama = properties.get(&#34;wama&#34;)
+    if wama:
+        props: Mapping[str, str] = dict(
+            cast(Tuple[str, str], prop.split(&#34;=&#34;, maxsplit=1))
+            for prop in (&#34;macaddress=&#34; + wama).split(&#34;,&#34;)
+        )
+        if not mac:
+            mac = props[&#34;macaddress&#34;].replace(&#34;-&#34;, &#34;:&#34;).upper()
+        version = props.get(&#34;syVs&#34;)
+
     return DeviceInfo(os_type, version, build, model, mac)</code></pre>
 </details>
 </dd>
diff --git a/docs/api/pyatv/const.html b/docs/api/pyatv/const.html
index bd51f389..42b1f9b3 100644
--- a/docs/api/pyatv/const.html
+++ b/docs/api/pyatv/const.html
@@ -20,6 +20,9 @@ link_group: api
 <li>
 <h4><code><a title="pyatv.const.DeviceModel" href="#pyatv.const.DeviceModel">DeviceModel</a></code></h4>
 <ul class="two-column">
+<li><code><a title="pyatv.const.DeviceModel.AirPortExpress" href="#pyatv.const.DeviceModel.AirPortExpress">AirPortExpress</a></code></li>
+<li><code><a title="pyatv.const.DeviceModel.AirPortExpressGen2" href="#pyatv.const.DeviceModel.AirPortExpressGen2">AirPortExpressGen2</a></code></li>
+<li><code><a title="pyatv.const.DeviceModel.AppleTV4KGen2" href="#pyatv.const.DeviceModel.AppleTV4KGen2">AppleTV4KGen2</a></code></li>
 <li><code><a title="pyatv.const.DeviceModel.Gen2" href="#pyatv.const.DeviceModel.Gen2">Gen2</a></code></li>
 <li><code><a title="pyatv.const.DeviceModel.Gen3" href="#pyatv.const.DeviceModel.Gen3">Gen3</a></code></li>
 <li><code><a title="pyatv.const.DeviceModel.Gen4" href="#pyatv.const.DeviceModel.Gen4">Gen4</a></code></li>
@@ -121,6 +124,7 @@ link_group: api
 <li>
 <h4><code><a title="pyatv.const.OperatingSystem" href="#pyatv.const.OperatingSystem">OperatingSystem</a></code></h4>
 <ul class="">
+<li><code><a title="pyatv.const.OperatingSystem.AirPortOS" href="#pyatv.const.OperatingSystem.AirPortOS">AirPortOS</a></code></li>
 <li><code><a title="pyatv.const.OperatingSystem.Legacy" href="#pyatv.const.OperatingSystem.Legacy">Legacy</a></code></li>
 <li><code><a title="pyatv.const.OperatingSystem.TvOS" href="#pyatv.const.OperatingSystem.TvOS">TvOS</a></code></li>
 <li><code><a title="pyatv.const.OperatingSystem.Unknown" href="#pyatv.const.OperatingSystem.Unknown">Unknown</a></code></li>
@@ -298,24 +302,35 @@ class OperatingSystem(Enum):
     TvOS = 2
     &#34;&#34;&#34;Operating system is tvOS.&#34;&#34;&#34;
 
+    AirPortOS = 3
+    &#34;&#34;&#34;Operating system is AirPortOS.
+
+    This OS is used by AirPort Express devices. It is not an official name but made up
+    in pyatv as no official name has been found.
+    &#34;&#34;&#34;
+
 
 class DeviceModel(Enum):
-    &#34;&#34;&#34;Hardware device model.&#34;&#34;&#34;
+    &#34;&#34;&#34;Hardware device model.
+
+    Gen2-Gen4K are Apple TV model names and will be renamed to AppleTVGenX in the
+    future.
+    &#34;&#34;&#34;
 
     Unknown = 0
     &#34;&#34;&#34;Device model is unknown.&#34;&#34;&#34;
 
     Gen2 = 1
-    &#34;&#34;&#34;Device model is second generation (Apple TV 2).&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is second generation Apple TV (Apple TV 2).&#34;&#34;&#34;
 
     Gen3 = 2
-    &#34;&#34;&#34;Device model is third generation (Apple TV 3).&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is third generation Apple TV (Apple TV 3).&#34;&#34;&#34;
 
     Gen4 = 3
-    &#34;&#34;&#34;Device model is fourth generation (Apple TV 4).&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is fourth generation Apple TV (Apple TV 4).&#34;&#34;&#34;
 
     Gen4K = 4
-    &#34;&#34;&#34;Device model is Apple TV 4K.&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is fifth generation Apple TV (Apple TV 4K).&#34;&#34;&#34;
 
     HomePod = 5
     &#34;&#34;&#34;Device model is HomePod (first generation).&#34;&#34;&#34;
@@ -323,6 +338,15 @@ class DeviceModel(Enum):
     HomePodMini = 6
     &#34;&#34;&#34;Device model is HomePod Mini (first generation).&#34;&#34;&#34;
 
+    AirPortExpress = 7
+    &#34;&#34;&#34;Device model is AirPort Express (first generation).&#34;&#34;&#34;
+
+    AirPortExpressGen2 = 8
+    &#34;&#34;&#34;Device model is AirPort Express (second generation).&#34;&#34;&#34;
+
+    AppleTV4KGen2 = 9
+    &#34;&#34;&#34;Device model is sixth generation Apple TV (Apple TV 4K gen 2).&#34;&#34;&#34;
+
 
 class InputAction(Enum):
     &#34;&#34;&#34;Type of input when pressing a button.&#34;&#34;&#34;
@@ -516,34 +540,49 @@ class FeatureName(Enum):
 <span>(</span><span>value, names=None, *, module=None, qualname=None, type=None, start=1)</span>
 </code></dt>
 <dd>
-<section class="desc"><p>Hardware device model.</p></section>
+<section class="desc"><p>Hardware device model.</p>
+<p>Gen2-Gen4K are Apple TV model names and will be renamed to AppleTVGenX in the
+future.</p></section>
 <details class="source">
 <summary>
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">class DeviceModel(Enum):
-    &#34;&#34;&#34;Hardware device model.&#34;&#34;&#34;
+    &#34;&#34;&#34;Hardware device model.
+
+    Gen2-Gen4K are Apple TV model names and will be renamed to AppleTVGenX in the
+    future.
+    &#34;&#34;&#34;
 
     Unknown = 0
     &#34;&#34;&#34;Device model is unknown.&#34;&#34;&#34;
 
     Gen2 = 1
-    &#34;&#34;&#34;Device model is second generation (Apple TV 2).&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is second generation Apple TV (Apple TV 2).&#34;&#34;&#34;
 
     Gen3 = 2
-    &#34;&#34;&#34;Device model is third generation (Apple TV 3).&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is third generation Apple TV (Apple TV 3).&#34;&#34;&#34;
 
     Gen4 = 3
-    &#34;&#34;&#34;Device model is fourth generation (Apple TV 4).&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is fourth generation Apple TV (Apple TV 4).&#34;&#34;&#34;
 
     Gen4K = 4
-    &#34;&#34;&#34;Device model is Apple TV 4K.&#34;&#34;&#34;
+    &#34;&#34;&#34;Device model is fifth generation Apple TV (Apple TV 4K).&#34;&#34;&#34;
 
     HomePod = 5
     &#34;&#34;&#34;Device model is HomePod (first generation).&#34;&#34;&#34;
 
     HomePodMini = 6
-    &#34;&#34;&#34;Device model is HomePod Mini (first generation).&#34;&#34;&#34;</code></pre>
+    &#34;&#34;&#34;Device model is HomePod Mini (first generation).&#34;&#34;&#34;
+
+    AirPortExpress = 7
+    &#34;&#34;&#34;Device model is AirPort Express (first generation).&#34;&#34;&#34;
+
+    AirPortExpressGen2 = 8
+    &#34;&#34;&#34;Device model is AirPort Express (second generation).&#34;&#34;&#34;
+
+    AppleTV4KGen2 = 9
+    &#34;&#34;&#34;Device model is sixth generation Apple TV (Apple TV 4K gen 2).&#34;&#34;&#34;</code></pre>
 </details>
 <h3>Ancestors</h3>
 <ul class="hlist">
@@ -551,21 +590,33 @@ class FeatureName(Enum):
 </ul>
 <h3>Class variables</h3>
 <dl>
+<dt id="pyatv.const.DeviceModel.AirPortExpress"><code class="name">var <span class="ident">AirPortExpress</span></code></dt>
+<dd>
+<section class="desc"><p>Device model is AirPort Express (first generation).</p></section>
+</dd>
+<dt id="pyatv.const.DeviceModel.AirPortExpressGen2"><code class="name">var <span class="ident">AirPortExpressGen2</span></code></dt>
+<dd>
+<section class="desc"><p>Device model is AirPort Express (second generation).</p></section>
+</dd>
+<dt id="pyatv.const.DeviceModel.AppleTV4KGen2"><code class="name">var <span class="ident">AppleTV4KGen2</span></code></dt>
+<dd>
+<section class="desc"><p>Device model is sixth generation Apple TV (Apple TV 4K gen 2).</p></section>
+</dd>
 <dt id="pyatv.const.DeviceModel.Gen2"><code class="name">var <span class="ident">Gen2</span></code></dt>
 <dd>
-<section class="desc"><p>Device model is second generation (Apple TV 2).</p></section>
+<section class="desc"><p>Device model is second generation Apple TV (Apple TV 2).</p></section>
 </dd>
 <dt id="pyatv.const.DeviceModel.Gen3"><code class="name">var <span class="ident">Gen3</span></code></dt>
 <dd>
-<section class="desc"><p>Device model is third generation (Apple TV 3).</p></section>
+<section class="desc"><p>Device model is third generation Apple TV (Apple TV 3).</p></section>
 </dd>
 <dt id="pyatv.const.DeviceModel.Gen4"><code class="name">var <span class="ident">Gen4</span></code></dt>
 <dd>
-<section class="desc"><p>Device model is fourth generation (Apple TV 4).</p></section>
+<section class="desc"><p>Device model is fourth generation Apple TV (Apple TV 4).</p></section>
 </dd>
 <dt id="pyatv.const.DeviceModel.Gen4K"><code class="name">var <span class="ident">Gen4K</span></code></dt>
 <dd>
-<section class="desc"><p>Device model is Apple TV 4K.</p></section>
+<section class="desc"><p>Device model is fifth generation Apple TV (Apple TV 4K).</p></section>
 </dd>
 <dt id="pyatv.const.DeviceModel.HomePod"><code class="name">var <span class="ident">HomePod</span></code></dt>
 <dd>
@@ -1164,7 +1215,14 @@ not report a valid media type.</p></section>
     &#34;&#34;&#34;Operating system  is Apple TV Software (pre-tvOS).&#34;&#34;&#34;
 
     TvOS = 2
-    &#34;&#34;&#34;Operating system is tvOS.&#34;&#34;&#34;</code></pre>
+    &#34;&#34;&#34;Operating system is tvOS.&#34;&#34;&#34;
+
+    AirPortOS = 3
+    &#34;&#34;&#34;Operating system is AirPortOS.
+
+    This OS is used by AirPort Express devices. It is not an official name but made up
+    in pyatv as no official name has been found.
+    &#34;&#34;&#34;</code></pre>
 </details>
 <h3>Ancestors</h3>
 <ul class="hlist">
@@ -1172,6 +1230,12 @@ not report a valid media type.</p></section>
 </ul>
 <h3>Class variables</h3>
 <dl>
+<dt id="pyatv.const.OperatingSystem.AirPortOS"><code class="name">var <span class="ident">AirPortOS</span></code></dt>
+<dd>
+<section class="desc"><p>Operating system is AirPortOS.</p>
+<p>This OS is used by AirPort Express devices. It is not an official name but made up
+in pyatv as no official name has been found.</p></section>
+</dd>
 <dt id="pyatv.const.OperatingSystem.Legacy"><code class="name">var <span class="ident">Legacy</span></code></dt>
 <dd>
 <section class="desc"><p>Operating system
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index ff37f40f..dc9a068e 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -1070,16 +1070,16 @@ class DeviceInfo:
 
     def __str__(self) -&gt; str:
         &#34;&#34;&#34;Convert device info to readable string.&#34;&#34;&#34;
-        if self.model != DeviceModel.Unknown:
-            output = self.model.name.replace(&#34;Gen&#34;, &#34;&#34;)
-        else:
-            output = &#34;Unknown Model&#34;
+        output = (
+            self.model.name if self.model != DeviceModel.Unknown else &#34;Unknown Model&#34;
+        )
 
         output += (
             &#34; &#34;
             + {
                 OperatingSystem.Legacy: &#34;ATV SW&#34;,
                 OperatingSystem.TvOS: &#34;tvOS&#34;,
+                OperatingSystem.AirPortOS: &#34;AirPortOS&#34;,
             }.get(self.operating_system, &#34;Unknown OS&#34;)
         )
 
@@ -1887,16 +1887,16 @@ def identifier(self) -&gt; Optional[str]:
 
     def __str__(self) -&gt; str:
         &#34;&#34;&#34;Convert device info to readable string.&#34;&#34;&#34;
-        if self.model != DeviceModel.Unknown:
-            output = self.model.name.replace(&#34;Gen&#34;, &#34;&#34;)
-        else:
-            output = &#34;Unknown Model&#34;
+        output = (
+            self.model.name if self.model != DeviceModel.Unknown else &#34;Unknown Model&#34;
+        )
 
         output += (
             &#34; &#34;
             + {
                 OperatingSystem.Legacy: &#34;ATV SW&#34;,
                 OperatingSystem.TvOS: &#34;tvOS&#34;,
+                OperatingSystem.AirPortOS: &#34;AirPortOS&#34;,
             }.get(self.operating_system, &#34;Unknown OS&#34;)
         )
 
diff --git a/pyatv/conf.py b/pyatv/conf.py
index 9ac72016..1a460f5a 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, Mapping, Optional
+from typing import Dict, List, Mapping, Optional, Tuple, cast
 
 from pyatv import exceptions
 from pyatv.const import DeviceModel, OperatingSystem, Protocol
@@ -30,6 +30,7 @@ class AppleTV:
         name: str,
         deep_sleep: bool = False,
         model: DeviceModel = DeviceModel.Unknown,
+        properties: Optional[Mapping[str, str]] = None,
     ) -> None:
         """Initialize a new AppleTV."""
         self._address = address
@@ -37,6 +38,7 @@ class AppleTV:
         self._deep_sleep = deep_sleep
         self._model = model
         self._services: Dict[Protocol, BaseService] = {}
+        self._properties: Mapping[str, str] = properties or {}
 
     @property
     def address(self) -> IPv4Address:
@@ -126,12 +128,14 @@ class AppleTV:
             return True
         return False
 
+    # TODO: The extraction should be generialized and moved somewhere else. It is
+    # very hard to test rght now.
     @property
     def device_info(self) -> DeviceInfo:
         """Return general device information."""
         properties = self._all_properties()
 
-        build: Optional[str] = properties.get("SystemBuildVersion")
+        build: Optional[str] = properties.get("systembuildversion")
         version = properties.get("ov")
         if not version:
             version = properties.get("osvers", lookup_version(build))
@@ -151,17 +155,33 @@ class AppleTV:
             os_type = OperatingSystem.TvOS
         elif Protocol.DMAP in self._services:
             os_type = OperatingSystem.Legacy
+        elif model in [DeviceModel.AirPortExpress, DeviceModel.AirPortExpressGen2]:
+            os_type = OperatingSystem.AirPortOS
         else:
             os_type = OperatingSystem.Unknown
 
-        mac = properties.get("macAddress", properties.get("deviceid"))
+        mac = properties.get("macaddress", properties.get("deviceid"))
         if mac:
             mac = mac.upper()
 
+        # The waMA property comes from the _airport._tcp.local service, announced by
+        # AirPort Expresses (used by the admin tool). It contains various information,
+        # for instance MAC address and software version.
+        wama = properties.get("wama")
+        if wama:
+            props: Mapping[str, str] = dict(
+                cast(Tuple[str, str], prop.split("=", maxsplit=1))
+                for prop in ("macaddress=" + wama).split(",")
+            )
+            if not mac:
+                mac = props["macaddress"].replace("-", ":").upper()
+            version = props.get("syVs")
+
         return DeviceInfo(os_type, version, build, model, mac)
 
     def _all_properties(self) -> Mapping[str, str]:
         properties: Dict[str, str] = {}
+        properties.update(self._properties)
         for service in self.services:
             properties.update(service.properties)
         return properties
diff --git a/pyatv/const.py b/pyatv/const.py
index 4d0c7559..02cf2abc 100644
--- a/pyatv/const.py
+++ b/pyatv/const.py
@@ -122,24 +122,35 @@ class OperatingSystem(Enum):
     TvOS = 2
     """Operating system is tvOS."""
 
+    AirPortOS = 3
+    """Operating system is AirPortOS.
+
+    This OS is used by AirPort Express devices. It is not an official name but made up
+    in pyatv as no official name has been found.
+    """
+
 
 class DeviceModel(Enum):
-    """Hardware device model."""
+    """Hardware device model.
+
+    Gen2-Gen4K are Apple TV model names and will be renamed to AppleTVGenX in the
+    future.
+    """
 
     Unknown = 0
     """Device model is unknown."""
 
     Gen2 = 1
-    """Device model is second generation (Apple TV 2)."""
+    """Device model is second generation Apple TV (Apple TV 2)."""
 
     Gen3 = 2
-    """Device model is third generation (Apple TV 3)."""
+    """Device model is third generation Apple TV (Apple TV 3)."""
 
     Gen4 = 3
-    """Device model is fourth generation (Apple TV 4)."""
+    """Device model is fourth generation Apple TV (Apple TV 4)."""
 
     Gen4K = 4
-    """Device model is Apple TV 4K."""
+    """Device model is fifth generation Apple TV (Apple TV 4K)."""
 
     HomePod = 5
     """Device model is HomePod (first generation)."""
@@ -147,6 +158,15 @@ class DeviceModel(Enum):
     HomePodMini = 6
     """Device model is HomePod Mini (first generation)."""
 
+    AirPortExpress = 7
+    """Device model is AirPort Express (first generation)."""
+
+    AirPortExpressGen2 = 8
+    """Device model is AirPort Express (second generation)."""
+
+    AppleTV4KGen2 = 9
+    """Device model is sixth generation Apple TV (Apple TV 4K gen 2)."""
+
 
 class InputAction(Enum):
     """Type of input when pressing a button."""
diff --git a/pyatv/interface.py b/pyatv/interface.py
index 0fe00d32..7b2671d3 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -830,16 +830,16 @@ class DeviceInfo:
 
     def __str__(self) -> str:
         """Convert device info to readable string."""
-        if self.model != DeviceModel.Unknown:
-            output = self.model.name.replace("Gen", "")
-        else:
-            output = "Unknown Model"
+        output = (
+            self.model.name if self.model != DeviceModel.Unknown else "Unknown Model"
+        )
 
         output += (
             " "
             + {
                 OperatingSystem.Legacy: "ATV SW",
                 OperatingSystem.TvOS: "tvOS",
+                OperatingSystem.AirPortOS: "AirPortOS",
             }.get(self.operating_system, "Unknown OS")
         )
 
diff --git a/pyatv/support/device_info.py b/pyatv/support/device_info.py
index b4d1f32c..c3ebddc2 100644
--- a/pyatv/support/device_info.py
+++ b/pyatv/support/device_info.py
@@ -6,11 +6,14 @@ from typing import Dict, Optional
 from pyatv.const import DeviceModel
 
 _MODEL_LIST: Dict[str, DeviceModel] = {
+    "AirPort4,107": DeviceModel.AirPortExpress,
+    "AirPort10,115": DeviceModel.AirPortExpressGen2,
     "AppleTV2,1": DeviceModel.Gen2,
     "AppleTV3,1": DeviceModel.Gen3,
     "AppleTV3,2": DeviceModel.Gen3,
     "AppleTV5,3": DeviceModel.Gen4,
     "AppleTV6,2": DeviceModel.Gen4K,
+    "AppleTV11,1": DeviceModel.AppleTV4KGen2,
     "AudioAccessory1,1": DeviceModel.HomePod,
     "AudioAccessory1,2": DeviceModel.HomePod,
     "AudioAccessory5,1": DeviceModel.HomePodMini,
@@ -23,9 +26,10 @@ _INTERNAL_NAME_LIST: Dict[str, DeviceModel] = {
     "J33IAP": DeviceModel.Gen3,
     "J42dAP": DeviceModel.Gen4,
     "J105aAP": DeviceModel.Gen4K,
+    "J305AP": DeviceModel.AppleTV4KGen2,
 }
 
-# Incomplete list here!
+# Incomplete list here! Only Apple TV version numbers for now.
 _VERSION_LIST: Dict[str, str] = {
     "17J586": "13.0",
     "17K82": "13.2",
diff --git a/pyatv/support/scan.py b/pyatv/support/scan.py
index 68d85657..f81b54d0 100644
--- a/pyatv/support/scan.py
+++ b/pyatv/support/scan.py
@@ -5,7 +5,7 @@ import asyncio
 from ipaddress import IPv4Address
 import logging
 import os
-from typing import Dict, Generator, List, Optional
+from typing import Dict, Generator, List, Mapping, Optional
 
 from pyatv import conf, interface
 from pyatv.support import knock, mdns
@@ -19,7 +19,9 @@ MEDIAREMOTE_SERVICE: str = "_mediaremotetv._tcp.local"
 AIRPLAY_SERVICE: str = "_airplay._tcp.local"
 COMPANION_SERVICE: str = "_companion-link._tcp.local"
 RAOP_SERVICE: str = "_raop._tcp.local"
+AIRPORT_ADMIN_SERVICE: str = "_airport._tcp.local"
 
+# These corresponds to services mapped to a protocol
 ALL_SERVICES: List[str] = [
     HOMESHARING_SERVICE,
     DEVICE_SERVICE,
@@ -29,12 +31,24 @@ ALL_SERVICES: List[str] = [
     RAOP_SERVICE,
 ]
 
+# Services that does not map to a protocol but provides additional metadata
+EXTRA_SERVICES: List[str] = [AIRPORT_ADMIN_SERVICE]
+
 # These ports have been "arbitrarily" chosen (see issue #580) because a device normally
 # listen on them (more or less). They are used as best-effort when for unicast scanning
 # to try to wake up a device. Both issue #580 and #595 are good references to read.
 KNOCK_PORTS: List[int] = [3689, 7000, 49152, 32498]
 
 
+def _get_service_properties(
+    services: List[mdns.Service], service_type
+) -> Mapping[str, str]:
+    for service in services:
+        if service.type == service_type:
+            return service.properties
+    return {}
+
+
 def get_unique_identifiers(
     response: mdns.Response,
 ) -> Generator[Optional[str], None, None]:
@@ -147,9 +161,12 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
     @staticmethod
     def _unsupported_service(mdns_service: mdns.Service, _: mdns.Response) -> None:
         """Handle unsupported service."""
-        _LOGGER.warning(
-            "Discovered unknown device %s (%s)", mdns_service.name, mdns_service.type
-        )
+        if mdns_service.type not in EXTRA_SERVICES:
+            _LOGGER.warning(
+                "Discovered unknown device %s (%s)",
+                mdns_service.name,
+                mdns_service.type,
+            )
 
     def _handle_service(
         self,
@@ -166,15 +183,23 @@ class BaseScanner(ABC):  # pylint: disable=too-few-public-methods
             service.protocol,
         )
 
-        self._found_devices.setdefault(
-            address,
-            conf.AppleTV(
+        if address not in self._found_devices:
+            # Extract properties from extra services that might be of interest
+            extra_properties: Dict[str, str] = {}
+            for extra_service in EXTRA_SERVICES:
+                extra_properties.update(
+                    _get_service_properties(response.services, extra_service)
+                )
+
+            self._found_devices[address] = conf.AppleTV(
                 address,
                 name,
                 deep_sleep=response.deep_sleep,
                 model=lookup_internal_name(response.model),
-            ),
-        ).add_service(service)
+                properties=extra_properties,
+            )
+
+        self._found_devices[address].add_service(service)
 
 
 class UnicastMdnsScanner(BaseScanner):
@@ -204,7 +229,11 @@ class UnicastMdnsScanner(BaseScanner):
         try:
             knocker = await knock.knocker(host, KNOCK_PORTS, self.loop, timeout=timeout)
             response = await mdns.unicast(
-                self.loop, str(host), ALL_SERVICES, port=port, timeout=timeout
+                self.loop,
+                str(host),
+                ALL_SERVICES + EXTRA_SERVICES,
+                port=port,
+                timeout=timeout,
             )
         except asyncio.TimeoutError:
             return mdns.Response([], False, None)
@@ -229,7 +258,7 @@ class MulticastMdnsScanner(BaseScanner):
         """Start discovery of devices and services."""
         responses = await mdns.multicast(
             self.loop,
-            ALL_SERVICES,
+            ALL_SERVICES + EXTRA_SERVICES,
             timeout=timeout,
             end_condition=self._end_if_identifier_found if self.identifier else None,
         )


Test patch:
diff --git a/tests/support/test_mdns.py b/tests/support/test_mdns.py
index 9ed9d15c..def4b872 100644
--- a/tests/support/test_mdns.py
+++ b/tests/support/test_mdns.py
@@ -226,3 +226,17 @@ def test_parse_ignore_link_local_address():
     parsed = mdns.parse_services(message)
     assert len(parsed) == 1
     assert parsed[0].address is None
+
+
+# Note: This is an unwanted side-effect of using CaseInsensitiveDict. It's very
+# unlikely that two properties will ever exist, only differ in case and is because of
+# that not a big problem. But a test should exist for it to make sure we don't break
+# anything in the future.
+def test_parse_properties_converts_keys_to_lower_case():
+    service_params = ("_abc._tcp.local", "service", [], 0, {"FOO": "bar", "Bar": "FOO"})
+    message = dns_utils.add_service(dns.DnsMessage(), *service_params)
+
+    parsed = mdns.parse_services(message)
+    assert len(parsed) == 1
+    assert parsed[0].properties["foo"] == "bar"
+    assert parsed[0].properties["Bar"] == "FOO"
diff --git a/tests/test_conf.py b/tests/test_conf.py
index 1afbdd10..5be446d2 100644
--- a/tests/test_conf.py
+++ b/tests/test_conf.py
@@ -19,13 +19,13 @@ IDENTIFIER_4 = "id4"
 CREDENTIALS_1 = "cred1"
 
 MRP_PROPERTIES = {
-    "SystemBuildVersion": "17K795",
-    "macAddress": "ff:ee:dd:cc:bb:aa",
+    "systembuildversion": "17K795",
+    "macaddress": "ff:ee:dd:cc:bb:aa",
 }
 
 AIRPLAY_PROPERTIES = {
     "model": "AppleTV6,2",
-    "deviceid": "aa:bb:cc:dd:ee:ff",
+    "deviceid": "ff:ee:dd:cc:bb:aa",
     "osvers": "8.0.0",
 }
 
@@ -34,6 +34,10 @@ RAOP_PROPERTIES = {
     "ov": "14.5",
 }
 
+AIRPORT_PROPERTIES = {
+    "am": "AirPort10,115",
+}
+
 DMAP_SERVICE = conf.DmapService(IDENTIFIER_1, None, port=PORT_1)
 MRP_SERVICE = conf.MrpService(IDENTIFIER_2, PORT_2, properties=MRP_PROPERTIES)
 AIRPLAY_SERVICE = conf.AirPlayService(
@@ -41,6 +45,7 @@ AIRPLAY_SERVICE = conf.AirPlayService(
 )
 COMPANION_SERVICE = conf.CompanionService(PORT_3)
 RAOP_SERVICE = conf.RaopService(IDENTIFIER_4, PORT_4, properties=RAOP_PROPERTIES)
+AIRPORT_SERVICE = conf.RaopService(IDENTIFIER_1, PORT_1, properties=AIRPORT_PROPERTIES)
 
 
 @pytest.fixture
@@ -191,7 +196,7 @@ def test_legacy_device_info(config):
     assert device_info.version == "8.0.0"
     assert device_info.build_number is None
     assert device_info.model == DeviceModel.Gen4K
-    assert device_info.mac == "AA:BB:CC:DD:EE:FF"
+    assert device_info.mac == "FF:EE:DD:CC:BB:AA"
 
 
 # Mainly to test devices which are pure AirPlay devices/speakers
@@ -203,7 +208,36 @@ def test_raop_device_info(config):
     assert device_info.version == "14.5"
     assert device_info.build_number is None
     assert device_info.model == DeviceModel.HomePodMini
-    assert not device_info.mac
+    assert device_info.mac is None
+
+
+def test_airport_express_info(config):
+    config.add_service(AIRPORT_SERVICE)
+
+    device_info = config.device_info
+    assert device_info.operating_system == OperatingSystem.AirPortOS
+    assert device_info.version is None
+    assert device_info.build_number is None
+    assert device_info.model == DeviceModel.AirPortExpressGen2
+    assert device_info.mac is None
+
+
+def test_airport_express_extra_properties():
+    extra_properties = {
+        # MAC, raMA=2.4GHz MAC, raM2=5GHz MAC
+        "wama": "AA-AA-AA-AA-AA-AA,raMA=BB-BB-BB-BB-BB-BB,raM2=CC-CC-CC-CC-CC-CC,"
+        + "raNm=MySsid,raCh=11,rCh2=112,raSt=1,raNA=0,syFl=0x80C,syAP=115,syVs=7.8.1,"
+        + "srcv=78100.3,bjSd=2"
+    }
+    config = conf.AppleTV(ADDRESS_1, NAME, deep_sleep=True, properties=extra_properties)
+    config.add_service(AIRPORT_SERVICE)
+
+    device_info = config.device_info
+    assert device_info.operating_system == OperatingSystem.AirPortOS
+    assert device_info.version == "7.8.1"
+    assert device_info.build_number is None
+    assert device_info.model == DeviceModel.AirPortExpressGen2
+    assert device_info.mac == "AA:AA:AA:AA:AA:AA"
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_interface.py b/tests/test_interface.py
index c912e7e6..e83a55a9 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -267,7 +267,7 @@ def test_apple_tv_software_str():
         "aa:bb:cc:dd:ee:ff",
     )
 
-    assert str(dev_info) == "3 ATV SW 2.2.3 build 13D333"
+    assert str(dev_info) == "Gen3 ATV SW 2.2.3 build 13D333"
 
 
 def test_tvos_str():
@@ -279,7 +279,7 @@ def test_tvos_str():
         "aa:bb:cc:dd:ee:ff",
     )
 
-    assert str(dev_info) == "4K tvOS 1.2.3 build 19A123"
+    assert str(dev_info) == "Gen4K tvOS 1.2.3 build 19A123"
 
 
 def test_unknown_str():