Repository: postlund/pyatv (python)

Issue:
Support for the audio interface in RAOP
**Short feature/function description**
I added the audio interface a while back in #1074, but it has not been implemented for any protocol yet. It should be pretty easy to implement for RAOP, so I would like to do that before releasing 0.8.0. I should also implement `volume_up` and `volume_down` in the remote control interface.

**What needs to be done?**

* Read out current volume and don't set a default volume level (I think this is possible by just sending `volume: 0.0` and I also think it's available via `/info` or `/server-info` for devices supporting those endpoints)
* Implement the audio interface, which should be trivial
* Add the remote control interface and implement volume controls
* Update documentation and tests

**Is this a breaking change?**
No

**Anything else worth knowing?**
When I experimented with the DACP remote, e.g. getting events about buttons being pressed on the receiver, I did actually get volume updates. That was the only thing I got, so I should probably implement support for those events as well to be more complete.

Pull request:
raop: Add support for volume controls
Fixes #1129

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



Patch:
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index 9f1f9bd3..8e8f5e2d 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -1129,7 +1129,10 @@ class Features:
 
 
 class Audio:
-    &#34;&#34;&#34;Base class for audio functionality.&#34;&#34;&#34;
+    &#34;&#34;&#34;Base class for audio functionality.
+
+    Volume level is managed in percent where 0 is muted and 100 is max volume.
+    &#34;&#34;&#34;
 
     @property  # type: ignore
     @feature(45, &#34;Volume&#34;, &#34;Current volume level.&#34;)
@@ -1686,13 +1689,17 @@ async def launch_app(self, bundle_id: str) -&gt; None:
 <span>class <span class="ident">Audio</span></span>
 </code></dt>
 <dd>
-<section class="desc"><p>Base class for audio functionality.</p></section>
+<section class="desc"><p>Base class for audio functionality.</p>
+<p>Volume level is managed in percent where 0 is muted and 100 is max volume.</p></section>
 <details class="source">
 <summary>
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">class Audio:
-    &#34;&#34;&#34;Base class for audio functionality.&#34;&#34;&#34;
+    &#34;&#34;&#34;Base class for audio functionality.
+
+    Volume level is managed in percent where 0 is muted and 100 is max volume.
+    &#34;&#34;&#34;
 
     @property  # type: ignore
     @feature(45, &#34;Volume&#34;, &#34;Current volume level.&#34;)
@@ -1707,6 +1714,7 @@ async def launch_app(self, bundle_id: str) -&gt; None:
 </details>
 <h3>Subclasses</h3>
 <ul class="hlist">
+<li>pyatv.raop.RaopAudio</li>
 <li>pyatv.support.facade.FacadeAudio</li>
 </ul>
 <h3>Instance variables</h3>
@@ -3464,6 +3472,7 @@ def stop(self) -&gt; None:
 <ul class="hlist">
 <li>pyatv.dmap.DmapRemoteControl</li>
 <li>pyatv.mrp.MrpRemoteControl</li>
+<li>pyatv.raop.RaopRemoteControl</li>
 <li>pyatv.support.facade.FacadeRemoteControl</li>
 </ul>
 <h3>Methods</h3>
diff --git a/docs/documentation/supported_features.md b/docs/documentation/supported_features.md
index 5c0dac4a..dad3c5d7 100644
--- a/docs/documentation/supported_features.md
+++ b/docs/documentation/supported_features.md
@@ -185,15 +185,18 @@ AirTunes).
 * Stream files with {% include api i="interface.Stream.stream_file" %}
 * Metadata is read from file and sent to receiver (artist, album and title)
 * Supports WAV, MP3, FLAG and OGG as file format (also for metadata)
-* Metadata
+* Metadata (device state, media type, title, artist, album, position, total_time)
 * Push Updates
+* Volume controls (volume level, set_volume, volume_up, volume_down)
+* One stream can be played at the time (second call raises {% include api i="exceptions.InvalidStateError" %})
 
 ### Limitations and notes
 
 * Metadata and push updates only reflect what pyatv is currently playing as there
   seems to not be possible to get current play state from an AirPlay receiver
 * Devices requiring password are not supported
-* Remote control commands does not work, e.g. play or pause {% include issue no="1068" %}
+* Remote control commands does not work (except for volume_up and volume_down),
+  e.g. play or pause {% include issue no="1068" %}
 
 ### Verified Devices
 
diff --git a/pyatv/interface.py b/pyatv/interface.py
index 2f789e4a..83c9a133 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -889,7 +889,10 @@ class Features:
 
 
 class Audio:
-    """Base class for audio functionality."""
+    """Base class for audio functionality.
+
+    Volume level is managed in percent where 0 is muted and 100 is max volume.
+    """
 
     @property  # type: ignore
     @feature(45, "Volume", "Current volume level.")
diff --git a/pyatv/raop/__init__.py b/pyatv/raop/__init__.py
index 10860cf7..4c24d32e 100644
--- a/pyatv/raop/__init__.py
+++ b/pyatv/raop/__init__.py
@@ -2,17 +2,20 @@
 
 import asyncio
 import logging
+import math
 from typing import Any, Awaitable, Callable, Dict, Optional, Set, Tuple, cast
 
 from pyatv import conf, const, exceptions
 from pyatv.airplay.srp import LegacyCredentials
 from pyatv.const import FeatureName, FeatureState, Protocol
 from pyatv.interface import (
+    Audio,
     FeatureInfo,
     Features,
     Metadata,
     Playing,
     PushUpdater,
+    RemoteControl,
     StateProducer,
     Stream,
 )
@@ -20,11 +23,19 @@ from pyatv.raop.metadata import EMPTY_METADATA, AudioMetadata, get_metadata
 from pyatv.raop.miniaudio import MiniaudioWrapper
 from pyatv.raop.raop import PlaybackInfo, RaopClient, RaopListener
 from pyatv.raop.rtsp import RtspContext, RtspSession
-from pyatv.support.http import ClientSessionManager, http_connect
+from pyatv.support import map_range
+from pyatv.support.http import ClientSessionManager, HttpConnection, http_connect
 from pyatv.support.relayer import Relayer
 
 _LOGGER = logging.getLogger(__name__)
 
+INITIAL_VOLUME = 33.0  # Percent
+
+DBFS_MIN = -30.0
+DBFS_MAX = 0.0
+PERCENTAGE_MIN = 0.0
+PERCENTAGE_MAX = 100.0
+
 
 class RaopPushUpdater(PushUpdater):
     """Implementation of push update support for RAOP."""
@@ -64,29 +75,75 @@ class RaopPushUpdater(PushUpdater):
             _LOGGER.debug("Playstatus error occurred: %s", ex)
 
 
-class RaopStateManager:
+class RaopPlaybackManager:
     """Manage current play state for RAOP."""
 
-    def __init__(self) -> None:
-        """Initialize a new RaopStateManager instance."""
+    def __init__(self, address: str, port: int) -> None:
+        """Initialize a new RaopPlaybackManager instance."""
         self.playback_info: Optional[PlaybackInfo] = None
+        self._is_acquired: bool = False
+        self._address: str = address
+        self._port: int = port
+        self._context: RtspContext = RtspContext()
+        self._connection: Optional[HttpConnection] = None
+        self._rtsp: Optional[RtspSession] = None
+        self._raop: Optional[RaopClient] = None
+
+    @property
+    def context(self) -> RtspContext:
+        """Return RTSP context if a session is active."""
+        return self._context
+
+    @property
+    def raop(self) -> Optional[RaopClient]:
+        """Return RAOP client if a session is active."""
+        return self._raop
+
+    def acquire(self) -> None:
+        """Acquire playback manager for playback."""
+        if self._is_acquired:
+            raise exceptions.InvalidStateError("already streaming to device")
+
+        self._is_acquired = True
+
+    async def setup(self) -> Tuple[RaopClient, RtspSession, RtspContext]:
+        """Set up a session or return active if it exists."""
+        if self._raop and self._rtsp and self._context:
+            return self._raop, self._rtsp, self._context
+
+        self._connection = await http_connect(self._address, self._port)
+        self._rtsp = RtspSession(self._connection, self._context)
+        self._raop = RaopClient(self._rtsp, self._context)
+        return self._raop, self._rtsp, self._context
+
+    async def teardown(self) -> None:
+        """Tear down and disconnect current session."""
+        if self._raop:
+            self._raop.close()
+        if self._connection:
+            self._connection = None
+        self._raop = None
+        self._context.reset()
+        self._rtsp = None
+        self._connection = None
+        self._is_acquired = False
 
 
 class RaopMetadata(Metadata):
     """Implementation of metadata interface for RAOP."""
 
-    def __init__(self, state_manager: RaopStateManager) -> None:
+    def __init__(self, playback_manager: RaopPlaybackManager) -> None:
         """Initialize a new RaopMetadata instance."""
-        self._state_manager = state_manager
+        self._playback_manager = playback_manager
 
     async def playing(self) -> Playing:
         """Return what is currently playing."""
-        if self._state_manager.playback_info is None:
+        if self._playback_manager.playback_info is None:
             return Playing(
                 device_state=const.DeviceState.Idle, media_type=const.MediaType.Unknown
             )
 
-        metadata = self._state_manager.playback_info.metadata
+        metadata = self._playback_manager.playback_info.metadata
         total_time = int(metadata.duration) if metadata.duration else None
         return Playing(
             device_state=const.DeviceState.Playing,
@@ -94,7 +151,7 @@ class RaopMetadata(Metadata):
             title=metadata.title,
             artist=metadata.artist,
             album=metadata.album,
-            position=int(self._state_manager.playback_info.position),
+            position=int(self._playback_manager.playback_info.position),
             total_time=total_time,
         )
 
@@ -102,18 +159,20 @@ class RaopMetadata(Metadata):
 class RaopFeatures(Features):
     """Implementation of supported feature functionality."""
 
-    def __init__(self, state_manager: RaopStateManager) -> None:
+    def __init__(self, playback_manager: RaopPlaybackManager) -> None:
         """Initialize a new RaopFeatures instance."""
-        self.state_manager = state_manager
+        self.playback_manager = playback_manager
 
-    def get_feature(self, feature_name: FeatureName) -> FeatureInfo:
+    def get_feature(  # pylint: disable=too-many-return-statements
+        self, feature_name: FeatureName
+    ) -> FeatureInfo:
         """Return current state of a feature."""
         if feature_name == FeatureName.StreamFile:
             return FeatureInfo(FeatureState.Available)
 
         metadata = EMPTY_METADATA
-        if self.state_manager.playback_info:
-            metadata = self.state_manager.playback_info.metadata
+        if self.playback_manager.playback_info:
+            metadata = self.playback_manager.playback_info.metadata
 
         if feature_name == FeatureName.Title:
             return self._availability(metadata.title)
@@ -124,6 +183,16 @@ class RaopFeatures(Features):
         if feature_name in [FeatureName.Position, FeatureName.TotalTime]:
             return self._availability(metadata.duration)
 
+        # As far as known, volume controls are always supported
+        if feature_name in [
+            FeatureName.SetVolume,
+            FeatureName.Volume,
+            FeatureName,
+            FeatureName.VolumeDown,
+            FeatureName.VolumeUp,
+        ]:
+            return FeatureInfo(FeatureState.Available)
+
         return FeatureInfo(FeatureState.Unavailable)
 
     @staticmethod
@@ -133,6 +202,52 @@ class RaopFeatures(Features):
         )
 
 
