Repository: postlund/pyatv
Pull request #2031 (2023-06-05 14:00:27)

Keyboard focus events and documentation
This PR adds a listener interface for updating the focus state of the virtual keyboard, and documentation for the Keyboard interface as discussed in comments to #1905.

I've opted to not create a listener for text content, or to pass the current text as part of the focus state update since my testing has found this unreliable. The device only sends text updates for changes made through on screen clicks, not made over the API, and only appears to have started doing so in a patch release that occurred after developing the initial PR. Using text_get gets the actual content always by resetting the text input session before each request.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/postlund/pyatv/pull/2031?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: **`100.00`**% and project coverage change: **`-0.04`** :warning:
> Comparison is base [(`990a82f`)](https://app.codecov.io/gh/postlund/pyatv/commit/990a82f9cf4782c6ec715aaeba5083e3801c614c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 87.72% compared to head [(`0a9a741`)](https://app.codecov.io/gh/postlund/pyatv/pull/2031?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 87.68%.

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


```diff
@@            Coverage Diff             @@
##           master    #2031      +/-   ##
==========================================
- Coverage   87.72%   87.68%   -0.04%     
==========================================
  Files         155      155              
  Lines        9985    10037      +52     
==========================================
+ Hits         8759     8801      +42     
- Misses       1226     1236      +10     
```


| [Impacted Files](https://app.codecov.io/gh/postlund/pyatv/pull/2031?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/const.py](https://app.codecov.io/gh/postlund/pyatv/pull/2031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvY29uc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [pyatv/core/\_\_init\_\_.py](https://app.codecov.io/gh/postlund/pyatv/pull/2031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvY29yZS9fX2luaXRfXy5weQ==) | `97.61% <100.00%> (+0.05%)` | :arrow_up: |
| [pyatv/core/facade.py](https://app.codecov.io/gh/postlund/pyatv/pull/2031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvY29yZS9mYWNhZGUucHk=) | `99.28% <100.00%> (+0.02%)` | :arrow_up: |
| [pyatv/interface.py](https://app.codecov.io/gh/postlund/pyatv/pull/2031?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.82% <100.00%> (+0.01%)` | :arrow_up: |
| [pyatv/protocols/companion/\_\_init\_\_.py](https://app.codecov.io/gh/postlund/pyatv/pull/2031?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.63% <100.00%> (+0.14%)` | :arrow_up: |
| [pyatv/protocols/companion/api.py](https://app.codecov.io/gh/postlund/pyatv/pull/2031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL2NvbXBhbmlvbi9hcGkucHk=) | `90.62% <100.00%> (+0.55%)` | :arrow_up: |

... and [5 files with indirect coverage changes](https://app.codecov.io/gh/postlund/pyatv/pull/2031/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)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/postlund/pyatv/pull/2031?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).


Patch:
diff --git a/docs/_config.yml b/docs/_config.yml
index 2b67c396..95de5bf7 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -78,6 +78,8 @@ page_links:
       title: Apps
     - link: /development/audio/
       title: Audio
+    - link: /development/keyboard/
+      title: Keyboard
     - link: /development/logging/
       title: Logging
     - link: /development/examples/
diff --git a/docs/api/pyatv/const.html b/docs/api/pyatv/const.html
index 7e1243a2..09bd3621 100644
--- a/docs/api/pyatv/const.html
+++ b/docs/api/pyatv/const.html
@@ -92,6 +92,7 @@ link_group: api
 <li><code><a title="pyatv.const.FeatureName.SwitchAccount" href="#pyatv.const.FeatureName.SwitchAccount">SwitchAccount</a></code></li>
 <li><code><a title="pyatv.const.FeatureName.TextAppend" href="#pyatv.const.FeatureName.TextAppend">TextAppend</a></code></li>
 <li><code><a title="pyatv.const.FeatureName.TextClear" href="#pyatv.const.FeatureName.TextClear">TextClear</a></code></li>
+<li><code><a title="pyatv.const.FeatureName.TextFocusState" href="#pyatv.const.FeatureName.TextFocusState">TextFocusState</a></code></li>
 <li><code><a title="pyatv.const.FeatureName.TextGet" href="#pyatv.const.FeatureName.TextGet">TextGet</a></code></li>
 <li><code><a title="pyatv.const.FeatureName.TextSet" href="#pyatv.const.FeatureName.TextSet">TextSet</a></code></li>
 <li><code><a title="pyatv.const.FeatureName.Title" href="#pyatv.const.FeatureName.Title">Title</a></code></li>
@@ -124,6 +125,14 @@ link_group: api
 </ul>
 </li>
 <li>
+<h4><code><a title="pyatv.const.KeyboardFocusState" href="#pyatv.const.KeyboardFocusState">KeyboardFocusState</a></code></h4>
+<ul class="">
+<li><code><a title="pyatv.const.KeyboardFocusState.Focused" href="#pyatv.const.KeyboardFocusState.Focused">Focused</a></code></li>
+<li><code><a title="pyatv.const.KeyboardFocusState.Unfocused" href="#pyatv.const.KeyboardFocusState.Unfocused">Unfocused</a></code></li>
+<li><code><a title="pyatv.const.KeyboardFocusState.Unknown" href="#pyatv.const.KeyboardFocusState.Unknown">Unknown</a></code></li>
+</ul>
+</li>
+<li>
 <h4><code><a title="pyatv.const.MediaType" href="#pyatv.const.MediaType">MediaType</a></code></h4>
 <ul class="">
 <li><code><a title="pyatv.const.MediaType.Music" href="#pyatv.const.MediaType.Music">Music</a></code></li>
@@ -195,7 +204,7 @@ link_group: api
 </header>
 <section id="section-intro">
 <p>Constants used in the public API.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L1-L398" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L1-L414" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -214,7 +223,7 @@ link_group: api
 <section class="desc"><p>Hardware device model.</p>
 <p>Gen2-Gen4K are Apple TV model names and will be renamed to AppleTVGenX in the
 future.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L133-L174" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L146-L187" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>enum.Enum</li>
@@ -316,7 +325,7 @@ future.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>All supported features.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L229-L398" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L242-L414" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>enum.Enum</li>
@@ -499,6 +508,10 @@ future.</p></section>
 <dd>
 <section class="desc"><p>Clear virtual keyboard text.</p></section>
 </dd>
+<dt id="pyatv.const.FeatureName.TextFocusState"><code class="name">var <span class="ident">TextFocusState</span></code></dt>
+<dd>
+<section class="desc"><p>Current virtual keyboard focus state.</p></section>
+</dd>
 <dt id="pyatv.const.FeatureName.TextGet"><code class="name">var <span class="ident">TextGet</span></code></dt>
 <dd>
 <section class="desc"><p>Get current virtual keyboard text.</p></section>
@@ -555,7 +568,7 @@ future.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>State of a particular feature.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L209-L225" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L222-L238" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>enum.Enum</li>
@@ -587,7 +600,7 @@ future.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Type of input when pressing a button.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L177-L187" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L190-L200" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>enum.Enum</li>
@@ -608,6 +621,33 @@ future.</p></section>
 </dd>
 </dl>
 </dd>
+<dt id="pyatv.const.KeyboardFocusState"><code class="flex name class">
+<span>class <span class="ident">KeyboardFocusState</span></span>
+<span>(</span><span>value, names=None, *, module=None, qualname=None, type=None, start=1)</span>
+</code></dt>
+<dd>
+<section class="desc"><p>All supported keyboard focus states.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L113-L123" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>enum.Enum</li>
+</ul>
+<h3>Class variables</h3>
+<dl>
+<dt id="pyatv.const.KeyboardFocusState.Focused"><code class="name">var <span class="ident">Focused</span></code></dt>
+<dd>
+<section class="desc"><p>Keyboard is focused.</p></section>
+</dd>
+<dt id="pyatv.const.KeyboardFocusState.Unfocused"><code class="name">var <span class="ident">Unfocused</span></code></dt>
+<dd>
+<section class="desc"><p>Keyboard is not focused.</p></section>
+</dd>
+<dt id="pyatv.const.KeyboardFocusState.Unknown"><code class="name">var <span class="ident">Unknown</span></code></dt>
+<dd>
+<section class="desc"><p>Keyboard focus state is not determinable.</p></section>
+</dd>
+</dl>
+</dd>
 <dt id="pyatv.const.MediaType"><code class="flex name class">
 <span>class <span class="ident">MediaType</span></span>
 <span>(</span><span>value, names=None, *, module=None, qualname=None, type=None, start=1)</span>
@@ -647,7 +687,7 @@ not report a valid media type.</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Operating system on device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L113-L130" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L126-L143" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>enum.Enum</li>
@@ -681,7 +721,7 @@ is Apple TV Software (pre-tvOS).</p></section>
 </code></dt>
 <dd>
 <section class="desc"><p>Pairing requirement for a service.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L190-L206" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/const.py#L203-L219" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>enum.Enum</li>
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index 78e41a8f..1611dcce 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -149,11 +149,18 @@ link_group: api
 <ul class="">
 <li><code><a title="pyatv.interface.Keyboard.text_append" href="#pyatv.interface.Keyboard.text_append">text_append</a></code></li>
 <li><code><a title="pyatv.interface.Keyboard.text_clear" href="#pyatv.interface.Keyboard.text_clear">text_clear</a></code></li>
+<li><code><a title="pyatv.interface.Keyboard.text_focus_state" href="#pyatv.interface.Keyboard.text_focus_state">text_focus_state</a></code></li>
 <li><code><a title="pyatv.interface.Keyboard.text_get" href="#pyatv.interface.Keyboard.text_get">text_get</a></code></li>
 <li><code><a title="pyatv.interface.Keyboard.text_set" href="#pyatv.interface.Keyboard.text_set">text_set</a></code></li>
 </ul>
 </li>
 <li>
+<h4><code><a title="pyatv.interface.KeyboardListener" href="#pyatv.interface.KeyboardListener">KeyboardListener</a></code></h4>
+<ul class="">
+<li><code><a title="pyatv.interface.KeyboardListener.focusstate_update" href="#pyatv.interface.KeyboardListener.focusstate_update">focusstate_update</a></code></li>
+</ul>
+</li>
+<li>
 <h4><code><a title="pyatv.interface.MediaMetadata" href="#pyatv.interface.MediaMetadata">MediaMetadata</a></code></h4>
 <ul class="">
 <li><code><a title="pyatv.interface.MediaMetadata.album" href="#pyatv.interface.MediaMetadata.album">album</a></code></li>
@@ -298,7 +305,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-L1352" 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-L1373" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -351,7 +358,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#L1277-L1352" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1298-L1373" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
@@ -367,62 +374,62 @@ 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#L1334-L1337" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1355-L1358" 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#L1344-L1347" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1365-L1368" 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#L1294-L1297" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1315-L1318" 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#L1329-L1332" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1350-L1353" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.keyboard"><code class="name">var <span class="ident">keyboard</span> -> <a title="pyatv.interface.Keyboard" href="#pyatv.interface.Keyboard">Keyboard</a></code></dt>
 <dd>
 <section class="desc"><p>Return keyboard interface.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1349-L1352" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1370-L1373" 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#L1309-L1312" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1330-L1333" 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#L1324-L1327" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1345-L1348" 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#L1314-L1317" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1335-L1338" 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#L1304-L1307" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1325-L1328" 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#L1299-L1302" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1320-L1323" 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#L1319-L1322" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1340-L1343" 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#L1339-L1342" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1360-L1363" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -434,7 +441,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#L1290-L1292" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1311-L1313" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.AppleTV.connect">
 <code class="name flex">
@@ -444,7 +451,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#L1283-L1288" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1304-L1309" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
@@ -646,7 +653,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#L1141-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#L1162-L1295" class="git-link">Browse git</a></div>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
@@ -660,47 +667,47 @@ AirPlay.</p>
 <dt id="pyatv.interface.BaseConfig.address"><code class="name">var <span class="ident">address</span> -> ipaddress.IPv4Address</code></dt>
 <dd>
 <section class="desc"><p>IP address of device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1153-L1156" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1174-L1177" class="git-link">Browse git</a></div>
 </dd>
 <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#L1221-L1224" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1242-L1245" 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>
 <section class="desc"><p>If device is in deep sleep.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1163-L1166" 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-L1187" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.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 general device information.</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#L1194-L1197" class="git-link">Browse git</a></div>
 </dd>
 <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#L1206-L1219" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1227-L1240" 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>
 <section class="desc"><p>Name of device.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1158-L1161" 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.properties"><code class="name">var <span class="ident">properties</span> -> Mapping[str, Mapping[str, str]]</code></dt>
 <dd>
 <section class="desc"><p>Return Zeroconf properties.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1193-L1196" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1214-L1217" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.ready"><code class="name">var <span class="ident">ready</span> -> bool</code></dt>
 <dd>
 <section class="desc"><p>Return if configuration is ready, (at least one service with identifier).</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1198-L1204" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1219-L1225" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.services"><code class="name">var <span class="ident">services</span> -> List[<a title="pyatv.interface.BaseService" href="#pyatv.interface.BaseService">BaseService</a>]</code></dt>
 <dd>
 <section class="desc"><p>Return all supported services.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1168-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#L1189-L1192" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 <h3>Methods</h3>
@@ -713,7 +720,7 @@ AirPlay.</p>
 <dd>
 <section class="desc"><p>Add a new service.</p>
 <p>If the service already exists, it will be merged.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1178-L1183" 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-L1204" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.get_service">
 <code class="name flex">
@@ -724,7 +731,7 @@ AirPlay.</p>
 <section class="desc"><p>Look up a service based on protocol.</p>
 <p>If a service with the specified protocol is not available, None is
 returned.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1185-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#L1206-L1212" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.main_service">
 <code class="name flex">
@@ -733,7 +740,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#L1226-L1239" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1247-L1260" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.BaseConfig.set_credentials">
 <code class="name flex">
@@ -742,7 +749,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#L1241-L1247" 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-L1268" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
@@ -977,15 +984,37 @@ in one of the listed states.</p></section>
 </dd>
 <dt id="pyatv.interface.Keyboard"><code class="flex name class">
 <span>class <span class="ident">Keyboard</span></span>
+<span>(</span><span>max_calls: int = 0)</span>
 </code></dt>
 <dd>
-<section class="desc"><p>Base class for keyboard handling.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1117-L1138" class="git-link">Browse git</a></div>
+<section class="desc"><p>Base class for keyboard handling.</p>
+<p>Listener
+interface: <code>pyatv.interfaces.KeyboardListener</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#L1128-L1159" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>abc.ABC</li>
+<li>pyatv.support.state_producer.StateProducer</li>
+<li>typing.Generic</li>
+</ul>
 <h3>Subclasses</h3>
 <ul class="hlist">
 <li>pyatv.core.facade.FacadeKeyboard</li>
 <li>pyatv.protocols.companion.CompanionKeyboard</li>
 </ul>
+<h3>Instance variables</h3>
+<dl>
+<dt id="pyatv.interface.Keyboard.text_focus_state"><code class="name">var <span class="ident">text_focus_state</span> -> <a title="pyatv.const.KeyboardFocusState" href="const#pyatv.const.KeyboardFocusState">KeyboardFocusState</a></code></dt>
+<dd>
+<div class="api_feature">
+<span>Feature: <a title="pyatv.const.FeatureName.TextFocusState" href="const#pyatv.const.FeatureName.TextFocusState">FeatureName.TextFocusState</a>,</span>
+<span>Supported by: <a title="pyatv.const.Protocol.Companion" href="const#pyatv.const.Protocol.Companion">Protocol.Companion</a></span>
+</div>
+<section class="desc"><p>Return keyboard focus state.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1135-L1139" class="git-link">Browse git</a></div>
+</dd>
+</dl>
 <h3>Methods</h3>
 <dl>
 <dt id="pyatv.interface.Keyboard.text_append">
@@ -999,7 +1028,7 @@ in one of the listed states.</p></section>
 <span>Supported by: <a title="pyatv.const.Protocol.Companion" href="const#pyatv.const.Protocol.Companion">Protocol.Companion</a></span>
 </div>
 <section class="desc"><p>Input text into virtual keyboard.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1130-L1133" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1151-L1154" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.Keyboard.text_clear">
 <code class="name flex">
@@ -1012,7 +1041,7 @@ in one of the listed states.</p></section>
 <span>Supported by: <a title="pyatv.const.Protocol.Companion" href="const#pyatv.const.Protocol.Companion">Protocol.Companion</a></span>
 </div>
 <section class="desc"><p>Clear virtual keyboard text.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1125-L1128" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1146-L1149" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.Keyboard.text_get">
 <code class="name flex">
@@ -1025,7 +1054,7 @@ in one of the listed states.</p></section>
 <span>Supported by: <a title="pyatv.const.Protocol.Companion" href="const#pyatv.const.Protocol.Companion">Protocol.Companion</a></span>
 </div>
 <section class="desc"><p>Get current virtual keyboard text.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1120-L1123" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1141-L1144" class="git-link">Browse git</a></div>
 </dd>
 <dt id="pyatv.interface.Keyboard.text_set">
 <code class="name flex">
@@ -1038,7 +1067,34 @@ in one of the listed states.</p></section>
 <span>Supported by: <a title="pyatv.const.Protocol.Companion" href="const#pyatv.const.Protocol.Companion">Protocol.Companion</a></span>
 </div>
 <section class="desc"><p>Replace text in virtual keyboard.</p></section>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1135-L1138" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1156-L1159" class="git-link">Browse git</a></div>
+</dd>
+</dl>
+</dd>
+<dt id="pyatv.interface.KeyboardListener"><code class="flex name class">
+<span>class <span class="ident">KeyboardListener</span></span>
+</code></dt>
+<dd>
+<section class="desc"><p>Listener interface for keyboard updates.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1117-L1125" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>abc.ABC</li>
+</ul>
+<h3>Subclasses</h3>
+<ul class="hlist">
+<li>pyatv.scripts.atvscript.KeyboardPrinter</li>
+</ul>
+<h3>Methods</h3>
+<dl>
+<dt id="pyatv.interface.KeyboardListener.focusstate_update">
+<code class="name flex">
+<span>def <span class="ident">focusstate_update</span></span>(<span>self, old_state: <a title="pyatv.const.KeyboardFocusState" href="const#pyatv.const.KeyboardFocusState">KeyboardFocusState</a>, new_state: <a title="pyatv.const.KeyboardFocusState" href="const#pyatv.const.KeyboardFocusState">KeyboardFocusState</a>)</span>
+</code>
+</dt>
+<dd>
+<section class="desc"><p>Keyboard focus state was updated.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/interface.py#L1120-L1125" class="git-link">Browse git</a></div>
 </dd>
 </dl>
 </dd>
diff --git a/docs/development/keyboard.md b/docs/development/keyboard.md
new file mode 100644
index 00000000..35b573c7
--- /dev/null
+++ b/docs/development/keyboard.md
@@ -0,0 +1,51 @@
+---
+layout: template
+title: Keyboard
+permalink: /development/keyboard/
+link_group: development
+---
+# Keyboard
+
+It is possible to interact with the Apple TV virtual keyboard via the Keyboard interface.
+To use this interface, the Companion protocol must be available.
+
+## Using the Keyboard API
+
+After connecting to a device, you get the keyboard interface via {% include api i="interface.AppleTV.keyboard" %}:
+
+```python
+atv = await pyatv.connect(config, ...)
+keyboard = atv.keyboard
+```
+
+To check whether the virtual keyboard is focused and active, use {% include api i="interface.Keyboard.text_focus_state" %}:
+
+```python
+print("Keyboard focus state:", keyboard.text_focus_state)
+```
+
+To fetch the current virtual keyboard text content, use {% include api i="interface.Keyboard.text_get" %}:
+
+```python
+print("Keyboard text:", await keyboard.text_get())
+```
+
+To set (replace) the virtual keyboard text, use {% include api i="interface.Keyboard.text_set" %}:
+
+```python
+await keyboard.text_set("text to set")
+```
+
+To append to the virtual keyboard text, use {% include api i="interface.Keyboard.text_append" %}:
+
+```python
+await keyboard.text_append("text to append")
+```
+
+Finally, to clear the virtual keyboard text, use {% include api i="interface.Keyboard.text_clear" %}:
+
+```python
+await keyboard.text_clear()
+```
+
+The keyboard API supports push updates via a listener, as described [here](../listeners#keyboard-updates).
diff --git a/docs/development/listeners.md b/docs/development/listeners.md
index fcf19635..5cb3518e 100644
--- a/docs/development/listeners.md
+++ b/docs/development/listeners.md
@@ -116,3 +116,24 @@ atv.audio.listener = listener
 
 Live volume level updates are only sent over the `MRP` protocol. If an Apple TV is connected to
 speakers in a way that doesn't support volume levels, it will not send these updates.
+
+## Keyboard Updates
+
+It is possible to get callbacks whenever the virtual keyboard focus state of a device is changed.
+The API is defined by the
+{% include api i="interface.KeyboardListener" %} interface and works similarly to how push updates works.
+
+Here is a simple example:
+
+```python
+class MyKeyboardListener(interface.KeyboardListener):
+
+    def focusstate_update(self, old_state, new_state):
+        print('Focus state changed from {0:s} to {1:s}'.format(old_state, new_state))
+
+
+listener = MyKeyboardListener()
+atv.keyboard.listener = listener
+```
+
+Keyboard focus state updates are only sent over the `Companion` protocol.
diff --git a/docs/documentation/atvscript.md b/docs/documentation/atvscript.md
index 111205ba..7ad07762 100644
--- a/docs/documentation/atvscript.md
+++ b/docs/documentation/atvscript.md
@@ -203,17 +203,20 @@ $ atvscript -s 10.0.10.81 menu
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "command": "menu"}
 ```
 
-## Push, Power and Audio Updates
+## Push, Power, Audio and Keyboard Updates
 
-Push, power and audio updates are printed to the terminal as they happen:
+Push, power, audio and keyboard updates are printed to the terminal as they happen:
 
 ```shell
 $ atvscript -s 10.0.10.81 push_updates
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "power_state": "off"}
+{"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "focus_state": "unfocused"}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "hash": "azyFEzFpSNOSGq9ZvcaX4A\u2206DcpumkUoRty+R098MQeIKA", "media_type": "music", "device_state": "paused", "title": "Ordinary World (Live)", "artist": "Duran Duran", "album": "From Mediterranea With Love - EP", "genre": "Rock", "total_time": 395, "position": 1, "shuffle": "off", "repeat": "off", "app": "Musik", "app_id": "com.apple.TVMusic"}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "power_state": "on"}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "volume": 20.0}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "volume": 15.0}
+{"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "focus_state": "focused"}
+{"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "focus_state": "unfocused"}
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "power_state": "off"}
 
 {"result": "success", "datetime": "2020-04-06T18:51:04.758569+02:00", "push_updates": "finished"}
diff --git a/pyatv/const.py b/pyatv/const.py
index 6da2263e..3beda682 100644
--- a/pyatv/const.py
+++ b/pyatv/const.py
@@ -110,6 +110,19 @@ class PowerState(Enum):
     """Device is turned on."""
 
 
+class KeyboardFocusState(Enum):
+    """All supported keyboard focus states."""
+
+    Unknown = 0
+    """Keyboard focus state is not determinable."""
+
+    Unfocused = 1
+    """Keyboard is not focused."""
+
+    Focused = 2
+    """Keyboard is focused."""
+
+
 class OperatingSystem(Enum):
     """Operating system on device."""
 
@@ -385,6 +398,9 @@ class FeatureName(Enum):
     SetVolume = 46
     """Set volume level."""
 
+    TextFocusState = 57
+    """Current virtual keyboard focus state."""
+
     TextGet = 51
     """Get current virtual keyboard text."""
 
diff --git a/pyatv/core/__init__.py b/pyatv/core/__init__.py
index 852639d4..71100d62 100644
--- a/pyatv/core/__init__.py
+++ b/pyatv/core/__init__.py
@@ -38,6 +38,9 @@ class UpdatedState(Enum):
     Volume = 2
     """Volume was updated."""
 
+    KeyboardFocus = 3
+    """Keyboard focus was updated."""
+
 
 # pylint: enable=invalid-name
 
@@ -51,6 +54,7 @@ class StateMessage(NamedTuple):
     # Type depending on value of state:
     # - UpdatedState.Playing -> interface.Playing
     # - UpdatedState.Volume -> float
+    # - UpdatedState.KeyboardFocus -> const.KeyboardFocusState
     value: Any
 
     def __str__(self):
diff --git a/pyatv/core/facade.py b/pyatv/core/facade.py
index 98fb0dde..9987e44d 100644
--- a/pyatv/core/facade.py
+++ b/pyatv/core/facade.py
@@ -487,9 +487,32 @@ class FacadeAudio(Relayer, interface.Audio):
 class FacadeKeyboard(Relayer, interface.Keyboard):
     """Facade implementation for keyboard handling."""
 
-    def __init__(self):
+    def __init__(self, core_dispatcher: CoreStateDispatcher):
         """Initialize a new FacadeKeyboard instance."""
-        super().__init__(interface.Keyboard, DEFAULT_PRIORITIES)
+        Relayer.__init__(self, interface.Keyboard, DEFAULT_PRIORITIES)
+        interface.Keyboard.__init__(self)
+        self._focus_state: const.KeyboardFocusState = const.KeyboardFocusState.Unknown
+        core_dispatcher.listen_to(
+            UpdatedState.KeyboardFocus,
+            self._focus_state_changed,
+            message_filter=lambda message: message.protocol == self.main_protocol,
+        )
+
+    def _focus_state_changed(self, message: StateMessage) -> None:
+        state = cast(const.KeyboardFocusState, message.value)
+
+        old_state = self._focus_state
+        new_state = self._focus_state = state
+
+        if new_state != old_state:
+            _LOGGER.debug("Focus state changed from %s to %s", old_state, new_state)
+            self.listener.focusstate_update(old_state, new_state)
+
+    @property
+    @shield.guard
+    def text_focus_state(self) -> const.KeyboardFocusState:
+        """Return keyboard focus state."""
+        return self.relay("text_focus_state")
 
     @shield.guard
     async def text_get(self) -> Optional[str]:
@@ -591,7 +614,7 @@ class FacadeAppleTV(interface.AppleTV):
             interface.Apps: FacadeApps(),
             interface.UserAccounts: FacadeUserAccounts(),
             interface.Audio: FacadeAudio(core_dispatcher),
-            interface.Keyboard: FacadeKeyboard(),
+            interface.Keyboard: FacadeKeyboard(core_dispatcher),
         }
         self._shield_everything()
 
diff --git a/pyatv/interface.py b/pyatv/interface.py
index b6eedd53..d9e0252b 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -1114,8 +1114,29 @@ class Audio(ABC, StateProducer):
         raise exceptions.NotSupportedError()
 
 
-class Keyboard:
-    """Base class for keyboard handling."""
+class KeyboardListener(ABC):  # pylint: disable=too-few-public-methods
+    """Listener interface for keyboard updates."""
+
+    @abstractmethod
+    def focusstate_update(
+        self, old_state: const.KeyboardFocusState, new_state: const.KeyboardFocusState
+    ):
+        """Keyboard focus state was updated."""
+        raise exceptions.NotSupportedError()
+
+
+class Keyboard(ABC, StateProducer):
+    """Base class for keyboard handling.
+
+    Listener
+    interface: `pyatv.interfaces.KeyboardListener`
+    """
+
+    @property
+    @feature(57, "TextFocusState", "Current virtual keyboard focus state.")
+    def text_focus_state(self) -> const.KeyboardFocusState:
+        """Return keyboard focus state."""
+        raise exceptions.NotSupportedError()
 
     @feature(51, "TextGet", "Get current virtual keyboard text.")
     async def text_get(self) -> Optional[str]:
diff --git a/pyatv/protocols/companion/__init__.py b/pyatv/protocols/companion/__init__.py
index 0e4363db..f596ea97 100644
--- a/pyatv/protocols/companion/__init__.py
+++ b/pyatv/protocols/companion/__init__.py
@@ -11,6 +11,7 @@ from pyatv.const import (
     FeatureName,
     FeatureState,
     InputAction,
+    KeyboardFocusState,
     PairingRequirement,
     Protocol,
 )
@@ -378,10 +379,31 @@ class CompanionAudio(Audio):
 class CompanionKeyboard(Keyboard):
     """Implementation of API for keyboard handling."""
 
-    def __init__(self, api: CompanionAPI):
+    def __init__(self, api: CompanionAPI, core: Core):
         """Initialize a new instance of CompanionKeyboard."""
         super().__init__()
         self.api = api
+        # _tiStarted will not be sent if session is started while already focused
+        self.api.listen_to("_tiStarted", self._handle_text_input)
+        self.api.listen_to("_tiStopped", self._handle_text_input)
+        # _tiStart is actually a command that we forward the response of,
+        self.api.listen_to("_tiStart", self._handle_text_input)
+        self.core = core
+        self._focus_state: KeyboardFocusState = KeyboardFocusState.Unknown
+
+    async def _handle_text_input(self, data: Mapping[str, Any]) -> None:
+        state = (
+            KeyboardFocusState.Focused
+            if "_tiD" in data
+            else KeyboardFocusState.Unfocused
+        )
+        self._focus_state = state
+        self.core.state_dispatcher.dispatch(UpdatedState.KeyboardFocus, state)
+
+    @property
+    def text_focus_state(self) -> KeyboardFocusState:
+        """Return keyboard focus state."""
+        return self._focus_state
 
     async def text_get(self) -> Optional[str]:
         """Get current virtual keyboard text."""
@@ -465,7 +487,7 @@ def setup(core: Core) -> Generator[SetupData, None, None]:
         Power: CompanionPower(api),
         RemoteControl: CompanionRemoteControl(api),
         Audio: CompanionAudio(api, core),
-        Keyboard: CompanionKeyboard(api),
+        Keyboard: CompanionKeyboard(api, core),
     }
 
     async def _connect() -> bool:
diff --git a/pyatv/protocols/companion/api.py b/pyatv/protocols/companion/api.py
index 584b8009..c74c91fb 100644
--- a/pyatv/protocols/companion/api.py
+++ b/pyatv/protocols/companion/api.py
@@ -1,5 +1,5 @@
 """High level implementation of Companion API."""
-
+import asyncio
 from enum import Enum
 import logging
 from random import randint
@@ -96,6 +96,7 @@ class CompanionAPI(
             # Sometimes unsubscribe fails for an unknown reason, but we are no
             # going to bother with that and just swallow the error.
             await self.unsubscribe_event("_iMC")
+            await self._text_input_stop()
             await self._session_stop()
         except Exception as ex:
             _LOGGER.debug("Ignoring error during disconnect: %s", ex)
@@ -128,6 +129,7 @@ class CompanionAPI(
 
         await self.system_info()
         await self._session_start()
+        await self._text_input_start()
         await self.subscribe_event("_iMC")
 
     async def _send_command(
@@ -264,13 +266,23 @@ class CompanionAPI(
         """Send a HID command."""
         return await self._send_command("_mcc", {"_mcc": command.value, **(args or {})})
 
+    async def _text_input_start(self) -> Mapping[str, Any]:
+        response = await self._send_command("_tiStart", {})
+        await asyncio.gather(*self.dispatch("_tiStart", response.get("_c", {})))
+        return response
+
+    async def _text_input_stop(self) -> None:
+        await self._send_command("_tiStop", {})
+
     async def text_input_command(
         self,
         text: str,
         clear_previous_input: bool = False,
     ) -> Optional[str]:
         """Send a text input command."""
-        response = await self._send_command("_tiStart", {})
+        # restart the text input session so that we have up-to-date data
+        await self._text_input_stop()
+        response = await self._text_input_start()
         ti_data = response.get("_c", {}).get("_tiD")
 
         if ti_data is None:
@@ -305,5 +317,4 @@ class CompanionAPI(
             )
             current_text += text
 
-        await self._send_command("_tiStop", {})
         return current_text
diff --git a/pyatv/scripts/atvscript.py b/pyatv/scripts/atvscript.py
index 76501711..1d5b2296 100644
--- a/pyatv/scripts/atvscript.py
+++ b/pyatv/scripts/atvscript.py
@@ -18,6 +18,7 @@ from pyatv.interface import (
     AppleTV,
     AudioListener,
     DeviceListener,
+    KeyboardListener,
     Playing,
     PowerListener,
     PushListener,
@@ -93,6 +94,25 @@ class AudioPrinter(AudioListener):
         )
 
 
+class KeyboardPrinter(KeyboardListener):
+    """Listen for keyboard updates and print changes."""
+
+    def __init__(self, formatter):
+        """Initialize a new KeyboardPrinter."""
+        self.formatter = formatter
+
+    def focusstate_update(
+        self, old_state: const.KeyboardFocusState, new_state: const.KeyboardFocusState
+    ):
+        """Keyboard focus state was updated."""
+        print(
+            self.formatter(
+                output(True, values={"focus_state": new_state.name.lower()})
+            ),
+            flush=True,
+        )
+
+
 class DevicePrinter(DeviceListener):
     """Listen for generic device updates and print them."""
 
@@ -236,11 +256,13 @@ async def _run_command(atv, args, abort_sem, loop):
     if args.command == "push_updates":
         power_listener = PowerPrinter(args.output)
         audio_listener = AudioPrinter(args.output)
+        keyboard_listener = KeyboardPrinter(args.output)
         device_listener = DevicePrinter(args.output, abort_sem)
         push_listener = PushPrinter(args.output, atv)
 
         atv.power.listener = power_listener
         atv.audio.listener = audio_listener
+        atv.keyboard.listener = keyboard_listener
         atv.listener = device_listener
         atv.push_updater.listener = push_listener
         atv.push_updater.start()


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 074bcc18..5a430fbb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -167,3 +167,8 @@ def mrp_state_dispatcher_fixture(core_dispatcher):
 @pytest.fixture(name="dmap_state_dispatcher")
 def dmap_state_dispatcher_fixture(core_dispatcher):
     yield ProtocolStateDispatcher(Protocol.DMAP, core_dispatcher)
+
+
+@pytest.fixture(name="companion_state_dispatcher")
+def companion_state_dispatcher_fixture(core_dispatcher):
+    yield ProtocolStateDispatcher(Protocol.Companion, core_dispatcher)
diff --git a/tests/core/test_facade.py b/tests/core/test_facade.py
index f4cc3975..11ffbd89 100644
--- a/tests/core/test_facade.py
+++ b/tests/core/test_facade.py
@@ -11,11 +11,12 @@ from unittest.mock import MagicMock
 import pytest
 import pytest_asyncio
 
-from pyatv import exceptions
+from pyatv import const, exceptions
 from pyatv.conf import AppleTV as AppleTVConf
 from pyatv.const import (
     DeviceState,
     FeatureName,
+    KeyboardFocusState,
     MediaType,
     OperatingSystem,
     PowerState,
@@ -33,6 +34,8 @@ from pyatv.interface import (
     FeatureInfo,
     Features,
     FeatureState,
+    Keyboard,
+    KeyboardListener,
     Metadata,
     Playing,
     Power,
@@ -133,6 +136,15 @@ class DummyAudio(Audio):
         self._volume = level
 
 
+class DummyKeyboard(Keyboard):
+    def __init__(self, focus_state: KeyboardFocusState) -> None:
+        self._focus_state = focus_state
+
+    @property
+    def focus_state(self) -> KeyboardFocusState:
+        return self._focus_state
+
+
 class DummyDeviceListener(DeviceListener):
     def __init__(self):
         self.lost_calls: int = 0
@@ -204,6 +216,18 @@ class SavingAudioListener(AudioListener):
         self.all_updates.append(new_level)
 
 
+class SavingKeyboardListener(KeyboardListener):
+    def __init__(self):
+        self.last_update = None
+        self.all_updates = []
+
+    def focusstate_update(
+        self, old_state: const.KeyboardFocusState, new_state: const.KeyboardFocusState
+    ):
+        self.last_update = new_state
+        self.all_updates.append(new_state)
+
+
 @pytest.fixture(name="register_interface")
 def register_interface_fixture(facade_dummy):
     def _register_func(feature: FeatureName, instance, protocol: Protocol):
@@ -537,6 +561,7 @@ def register_basic_interfaces(reg_interface, protocol: Protocol) -> None:
         FeatureName.PlayUrl, DummyFeatures(FeatureName.PlayUrl), protocol
     )
     sdg.interfaces[Audio] = DummyAudio(0.0)
+    sdg.interfaces[Keyboard] = DummyKeyboard(KeyboardFocusState.Unfocused)
     sdg.interfaces[Stream] = DummyStream()
     return sdg
 
@@ -854,6 +879,60 @@ async def test_audio_no_listener_duplicates(mrp_state_dispatcher, audio_setup):
     assert audio_setup.listener.last_update == 10.0
 
 
+# KEYBOARD RELATED TESTS
+
+
+@pytest_asyncio.fixture(name="keyboard_setup")
+async def keyboard_setup_fixture(facade_dummy, register_interface):
+    listener = SavingKeyboardListener()
+
+    register_basic_interfaces(register_interface, Protocol.Companion)
+
+    await facade_dummy.connect()
+    facade_dummy.keyboard.listener = listener
+
+    yield facade_dummy.keyboard
+
+
+async def dispatch_focus_state_update(
+    companion_state_dispatcher, state, protocol=Protocol.Companion
+):
+    event = asyncio.Event()
+
+    # Add a listener last in the last and make it set an asyncio.Event. That way we
+    # can synchronize and know that all other listeners have been called.
+    companion_state_dispatcher.listen_to(
+        UpdatedState.KeyboardFocus, lambda message: event.set()
+    )
+    companion_state_dispatcher.dispatch(UpdatedState.KeyboardFocus, state)
+
+    await event.wait()
+
+
+async def test_keyboard_listener_updates(companion_state_dispatcher, keyboard_setup):
+    await dispatch_focus_state_update(
+        companion_state_dispatcher, KeyboardFocusState.Focused
+    )
+    assert keyboard_setup.listener.last_update == KeyboardFocusState.Focused
+    await dispatch_focus_state_update(
+        companion_state_dispatcher, KeyboardFocusState.Unfocused
+    )
+    assert keyboard_setup.listener.last_update == KeyboardFocusState.Unfocused
+
+
+async def test_keyboard_no_listener_duplicates(
+    companion_state_dispatcher, keyboard_setup
+):
+    await dispatch_focus_state_update(
+        companion_state_dispatcher, KeyboardFocusState.Focused
+    )
+    await dispatch_focus_state_update(
+        companion_state_dispatcher, KeyboardFocusState.Focused
+    )
+    assert len(keyboard_setup.listener.all_updates) == 1
+    assert keyboard_setup.listener.last_update == KeyboardFocusState.Focused
+
+
 # GUARD CALLS AFTER CLOSE
 
 
diff --git a/tests/fake_device/companion.py b/tests/fake_device/companion.py
index f927108d..37300a5c 100644
--- a/tests/fake_device/companion.py
+++ b/tests/fake_device/companion.py
@@ -3,8 +3,9 @@
 import asyncio
 import logging
 import plistlib
-from typing import Dict, Optional, Set
+from typing import Any, Dict, List, Mapping, Optional, Set
 
+from pyatv.const import KeyboardFocusState
 from pyatv.protocols.companion import (
     HidCommand,
     MediaControlCommand,
@@ -69,9 +70,62 @@ class FakeCompanionState:
         self.media_control_flags: int = MediaControlFlags.Volume
         self.interests: Set[str] = set()
         self.volume: float = INITIAL_VOLUME
+        self.rti_clients: List[FakeCompanionService] = []
+        self._rti_focus_state: KeyboardFocusState = KeyboardFocusState.Focused
         self.rti_text: Optional[str] = INITIAL_RTI_TEXT
         self.rti_session_uuid: Optional[bytes] = None
 
+    def _send_rti(self, identifier, content):
+        for client in self.rti_clients:
+            client.send_event(identifier, 1234, content)
+
+    @property
+    def rti_focus_state(self) -> KeyboardFocusState:
+        return self._rti_focus_state
+
+    @rti_focus_state.setter
+    def rti_focus_state(self, value: KeyboardFocusState) -> None:
+        if value == self._rti_focus_state:
+            return
+        self._rti_focus_state = value
+        if value == KeyboardFocusState.Focused:
+            self._send_rti("_tiStarted", self.rti_encoded_data)
+        elif value == KeyboardFocusState.Unfocused:
+            self._send_rti("_tiStopped", self.rti_encoded_data)
+
+    @property
+    def rti_encoded_data(self) -> Mapping[str, Any]:
+        if self.rti_focus_state == KeyboardFocusState.Focused:
+            return {
+                "_tiD": plistlib.dumps(
+                    {
+                        "$top": {
+                            "sessionUUID": plistlib.UID(1),
+                            "documentState": plistlib.UID(2),
+                        },
+                        "$objects": [
+                            "$null",
+                            self.rti_session_uuid,
+                            {
+                                "docSt": plistlib.UID(3),
+                            },
+                        ]
+                        + [
+                            {
+                                "contextBeforeInput": plistlib.UID(4),
+                            },
+                            self.rti_text,
+                        ]
+                        if self.rti_text is not None
+                        else [{}],
+                    },
+                    fmt=plistlib.PlistFormat.FMT_BINARY,
+                    sort_keys=False,
+                )
+            }
+        else:
+            return {}
+
 
 class FakeCompanionServiceFactory:
     def __init__(self, state, app, loop):
@@ -331,36 +385,13 @@ class FakeCompanionService(CompanionServerAuth, asyncio.Protocol):
             return
         elif self.state.rti_text is None:
             self.send_response(message, {})
+            self.state.rti_clients.append(self)
         elif self.state.rti_session_uuid is not None:
             _LOGGER.warning("RTI session already started")
         else:
             self.state.rti_session_uuid = b"0123456789abcdef"
-            self.send_response(
-                message,
-                {
-                    "_tiD": plistlib.dumps(
-                        {
-                            "$top": {
-                                "sessionUUID": plistlib.UID(1),
-                                "documentState": plistlib.UID(2),
-                            },
-                            "$objects": [
-                                "$null",
-                                self.state.rti_session_uuid,
-                                {
-                                    "docSt": plistlib.UID(3),
-                                },
-                                {
-                                    "contextBeforeInput": plistlib.UID(4),
-                                },
-                                self.state.rti_text,
-                            ],
-                        },
-                        fmt=plistlib.PlistFormat.FMT_BINARY,
-                        sort_keys=False,
-                    )
-                },
-            )
+            self.send_response(message, self.state.rti_encoded_data)
+            self.state.rti_clients.append(self)
 
     def handle__tistop(self, message):
         if message["_t"] != 2:
@@ -368,6 +399,7 @@ class FakeCompanionService(CompanionServerAuth, asyncio.Protocol):
         elif self.state.rti_session_uuid is not None:
             self.state.rti_session_uuid = None
             self.send_response(message, {})
+            self.state.rti_clients.remove(self)
         else:
             _LOGGER.warning("No RTI session")
 
@@ -416,5 +448,8 @@ class FakeCompanionUseCases:
         """Set media control flags."""
         self.state.media_control_flags = flags
 
+    def set_rti_focus_state(self, state: KeyboardFocusState) -> None:
+        self.state.rti_focus_state = state
+
     def set_rti_text(self, text: Optional[str]) -> None:
         self.state.rti_text = text
diff --git a/tests/protocols/companion/test_companion_functional.py b/tests/protocols/companion/test_companion_functional.py
index 7442d17b..2185d315 100644
--- a/tests/protocols/companion/test_companion_functional.py
+++ b/tests/protocols/companion/test_companion_functional.py
@@ -10,7 +10,7 @@ import pytest
 import pyatv
 from pyatv import exceptions
 from pyatv.conf import AppleTV, ManualService
-from pyatv.const import Protocol
+from pyatv.const import KeyboardFocusState, Protocol
 from pyatv.interface import App, FeatureName, FeatureState, UserAccount
 
 from tests.fake_device.companion import INITIAL_RTI_TEXT, INITIAL_VOLUME, VOLUME_STEP
@@ -207,6 +207,22 @@ async def test_audio_volume_down(companion_client, companion_state):
     assert companion_state.latest_button == "volume_down"
 
 
+async def test_text_input_text_focus_state(companion_client, companion_usecase):
+    state = companion_client.keyboard.text_focus_state
+    assert state == KeyboardFocusState.Focused
+
+    companion_usecase.set_rti_focus_state(KeyboardFocusState.Unfocused)
+    await until(
+        lambda: companion_client.keyboard.text_focus_state
+        == KeyboardFocusState.Unfocused
+    )
+
+    companion_usecase.set_rti_focus_state(KeyboardFocusState.Focused)
+    await until(
+        lambda: companion_client.keyboard.text_focus_state == KeyboardFocusState.Focused
+    )
+
+
 async def test_text_input_text_get(companion_client, companion_usecase):
     text = await companion_client.keyboard.text_get()
     assert text == INITIAL_RTI_TEXT
@@ -219,21 +235,21 @@ async def test_text_input_text_get(companion_client, companion_usecase):
 async def test_text_input_text_get_when_no_keyboard(
     companion_client, companion_usecase
 ):
-    companion_usecase.set_rti_text(None)
+    companion_usecase.set_rti_focus_state(KeyboardFocusState.Unfocused)
     text = await companion_client.keyboard.text_get()
     assert text is None
 
 
 async def test_text_input_text_clear(companion_client, companion_state):
     await companion_client.keyboard.text_clear()
-    assert companion_state.rti_text == ""
+    await until(lambda: companion_state.rti_text == "")
 
 
 async def test_text_input_text_append(companion_client, companion_state):
     await companion_client.keyboard.text_append("test")
-    assert companion_state.rti_text == INITIAL_RTI_TEXT + "test"
+    await until(lambda: companion_state.rti_text == INITIAL_RTI_TEXT + "test")
 
 
 async def test_text_input_text_set(companion_client, companion_state):
     await companion_client.keyboard.text_set("test")
-    assert companion_state.rti_text == "test"
+    await until(lambda: companion_state.rti_text == "test")
diff --git a/tests/protocols/companion/test_companion_interface.py b/tests/protocols/companion/test_companion_interface.py
new file mode 100644
index 00000000..390266d2
--- /dev/null
+++ b/tests/protocols/companion/test_companion_interface.py
@@ -0,0 +1,62 @@
+import asyncio
+from typing import Any, Mapping, cast
+from unittest.mock import Mock
+
+import pytest
+
+from pyatv import Core
+from pyatv.const import KeyboardFocusState
+from pyatv.core import MessageDispatcher, ProtocolStateDispatcher, UpdatedState
+from pyatv.protocols.companion import CompanionKeyboard
+
+pytestmark = pytest.mark.asyncio
+
+
+class CompanionApiMock(MessageDispatcher[str, Mapping[str, Any]]):
+    async def inject(self, event_name: str, data: Mapping[str, Any]) -> None:
+        await asyncio.gather(*self.dispatch(event_name, data))
+
+
+class CompanionCoreMock:
+    def __init__(
+        self,
+        state_dispatcher: ProtocolStateDispatcher,
+    ) -> None:
+        self.state_dispatcher = state_dispatcher
+
+
+@pytest.fixture(name="api_mock")
+def api_mock_fixture(event_loop):
+    yield CompanionApiMock()
+
+
+@pytest.fixture(name="keyboard")
+def audio_fixture(api_mock, companion_state_dispatcher):
+    yield CompanionKeyboard(
+        api_mock, cast(Core, CompanionCoreMock(companion_state_dispatcher))
+    )
+
+
+@pytest.mark.parametrize(
+    "event,data,expected_volume",
+    [
+        ("_tiStarted", {"_tiD": b""}, KeyboardFocusState.Focused),
+        ("_tiStopped", {}, KeyboardFocusState.Unfocused),
+    ],
+)
+async def test_keyboard_handle_text_input_dispatches(
+    api_mock,
+    keyboard,
+    companion_state_dispatcher,
+    event,
+    data,
+    expected_volume,
+):
+    callback = Mock()
+    companion_state_dispatcher.listen_to(UpdatedState.KeyboardFocus, callback)
+
+    await api_mock.inject(event, data)
+
+    assert callback.called is True
+    message = callback.call_args.args[0]
+    assert message.value == expected_volume