Repository: postlund/pyatv
Pull request #1912 (2023-02-04 19:38:46)

Fix companion proxy
This PR gets the Companion protocol support of atvproxy to a working state.

Contrary to what was written in the documentation, i've discovered that the Apple TV ignores the signatures in the `_systemInfo` request. However, both ends of the connection do track a persistent unique identifier (the `_i` property of the request and response) and will terminate old connections with the same identifier.

The events that caused the proxy to fail before this PR were:
1.  The proxy would connect to the Apple TV with the same identifier as the direct connection from the iOS device.
2. The Apple TV would terminate the direct connection from the iOS device.
3. The iOS device would attempt to reconnect with the same identifier, after being unexpectedly disconnected.
4. The Apple TV would disconnect the now older proxy connection.
By coincidence the disconnection appeared in the logs close to the lines about signatures, which I assume lead to this misunderstanding.

Other issues were related to incorrect OPACK re-encoding, incorrect encryption when receiving NoOP frames from the client device, and name and identifier collisions, which apparently cause the iOS device to sometimes connect directly to the target device bypassing the proxy. I assume the iOS device keeps a mapping of identifiers to IP addresses.

I have also updated the published mdns properties, to expose some values from the proxied device, and replace others to avoid collisions that prevented the proxy from working consistently. The values used were from an Xcode emulated Apple TV device, since the previous ones were not working consistently for me.

Occasionally iOS devices will fail to complete the connection ("Connecting" visible in the remote app despite seeing initialization traffic in the proxy). In such situations rebooting the iOS device, restarting the proxy, restarting the Apple TV Remote app (in newer iOS versions), or a combination of these gets it working again. 

But it's working well enough to share, and allowed me to complete [a PR with support for keyboard input](https://github.com/postlund/pyatv/pull/1905).

postlund: > > Do you know what is required to make the Shortcut app pick the device up? It doesn't seem to do that (and I haven't succeeded with that before AFAIK).
> 
> From my testing the proxy appears in the remote app always when it's advertised, but in the shortcuts app only if it's able to successfully connect. Also not all commands in the shortcuts app show the proxy. The Apple TV remote ones do, the music player ones (which I assume use the AirPlay protocol) don't.
> 
> If something isn't showing up, I've found that restarting things consistently solves it as described in the PR's description ("Occasionally iOS devices will fail to complete the connection ...").