+class RaopAudio(Audio):
+    """Implementation of audio functionality."""
+
+    def __init__(self, playback_manager: RaopPlaybackManager):
+        """Initialize a new RaopAudio instance."""
+        self.playback_manager = playback_manager
+
+    @property
+    def has_changed_volume(self) -> bool:
+        """Return whether volume has changed from default or not."""
+        return self.playback_manager.context.volume is not None
+
+    @property
+    def volume(self) -> float:
+        """Return current volume level."""
+        vol = self.playback_manager.context.volume
+        if vol is None:
+            return INITIAL_VOLUME
+
+        # AirPlay uses -144.0 as "muted", but we treat everything below -30.0 as
+        # muted to be a bit defensive
+        if vol < DBFS_MIN:
+            return PERCENTAGE_MIN
+
+        # Map dBFS to percentage
+        return map_range(vol, DBFS_MIN, DBFS_MAX, PERCENTAGE_MIN, PERCENTAGE_MAX)
+
+    async def set_volume(self, level: float) -> None:
+        """Change current volume level."""
+        raop = self.playback_manager.raop
+
+        # AirPlay uses -144.0 as muted volume, so re-map 0.0 to that
+        if math.isclose(level, 0.0):
+            remapped = -144.0
+        else:
+            # Map percentage to dBFS
+            remapped = map_range(
+                level, PERCENTAGE_MIN, PERCENTAGE_MAX, DBFS_MIN, DBFS_MAX
+            )
+
+        if raop:
+            await raop.set_volume(remapped)
+        else:
+            self.playback_manager.context.volume = remapped
+
+
 class RaopStream(Stream):
     """Implementation of stream functionality."""
 
