Repository: postlund/pyatv
Pull request #2722 (2025-06-30 05:13:47)

build(deps): Bump flake8 from 7.2.0 to 7.3.0 in /requirements
Bumps [flake8](https://github.com/pycqa/flake8) from 7.2.0 to 7.3.0.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/PyCQA/flake8/commit/c48217e1fc006c2dddd14df54e83b67da15de5cd"><code>c48217e</code></a> Release 7.3.0</li>
<li><a href="https://github.com/PyCQA/flake8/commit/f9e0f33281d5bb892cae2c674ac398439d99c732"><code>f9e0f33</code></a> Merge pull request <a href="https://redirect.github.com/pycqa/flake8/issues/1986">#1986</a> from PyCQA/document-f542</li>
<li><a href="https://github.com/PyCQA/flake8/commit/6bcdb628597fa2d03494965089ff87a492ffc1e9"><code>6bcdb62</code></a> document F542</li>
<li><a href="https://github.com/PyCQA/flake8/commit/70a15b8890d5bdf8c449ebb1b5259bd0140cc16b"><code>70a15b8</code></a> Merge pull request <a href="https://redirect.github.com/pycqa/flake8/issues/1985">#1985</a> from PyCQA/upgrade-deps</li>
<li><a href="https://github.com/PyCQA/flake8/commit/4941a3e32e54488698ecbc23993bfeb2a60c0fc5"><code>4941a3e</code></a> upgrade pyflakes / pycodestyle</li>
<li><a href="https://github.com/PyCQA/flake8/commit/23e4005c5501999c29e1e3774de7ed18d1e4e22d"><code>23e4005</code></a> Merge pull request <a href="https://redirect.github.com/pycqa/flake8/issues/1983">#1983</a> from PyCQA/py314</li>
<li><a href="https://github.com/PyCQA/flake8/commit/019424b80d3d7d5d8a2a1638f5877080546e3f46"><code>019424b</code></a> add support for t-strings</li>
<li><a href="https://github.com/PyCQA/flake8/commit/6b6f3d5fefecc994a06f425c18c3cddc9b71e4a4"><code>6b6f3d5</code></a> Merge pull request <a href="https://redirect.github.com/pycqa/flake8/issues/1980">#1980</a> from PyCQA/asottile-patch-1</li>
<li><a href="https://github.com/PyCQA/flake8/commit/8dfa6695b4fb1e1401b357367a0a71037d29f6aa"><code>8dfa669</code></a> add rtd sphinx config</li>
<li><a href="https://github.com/PyCQA/flake8/commit/ce3411118382d9332feb2749c8f417d10bfa2584"><code>ce34111</code></a> Merge pull request <a href="https://redirect.github.com/pycqa/flake8/issues/1976">#1976</a> from PyCQA/document-f824</li>
<li>Additional commits viewable in <a href="https://github.com/pycqa/flake8/compare/7.2.0...7.3.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=flake8&package-manager=pip&previous-version=7.2.0&new-version=7.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

dependabot[bot]: The `reviewers` field in the `dependabot.yml` file will be removed soon. Please use the code owners file to specify reviewers for Dependabot PRs. For more information, see [this blog post](https://github.blog/changelog/2025-04-29-dependabot-reviewers-configuration-option-being-replaced-by-code-owners/).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/postlund/pyatv/pull/2722?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.23%. Comparing base [(`16285cf`)](https://app.codecov.io/gh/postlund/pyatv/commit/16285cf9d60b488f08ac5012664f720e75cdbf5c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) to head [(`66ddbdb`)](https://app.codecov.io/gh/postlund/pyatv/commit/66ddbdbebeea06f8fe7b89a56af0b94cbeae9b0d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).

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


```diff
@@            Coverage Diff             @@
##           master    #2722      +/-   ##
==========================================
- Coverage   88.82%   88.23%   -0.60%     
==========================================
  Files         168      165       -3     
  Lines       11574    10903     -671     
==========================================
- Hits        10281     9620     -661     
+ Misses       1293     1283      -10     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/postlund/pyatv/pull/2722?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).   
:loudspeaker: Have feedback on the report? [Share it here](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).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/docs/api/pyatv.settings.html b/docs/api/pyatv.settings.html
index 941800a5..9a6dd245 100644
--- a/docs/api/pyatv.settings.html
+++ b/docs/api/pyatv.settings.html
@@ -54,6 +54,7 @@ This module has additional documentation
 <h4><code><a title="pyatv.settings.InfoSettings" href="#pyatv.settings.InfoSettings">InfoSettings</a></code></h4>
 <ul class="two-column">
 <li><code><a title="pyatv.settings.InfoSettings.device_id" href="#pyatv.settings.InfoSettings.device_id">device_id</a></code></li>
+<li><code><a title="pyatv.settings.InfoSettings.fill_missing_rp_id" href="#pyatv.settings.InfoSettings.fill_missing_rp_id">fill_missing_rp_id</a></code></li>
 <li><code><a title="pyatv.settings.InfoSettings.mac" href="#pyatv.settings.InfoSettings.mac">mac</a></code></li>
 <li><code><a title="pyatv.settings.InfoSettings.mac_validator" href="#pyatv.settings.InfoSettings.mac_validator">mac_validator</a></code></li>
 <li><code><a title="pyatv.settings.InfoSettings.model" href="#pyatv.settings.InfoSettings.model">model</a></code></li>
@@ -61,6 +62,7 @@ This module has additional documentation
 <li><code><a title="pyatv.settings.InfoSettings.os_build" href="#pyatv.settings.InfoSettings.os_build">os_build</a></code></li>
 <li><code><a title="pyatv.settings.InfoSettings.os_name" href="#pyatv.settings.InfoSettings.os_name">os_name</a></code></li>
 <li><code><a title="pyatv.settings.InfoSettings.os_version" href="#pyatv.settings.InfoSettings.os_version">os_version</a></code></li>
+<li><code><a title="pyatv.settings.InfoSettings.rp_id" href="#pyatv.settings.InfoSettings.rp_id">rp_id</a></code></li>
 </ul>
 </li>
 <li>
@@ -116,7 +118,7 @@ This module has additional documentation
 </header>
 <section id="section-intro">
 <p>Settings for configuring pyatv.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L1-L168" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L1-L179" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -135,7 +137,7 @@ This module has additional documentation
 <section class="desc"><p>Settings related to AirPlay.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L96-L103" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L107-L114" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -167,7 +169,7 @@ This module has additional documentation
 </code></dt>
 <dd>
 <section class="desc"><p>AirPlay version to use.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L47-L57" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L49-L59" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.str</li>
@@ -197,7 +199,7 @@ This module has additional documentation
 <section class="desc"><p>Settings related to Companion.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L106-L110" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L117-L121" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -223,7 +225,7 @@ This module has additional documentation
 <section class="desc"><p>Settings related to DMAP.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L113-L117" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L124-L128" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -249,7 +251,7 @@ This module has additional documentation
 <section class="desc"><p>Information related settings.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L76-L93" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L78-L104" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -285,9 +287,21 @@ This module has additional documentation
 <dd>
 <section class="desc"><p>The type of the None singleton.</p></section>
 </dd>
+<dt id="pyatv.settings.InfoSettings.rp_id"><code class="name">var <span class="ident">rp_id</span> -> str | None</code></dt>
+<dd>
+<section class="desc"><p>The type of the None singleton.</p></section>
+</dd>
 </dl>
 <h3>Static methods</h3>
 <dl>
+<dt id="pyatv.settings.InfoSettings.fill_missing_rp_id">
+<code class="name flex">
+<span>def <span class="ident">fill_missing_rp_id</span></span>(<span>v)</span>
+</code>
+</dt>
+<dd>
+<section class="desc"><p>Generate a new random rp_id if it is missing.</p></section>
+</dd>
 <dt id="pyatv.settings.InfoSettings.mac_validator">
 <code class="name flex">
 <span>def <span class="ident">mac_validator</span></span>(<span>mac: str) -> str</span>
@@ -306,7 +320,7 @@ This module has additional documentation
 <section class="desc"><p>Settings related to MRP.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L120-L124" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L131-L135" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -330,7 +344,7 @@ This module has additional documentation
 </code></dt>
 <dd>
 <section class="desc"><p>How MRP tunneling over AirPlay is handled.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L60-L70" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L62-L72" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>builtins.str</li>
@@ -360,7 +374,7 @@ This module has additional documentation
 <section class="desc"><p>Container for protocol specific settings.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L154-L161" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L165-L172" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -398,7 +412,7 @@ This module has additional documentation
 <section class="desc"><p>Settings related to RAOP.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L127-L151" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L138-L162" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
@@ -444,7 +458,7 @@ mode (recommended), or 1 or 2 for AirPlay 1 or 2 respectively.</p></section>
 <section class="desc"><p>Settings container class.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L164-L168" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L175-L179" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
diff --git a/docs/api/pyatv.storage.file_storage.html b/docs/api/pyatv.storage.file_storage.html
index 47bd205f..1c8b038e 100644
--- a/docs/api/pyatv.storage.file_storage.html
+++ b/docs/api/pyatv.storage.file_storage.html
@@ -35,7 +35,7 @@ This module has additional documentation
 </header>
 <section id="section-intro">
 <p>File based storage module.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/file_storage.py#L1-L69" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/file_storage.py#L1-L70" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -53,7 +53,7 @@ This module has additional documentation
 <dd>
 <section class="desc"><p>Storage module storing settings in a file.</p>
 <p>Initialize a new FileStorage instance.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/file_storage.py#L16-L69" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/file_storage.py#L16-L70" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li><a title="pyatv.storage.AbstractStorage" href="#pyatv.storage.AbstractStorage">AbstractStorage</a></li>
@@ -81,14 +81,14 @@ on Windows).</p></section>
 <ul class="hlist">
 <li><code><b><a title="pyatv.storage.AbstractStorage" href="#pyatv.storage.AbstractStorage">AbstractStorage</a></b></code>:
 <ul class="hlist">
-<li><code><a title="pyatv.storage.AbstractStorage.changed" href="#pyatv.storage.AbstractStorage.changed">changed</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.get_settings" href="#pyatv.storage.AbstractStorage.get_settings">get_settings</a></code></li>
+<li><code><a title="pyatv.storage.AbstractStorage.has_changed" href="#pyatv.storage.AbstractStorage.has_changed">has_changed</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.load" href="../../interface#pyatv.interface.Storage.load">load</a></code></li>
-<li><code><a title="pyatv.storage.AbstractStorage.mark_as_saved" href="#pyatv.storage.AbstractStorage.mark_as_saved">mark_as_saved</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.remove_settings" href="../../interface#pyatv.interface.Storage.remove_settings">remove_settings</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.save" href="../../interface#pyatv.interface.Storage.save">save</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.settings" href="../../interface#pyatv.interface.Storage.settings">settings</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.storage_model" href="#pyatv.storage.AbstractStorage.storage_model">storage_model</a></code></li>
+<li><code><a title="pyatv.storage.AbstractStorage.update_hash" href="#pyatv.storage.AbstractStorage.update_hash">update_hash</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.update_settings" href="../../interface#pyatv.interface.Storage.update_settings">update_settings</a></code></li>
 </ul>
 </li>
diff --git a/docs/api/pyatv.storage.html b/docs/api/pyatv.storage.html
index 74e062a9..7f7151a1 100644
--- a/docs/api/pyatv.storage.html
+++ b/docs/api/pyatv.storage.html
@@ -28,10 +28,10 @@ This module has additional documentation
 <li>
 <h4><code><a title="pyatv.storage.AbstractStorage" href="#pyatv.storage.AbstractStorage">AbstractStorage</a></code></h4>
 <ul class="">
-<li><code><a title="pyatv.storage.AbstractStorage.changed" href="#pyatv.storage.AbstractStorage.changed">changed</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.get_settings" href="#pyatv.storage.AbstractStorage.get_settings">get_settings</a></code></li>
-<li><code><a title="pyatv.storage.AbstractStorage.mark_as_saved" href="#pyatv.storage.AbstractStorage.mark_as_saved">mark_as_saved</a></code></li>
+<li><code><a title="pyatv.storage.AbstractStorage.has_changed" href="#pyatv.storage.AbstractStorage.has_changed">has_changed</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.storage_model" href="#pyatv.storage.AbstractStorage.storage_model">storage_model</a></code></li>
+<li><code><a title="pyatv.storage.AbstractStorage.update_hash" href="#pyatv.storage.AbstractStorage.update_hash">update_hash</a></code></li>
 </ul>
 </li>
 <li>
@@ -51,7 +51,7 @@ This module has additional documentation
 </header>
 <section id="section-intro">
 <p>Storage module.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L1-L175" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L1-L177" class="git-link">Browse git</a></div>
 </section>
 <section>
 <h2 class="section-title" id="header-submodules">Sub-modules</h2>
@@ -81,7 +81,7 @@ This module has additional documentation
 <p>New storage modules should generally inherit from this class and implement save and
 load according to the underlying storage mechanism.</p>
 <p>Initialize a new AbstractStorage instance.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L42-L175" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L41-L177" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li><a title="pyatv.interface.Storage" href="../../interface#pyatv.interface.Storage">Storage</a></li>
@@ -94,18 +94,10 @@ load according to the underlying storage mechanism.</p>
 </ul>
 <h3>Instance variables</h3>
 <dl>
-<dt id="pyatv.storage.AbstractStorage.changed"><code class="name">var <span class="ident">changed</span> -> bool</code></dt>
-<dd>
-<section class="desc"><p>Return if anything has changed in the model since loading.</p>
-<p>This property will return True if a any setting has been changed. It is reset
-when data is loaded into storage (by calling load) or manually by calling
-mark_as_solved (typically done by save).</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L54-L62" class="git-link">Browse git</a></div>
-</dd>
 <dt id="pyatv.storage.AbstractStorage.storage_model"><code class="name">var <span class="ident">storage_model</span> -> <a title="pyatv.storage.StorageModel" href="#pyatv.storage.StorageModel">StorageModel</a></code></dt>
 <dd>
 <section class="desc"><p>Return storage model representation.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L69-L72" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L66-L69" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -124,18 +116,27 @@ automatically and returned. If the configuration does not contain any valid
 identitiers, DeviceIdMissingError will be raised.</p>
 <p>If settings exists for a configuration but mismatch, they will be automatically
 updated in the storage. Set ignore_update to False to not update storage.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L89-L122" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L82-L115" class="git-link">Browse git</a></div>
+</dd>
+<dt id="pyatv.storage.AbstractStorage.has_changed">
+<code class="name flex">
+<span>def <span class="ident">has_changed</span></span>(<span>self, data: dict) -> bool</span>
+</code>
+</dt>
+<dd>
+<section class="desc"><p>Return if anything has changed in the model since loading.</p>
+<p>This method compares a hash of the saved data with the provided data to deduce
+if anything has changed.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L53-L59" class="git-link">Browse git</a></div>
 </dd>
-<dt id="pyatv.storage.AbstractStorage.mark_as_saved">
+<dt id="pyatv.storage.AbstractStorage.update_hash">
 <code class="name flex">
-<span>def <span class="ident">mark_as_saved</span></span>(<span>self) -> None</span>
+<span>def <span class="ident">update_hash</span></span>(<span>self, data: dict) -> None</span>
 </code>
 </dt>
 <dd>
-<section class="desc"><p>Call after saving to indicate settings have been saved.</p>
-<p>The changed property reflects whether something has been changed in the model
-or not based on calling this method.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L81-L87" class="git-link">Browse git</a></div>
+<section class="desc"><p>Call after saving to indicate settings have been saved.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L78-L80" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Inherited members</h3>
@@ -159,7 +160,7 @@ or not based on calling this method.</p></section>
 <section class="desc"><p>Storage model of data that is saved or restored to underlying storage.</p>
 <p>Create a new model by parsing and validating input data from keyword arguments.</p>
 <p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L35-L39" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/storage/__init__.py#L34-L38" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>pydantic.v1.main.BaseModel</li>
diff --git a/docs/api/pyatv.storage.memory_storage.html b/docs/api/pyatv.storage.memory_storage.html
index 3f55c58e..c9fb3da9 100644
--- a/docs/api/pyatv.storage.memory_storage.html
+++ b/docs/api/pyatv.storage.memory_storage.html
@@ -62,14 +62,14 @@ be forgotten when restarting the python interpreter.</p>
 <ul class="hlist">
 <li><code><b><a title="pyatv.storage.AbstractStorage" href="#pyatv.storage.AbstractStorage">AbstractStorage</a></b></code>:
 <ul class="hlist">
-<li><code><a title="pyatv.storage.AbstractStorage.changed" href="#pyatv.storage.AbstractStorage.changed">changed</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.get_settings" href="#pyatv.storage.AbstractStorage.get_settings">get_settings</a></code></li>
+<li><code><a title="pyatv.storage.AbstractStorage.has_changed" href="#pyatv.storage.AbstractStorage.has_changed">has_changed</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.load" href="../../interface#pyatv.interface.Storage.load">load</a></code></li>
-<li><code><a title="pyatv.storage.AbstractStorage.mark_as_saved" href="#pyatv.storage.AbstractStorage.mark_as_saved">mark_as_saved</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.remove_settings" href="../../interface#pyatv.interface.Storage.remove_settings">remove_settings</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.save" href="../../interface#pyatv.interface.Storage.save">save</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.settings" href="../../interface#pyatv.interface.Storage.settings">settings</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.storage_model" href="#pyatv.storage.AbstractStorage.storage_model">storage_model</a></code></li>
+<li><code><a title="pyatv.storage.AbstractStorage.update_hash" href="#pyatv.storage.AbstractStorage.update_hash">update_hash</a></code></li>
 <li><code><a title="pyatv.storage.AbstractStorage.update_settings" href="../../interface#pyatv.interface.Storage.update_settings">update_settings</a></code></li>
 </ul>
 </li>
diff --git a/pyatv/protocols/companion/api.py b/pyatv/protocols/companion/api.py
index 0adf6e27..d8cd3f0c 100644
--- a/pyatv/protocols/companion/api.py
+++ b/pyatv/protocols/companion/api.py
@@ -196,7 +196,7 @@ class CompanionAPI(
                 "_bf": 0,
                 "_cf": 512,
                 "_clFl": 128,
-                "_i": "cafecafecafe",  # TODO: Figure out what to put here
+                "_i": info.rp_id,
                 "_idsID": creds.client_id,
                 # Not really device id here, but better then anything...
                 "_pubID": info.device_id,
diff --git a/pyatv/settings.py b/pyatv/settings.py
index 999fd689..6acc8f00 100644
--- a/pyatv/settings.py
+++ b/pyatv/settings.py
@@ -1,6 +1,7 @@
 """Settings for configuring pyatv."""
 
 from enum import Enum
+import os
 import re
 from typing import Optional
 
@@ -36,6 +37,7 @@ _MAC_REGEX = r"[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}"
 DEFAULT_NAME = "pyatv"
 DEFUALT_MAC = "02:70:79:61:74:76"  # Locally administrated (02) + "pyatv" in hex
 DEFAULT_DEVICE_ID = "FF:70:79:61:74:76"  # 0xFF + "pyatv"
+DEFAULT_RP_ID = "cafecafecafe"
 DEFAULT_MODEL = "iPhone10,6"
 DEFAULT_OS_NAME = "iPhone OS"
 DEFAULT_OS_BUILD = "18G82"
@@ -80,6 +82,7 @@ class InfoSettings(BaseModel, extra="ignore"):  # type: ignore[call-arg]
     mac: str = DEFUALT_MAC
     model: str = DEFAULT_MODEL
     device_id: str = DEFAULT_DEVICE_ID
+    rp_id: Optional[str] = None
     os_name: str = DEFAULT_OS_NAME
     os_build: str = DEFAULT_OS_BUILD
     os_version: str = DEFAULT_OS_VERSION
@@ -92,6 +95,14 @@ class InfoSettings(BaseModel, extra="ignore"):  # type: ignore[call-arg]
             raise ValueError(f"{mac} is not a valid MAC address")
         return mac
 
+    @field_validator("rp_id", always=True)
+    @classmethod
+    def fill_missing_rp_id(cls, v):
+        """Generate a new random rp_id if it is missing."""
+        if v is None:
+            return os.urandom(6).hex()
+        return v
+
 
 class AirPlaySettings(BaseModel, extra="ignore"):  # type: ignore[call-arg]
     """Settings related to AirPlay."""
diff --git a/pyatv/storage/__init__.py b/pyatv/storage/__init__.py
index 741f86c5..2718ded1 100644
--- a/pyatv/storage/__init__.py
+++ b/pyatv/storage/__init__.py
@@ -1,7 +1,8 @@
 """Storage module."""
 
 from hashlib import sha256
-from typing import List, Sequence
+import json
+from typing import Any, Iterator, List, Sequence, Tuple
 
 from pyatv.const import Protocol
 from pyatv.exceptions import DeviceIdMissingError, SettingsError
@@ -19,14 +20,12 @@ __pdoc__ = {
 MODEL_VERSION = 1
 
 
-def _calculate_settings_hash(settings: List[Settings]) -> str:
+def _dict_hash(data: dict) -> str:
     # Calculate a hash of all settings by dumping content to JSON and hashing all
     # output using SHA256 (arbitrarily chosen for now). This is not very efficient
     # (especially when making multiple calls) but should be reliable and good enough.
     hasher = sha256()
-    for setting in settings:
-        setting_json = setting.json(exclude_defaults=True)
-        hasher.update(setting_json.encode("utf-8"))
+    hasher.update(json.dumps(data).encode("utf-8"))
     return hasher.hexdigest()
 
 
@@ -49,17 +48,15 @@ class AbstractStorage(Storage):
     def __init__(self) -> None:
         """Initialize a new AbstractStorage instance."""
         self._settings: List[Settings] = []
-        self._settings_hash: str = _calculate_settings_hash(self._settings)
+        self._hash: str = _dict_hash({})
 
-    @property
-    def changed(self) -> bool:
+    def has_changed(self, data: dict) -> bool:
         """Return if anything has changed in the model since loading.
 
-        This property will return True if a any setting has been changed. It is reset
-        when data is loaded into storage (by calling load) or manually by calling
-        mark_as_solved (typically done by save).
+        This method compares a hash of the saved data with the provided data to deduce
+        if anything has changed.
         """
-        return self._settings_hash != _calculate_settings_hash(self._settings)
+        return self._hash != _dict_hash(data)
 
     @property
     def settings(self) -> Sequence[Settings]:
@@ -78,13 +75,9 @@ class AbstractStorage(Storage):
             raise SettingsError(f"unsupported version: {other.version}")
         self._settings = other.devices
 
-    def mark_as_saved(self) -> None:
-        """Call after saving to indicate settings have been saved.
-
-        The changed property reflects whether something has been changed in the model
-        or not based on calling this method.
-        """
-        self._settings_hash = _calculate_settings_hash(self._settings)
+    def update_hash(self, data: dict) -> None:
+        """Call after saving to indicate settings have been saved."""
+        self._hash = _dict_hash(data)
 
     async def get_settings(self, config: BaseConfig) -> Settings:
         """Return settings for a specific configuration (device).
@@ -170,6 +163,15 @@ class AbstractStorage(Storage):
                 )
                 settings.protocols.raop.identifier = service.identifier
 
+    def __iter__(self) -> Iterator[Tuple[str, Any]]:
+        """Iterate over stored devices."""
+        # If settings are empty for a device (e.e. no settings overridden or credentials
+        # saved), then the output will just be an empty dict. To not pollute the output
+        # with those, we do some filtering here.
+        dumped = self.storage_model.dict(exclude_defaults=True)
+        dumped["devices"] = [device for device in dumped["devices"] if device != {}]
+        return iter(dumped.items())
+
     def __repr__(self) -> str:
         """Return representation of MemoryStorage."""
         return str(self)
diff --git a/pyatv/storage/file_storage.py b/pyatv/storage/file_storage.py
index 22c2fa1c..5fb05355 100644
--- a/pyatv/storage/file_storage.py
+++ b/pyatv/storage/file_storage.py
@@ -37,18 +37,13 @@ class FileStorage(AbstractStorage):
 
     async def save(self) -> None:
         """Save settings to active storage."""
-        if self.changed:
+        dumped = dict(self)
+        if self.has_changed(dumped):
             _LOGGER.debug("Saving settings to %s", self._filename)
-            await self._loop.run_in_executor(None, self._save_file)
-            self.mark_as_saved()
-
-    def _save_file(self) -> None:
-        # If settings are empty for a device (e.e. no settings overridden or credentials
-        # saved), then the output will just be an empty dict. To not pollute the output
-        # with those, we do some filtering here.
-        dumped = self.storage_model.dict(exclude_defaults=True)
-        dumped["devices"] = [device for device in dumped["devices"] if device != {}]
+            await self._loop.run_in_executor(None, self._save_file, dumped)
+            self.update_hash(dumped)
 
+    def _save_file(self, dumped: dict) -> None:
         with open(self._filename, "w", encoding="utf-8") as _fh:
             _fh.write(json.dumps(dumped) + "\n")
 
@@ -57,8 +52,14 @@ class FileStorage(AbstractStorage):
         if path.exists(self._filename):
             _LOGGER.debug("Loading settings from %s", self._filename)
             model_json = await self._loop.run_in_executor(None, self._read_file)
-            self.storage_model = StorageModel(**json.loads(model_json))
-            self.mark_as_saved()
+            raw_data = json.loads(model_json)
+            self.storage_model = StorageModel.parse_obj(raw_data)
+
+            # Update hash based on what we read from file rather than serializing the
+            # model. The reasonf for this is that pydantic might (because of
+            # validators) modify the data we read and in that case we want to ensure
+            # catch the update and actually save to file again when save is called.
+            self.update_hash(raw_data)
 
     def _read_file(self) -> str:
         with open(self._filename, "r", encoding="utf-8") as _fh:
diff --git a/pyatv/storage/memory_storage.py b/pyatv/storage/memory_storage.py
index ce67b2de..fbea86e9 100644
--- a/pyatv/storage/memory_storage.py
+++ b/pyatv/storage/memory_storage.py
@@ -14,7 +14,7 @@ class MemoryStorage(AbstractStorage):
 
     async def save(self) -> None:
         """Save settings to active storage."""
-        self.mark_as_saved()
+        self.update_hash(dict(self))
 
     async def load(self) -> None:
         """Load settings from active storage."""
diff --git a/pyproject.toml b/pyproject.toml
index 850b1cda..622e520e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,3 +24,6 @@ module = [
     "srptools",
 ]
 ignore_missing_imports = true
+
+[tool.pytest.ini_options]
+asyncio_default_fixture_loop_scope = "function"
\ No newline at end of file


Test patch:
diff --git a/requirements/requirements_test.txt b/requirements/requirements_test.txt
index d94a6fe4..fb6e1afc 100644
--- a/requirements/requirements_test.txt
+++ b/requirements/requirements_test.txt
@@ -1,6 +1,6 @@
 black==25.1.0
 deepdiff==8.5.0
-flake8==7.2.0
+flake8==7.3.0
 isort==6.0.1
 mutagen==1.47.0
 pyfakefs==5.9.1
@@ -13,8 +13,8 @@ pytest-aiohttp==1.0.5
 pytest_httpserver==1.1.3
 pytest-xdist==3.8.0
 pydocstyle==6.3.0
-mypy==1.15.0
+mypy==1.16.1
 mypy-protobuf==3.6.0
 types-protobuf==6.30.2.20250703
-types-requests==2.32.0.20250328
+types-requests==2.32.4.20250611
 types-tabulate==0.9.0.20241207
diff --git a/tests/storage/test_memory_storage.py b/tests/storage/test_memory_storage.py
index 585a5f5a..5e81a3e5 100644
--- a/tests/storage/test_memory_storage.py
+++ b/tests/storage/test_memory_storage.py
@@ -169,21 +169,21 @@ async def test_unsupported_version_raises(memory_storage):
 
 
 async def test_change_field_reflected_in_changed_property(memory_storage):
-    assert not memory_storage.changed
+    assert memory_storage.has_changed(dict(memory_storage))
+    memory_storage.update_hash(dict(memory_storage))
+    assert not memory_storage.has_changed(dict(memory_storage))
 
     atv = AppleTV(IPv4Address("127.0.0.1"), "test")
     atv.add_service(ManualService("id1", Protocol.DMAP, 1234, {}))
     settings = await memory_storage.get_settings(atv)
 
-    assert memory_storage.changed
-    memory_storage.mark_as_saved()
-    assert not memory_storage.changed
+    assert memory_storage.has_changed(dict(memory_storage))
+    memory_storage.update_hash(dict(memory_storage))
 
     settings.info.name = "test"
-
-    assert memory_storage.changed
-    memory_storage.mark_as_saved()
-    assert not memory_storage.changed
+    assert memory_storage.has_changed(dict(memory_storage))
+    memory_storage.update_hash(dict(memory_storage))
+    assert not memory_storage.has_changed(dict(memory_storage))
 
 
 async def test_set_model_reflects_changed_property(memory_storage):
@@ -192,13 +192,13 @@ async def test_set_model_reflects_changed_property(memory_storage):
     await memory_storage.get_settings(atv)
 
     new_storage = MemoryStorage()
-    assert not new_storage.changed
+    new_storage.update_hash(dict(new_storage))
 
     new_storage.storage_model = memory_storage.storage_model
-    assert new_storage.changed
+    assert new_storage.has_changed(dict(new_storage))
 
-    new_storage.mark_as_saved()
-    assert not new_storage.changed
+    new_storage.update_hash(dict(new_storage))
+    assert not new_storage.has_changed(dict(new_storage))
 
 
 async def test_save_updates_changed_property(memory_storage):
@@ -206,6 +206,6 @@ async def test_save_updates_changed_property(memory_storage):
     atv.add_service(ManualService("id1", Protocol.DMAP, 1234, {}))
     await memory_storage.get_settings(atv)
 
-    assert memory_storage.changed
+    assert memory_storage.has_changed(dict(memory_storage))
     await memory_storage.save()
-    assert not memory_storage.changed
+    assert not memory_storage.has_changed(dict(memory_storage))
diff --git a/tests/test_storage_functional.py b/tests/test_storage_functional.py
index 6e0e68d1..c8c8c7e0 100644
--- a/tests/test_storage_functional.py
+++ b/tests/test_storage_functional.py
@@ -5,7 +5,7 @@ Uses MemoryStorage as storage for simplicity.
 """
 
 import asyncio
-from unittest.mock import MagicMock, patch
+import os
 
 from deepdiff import DeepDiff
 import pytest
@@ -13,7 +13,6 @@ import pytest
 from pyatv import connect, pair
 from pyatv.const import Protocol
 from pyatv.interface import Storage
-from pyatv.protocols import ProtocolMethods
 from pyatv.storage.file_storage import FileStorage
 
 from tests.fake_udns import airplay_service
@@ -56,8 +55,13 @@ async def test_scan_inserts_into_storage(unicast_scan, mockfs):
 
 
 async def test_provides_storage_to_pairing_handler(
-    unicast_scan, session_manager, mockfs
+    unicast_scan, session_manager, monkeypatch
 ):
+    def fake_urandom(n):
+        return b"\x00" * n
+
+    monkeypatch.setattr(os, "urandom", fake_urandom)
+
     loop = asyncio.get_running_loop()
 
     storage = await new_storage(STORAGE_FILENAME, loop)