Right, I will wiggle it a bit and see if it works better. Thanks for mentioning that! 👍 
michalmo: I'm not able to reproduce this [pylint error](https://github.com/postlund/pyatv/actions/runs/4742921556/jobs/8421794691?pr=1912#step:10:43) in either Python 3.9.16 or 3.8.16. @postlund is this just a CI issue?
postlund: I will try to reproduce it later today.
postlund: Hmm, I can't reproduce it myself either. Looks like pylint gets confused about the types for some reason. Since `self.listener` can be `None`, there should be an if-clause checking for that. Maybe you can add that and see what happens (at this point it looks like a pylint bug to me)?
codecov[bot]: ## [Codecov](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) Report
Patch coverage: **`63.33`**% and project coverage change: **`-0.14`** :warning:
> Comparison is base [(`7acaf6e`)](https://codecov.io/gh/postlund/pyatv/commit/7acaf6e0c1e31dcac018e22724597ddfdc945484?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 87.54% compared to head [(`cf56fb1`)](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 87.41%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1912      +/-   ##
==========================================
- Coverage   87.54%   87.41%   -0.14%     
==========================================
  Files         151      151              
  Lines        9822     9847      +25     
==========================================
+ Hits         8599     8608       +9     
- Misses       1223     1239      +16     
```


| [Impacted Files](https://codecov.io/gh/postlund/pyatv/pull/1912?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/\_\_init\_\_.py](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9fX2luaXRfXy5weQ==) | `90.76% <14.28%> (-9.24%)` | :arrow_down: |
| [pyatv/support/opack.py](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9vcGFjay5weQ==) | `92.45% <63.63%> (-2.18%)` | :arrow_down: |
| [pyatv/protocols/companion/connection.py](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL2NvbXBhbmlvbi9jb25uZWN0aW9uLnB5) | `90.90% <85.71%> (-0.67%)` | :arrow_down: |
| [pyatv/helpers.py](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvaGVscGVycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [pyatv/interface.py](https://codecov.io/gh/postlund/pyatv/pull/1912?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) | `98.76% <100.00%> (ø)` | |
| [pyatv/protocols/companion/\_\_init\_\_.py](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL2NvbXBhbmlvbi9fX2luaXRfXy5weQ==) | `97.32% <100.00%> (+0.01%)` | :arrow_up: |

... and [1 file with indirect coverage changes](https://codecov.io/gh/postlund/pyatv/pull/1912/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/postlund/pyatv/pull/1912?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).

michalmo: [This change](https://github.com/postlund/pyatv/pull/1912/commits/b7b0eb37169fcf71645bcf6b49ac60289bc23f2b) resolves the pylint issue.
postlund: Looks good now 👍 

Patch:
diff --git a/docs/api/pyatv/helpers.html b/docs/api/pyatv/helpers.html
index 897c762f..cf897fc3 100644
--- a/docs/api/pyatv/helpers.html
+++ b/docs/api/pyatv/helpers.html
@@ -30,7 +30,7 @@ link_group: api
 </header>
 <section id="section-intro">
 <p>Various helper methods.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L1-L97" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L1-L102" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -51,7 +51,7 @@ device found, connects to it and passes it to a user provided handler. An
 optional error handler can be provided that is called when no device was found.
 Very inflexible in many cases, but can be handys sometimes when trying things.</p>
 <p>Note: both handler and not_found must be coroutines</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L18-L50" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L19-L51" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.helpers.get_unique_id">
 <code class="name flex">
@@ -64,7 +64,7 @@ Very inflexible in many cases, but can be handys sometimes when trying things.</
 <code>service_name</code> name of the service (e.g. <em>Office</em> or <em>Living Room</em>) and
 <code>properties</code> all key-value properties belonging to the service.</p>
 <p>The unique identifier is returned if available, otherwise <code>None</code> is returned.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L53-L82" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L54-L87" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.helpers.is_streamable">
 <code class="name flex">
@@ -76,7 +76,7 @@ Very inflexible in many cases, but can be handys sometimes when trying things.</
 <p>This method will return if the file format of the given file is supported
 and streamable by pyatv. It will never raise an exception, e.g. because the
 file is missing or lack of permissions.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L85-L97" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/helpers.py#L90-L102" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </section>
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index 17f302f6..36286472 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -279,7 +279,7 @@ link_group: api
 <p>Public interface exposed by library.</p>
 <p>This module contains all the interfaces that represents a generic Apple TV device and
 all its features.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1-L1299" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1-L1305" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -332,7 +332,7 @@ all its features.</p>
 <section class="desc"><p>Base class representing an Apple TV.</p>
 <p>Listener interface: <code>pyatv.interfaces.DeviceListener</code></p>
 <p>Initialize a new StateProducer instance.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1229-L1299" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1235-L1305" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
@@ -348,57 +348,57 @@ all its features.</p>
 <dt id="pyatv.interface.AppleTV.apps"><code class="name">var <span class="ident">apps</span> -> <a title="pyatv.interface.Apps" href="#pyatv.interface.Apps">Apps</a></code></dt>
 <dd>
 <section class="desc"><p>Return apps interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1286-L1289" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1292-L1295" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.audio"><code class="name">var <span class="ident">audio</span> -> <a title="pyatv.interface.Audio" href="#pyatv.interface.Audio">Audio</a></code></dt>
 <dd>
 <section class="desc"><p>Return audio interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1296-L1299" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1302-L1305" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.device_info"><code class="name">var <span class="ident">device_info</span> -> <a title="pyatv.interface.DeviceInfo" href="#pyatv.interface.DeviceInfo">DeviceInfo</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for device information.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1246-L1249" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1252-L1255" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.features"><code class="name">var <span class="ident">features</span> -> <a title="pyatv.interface.Features" href="#pyatv.interface.Features">Features</a></code></dt>
 <dd>
 <section class="desc"><p>Return features interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1281-L1284" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1287-L1290" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.metadata"><code class="name">var <span class="ident">metadata</span> -> <a title="pyatv.interface.Metadata" href="#pyatv.interface.Metadata">Metadata</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for retrieving metadata from the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1261-L1264" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1267-L1270" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.power"><code class="name">var <span class="ident">power</span> -> <a title="pyatv.interface.Power" href="#pyatv.interface.Power">Power</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for power management.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1276-L1279" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1282-L1285" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.push_updater"><code class="name">var <span class="ident">push_updater</span> -> <a title="pyatv.interface.PushUpdater" href="#pyatv.interface.PushUpdater">PushUpdater</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for handling push update from the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1266-L1269" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1272-L1275" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.remote_control"><code class="name">var <span class="ident">remote_control</span> -> <a title="pyatv.interface.RemoteControl" href="#pyatv.interface.RemoteControl">RemoteControl</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for controlling the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1256-L1259" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1262-L1265" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.service"><code class="name">var <span class="ident">service</span> -> <a title="pyatv.interface.BaseService" href="#pyatv.interface.BaseService">BaseService</a></code></dt>
 <dd>
 <section class="desc"><p>Return service used to connect to the Apple TV.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1251-L1254" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1257-L1260" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.stream"><code class="name">var <span class="ident">stream</span> -> <a title="pyatv.interface.Stream" href="#pyatv.interface.Stream">Stream</a></code></dt>
 <dd>
 <section class="desc"><p>Return API for streaming media.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1271-L1274" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1277-L1280" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.user_accounts"><code class="name">var <span class="ident">user_accounts</span> -> <a title="pyatv.interface.UserAccounts" href="#pyatv.interface.UserAccounts">UserAccounts</a></code></dt>
 <dd>
 <section class="desc"><p>Return user accounts interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1291-L1294" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1297-L1300" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -410,7 +410,7 @@ all its features.</p>
 </dt>
 <dd>
 <section class="desc"><p>Close connection and release allocated resources.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1242-L1244" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1248-L1250" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.connect">
 <code class="name flex">
@@ -420,7 +420,7 @@ all its features.</p>
 <dd>
 <section class="desc"><p>Initiate connection to device.</p>
 <p>No need to call it yourself, it's done automatically.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1235-L1240" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1241-L1246" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
@@ -622,7 +622,7 @@ possible and supported).</p></section>
 several services depending on the protocols it supports, e.g. DMAP or
 AirPlay.</p>
 <p>Initialize a new BaseConfig instance.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1099-L1226" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1099-L1232" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
@@ -641,7 +641,7 @@ AirPlay.</p>
 <dt id="pyatv.interface.BaseConfig.all_identifiers"><code class="name">var <span class="ident">all_identifiers</span> -> List[str]</code></dt>
 <dd>
 <section class="desc"><p>Return all unique identifiers for this device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1173-L1176" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1179-L1182" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.deep_sleep"><code class="name">var <span class="ident">deep_sleep</span> -> bool</code></dt>
 <dd>
@@ -656,7 +656,7 @@ AirPlay.</p>
 <dt id="pyatv.interface.BaseConfig.identifier"><code class="name">var <span class="ident">identifier</span> -> Optional[str]</code></dt>
 <dd>
 <section class="desc"><p>Return the main identifier associated with this device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1164-L1171" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1164-L1177" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.name"><code class="name">var <span class="ident">name</span> -> str</code></dt>
 <dd>
@@ -709,7 +709,7 @@ returned.</p></section>
 </dt>
 <dd>
 <section class="desc"><p>Return suggested service used to establish connection.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1178-L1191" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1184-L1197" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.set_credentials">
 <code class="name flex">
@@ -718,7 +718,7 @@ returned.</p></section>
 </dt>
 <dd>
 <section class="desc"><p>Set credentials for a protocol if it exists.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1193-L1199" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1199-L1205" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
diff --git a/docs/documentation/atvproxy.md b/docs/documentation/atvproxy.md
index 84c4c96e..fad48c62 100644
--- a/docs/documentation/atvproxy.md
+++ b/docs/documentation/atvproxy.md
@@ -93,11 +93,13 @@ work and behave as expected and all traffic should be logged to console.
 
 # Companion Proxy
 
-There is basic support for the Companion protocol. However, there's a big limitation, making
-the proxy less useful at the moment. During connection (after encryption has been set up),
-a `_systemInfo` message is sent which includes a signture that the Apple TV fails to verify
-and because of that closes the connection. Until this has been resolved, the proxy is fairly
-unusable and any help to fix this is appreciated.
+There is also support for the Companion protocol. Occasionally iOS devices will
+fail to complete the connection ("Connecting" visible in the remote app despite
+seeing initialization traffic in the proxy), possibly due to some kind of IP
+address & port or identifier caching. In such situations rebooting the iOS
+device, restarting the proxy, restarting the Apple TV Remote app (in newer iOS
+versions), or a combination of these has been known to bring it back to a
+working state.
 
 ## Device Credentials
 
diff --git a/pyatv/helpers.py b/pyatv/helpers.py
index 89670c3b..e851da69 100644
--- a/pyatv/helpers.py
+++ b/pyatv/helpers.py
@@ -11,6 +11,7 @@ HOMESHARING_SERVICE: str = "_appletv-v2._tcp.local"
 DEVICE_SERVICE: str = "_touch-able._tcp.local"
 MEDIAREMOTE_SERVICE: str = "_mediaremotetv._tcp.local"
 AIRPLAY_SERVICE: str = "_airplay._tcp.local"
+COMPANION_SERVICE: str = "_companion-link._tcp.local"
 RAOP_SERVICE: str = "_raop._tcp.local"
 HSCP_SERVICE: str = "_hscp._tcp.local"
 
@@ -69,6 +70,10 @@ def get_unique_id(  # pylint: disable=too-many-return-statements
         return properties.get("UniqueIdentifier")
     if service_type == AIRPLAY_SERVICE:
         return properties.get("deviceid")
+    if service_type == COMPANION_SERVICE:
+        # Apple TV devices on tvOS 16 (maybe earlier) have a static rpMRtID
+        # identifier.
+        return properties.get("rpmrtid")
     if service_type == RAOP_SERVICE:
         split = service_name.split("@", maxsplit=1)
 
diff --git a/pyatv/interface.py b/pyatv/interface.py
index 9db342d8..52eaa259 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -1164,7 +1164,13 @@ class BaseConfig(ABC):
     @property
     def identifier(self) -> Optional[str]:
         """Return the main identifier associated with this device."""
-        for prot in [Protocol.MRP, Protocol.DMAP, Protocol.AirPlay, Protocol.RAOP]:
+        for prot in [
+            Protocol.MRP,
+            Protocol.DMAP,
+            Protocol.AirPlay,
+            Protocol.RAOP,
+            Protocol.Companion,
+        ]:
             service = self.get_service(prot)
             if service and service.identifier is not None:
                 return service.identifier
diff --git a/pyatv/protocols/companion/__init__.py b/pyatv/protocols/companion/__init__.py
index 1538b2ed..3542eb5a 100644
--- a/pyatv/protocols/companion/__init__.py
+++ b/pyatv/protocols/companion/__init__.py
@@ -20,6 +20,7 @@ from pyatv.core.scan import (
     ScanHandlerReturn,
     device_info_name_from_unique_short_name,
 )
+from pyatv.helpers import get_unique_id
 from pyatv.interface import (
     App,
     Apps,
@@ -373,7 +374,7 @@ def companion_service_handler(
 ) -> Optional[ScanHandlerReturn]:
     """Parse and return a new Companion service."""
     service = MutableService(
-        None,
+        get_unique_id(mdns_service.type, mdns_service.name, mdns_service.properties),
         Protocol.Companion,
         mdns_service.port,
         mdns_service.properties,
diff --git a/pyatv/protocols/companion/connection.py b/pyatv/protocols/companion/connection.py
index 29278da7..2ca45ba7 100644
--- a/pyatv/protocols/companion/connection.py
+++ b/pyatv/protocols/companion/connection.py
@@ -63,7 +63,7 @@ class CompanionConnection(asyncio.Protocol):
         self.loop = loop
         self.host = host
         self.port = port
-        self.listener: Optional[CompanionConnectionListener] = None
+        self._listener: Optional[CompanionConnectionListener] = None
         self._device_listener = device_listener
         self.transport = None
         self._buffer: bytes = b""
@@ -90,12 +90,18 @@ class CompanionConnection(asyncio.Protocol):
         """Enable encryption with the specified keys."""
         self._chacha = chacha20.Chacha20Cipher(output_key, input_key, nonce_length=12)
 
+    def set_listener(self, listener: CompanionConnectionListener) -> None:
+        """Set the CompanionConnectionListener in a way that doesn't break pylint."""
+        self._listener = listener
+
     def send(self, frame_type: FrameType, data: bytes) -> None:
         """Send message without waiting for a response."""
         if self.transport is None:
             raise exceptions.InvalidStateError("not connected")
 
-        payload_length = len(data) + (AUTH_TAG_LENGTH if self._chacha else 0)
+        payload_length = len(data)
+        if self._chacha and payload_length > 0:
+            payload_length += AUTH_TAG_LENGTH
         header = bytes([frame_type.value]) + payload_length.to_bytes(3, byteorder="big")
 
         log_binary(
@@ -105,7 +111,7 @@ class CompanionConnection(asyncio.Protocol):
             Data=data,
         )
 
-        if self._chacha:
+        if self._chacha and len(data) > 0:
             data = self._chacha.encrypt(data, aad=header)
             log_binary(_LOGGER, ">> Send", Header=header, Encrypted=data)
 
@@ -138,10 +144,10 @@ class CompanionConnection(asyncio.Protocol):
             self._buffer = self._buffer[payload_length:]
 
             try:
-                if self._chacha:
+                if self._chacha and len(payload) > 0:
                     payload = self._chacha.decrypt(payload, aad=header)
 
-                self.listener.frame_received(FrameType(header[0]), payload)
+                self._listener.frame_received(FrameType(header[0]), payload)
             except Exception:
                 _LOGGER.exception("failed to handle frame")
 
diff --git a/pyatv/protocols/companion/protocol.py b/pyatv/protocols/companion/protocol.py
index bf6b7a16..2ef871b5 100644
--- a/pyatv/protocols/companion/protocol.py
+++ b/pyatv/protocols/companion/protocol.py
@@ -82,7 +82,7 @@ class CompanionProtocol(
         """Initialize a new CompanionProtocol."""
         super().__init__()
         self.connection = connection
-        self.connection.listener = self
+        self.connection.set_listener(self)
         self.srp = srp
         self.service = service
         self._xid: int = randint(
diff --git a/pyatv/scripts/atvproxy.py b/pyatv/scripts/atvproxy.py
index e47a4113..8b697bc1 100755
--- a/pyatv/scripts/atvproxy.py
+++ b/pyatv/scripts/atvproxy.py
@@ -11,24 +11,60 @@ from typing import Any, Dict, Optional, cast
 from google.protobuf.message import Message as ProtobufMessage
 from zeroconf import Zeroconf
 
+from pyatv.auth.hap_pairing import parse_credentials
 from pyatv.auth.hap_srp import SRPAuthHandler
 from pyatv.const import Protocol
 from pyatv.core import MutableService, mdns
-from pyatv.protocols.companion.connection import CompanionConnection
-from pyatv.protocols.companion.protocol import CompanionProtocol, FrameType
+from pyatv.protocols.companion.connection import (
+    AUTH_TAG_LENGTH,
+    CompanionConnection,
+    CompanionConnectionListener,
+)
+from pyatv.protocols.companion.protocol import (
+    _AUTH_FRAMES,
+    _OPACK_FRAMES,
+    CompanionProtocol,
+    FrameType,
+    MessageType,
+)
+from pyatv.protocols.companion.server_auth import (
+    SERVER_IDENTIFIER as COMPANION_SERVER_IDENTIFIER,
+)
 from pyatv.protocols.companion.server_auth import CompanionServerAuth
 from pyatv.protocols.mrp import protobuf
 from pyatv.protocols.mrp.connection import MrpConnection
 from pyatv.protocols.mrp.protocol import MrpProtocol
-from pyatv.protocols.mrp.server_auth import SERVER_IDENTIFIER, MrpServerAuth
+from pyatv.protocols.mrp.server_auth import MrpServerAuth
+from pyatv.protocols.mrp.server_auth import SERVER_IDENTIFIER as MRP_SERVER_IDENTIFIER
 from pyatv.scripts import log_current_version
-from pyatv.support import chacha20, log_binary, net, opack, variant
+from pyatv.support import (
+    chacha20,
+    log_binary,
+    net,
+    opack,
+    shift_hex_identifier,
+    variant,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
 DEVICE_NAME = "Proxy"
+BLUETOOTH_ADDRESS = "DA:97:7C:BA:A3:7A"
 AIRPLAY_IDENTIFIER = "4D797FD3-3538-427E-A47B-A32FC6CF3A6A"
-
+MEDIA_REMOTE_ROUTE_IDENTIFIER = "DA4A238E-EE6C-4F5B-9691-4D0A8FC03532"
+
+PROPERTY_CASE_MAP = {
+    "rpad": "rpAD",
+    "rpba": "rpBA",
+    "rpfl": "rpFl",
+    "rpha": "rpHA",
+    "rphi": "rpHI",
+    "rphn": "rpHN",
+    "rpmac": "rpMac",
+    "rpmd": "rpMd",
+    "rpmrtid": "rpMRtID",
+    "rpvr": "rpVr",
+}
 COMPANION_AUTH_FRAMES = [
     FrameType.PS_Start,
     FrameType.PS_Next,
@@ -145,7 +181,9 @@ class MrpAppleTVProxy(MrpServerAuth, asyncio.Protocol):
                 _LOGGER.exception("Error while dispatching message")
 
 
-class CompanionAppleTVProxy(CompanionServerAuth, asyncio.Protocol):
+class CompanionAppleTVProxy(
+    CompanionServerAuth, CompanionConnectionListener, asyncio.Protocol
+):
     """Implementation of a fake Companion device."""
 
     def __init__(
@@ -155,9 +193,10 @@ class CompanionAppleTVProxy(CompanionServerAuth, asyncio.Protocol):
         super().__init__(DEVICE_NAME)
         self.loop = loop
         self.buffer: bytes = b""
+        self.credentials: str = credentials
         self.transport = None
         self.chacha: Optional[chacha20.Chacha20Cipher] = None
-        self.connection: Optional[CompanionConnection] = CompanionConnection(
+        self.connection: CompanionConnection = CompanionConnection(
             self.loop, address, port
         )
         self.protocol: CompanionProtocol = CompanionProtocol(
@@ -165,6 +204,9 @@ class CompanionAppleTVProxy(CompanionServerAuth, asyncio.Protocol):
             SRPAuthHandler(),
             MutableService(None, Protocol.Companion, port, {}, credentials=credentials),
         )
+        # CompanionProtocol sets listener, override it now
+        self.connection.set_listener(self)
+        self.system_info_xid = None
         self._receive_event: asyncio.Event = asyncio.Event()
         self._receive_task: Optional[asyncio.Future] = None
 
@@ -213,13 +255,10 @@ class CompanionAppleTVProxy(CompanionServerAuth, asyncio.Protocol):
                 _LOGGER.exception("failed to handle auth frame")
         else:
             try:
-                resp = await self.send_to_atv(frame_type, data)
-                self.send_to_client(frame_type, resp)
+                self.send_bytes_to_atv(frame_type, data)
             except Exception:
                 _LOGGER.exception("data exchange failed")
 
-        self._receive_event.clear()
-
     def connection_made(self, transport):
         """Client did connect to proxy."""
         _LOGGER.debug("Client connected to Companion proxy")
@@ -228,6 +267,8 @@ class CompanionAppleTVProxy(CompanionServerAuth, asyncio.Protocol):
     def connection_lost(self, exc):
         """Handle that connection was lost to client."""
         _LOGGER.debug("Connection lost to client device: %s", exc)
+        if self.connection.connected:
+            self.connection.close()
         if self._receive_task is not None:
             self._receive_task.cancel()
 
@@ -238,36 +279,127 @@ class CompanionAppleTVProxy(CompanionServerAuth, asyncio.Protocol):
         if self.connection.connected:
             self._receive_event.set()
 
-    async def send_to_atv(self, frame_type: FrameType, data: bytes):
-        """Send data to remote device (ATV)."""
+    def send_bytes_to_atv(self, frame_type: FrameType, data: bytes):
+        """Send encoded data to remote device (ATV)."""
         log_binary(_LOGGER, f">>(ENCRYPTED) FrameType={frame_type}", Message=data)
 
-        if self.chacha:
+        if self.chacha and len(data) > 0:
             header = bytes([frame_type.value]) + len(data).to_bytes(3, byteorder="big")
             data = self.chacha.decrypt(data, aad=header)
             log_binary(_LOGGER, "<<(DECRYPTED)", Message=data)
 
+        if frame_type != FrameType.E_OPACK:
+            self.connection.send(frame_type, data)
+            return
+
         unpacked = cast(Dict[Any, Any], opack.unpack(data)[0])  # TODO: Bad cast
-        if frame_type in COMPANION_AUTH_FRAMES:
-            return await self.protocol.exchange_auth(frame_type, unpacked)
-        return await self.protocol.exchange_opack(frame_type, unpacked)
+        self.process_outgoing_data(frame_type, unpacked)
+        self.protocol.send_opack(frame_type, unpacked)
+
+    def frame_received(self, frame_type: FrameType, data: bytes) -> None:
+        """Frame was received from remote device."""
+        if frame_type in _AUTH_FRAMES:
+            # do not override authentication
+            self.protocol.frame_received(frame_type, data)
+            return
 
-    def send_to_client(self, frame_type: FrameType, data: object) -> None:
-        """Send data to client device (iOS)."""
+        _LOGGER.debug("Received frame %s: %s", frame_type, data)
+        if frame_type in _OPACK_FRAMES:
+            opack_data, _ = opack.unpack(data)
+            _LOGGER.debug("Received OPACK: %s", opack_data)
+            self.send_to_client(frame_type, opack_data)
+        else:
+            self.send_bytes_to_client(frame_type, data)
+
+    def send_bytes_to_client(self, frame_type: FrameType, data: bytes) -> None:
+        """Send encoded data to client device (iOS)."""
         if not self.transport:
             _LOGGER.error("Tried to send to client, but not connected")
             return
 
-        payload = opack.pack(data)
-
-        payload_length = len(payload) + (16 if self.chacha else 0)
+        payload_length = len(data)
+        if self.chacha and payload_length > 0:
+            payload_length += AUTH_TAG_LENGTH
         header = bytes([frame_type.value]) + payload_length.to_bytes(3, byteorder="big")
 
-        if self.chacha:
-            payload = self.chacha.encrypt(payload, aad=header)
-            log_binary(_LOGGER, ">> Send", Header=header, Encrypted=payload)
+        if self.chacha and len(data) > 0:
+            data = self.chacha.encrypt(data, aad=header)
+            log_binary(_LOGGER, ">> Send", Header=header, Encrypted=data)
+
+        self.transport.write(header + data)
 
-        self.transport.write(header + payload)
+    def send_to_client(self, frame_type: FrameType, data: object) -> None:
+        """Send data to client device (iOS)."""
+        self.process_incoming_data(frame_type, cast(Dict[str, Any], data))
+        self.send_bytes_to_client(frame_type, opack.pack(data))
+
+    def process_outgoing_data(self, frame_type: FrameType, data: Dict[str, Any]):
+        """Apply any required modifications to outgoing data."""
+        if frame_type != FrameType.E_OPACK:
+            return
+
+        data_type = data.get("_i")
+        if data_type == "_systemInfo":
+            self.system_info_xid = data.get("_x")
+            creds = parse_credentials(self.credentials)
+            payload = data["_c"]
+            payload.update(
+                {
+                    # The server will drop older connections with the same
+                    # identifier, so mangle them here to prevent disconnects if
+                    # the client device is also directly connected to the
+                    # server.
+                    "_i": shift_hex_identifier(payload["_i"]),
+                    "_idsID": creds.client_id.upper(),
+                    "_pubID": shift_hex_identifier(payload["_pubID"]),
+                }
+            )
+
+    def process_incoming_data(self, frame_type: FrameType, data: Dict[str, Any]):
+        """Apply any required modifications to incoming data."""
+        if frame_type != FrameType.E_OPACK:
+            return
+
+        message_type = data.get("_t")
+        xid = data.get("_x")
+        if message_type == MessageType.Response.value and xid == self.system_info_xid:
+            self.system_info_xid = None
+            payload = data["_c"]
+            payload.update(
+                {
+                    "name": DEVICE_NAME,
+                    "_i": "cafecafecafe",
+                    "_idsID": COMPANION_SERVER_IDENTIFIER,
+                    "_pubID": BLUETOOTH_ADDRESS,
+                }
+            )
+            if "_mrID" in payload:
+                payload["_mrID"] = MEDIA_REMOTE_ROUTE_IDENTIFIER
+            if "_mRtID" in payload:
+                payload["_mRtID"] = MEDIA_REMOTE_ROUTE_IDENTIFIER
+            if "_siriInfo" in payload:
+                siri_info = payload["_siriInfo"]
+                if "peerData" in siri_info:
+                    peer_data = siri_info["peerData"]
+                    peer_data.update(
+                        {
+                            "userAssignedDeviceName": DEVICE_NAME,
+                        }
+                    )
+                    if "homeAccessoryInfo" in peer_data:
+                        peer_data["homeAccessoryInfo"].update(
+                            {
+                                "name": DEVICE_NAME,
+                            }
+                        )
+                if "audio-session-coordination.system-info" in siri_info:
+                    audio_system_info = siri_info[
+                        "audio-session-coordination.system-info"
+                    ]
+                    if "mediaRemoteRouteIdentifier" in audio_system_info:
+                        audio_system_info[
+                            "mediaRemoteRouteIdentifier"
+                        ] = MEDIA_REMOTE_ROUTE_IDENTIFIER
 
 
 class RemoteConnection(asyncio.Protocol):
@@ -333,7 +465,7 @@ async def publish_mrp_service(zconf: Zeroconf, address: str, port: int, name: st
         "macAddress": "40:cb:c0:12:34:56",
         "BluetoothAddress": "False",
         "Name": name,
-        "UniqueIdentifier": SERVER_IDENTIFIER,
+        "UniqueIdentifier": MRP_SERVER_IDENTIFIER,
         "SystemBuildVersion": "17K499",
         "LocalAirPlayReceiverPairingIdentity": AIRPLAY_IDENTIFIER,
     }
@@ -347,18 +479,18 @@ async def publish_mrp_service(zconf: Zeroconf, address: str, port: int, name: st
     )
 
 
-async def publish_companion_service(zconf: Zeroconf, address: str, port: int):
+async def publish_companion_service(
+    zconf: Zeroconf, address: str, port: int, target_properties: Dict[str, str]
+):
     """Publish zeroconf service for ATV Companion proxy instance."""
     properties = {
-        "rpMac": "1",
+        **target_properties,
         "rpHA": "9948cfb6da55",
-        "rpHN": "88f979f04023",
-        "rpVr": "230.1",
-        "rpMd": "AppleTV6,2",
-        "rpFl": "0x36782",
-        "rpAD": "657c1b9d3484",
+        "rpHN": "cef88e5db6fa",
+        "rpAD": "3b2210518c58",
         "rpHI": "91756a18d8e5",
-        "rpBA": "9D:19:F9:74:65:EA",
+        "rpBA": BLUETOOTH_ADDRESS,
+        "rpMRtID": MEDIA_REMOTE_ROUTE_IDENTIFIER,
     }
 
     return await mdns.publish(
@@ -438,17 +570,21 @@ async def _start_companion_proxy(loop, args, zconf):
 
     _LOGGER.debug("Binding to local address %s", args.local_ip)
 
-    if not args.remote_port:
-        resp = await mdns.unicast(loop, args.remote_ip, ["_companion-link._tcp.local"])
+    service_type = "_companion-link._tcp.local"
+    resp = await mdns.unicast(loop, args.remote_ip, [service_type])
+    service = next((s for s in resp.services if s.type == service_type), None)
 
-        if not args.remote_port:
-            args.remote_port = resp.services[0].port
+    if not args.remote_port:
+        args.remote_port = service.port
 
     server = await loop.create_server(proxy_factory, "0.0.0.0")
     port = server.sockets[0].getsockname()[1]
     _LOGGER.info("Started Companion server at port %d", port)
 
-    unpublisher = await publish_companion_service(zconf, args.local_ip, port)
+    properties = {PROPERTY_CASE_MAP.get(k, k): v for k, v in service.properties.items()}
+    unpublisher = await publish_companion_service(
+        zconf, args.local_ip, port, properties
+    )
 
     print("Press ENTER to quit")
     await loop.run_in_executor(None, sys.stdin.readline)
diff --git a/pyatv/support/__init__.py b/pyatv/support/__init__.py
index 9b6c8e43..84b44773 100644
--- a/pyatv/support/__init__.py
+++ b/pyatv/support/__init__.py
@@ -98,3 +98,13 @@ def map_range(
     if value < in_min or value > in_max:
         raise ValueError("input value out of range")
     return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
+
+
+def shift_hex_identifier(identifier: str) -> str:
+    """Repeatably modify a unique identifier to avoid collisions."""
+    assert len(identifier) > 0
+    first, rest = identifier[:2], identifier[2:]
+    shifted = f"{(int(first, 16) + 1) % 256:02x}"
+    if first.isupper():
+        shifted = shifted.upper()
+    return shifted + rest
diff --git a/pyatv/support/opack.py b/pyatv/support/opack.py
index 2ff63fe1..c4226b27 100644
--- a/pyatv/support/opack.py
+++ b/pyatv/support/opack.py
@@ -97,7 +97,17 @@ def _pack(data, object_list):
 
     # Re-use if in object list, otherwise add it to list
     if packed_bytes in object_list:
-        packed_bytes = bytes([0xA0 + object_list.index(packed_bytes)])
+        object_index = object_list.index(packed_bytes)
+        if object_index < 0x21:
+            packed_bytes = bytes([0xA0 + object_index])
+        elif object_index <= 0xFF:
+            packed_bytes = bytes([0xC1]) + object_index.to_bytes(1, byteorder="little")
+        elif object_index <= 0xFFFF:
+            packed_bytes = bytes([0xC2]) + object_index.to_bytes(2, byteorder="little")
+        elif object_index <= 0xFFFFFFFF:
+            packed_bytes = bytes([0xC3]) + object_index.to_bytes(4, byteorder="little")
+        elif object_index <= 0xFFFFFFFFFFFFFFFF:
+            packed_bytes = bytes([0xC4]) + object_index.to_bytes(8, byteorder="little")
     elif len(packed_bytes) > 1:
         object_list.append(packed_bytes)
 


Test patch:
diff --git a/tests/support/test_opack.py b/tests/support/test_opack.py
index d01ee111..c7c82f8a 100644
--- a/tests/support/test_opack.py
+++ b/tests/support/test_opack.py
@@ -112,6 +112,84 @@ def test_pack_ptr():
     )
 
 
+def test_pack_more_ptr():
+    data = list(chr(x).encode() for x in range(257))
+    assert (
+        pack(data + data)
+        == b"\xDF\x71\x00\x71\x01\x71\x02\x71\x03\x71\x04\x71\x05\x71\x06\x71"
+        b"\x07\x71\x08\x71\x09\x71\x0A\x71\x0B\x71\x0C\x71\x0D\x71\x0E\x71"
+        b"\x0F\x71\x10\x71\x11\x71\x12\x71\x13\x71\x14\x71\x15\x71\x16\x71"
+        b"\x17\x71\x18\x71\x19\x71\x1A\x71\x1B\x71\x1C\x71\x1D\x71\x1E\x71"
+        b"\x1F\x71\x20\x71\x21\x71\x22\x71\x23\x71\x24\x71\x25\x71\x26\x71"
+        b"\x27\x71\x28\x71\x29\x71\x2A\x71\x2B\x71\x2C\x71\x2D\x71\x2E\x71"
+        b"\x2F\x71\x30\x71\x31\x71\x32\x71\x33\x71\x34\x71\x35\x71\x36\x71"
+        b"\x37\x71\x38\x71\x39\x71\x3A\x71\x3B\x71\x3C\x71\x3D\x71\x3E\x71"
+        b"\x3F\x71\x40\x71\x41\x71\x42\x71\x43\x71\x44\x71\x45\x71\x46\x71"
+        b"\x47\x71\x48\x71\x49\x71\x4A\x71\x4B\x71\x4C\x71\x4D\x71\x4E\x71"
+        b"\x4F\x71\x50\x71\x51\x71\x52\x71\x53\x71\x54\x71\x55\x71\x56\x71"
+        b"\x57\x71\x58\x71\x59\x71\x5A\x71\x5B\x71\x5C\x71\x5D\x71\x5E\x71"
+        b"\x5F\x71\x60\x71\x61\x71\x62\x71\x63\x71\x64\x71\x65\x71\x66\x71"
+        b"\x67\x71\x68\x71\x69\x71\x6A\x71\x6B\x71\x6C\x71\x6D\x71\x6E\x71"
+        b"\x6F\x71\x70\x71\x71\x71\x72\x71\x73\x71\x74\x71\x75\x71\x76\x71"
+        b"\x77\x71\x78\x71\x79\x71\x7A\x71\x7B\x71\x7C\x71\x7D\x71\x7E\x71"
+        b"\x7F\x72\xC2\x80\x72\xC2\x81\x72\xC2\x82\x72\xC2\x83\x72\xC2\x84"
+        b"\x72\xC2\x85\x72\xC2\x86\x72\xC2\x87\x72\xC2\x88\x72\xC2\x89\x72"
+        b"\xC2\x8A\x72\xC2\x8B\x72\xC2\x8C\x72\xC2\x8D\x72\xC2\x8E\x72\xC2"
+        b"\x8F\x72\xC2\x90\x72\xC2\x91\x72\xC2\x92\x72\xC2\x93\x72\xC2\x94"
+        b"\x72\xC2\x95\x72\xC2\x96\x72\xC2\x97\x72\xC2\x98\x72\xC2\x99\x72"
+        b"\xC2\x9A\x72\xC2\x9B\x72\xC2\x9C\x72\xC2\x9D\x72\xC2\x9E\x72\xC2"
+        b"\x9F\x72\xC2\xA0\x72\xC2\xA1\x72\xC2\xA2\x72\xC2\xA3\x72\xC2\xA4"
+        b"\x72\xC2\xA5\x72\xC2\xA6\x72\xC2\xA7\x72\xC2\xA8\x72\xC2\xA9\x72"
+        b"\xC2\xAA\x72\xC2\xAB\x72\xC2\xAC\x72\xC2\xAD\x72\xC2\xAE\x72\xC2"
+        b"\xAF\x72\xC2\xB0\x72\xC2\xB1\x72\xC2\xB2\x72\xC2\xB3\x72\xC2\xB4"
+        b"\x72\xC2\xB5\x72\xC2\xB6\x72\xC2\xB7\x72\xC2\xB8\x72\xC2\xB9\x72"
+        b"\xC2\xBA\x72\xC2\xBB\x72\xC2\xBC\x72\xC2\xBD\x72\xC2\xBE\x72\xC2"
+        b"\xBF\x72\xC3\x80\x72\xC3\x81\x72\xC3\x82\x72\xC3\x83\x72\xC3\x84"
+        b"\x72\xC3\x85\x72\xC3\x86\x72\xC3\x87\x72\xC3\x88\x72\xC3\x89\x72"
+        b"\xC3\x8A\x72\xC3\x8B\x72\xC3\x8C\x72\xC3\x8D\x72\xC3\x8E\x72\xC3"
+        b"\x8F\x72\xC3\x90\x72\xC3\x91\x72\xC3\x92\x72\xC3\x93\x72\xC3\x94"
+        b"\x72\xC3\x95\x72\xC3\x96\x72\xC3\x97\x72\xC3\x98\x72\xC3\x99\x72"
+        b"\xC3\x9A\x72\xC3\x9B\x72\xC3\x9C\x72\xC3\x9D\x72\xC3\x9E\x72\xC3"
+        b"\x9F\x72\xC3\xA0\x72\xC3\xA1\x72\xC3\xA2\x72\xC3\xA3\x72\xC3\xA4"
+        b"\x72\xC3\xA5\x72\xC3\xA6\x72\xC3\xA7\x72\xC3\xA8\x72\xC3\xA9\x72"
+        b"\xC3\xAA\x72\xC3\xAB\x72\xC3\xAC\x72\xC3\xAD\x72\xC3\xAE\x72\xC3"
+        b"\xAF\x72\xC3\xB0\x72\xC3\xB1\x72\xC3\xB2\x72\xC3\xB3\x72\xC3\xB4"
+        b"\x72\xC3\xB5\x72\xC3\xB6\x72\xC3\xB7\x72\xC3\xB8\x72\xC3\xB9\x72"
+        b"\xC3\xBA\x72\xC3\xBB\x72\xC3\xBC\x72\xC3\xBD\x72\xC3\xBE\x72\xC3"
+        b"\xBF\x72\xC4\x80\xA0\xA1\xA2\xA3\xA4\xA5\xA6\xA7\xA8\xA9\xAA\xAB"
+        b"\xAC\xAD\xAE\xAF\xB0\xB1\xB2\xB3\xB4\xB5\xB6\xB7\xB8\xB9\xBA\xBB"
+        b"\xBC\xBD\xBE\xBF\xC0\xC1\x21\xC1\x22\xC1\x23\xC1\x24\xC1\x25\xC1"
+        b"\x26\xC1\x27\xC1\x28\xC1\x29\xC1\x2A\xC1\x2B\xC1\x2C\xC1\x2D\xC1"
+        b"\x2E\xC1\x2F\xC1\x30\xC1\x31\xC1\x32\xC1\x33\xC1\x34\xC1\x35\xC1"
+        b"\x36\xC1\x37\xC1\x38\xC1\x39\xC1\x3A\xC1\x3B\xC1\x3C\xC1\x3D\xC1"
+        b"\x3E\xC1\x3F\xC1\x40\xC1\x41\xC1\x42\xC1\x43\xC1\x44\xC1\x45\xC1"
+        b"\x46\xC1\x47\xC1\x48\xC1\x49\xC1\x4A\xC1\x4B\xC1\x4C\xC1\x4D\xC1"
+        b"\x4E\xC1\x4F\xC1\x50\xC1\x51\xC1\x52\xC1\x53\xC1\x54\xC1\x55\xC1"
+        b"\x56\xC1\x57\xC1\x58\xC1\x59\xC1\x5A\xC1\x5B\xC1\x5C\xC1\x5D\xC1"
+        b"\x5E\xC1\x5F\xC1\x60\xC1\x61\xC1\x62\xC1\x63\xC1\x64\xC1\x65\xC1"
+        b"\x66\xC1\x67\xC1\x68\xC1\x69\xC1\x6A\xC1\x6B\xC1\x6C\xC1\x6D\xC1"
+        b"\x6E\xC1\x6F\xC1\x70\xC1\x71\xC1\x72\xC1\x73\xC1\x74\xC1\x75\xC1"
+        b"\x76\xC1\x77\xC1\x78\xC1\x79\xC1\x7A\xC1\x7B\xC1\x7C\xC1\x7D\xC1"
+        b"\x7E\xC1\x7F\xC1\x80\xC1\x81\xC1\x82\xC1\x83\xC1\x84\xC1\x85\xC1"
+        b"\x86\xC1\x87\xC1\x88\xC1\x89\xC1\x8A\xC1\x8B\xC1\x8C\xC1\x8D\xC1"
+        b"\x8E\xC1\x8F\xC1\x90\xC1\x91\xC1\x92\xC1\x93\xC1\x94\xC1\x95\xC1"
+        b"\x96\xC1\x97\xC1\x98\xC1\x99\xC1\x9A\xC1\x9B\xC1\x9C\xC1\x9D\xC1"
+        b"\x9E\xC1\x9F\xC1\xA0\xC1\xA1\xC1\xA2\xC1\xA3\xC1\xA4\xC1\xA5\xC1"
+        b"\xA6\xC1\xA7\xC1\xA8\xC1\xA9\xC1\xAA\xC1\xAB\xC1\xAC\xC1\xAD\xC1"
+        b"\xAE\xC1\xAF\xC1\xB0\xC1\xB1\xC1\xB2\xC1\xB3\xC1\xB4\xC1\xB5\xC1"
+        b"\xB6\xC1\xB7\xC1\xB8\xC1\xB9\xC1\xBA\xC1\xBB\xC1\xBC\xC1\xBD\xC1"
+        b"\xBE\xC1\xBF\xC1\xC0\xC1\xC1\xC1\xC2\xC1\xC3\xC1\xC4\xC1\xC5\xC1"
+        b"\xC6\xC1\xC7\xC1\xC8\xC1\xC9\xC1\xCA\xC1\xCB\xC1\xCC\xC1\xCD\xC1"
+        b"\xCE\xC1\xCF\xC1\xD0\xC1\xD1\xC1\xD2\xC1\xD3\xC1\xD4\xC1\xD5\xC1"
+        b"\xD6\xC1\xD7\xC1\xD8\xC1\xD9\xC1\xDA\xC1\xDB\xC1\xDC\xC1\xDD\xC1"
+        b"\xDE\xC1\xDF\xC1\xE0\xC1\xE1\xC1\xE2\xC1\xE3\xC1\xE4\xC1\xE5\xC1"
+        b"\xE6\xC1\xE7\xC1\xE8\xC1\xE9\xC1\xEA\xC1\xEB\xC1\xEC\xC1\xED\xC1"
+        b"\xEE\xC1\xEF\xC1\xF0\xC1\xF1\xC1\xF2\xC1\xF3\xC1\xF4\xC1\xF5\xC1"
+        b"\xF6\xC1\xF7\xC1\xF8\xC1\xF9\xC1\xFA\xC1\xFB\xC1\xFC\xC1\xFD\xC1"
+        b"\xFE\xC1\xFF\xC2\x00\x01\x03"
+    )
+
+
 # unpack
 
 
@@ -221,6 +299,83 @@ def test_unpack_ptr():
     )
 
 
+def test_unpack_more_ptr():
+    data = list(chr(x).encode() for x in range(257))
+    assert unpack(
+        b"\xDF\x71\x00\x71\x01\x71\x02\x71\x03\x71\x04\x71\x05\x71\x06\x71"
+        b"\x07\x71\x08\x71\x09\x71\x0A\x71\x0B\x71\x0C\x71\x0D\x71\x0E\x71"
+        b"\x0F\x71\x10\x71\x11\x71\x12\x71\x13\x71\x14\x71\x15\x71\x16\x71"
+        b"\x17\x71\x18\x71\x19\x71\x1A\x71\x1B\x71\x1C\x71\x1D\x71\x1E\x71"
+        b"\x1F\x71\x20\x71\x21\x71\x22\x71\x23\x71\x24\x71\x25\x71\x26\x71"
+        b"\x27\x71\x28\x71\x29\x71\x2A\x71\x2B\x71\x2C\x71\x2D\x71\x2E\x71"
+        b"\x2F\x71\x30\x71\x31\x71\x32\x71\x33\x71\x34\x71\x35\x71\x36\x71"
+        b"\x37\x71\x38\x71\x39\x71\x3A\x71\x3B\x71\x3C\x71\x3D\x71\x3E\x71"
+        b"\x3F\x71\x40\x71\x41\x71\x42\x71\x43\x71\x44\x71\x45\x71\x46\x71"
+        b"\x47\x71\x48\x71\x49\x71\x4A\x71\x4B\x71\x4C\x71\x4D\x71\x4E\x71"
+        b"\x4F\x71\x50\x71\x51\x71\x52\x71\x53\x71\x54\x71\x55\x71\x56\x71"
+        b"\x57\x71\x58\x71\x59\x71\x5A\x71\x5B\x71\x5C\x71\x5D\x71\x5E\x71"
+        b"\x5F\x71\x60\x71\x61\x71\x62\x71\x63\x71\x64\x71\x65\x71\x66\x71"
+        b"\x67\x71\x68\x71\x69\x71\x6A\x71\x6B\x71\x6C\x71\x6D\x71\x6E\x71"
+        b"\x6F\x71\x70\x71\x71\x71\x72\x71\x73\x71\x74\x71\x75\x71\x76\x71"
+        b"\x77\x71\x78\x71\x79\x71\x7A\x71\x7B\x71\x7C\x71\x7D\x71\x7E\x71"
+        b"\x7F\x72\xC2\x80\x72\xC2\x81\x72\xC2\x82\x72\xC2\x83\x72\xC2\x84"
+        b"\x72\xC2\x85\x72\xC2\x86\x72\xC2\x87\x72\xC2\x88\x72\xC2\x89\x72"
+        b"\xC2\x8A\x72\xC2\x8B\x72\xC2\x8C\x72\xC2\x8D\x72\xC2\x8E\x72\xC2"
+        b"\x8F\x72\xC2\x90\x72\xC2\x91\x72\xC2\x92\x72\xC2\x93\x72\xC2\x94"
+        b"\x72\xC2\x95\x72\xC2\x96\x72\xC2\x97\x72\xC2\x98\x72\xC2\x99\x72"
+        b"\xC2\x9A\x72\xC2\x9B\x72\xC2\x9C\x72\xC2\x9D\x72\xC2\x9E\x72\xC2"
+        b"\x9F\x72\xC2\xA0\x72\xC2\xA1\x72\xC2\xA2\x72\xC2\xA3\x72\xC2\xA4"
+        b"\x72\xC2\xA5\x72\xC2\xA6\x72\xC2\xA7\x72\xC2\xA8\x72\xC2\xA9\x72"
+        b"\xC2\xAA\x72\xC2\xAB\x72\xC2\xAC\x72\xC2\xAD\x72\xC2\xAE\x72\xC2"
+        b"\xAF\x72\xC2\xB0\x72\xC2\xB1\x72\xC2\xB2\x72\xC2\xB3\x72\xC2\xB4"
+        b"\x72\xC2\xB5\x72\xC2\xB6\x72\xC2\xB7\x72\xC2\xB8\x72\xC2\xB9\x72"
+        b"\xC2\xBA\x72\xC2\xBB\x72\xC2\xBC\x72\xC2\xBD\x72\xC2\xBE\x72\xC2"
+        b"\xBF\x72\xC3\x80\x72\xC3\x81\x72\xC3\x82\x72\xC3\x83\x72\xC3\x84"
+        b"\x72\xC3\x85\x72\xC3\x86\x72\xC3\x87\x72\xC3\x88\x72\xC3\x89\x72"
+        b"\xC3\x8A\x72\xC3\x8B\x72\xC3\x8C\x72\xC3\x8D\x72\xC3\x8E\x72\xC3"
+        b"\x8F\x72\xC3\x90\x72\xC3\x91\x72\xC3\x92\x72\xC3\x93\x72\xC3\x94"
+        b"\x72\xC3\x95\x72\xC3\x96\x72\xC3\x97\x72\xC3\x98\x72\xC3\x99\x72"
+        b"\xC3\x9A\x72\xC3\x9B\x72\xC3\x9C\x72\xC3\x9D\x72\xC3\x9E\x72\xC3"
+        b"\x9F\x72\xC3\xA0\x72\xC3\xA1\x72\xC3\xA2\x72\xC3\xA3\x72\xC3\xA4"
+        b"\x72\xC3\xA5\x72\xC3\xA6\x72\xC3\xA7\x72\xC3\xA8\x72\xC3\xA9\x72"
+        b"\xC3\xAA\x72\xC3\xAB\x72\xC3\xAC\x72\xC3\xAD\x72\xC3\xAE\x72\xC3"
+        b"\xAF\x72\xC3\xB0\x72\xC3\xB1\x72\xC3\xB2\x72\xC3\xB3\x72\xC3\xB4"
+        b"\x72\xC3\xB5\x72\xC3\xB6\x72\xC3\xB7\x72\xC3\xB8\x72\xC3\xB9\x72"
+        b"\xC3\xBA\x72\xC3\xBB\x72\xC3\xBC\x72\xC3\xBD\x72\xC3\xBE\x72\xC3"
+        b"\xBF\x72\xC4\x80\xA0\xA1\xA2\xA3\xA4\xA5\xA6\xA7\xA8\xA9\xAA\xAB"
+        b"\xAC\xAD\xAE\xAF\xB0\xB1\xB2\xB3\xB4\xB5\xB6\xB7\xB8\xB9\xBA\xBB"
+        b"\xBC\xBD\xBE\xBF\xC0\xC1\x21\xC1\x22\xC1\x23\xC1\x24\xC1\x25\xC1"
+        b"\x26\xC1\x27\xC1\x28\xC1\x29\xC1\x2A\xC1\x2B\xC1\x2C\xC1\x2D\xC1"
+        b"\x2E\xC1\x2F\xC1\x30\xC1\x31\xC1\x32\xC1\x33\xC1\x34\xC1\x35\xC1"
+        b"\x36\xC1\x37\xC1\x38\xC1\x39\xC1\x3A\xC1\x3B\xC1\x3C\xC1\x3D\xC1"
+        b"\x3E\xC1\x3F\xC1\x40\xC1\x41\xC1\x42\xC1\x43\xC1\x44\xC1\x45\xC1"
+        b"\x46\xC1\x47\xC1\x48\xC1\x49\xC1\x4A\xC1\x4B\xC1\x4C\xC1\x4D\xC1"
+        b"\x4E\xC1\x4F\xC1\x50\xC1\x51\xC1\x52\xC1\x53\xC1\x54\xC1\x55\xC1"
+        b"\x56\xC1\x57\xC1\x58\xC1\x59\xC1\x5A\xC1\x5B\xC1\x5C\xC1\x5D\xC1"
+        b"\x5E\xC1\x5F\xC1\x60\xC1\x61\xC1\x62\xC1\x63\xC1\x64\xC1\x65\xC1"
+        b"\x66\xC1\x67\xC1\x68\xC1\x69\xC1\x6A\xC1\x6B\xC1\x6C\xC1\x6D\xC1"
+        b"\x6E\xC1\x6F\xC1\x70\xC1\x71\xC1\x72\xC1\x73\xC1\x74\xC1\x75\xC1"
+        b"\x76\xC1\x77\xC1\x78\xC1\x79\xC1\x7A\xC1\x7B\xC1\x7C\xC1\x7D\xC1"
+        b"\x7E\xC1\x7F\xC1\x80\xC1\x81\xC1\x82\xC1\x83\xC1\x84\xC1\x85\xC1"
+        b"\x86\xC1\x87\xC1\x88\xC1\x89\xC1\x8A\xC1\x8B\xC1\x8C\xC1\x8D\xC1"
+        b"\x8E\xC1\x8F\xC1\x90\xC1\x91\xC1\x92\xC1\x93\xC1\x94\xC1\x95\xC1"
+        b"\x96\xC1\x97\xC1\x98\xC1\x99\xC1\x9A\xC1\x9B\xC1\x9C\xC1\x9D\xC1"
+        b"\x9E\xC1\x9F\xC1\xA0\xC1\xA1\xC1\xA2\xC1\xA3\xC1\xA4\xC1\xA5\xC1"
+        b"\xA6\xC1\xA7\xC1\xA8\xC1\xA9\xC1\xAA\xC1\xAB\xC1\xAC\xC1\xAD\xC1"
+        b"\xAE\xC1\xAF\xC1\xB0\xC1\xB1\xC1\xB2\xC1\xB3\xC1\xB4\xC1\xB5\xC1"
+        b"\xB6\xC1\xB7\xC1\xB8\xC1\xB9\xC1\xBA\xC1\xBB\xC1\xBC\xC1\xBD\xC1"
+        b"\xBE\xC1\xBF\xC1\xC0\xC1\xC1\xC1\xC2\xC1\xC3\xC1\xC4\xC1\xC5\xC1"
+        b"\xC6\xC1\xC7\xC1\xC8\xC1\xC9\xC1\xCA\xC1\xCB\xC1\xCC\xC1\xCD\xC1"
+        b"\xCE\xC1\xCF\xC1\xD0\xC1\xD1\xC1\xD2\xC1\xD3\xC1\xD4\xC1\xD5\xC1"
+        b"\xD6\xC1\xD7\xC1\xD8\xC1\xD9\xC1\xDA\xC1\xDB\xC1\xDC\xC1\xDD\xC1"
+        b"\xDE\xC1\xDF\xC1\xE0\xC1\xE1\xC1\xE2\xC1\xE3\xC1\xE4\xC1\xE5\xC1"
+        b"\xE6\xC1\xE7\xC1\xE8\xC1\xE9\xC1\xEA\xC1\xEB\xC1\xEC\xC1\xED\xC1"
+        b"\xEE\xC1\xEF\xC1\xF0\xC1\xF1\xC1\xF2\xC1\xF3\xC1\xF4\xC1\xF5\xC1"
+        b"\xF6\xC1\xF7\xC1\xF8\xC1\xF9\xC1\xFA\xC1\xFB\xC1\xFC\xC1\xFD\xC1"
+        b"\xFE\xC1\xFF\xC2\x00\x01\x03"
+    ) == (data + data, b"")
+
+
 def test_unpack_uid():
     assert unpack(b"\xDF\x30\x01\x30\x02\xC1\x01\x03") == ([1, 2, 2], b"")
     assert unpack(b"\xDF\x30\x01\x30\x02\xC2\x01\x00\x03") == ([1, 2, 2], b"")