@@ -141,22 +256,22 @@ class RaopStream(Stream):
         address: str,
         service: conf.RaopService,
         listener: RaopListener,
-        loop: asyncio.AbstractEventLoop,
+        audio: RaopAudio,
+        playback_manager: RaopPlaybackManager,
     ) -> None:
         """Initialize a new RaopStream instance."""
-        self.loop = loop
         self.address = address
         self.service = service
         self.listener = listener
+        self.audio = audio
+        self.playback_manager = playback_manager
 
     async def stream_file(self, filename: str, **kwargs) -> None:
         """Stream local file to device.
 
         INCUBATING METHOD - MIGHT CHANGE IN THE FUTURE!
         """
-        connection = await http_connect(self.address, self.service.port)
-        context = RtspContext()
-        session = RtspSession(connection, context)
+        self.playback_manager.acquire()
 
         # For now, we hi-jack credentials from AirPlay (even though they are passed via
         # the RAOP service) and use the same verification procedure as AirPlay, since
@@ -166,8 +281,10 @@ class RaopStream(Stream):
             if self.service.credentials
             else None
         )
-        client = RaopClient(cast(RtspSession, session), context, credentials)
         try:
+            client, _, context = await self.playback_manager.setup()
+            client.credentials = credentials
+
             client.listener = self.listener
             await client.initialize(self.service.properties)
 
@@ -187,11 +304,39 @@ class RaopStream(Stream):
             except Exception as ex:
                 _LOGGER.warning("Failed to extract metadata from %s: %s", filename, ex)
 
+            # If the user didn't change volume level prior to streaming, try to extract
+            # volume level from device (if supported). Otherwise set the default level
+            # in pyatv.
+            if not self.audio.has_changed_volume and "initialVolume" in client.info:
+                initial_volume = client.info["initialVolume"]
+                if not isinstance(initial_volume, float):
+                    raise exceptions.ProtocolError(
+                        f"initial volume {initial_volume} has "
+                        "incorrect type {type(initial_volume)}",
+                    )
+                context.volume = initial_volume
+            else:
+                await self.audio.set_volume(self.audio.volume)
+
             await client.send_audio(audio_file, metadata)
         finally:
-            if client:
-                client.close()
-            connection.close()
+            await self.playback_manager.teardown()
+
+
+class RaopRemoteControl(RemoteControl):
+    """Implementation of remote control functionality."""
+
+    def __init__(self, audio: RaopAudio):
+        """Initialize a new RaopRemoteControl instance."""
+        self.audio = audio
+
+    async def volume_up(self) -> None:
+        """Press key volume up."""
+        await self.audio.set_volume(min(self.audio.volume + 5.0, 100.0))
+
+    async def volume_down(self) -> None:
+        """Press key volume down."""
+        await self.audio.set_volume(max(self.audio.volume - 5.0, 0.0))
 
 
 def setup(
@@ -207,8 +352,8 @@ def setup(
     service = config.get_service(Protocol.RAOP)
     assert service is not None
 
-    state_manager = RaopStateManager()
-    metadata = RaopMetadata(state_manager)
+    playback_manager = RaopPlaybackManager(str(config.address), service.port)
+    metadata = RaopMetadata(playback_manager)
     push_updater = RaopPushUpdater(metadata, loop)
 
     class RaopStateListener(RaopListener):
@@ -216,12 +361,12 @@ def setup(
 
         def playing(self, playback_info: PlaybackInfo) -> None:
             """Media started playing with metadata."""
-            state_manager.playback_info = playback_info
+            playback_manager.playback_info = playback_info
             self._trigger()
 
         def stopped(self) -> None:
             """Media stopped playing."""
-            state_manager.playback_info = None
+            playback_manager.playback_info = None
             self._trigger()
 
         @staticmethod
@@ -233,13 +378,19 @@ def setup(
     service = cast(conf.RaopService, service)
 
     raop_listener = RaopStateListener()
+    raop_audio = RaopAudio(playback_manager)
 
     interfaces[Stream].register(
-        RaopStream(str(config.address), service, raop_listener, loop), Protocol.RAOP
+        RaopStream(
+            str(config.address), service, raop_listener, raop_audio, playback_manager
+        ),
+        Protocol.RAOP,
     )
-    interfaces[Features].register(RaopFeatures(state_manager), Protocol.RAOP)
+    interfaces[Features].register(RaopFeatures(playback_manager), Protocol.RAOP)
     interfaces[PushUpdater].register(push_updater, Protocol.RAOP)
     interfaces[Metadata].register(metadata, Protocol.RAOP)
+    interfaces[Audio].register(raop_audio, Protocol.RAOP)
+    interfaces[RemoteControl].register(RaopRemoteControl(raop_audio), Protocol.RAOP)
 
     async def _connect() -> None:
         pass
@@ -259,6 +410,10 @@ def setup(
                 FeatureName.Title,
                 FeatureName.Position,
                 FeatureName.TotalTime,
+                FeatureName.SetVolume,
+                FeatureName.Volume,
+                FeatureName.VolumeUp,
+                FeatureName.VolumeDown,
             ]
         ),
     )
diff --git a/pyatv/raop/raop.py b/pyatv/raop/raop.py
index 9c904448..d540f6d4 100644
--- a/pyatv/raop/raop.py
+++ b/pyatv/raop/raop.py
@@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
 import asyncio
 import logging
 from time import monotonic
-from typing import Any, List, Mapping, NamedTuple, Optional, Tuple, cast
+from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, cast
 import wave
 import weakref
 
@@ -282,13 +282,12 @@ class RaopClient:
         self,
         rtsp: RtspSession,
         context: RtspContext,
-        credentials: Optional[LegacyCredentials],
     ):
         """Initialize a new RaopClient instance."""
         self.loop = asyncio.get_event_loop()
         self.rtsp: RtspSession = rtsp
         self.context: RtspContext = context
-        self.credentials: Optional[LegacyCredentials] = credentials
+        self.credentials: Optional[LegacyCredentials] = None
         self.control_client: Optional[ControlClient] = None
         self.timing_client: Optional[TimingClient] = None
         self._packet_backlog: PacketFifo = PacketFifo(PACKET_BACKLOG_SIZE)
@@ -297,6 +296,7 @@ class RaopClient:
         self._metadata: AudioMetadata = EMPTY_METADATA
         self._keep_alive_task: Optional[asyncio.Future] = None
         self._listener: Optional[weakref.ReferenceType[Any]] = None
+        self._info: Dict[str, object] = {}
 
     @property
     def listener(self):
@@ -321,6 +321,11 @@ class RaopClient:
         )
         return PlaybackInfo(metadata, self.context.position)
 
+    @property
+    def info(self) -> Dict[str, object]:
+        """Return value mappings for server /info values."""
+        return self._info
+
     def close(self):
         """Close session and free up resources."""
         if self.control_client:
@@ -384,6 +389,12 @@ class RaopClient:
             self.timing_client.port,
         )
 
+        self._info.update(await self.rtsp.info())
+        _LOGGER.debug("Updated info parameters to: %s", self.info)
+
+        # Set up the streaming session
+        await self._setup_session()
+
     def _update_output_properties(self, properties: Mapping[str, str]) -> None:
         (
             self.context.sample_rate,
@@ -425,6 +436,11 @@ class RaopClient:
             self.context.server_port,
         )
 
+    async def set_volume(self, volume: float) -> None:
+        """Change volume on the receiver."""
+        await self.rtsp.set_parameter("volume", str(volume))
+        self.context.volume = volume
+
     async def send_audio(  # pylint: disable=too-many-branches
         self, wave_file, metadata: AudioMetadata = EMPTY_METADATA
     ):
@@ -434,9 +450,6 @@ class RaopClient:
 
         transport = None
         try:
-            # Set up the streaming session
-            await self._setup_session()
-
             # Create a socket used for writing audio packets (ugly)
             transport, _ = await self.loop.create_datagram_endpoint(
                 AudioProtocol,
@@ -466,9 +479,6 @@ class RaopClient:
                     self.playback_info.metadata,
                 )
 
-            # Set a decent volume (range is [-30.0, 0] and -144 is muted)
-            await self.rtsp.set_parameter("volume", "-20")
-
             # Start keep-alive task to ensure connection is not closed by remote device
             feedback = await self.rtsp.feedback(allow_error=True)
             if feedback.code == 200:
diff --git a/pyatv/raop/rtsp.py b/pyatv/raop/rtsp.py
index 25ae9521..26f9c5da 100644
--- a/pyatv/raop/rtsp.py
+++ b/pyatv/raop/rtsp.py
@@ -1,6 +1,7 @@
 """Implementation of the RTSP protocol."""
 import asyncio
 import logging
+import plistlib
 from random import randrange
 from typing import Dict, Mapping, Optional, Tuple, Union
 
@@ -69,6 +70,8 @@ class RtspContext:
         self.dacp_id: str = f"{randrange(2 ** 64):X}"
         self.active_remote: int = randrange(2 ** 32)
 
+        self.volume: Optional[float] = None
+
     def reset(self) -> None:
         """Reset seasion.
 
@@ -112,6 +115,22 @@ class RtspSession:
         """Handle a connection error."""
         _LOGGER.error("Error received: %s", exc)
 
+    async def info(self) -> Dict[str, object]:
+        """Return device information."""
+        device_info = await self.exchange("GET", "/info", allow_error=True)
+
+        # If not supported, just return an empty dict
+        if device_info.code != 200:
+            _LOGGER.debug("Device does not support /info")
+            return {}
+
+        body = (
+            device_info.body
+            if isinstance(device_info.body, bytes)
+            else device_info.body.encode("utf-8")
+        )
+        return plistlib.loads(body)
+
     async def auth_setup(self) -> HttpResponse:
         """Send auth-setup message."""
         # Payload to say that we want to proceed unencrypted
diff --git a/pyatv/support/__init__.py b/pyatv/support/__init__.py
index 6d874d0d..ed707e27 100644
--- a/pyatv/support/__init__.py
+++ b/pyatv/support/__init__.py
@@ -85,3 +85,16 @@ def deprecated(func):
         return func(*args, **kwargs)
 
     return new_func
+
+
+def map_range(
+    value: float, in_min: float, in_max: float, out_min: float, out_max: float
+) -> float:
+    """Map a value in one range to another."""
+    if in_max - in_min <= 0.0:
+        raise ValueError("invalid input range")
+    if out_max - out_min <= 0.0:
+        raise ValueError("invalid output range")
+    if value < in_min or value > in_max:
+        raise ValueError("input value out of range")
+    return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
diff --git a/pyatv/support/collections.py b/pyatv/support/collections.py
index cf8c4eed..a91496bb 100644
--- a/pyatv/support/collections.py
+++ b/pyatv/support/collections.py
@@ -93,3 +93,11 @@ class CaseInsensitiveDict(  # pylint: disable=too-many-ancestors
                     return False
             return True
         raise NotImplementedError
+
+    def __str__(self) -> str:
+        """Return string representation of instance."""
+        return str(self._data)
+
+    def __repr__(self) -> str:
+        """Return representation of instance."""
+        return repr(self._data)
diff --git a/pyatv/support/facade.py b/pyatv/support/facade.py
index 3090f4a2..421bd31c 100644
--- a/pyatv/support/facade.py
+++ b/pyatv/support/facade.py
@@ -347,11 +347,17 @@ class FacadeAudio(Relayer, interface.Audio):
     @property
     def volume(self) -> float:
         """Return current volume level."""
-        return self.relay("volume")
+        volume = self.relay("volume")
+        if 0.0 <= volume <= 100.0:
+            return volume
+        raise exceptions.ProtocolError(f"volume {volume} is out of range")
 
     async def set_volume(self, level: float) -> None:
         """Change current volume level."""
-        await self.relay("set_volume")(level)
+        if 0.0 <= level <= 100.0:
+            await self.relay("set_volume")(level)
+        else:
+            raise exceptions.ProtocolError(f"volume {level} is out of range")
 
 
 class FacadeAppleTV(interface.AppleTV):
diff --git a/pyatv/support/http.py b/pyatv/support/http.py
index b3c25cdc..2123d124 100644
--- a/pyatv/support/http.py
+++ b/pyatv/support/http.py
@@ -438,9 +438,15 @@ class BasicHttpServer(asyncio.Protocol):
         """Handle incoming HTTP request."""
         _LOGGER.debug("Received: %s", data)
 
+        # Process all requests in packet
+        while data:
+            data = self._parse_and_send_next(data)
+
+    def _parse_and_send_next(self, data: bytes):
         resp: Optional[HttpResponse] = None
+        rest: bytes = b""
         try:
-            request, _ = parse_request(data)
+            request, rest = parse_request(data)
 
             # TODO: If no request could be parsed, then there's not enough data.
             # Segmented requests (over several IP packets) are currently not
@@ -473,6 +479,8 @@ class BasicHttpServer(asyncio.Protocol):
         if self.transport:
             self.transport.write(response.encode("utf-8") + b"\r\n" + body)
 
+        return rest
+
 
 class StaticFileWebServer:
     """Web server serving only a single file."""


Test patch:
diff --git a/tests/fake_device/raop.py b/tests/fake_device/raop.py
index 6bedbd36..b671f0ba 100644
--- a/tests/fake_device/raop.py
+++ b/tests/fake_device/raop.py
@@ -1,6 +1,8 @@
 """Fake RAOP device for tests."""
 import asyncio
+from functools import wraps
 import logging
+import plistlib
 from types import SimpleNamespace
 from typing import Dict, Optional, cast
 
@@ -20,6 +22,20 @@ from pyatv.support.http import (
 
 _LOGGER = logging.getLogger(__name__)
 
+INITIAL_VOLUME = -15.0
+
+
+def requires_auth(method):
+    @wraps(method)
+    def _impl(self, request: HttpRequest, *args, **kwargs):
+        if self.state.auth_required and not self.state.auth_setup_performed:
+            return HttpResponse(
+                "RTSP", "1.0", 403, "Forbidden", {"CSeq": request.headers["CSeq"]}, b""
+            )
+        return method(self, request, *args, **kwargs)
+
+    return _impl
+
 
 def alac_decode(data: bytes) -> bytes:
     """Decode an ALAC frame and return raw audio data."""
@@ -37,6 +53,7 @@ class FakeRaopState:
     def __init__(self):
         self.metadata = SimpleNamespace(title=None, artist=None, album=None)
         self.audio_packets: Dict[int, bytes] = {}  # seqo -> raw audio
+        self.auth_required: bool = False
         self.auth_setup_performed: bool = False
         self.supports_retransmissions: bool = True
         self.supports_feedback: bool = True
@@ -45,6 +62,9 @@ class FakeRaopState:
         self.drop_packets: int = 0
         self.control_port: int = 0
         self.remote_address: Optional[str] = None
+        self.volume: float = INITIAL_VOLUME
+        self.initial_audio_level_supported: bool = False
+        self.info_supported: bool = True
 
     @property
     def raw_audio(self) -> bytes:
@@ -227,6 +247,7 @@ class FakeRaopService(HttpSimpleRouter):
         self.add_route("POST", "/feedback", self.handle_feedback)
         self.add_route("RECORD", "rtsp://*", self.handle_record)
         self.add_route("POST", "/auth-setup", self.handle_auth_setup)
+        self.add_route("GET", "/info", self.handle_info)
 
     async def start(self, start_web_server: bool):
         """Start the fake RAOP service."""
@@ -268,6 +289,7 @@ class FakeRaopService(HttpSimpleRouter):
         if self._control_server:
             self._control_server.close()
 
+    @requires_auth
     def handle_announce(self, request: HttpRequest) -> Optional[HttpResponse]:
         """Handle incoming ANNOUNCE request."""
         _LOGGER.debug("Received ANNOUNCE: %s", request)
@@ -280,6 +302,7 @@ class FakeRaopService(HttpSimpleRouter):
             "RTSP", "1.0", 200, "OK", {"CSeq": request.headers["CSeq"]}, b""
         )
 
+    @requires_auth
     def handle_setup(self, request: HttpRequest) -> Optional[HttpResponse]:
         """Handle incoming SETUP request."""
         _LOGGER.debug("Received SETUP: %s", request)
@@ -297,6 +320,7 @@ class FakeRaopService(HttpSimpleRouter):
         }
         return HttpResponse("RTSP", "1.0", 200, "OK", headers, b"")
 
+    @requires_auth
     def handle_set_parameter(self, request: HttpRequest) -> Optional[HttpResponse]:
         """Handle incoming SET_PARAMETER request."""
         _LOGGER.debug("Received SET_PARAMETER: %s", request)
@@ -305,10 +329,23 @@ class FakeRaopService(HttpSimpleRouter):
             self.state.metadata.title = parser.first(tags, "mlit", "minm")
             self.state.metadata.artist = parser.first(tags, "mlit", "asar")
             self.state.metadata.album = parser.first(tags, "mlit", "asal")
+        elif request.body.startswith("volume:"):
+            self.state.volume = float(request.body.split(" ", maxsplit=1)[1])
+            _LOGGER.debug("Changing volume to %f", self.state.volume)
+        else:
+            return HttpResponse(
+                "RTSP",
+                "1.0",
+                501,
+                "Not implemented",
+                {"CSeq": request.headers["CSeq"]},
+                b"",
+            )
         return HttpResponse(
             "RTSP", "1.0", 200, "OK", {"CSeq": request.headers["CSeq"]}, b""
         )
 
+    @requires_auth
     def handle_feedback(self, request: HttpRequest) -> Optional[HttpResponse]:
         """Handle incoming feedback request."""
         _LOGGER.debug("Received feedback: %s", request)
@@ -326,6 +363,7 @@ class FakeRaopService(HttpSimpleRouter):
             "RTSP", "1.0", 200, "OK", {"CSeq": request.headers["CSeq"]}, b""
         )
 
+    @requires_auth
     def handle_record(self, request: HttpRequest) -> Optional[HttpResponse]:
         """Handle incoming RECORD request."""
         _LOGGER.debug("Received RECORD: %s", request)
@@ -334,6 +372,7 @@ class FakeRaopService(HttpSimpleRouter):
         )
 
     def handle_auth_setup(self, request: HttpRequest) -> Optional[HttpResponse]:
+        """Handle incoming auth-setup request."""
         _LOGGER.debug("Received auth-setup: %s", request)
         # Just check if decent sized payload is there
         if len(request.body) == 1 + 32:  # auth type + public key
@@ -345,6 +384,34 @@ class FakeRaopService(HttpSimpleRouter):
             "RTSP", "1.0", 403, "Forbidden", {"CSeq": request.headers["CSeq"]}, b""
         )
 
+    def handle_info(self, request: HttpRequest) -> Optional[HttpRequest]:
+        """Handle incoming info request."""
+        if not self.state.info_supported:
+            return HttpResponse(
+                "RTSP",
+                "1.0",
+                400,
+                "Bad Request",
+                {
+                    "CSeq": request.headers["CSeq"],
+                },
+                b"",
+            )
+        info = {}
+        if self.state.initial_audio_level_supported:
+            info["initialVolume"] = self.state.volume
+        return HttpResponse(
+            "RTSP",
+            "1.0",
+            200,
+            "OK",
+            {
+                "CSeq": request.headers["CSeq"],
+                "content-type": "application/x-apple-binary-plist",
+            },
+            plistlib.dumps(info),
+        )
+
 
 class FakeRaopUseCases:
     """Wrapper for altering behavior of a FakeRaopService instance."""
@@ -364,3 +431,15 @@ class FakeRaopUseCases:
     def feedback_enabled(self, enabled: bool) -> None:
         """Enable or disable support for /feedback endpoint."""
         self.state.supports_feedback = enabled
+
+    def initial_audio_level_supported(self, supported: bool) -> None:
+        """Initial audio level reported in info from device."""
+        self.state.initial_audio_level_supported = supported
+
+    def require_auth(self, is_required: bool) -> None:
+        """Enable or disable requirement to perform authentication."""
+        self.state.auth_required = is_required
+
+    def supports_info(self, is_supported: bool) -> None:
+        """State if /info is supported or not."""
+        self.state.info_supported = is_supported
diff --git a/tests/raop/test_raop_functional.py b/tests/raop/test_raop_functional.py
index 6a47675b..479e9ded 100644
--- a/tests/raop/test_raop_functional.py
+++ b/tests/raop/test_raop_functional.py
@@ -6,11 +6,14 @@ TODO: Things to improve:
 * Improve sync tests
 """
 
+import asyncio
 import logging
-from typing import Dict, List, Tuple
+import math
+from typing import Dict, List
 
 import pytest
 
+from pyatv import exceptions, raop
 from pyatv.const import DeviceState, FeatureName, FeatureState, MediaType
 from pyatv.interface import FeatureInfo, Playing, PushListener
 
@@ -25,6 +28,12 @@ FRAMES_PER_PACKET = 352
 
 METADATA_FIELDS = [FeatureName.Title, FeatureName.Artist, FeatureName.Album]
 PROGRESS_FIELDS = [FeatureName.Position, FeatureName.TotalTime]
+VOLUME_FIELDS = [
+    FeatureName.SetVolume,
+    FeatureName.Volume,
+    FeatureName.VolumeUp,
+    FeatureName.VolumeDown,
+]
 
 
 @pytest.fixture(name="playing_listener")
@@ -34,12 +43,14 @@ async def playing_listener_fixture(raop_client):
             """Initialize a new PlayingListener instance."""
             self.updates: List[Playing] = []
             self.all_features: Dict[FeatureName, FeatureInfo] = {}
+            self.playing_event = asyncio.Event()
 
         def playstatus_update(self, updater, playstatus: Playing) -> None:
             """Inform about changes to what is currently playing."""
             self.updates.append(playstatus)
             if playstatus.device_state == DeviceState.Playing:
                 self.all_features = raop_client.features.all_features()
+                self.playing_event.set()
 
         def playstatus_error(self, updater, exception: Exception) -> None:
             """Inform about an error when updating play status."""
@@ -111,7 +122,9 @@ async def test_stream_complete_file(raop_client, raop_state):
 
 
 @pytest.mark.parametrize("raop_properties", [({"et": "4"})])
-async def test_stream_complete_legacy_auth(raop_client, raop_state):
+async def test_stream_complete_legacy_auth(raop_client, raop_state, raop_usecase):
+    raop_usecase.require_auth(True)
+
     await raop_client.stream.stream_file(data_path("audio_10_frames.wav"))
 
     assert raop_state.auth_setup_performed
@@ -246,3 +259,144 @@ async def test_send_feedback(raop_client, raop_usecase, raop_state, feedback_sup
         assert raop_state.feedback_packets_received > 1
     else:
         assert raop_state.feedback_packets_received == 1
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_set_volume_prior_to_streaming(raop_client, raop_state):
+    # Initial client sound level
+    assert math.isclose(raop_client.audio.volume, 33.0)
+
+    await raop_client.audio.set_volume(60)
+    assert math.isclose(raop_client.audio.volume, 60)
+
+    await raop_client.stream.stream_file(data_path("only_metadata.wav"))
+    assert math.isclose(raop_state.volume, -12.0)
+
+
+@pytest.mark.parametrize(
+    "raop_properties,initial_level_supported,sender_expected,receiver_expected",
+    [
+        # Device supports default level: use that
+        ({"et": "0"}, True, 50.0, -15.0),
+        # Device does NOT support default level: use pyatv default
+        ({"et": "0"}, False, 33.0, -20.1),
+    ],
+)
+async def test_use_default_volume_from_device(
+    raop_client,
+    raop_state,
+    raop_usecase,
+    initial_level_supported,
+    sender_expected,
+    receiver_expected,
+):
+    raop_usecase.initial_audio_level_supported(initial_level_supported)
+
+    # Prior to streaming, we don't know the volume of the receiver so return default level
+    assert math.isclose(raop_client.audio.volume, 33.0)
+
+    # Default level on remote device
+    assert math.isclose(raop_state.volume, -15.0)
+
+    await raop_client.stream.stream_file(data_path("only_metadata.wav"))
+
+    # Level on the client and receiver should match now
+    assert math.isclose(raop_state.volume, receiver_expected)
+    assert math.isclose(raop_client.audio.volume, sender_expected)
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_set_volume_during_playback(raop_client, raop_state, playing_listener):
+    # Set maximum volume as initial volume
+    await raop_client.audio.set_volume(100.0)
+
+    # Start playback in the background
+    future = asyncio.ensure_future(
+        raop_client.stream.stream_file(data_path("audio_3_packets.wav"))
+    )
+
+    # Wait for device to move to playing state and verify volume
+    await playing_listener.playing_event.wait()
+    assert math.isclose(raop_state.volume, -0.0)
+
+    # Change volume, which we now know will happen during playback
+    await raop_client.audio.set_volume(50.0)
+    assert math.isclose(raop_state.volume, -15.0)
+
+    await future
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_volume_features(raop_client):
+    assert_features_in_state(
+        raop_client.features.all_features(), VOLUME_FIELDS, FeatureState.Available
+    )
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_volume_up_volume_down(raop_client):
+    # Only test on the client as other tests should confirm that it is set correctly
+    # on the receiver
+    await raop_client.audio.set_volume(95.0)
+
+    # Increase by 5% if volume_up is called
+    await raop_client.remote_control.volume_up()
+    assert math.isclose(raop_client.audio.volume, 100.0)
+
+    # Stop at max level without any error
+    await raop_client.remote_control.volume_up()
+    assert math.isclose(raop_client.audio.volume, 100.0)
+
+    await raop_client.audio.set_volume(5.0)
+
+    # Decrease by 5% if volume_down is called
+    await raop_client.remote_control.volume_down()
+    assert math.isclose(raop_client.audio.volume, 0.0)
+
+    # Stop at min level without any error
+    await raop_client.remote_control.volume_down()
+    assert math.isclose(raop_client.audio.volume, 0.0)
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_only_allow_one_stream_at_the_time(raop_client):
+    # This is not pretty, but the idea is to start two concurrent streaming tasks, wait
+    # for them to finish and verify that one of them raised an exception. This is to
+    # avoid making any assumptions regarding in which order they are scheduled on the
+    # event loop.
+    result = await asyncio.gather(
+        raop_client.stream.stream_file(data_path("audio_3_packets.wav")),
+        raop_client.stream.stream_file(data_path("only_metadata.wav")),
+        return_exceptions=True,
+    )
+
+    result.remove(None)  # Should be one None for success and one exception
+    assert len(result) == 1
+    assert isinstance(result[0], exceptions.InvalidStateError)
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_muted_volume_from_receiver(raop_client, raop_state, raop_usecase):
+    raop_usecase.initial_audio_level_supported(True)
+    raop_state.volume = -144.0
+
+    await raop_client.stream.stream_file(data_path("only_metadata.wav"))
+
+    assert math.isclose(raop_client.audio.volume, 0.0)
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_mute_volume_from_client(raop_client, raop_state):
+    await raop_client.audio.set_volume(0.0)
+
+    await raop_client.stream.stream_file(data_path("only_metadata.wav"))
+
+    assert math.isclose(raop_state.volume, -144.0)
+
+
+@pytest.mark.parametrize("raop_properties", [({"et": "0"})])
+async def test_device_not_supporting_info_requests(raop_client, raop_usecase):
+    raop_usecase.supports_info(False)
+
+    # Should just not crash with an error if endpoint is not supported
+    await raop_client.stream.stream_file(data_path("only_metadata.wav"))
diff --git a/tests/support/test_facade.py b/tests/support/test_facade.py
index d5cd0672..be588249 100644
--- a/tests/support/test_facade.py
+++ b/tests/support/test_facade.py
@@ -7,7 +7,14 @@ import pytest
 from pyatv import exceptions
 from pyatv.conf import AppleTV
 from pyatv.const import FeatureName, Protocol
-from pyatv.interface import FeatureInfo, Features, FeatureState, Power, PushUpdater
+from pyatv.interface import (
+    Audio,
+    FeatureInfo,
+    Features,
+    FeatureState,
+    Power,
+    PushUpdater,
+)
 from pyatv.support.facade import FacadeAppleTV, SetupData
 
 
@@ -74,6 +81,18 @@ class DummyPower(Power):
         self.turn_off_called = True
 
 
+class DummyAudio(Audio):
+    def __init__(self, volume: float) -> None:
+        self._volume = volume
+
+    @property
+    def volume(self) -> float:
+        return self._volume
+
+    async def set_volume(self, level: float) -> None:
+        self._volume = volume
+
+
 @pytest.fixture(name="register_interface")
 def register_interface_fixture(facade_dummy):
     def _register_func(feature: FeatureName, instance, protocol: Protocol):
@@ -151,3 +170,19 @@ async def test_power_prefer_companion(feature, func, facade_dummy, register_inte
 
     assert not getattr(power_mrp, f"{func}_called")
     assert getattr(power_comp, f"{func}_called")
+
+
+@pytest.mark.parametrize("volume", [-0.1, 100.1])
+async def test_audio_get_volume_out_of_range(facade_dummy, register_interface, volume):
+    register_interface(FeatureName.Volume, DummyAudio(volume), Protocol.RAOP)
+
+    with pytest.raises(exceptions.ProtocolError):
+        facade_dummy.audio.volume
+
+
+@pytest.mark.parametrize("volume", [-0.1, 100.1])
+async def test_audio_set_volume_out_of_range(facade_dummy, register_interface, volume):
+    register_interface(FeatureName.Volume, DummyAudio(volume), Protocol.RAOP)
+
+    with pytest.raises(exceptions.ProtocolError):
+        await facade_dummy.audio.set_volume(volume)
diff --git a/tests/support/test_support.py b/tests/support/test_support.py
index c05dd97f..c14c0177 100644
--- a/tests/support/test_support.py
+++ b/tests/support/test_support.py
@@ -2,6 +2,7 @@
 
 import asyncio
 import logging
+import math
 import os
 from unittest.mock import MagicMock, patch
 
@@ -9,7 +10,7 @@ import pytest
 
 from pyatv import exceptions
 from pyatv.mrp.protobuf import ProtocolMessage
-from pyatv.support import error_handler, log_binary, log_protobuf
+from pyatv.support import error_handler, log_binary, log_protobuf, map_range
 
 
 class DummyException(Exception):
@@ -142,3 +143,29 @@ def test_protobuf_log_limit_message_max_length(logger, message):
 def test_protobuf_log_with_length_override(logger, message):
     log_protobuf(logger, "text", message)
     assert _debug_string(logger) == "text: id..."
+
+
+def test_map_range():
+    assert math.isclose(map_range(1.0, 0.0, 25.0, 0.0, 100.0), 4.0)
+
+
+@pytest.mark.parametrize(
+    "in_min,in_max,out_min,out_max",
+    [
+        # Bad in-ranges
+        (0.0, 0.0, 0.0, 1.0),
+        (1.0, 0.0, 0.0, 1.0),
+        # Bad out-ranges
+        (0.0, 1.0, 0.0, 0.0),
+        (0.0, 1.0, 1.0, 0.0),
+    ],
+)
+def test_map_range_bad_ranges(in_min, in_max, out_min, out_max):
+    with pytest.raises(ValueError):
+        map_range(1, in_min, in_max, out_min, out_max)
+
+
+@pytest.mark.parametrize("value", [-1.0, 11.0])
+def test_map_range_bad_input_values(value):
+    with pytest.raises(ValueError):
+        map_range(value, 0.0, 10.0, 20.0, 30.0)