Skip to content

Commit

Permalink
Merge pull request #12 from noahhusby/feat/advanced-transport-controls
Browse files Browse the repository at this point in the history
feat: add advanced transport controls
  • Loading branch information
noahhusby authored Sep 10, 2024
2 parents 14941ad + 102b227 commit 135f52a
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 45 deletions.
24 changes: 24 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
repos:
- repo: local
hooks:
- id: ruff-check
name: 🐶 Ruff Linter
language: system
types: [python]
entry: poetry run ruff check --fix
require_serial: true
stages: [commit, push, manual]
- id: ruff-format
name: 🐶 Ruff Formatter
language: system
types: [python]
entry: poetry run ruff format
require_serial: true
stages: [commit, push, manual]
- id: pytest
name: 🧪 Running tests and test coverage with pytest
language: system
types: [python]
entry: poetry run pytest
pass_filenames: false
5 changes: 3 additions & 2 deletions aiostreammagic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .exceptions import StreamMagicError, StreamMagicConnectionError
from .models import Info, PlayStateMetadata, PlayState, State, Source
from .stream_magic import StreamMagicClient

__all__ = [
"StreamMagicClient",
"StreamMagicError",
Expand All @@ -11,5 +12,5 @@
"Source",
"State",
"PlayState",
"PlayStateMetadata"
]
"PlayStateMetadata",
]
56 changes: 50 additions & 6 deletions aiostreammagic/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from enum import StrEnum

from mashumaro import field_options
from mashumaro.mixins.orjson import DataClassORJSONMixin
Expand All @@ -11,6 +12,7 @@
@dataclass
class Info(DataClassORJSONMixin):
"""Cambridge Audio device metadata."""

name: str = field(metadata=field_options(alias="name"))
model: str = field(metadata=field_options(alias="model"))
timezone: str = field(metadata=field_options(alias="timezone"))
Expand All @@ -23,6 +25,7 @@ class Info(DataClassORJSONMixin):
@dataclass
class Source(DataClassORJSONMixin):
"""Data class representing StreamMagic source."""

id: str = field(metadata=field_options(alias="id"))
name: str = field(metadata=field_options(alias="name"))
default_name: str = field(metadata=field_options(alias="default_name"))
Expand All @@ -43,7 +46,9 @@ class State(DataClassORJSONMixin):
pre_amp_state: bool = field(metadata=field_options(alias="pre_amp_state"))
volume_step: int = field(metadata=field_options(alias="volume_step"), default=None)
volume_db: int = field(metadata=field_options(alias="volume_db"), default=None)
volume_percent: int = field(metadata=field_options(alias="volume_percent"), default=None)
volume_percent: int = field(
metadata=field_options(alias="volume_percent"), default=None
)
mute: bool = field(metadata=field_options(alias="mute"), default=False)


Expand All @@ -52,11 +57,17 @@ class PlayState(DataClassORJSONMixin):
"""Data class representing StreamMagic play state."""

state: str = field(metadata=field_options(alias="state"), default="not_ready")
metadata: PlayStateMetadata = field(metadata=field_options(alias="metadata"), default=None)
presettable: bool = field(metadata=field_options(alias="presettable"), default=False)
metadata: PlayStateMetadata = field(
metadata=field_options(alias="metadata"), default=None
)
presettable: bool = field(
metadata=field_options(alias="presettable"), default=False
)
position: int = field(metadata=field_options(alias="position"), default=None)
mode_repeat: str = field(metadata=field_options(alias="mode_repeat"), default="off")
mode_shuffle: str = field(metadata=field_options(alias="mode_shuffle"), default="off")
mode_shuffle: str = field(
metadata=field_options(alias="mode_shuffle"), default="off"
)


@dataclass
Expand All @@ -68,7 +79,9 @@ class PlayStateMetadata(DataClassORJSONMixin):
name: str = field(metadata=field_options(alias="name"), default=None)
title: str = field(metadata=field_options(alias="title"), default=None)
art_url: str = field(metadata=field_options(alias="art_url"), default=None)
sample_format: str = field(metadata=field_options(alias="sample_format"), default=None)
sample_format: str = field(
metadata=field_options(alias="sample_format"), default=None
)
mqa: str = field(metadata=field_options(alias="mqa"), default=None)
signal: bool = field(metadata=field_options(alias="signal"), default=None)
codec: str = field(metadata=field_options(alias="codec"), default=None)
Expand All @@ -86,4 +99,35 @@ class PlayStateMetadata(DataClassORJSONMixin):
@dataclass
class NowPlaying(DataClassORJSONMixin):
"""Data class representing NowPlaying state."""
controls: list[str] = field(metadata=field_options(alias="controls"), default=None)

controls: list[TransportControl] = field(
metadata=field_options(alias="controls"), default=None
)


class TransportControl(StrEnum):
"""Control enum."""

PAUSE = "pause"
PLAY_PAUSE = "play_pause"
TOGGLE_SHUFFLE = "toggle_shuffle"
TOGGLE_REPEAT = "toggle_repeat"
TRACK_NEXT = "track_next"
TRACK_PREVIOUS = "track_previous"
SEEK = "seek"


class ShuffleMode(StrEnum):
"""Shuffle mode."""

OFF = "off"
ALL = "all"
TOGGLE = "toggle"


class RepeatMode(StrEnum):
"""Repeat mode."""

OFF = "off"
ALL = "all"
TOGGLE = "toggle"
119 changes: 83 additions & 36 deletions aiostreammagic/stream_magic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Asynchronous Python client for StreamMagic API."""

import asyncio
import json
from asyncio import AbstractEventLoop, Future, Task
Expand All @@ -10,11 +11,19 @@
from websockets.client import connect as ws_connect

from aiostreammagic.exceptions import StreamMagicError
from aiostreammagic.models import Info, Source, State, PlayState, NowPlaying
from aiostreammagic.models import (
Info,
Source,
State,
PlayState,
NowPlaying,
ShuffleMode,
RepeatMode,
)
from . import endpoints as ep
from .const import _LOGGER

VERSION = '1.0.0'
VERSION = "1.0.0"


class StreamMagicClient:
Expand Down Expand Up @@ -89,7 +98,7 @@ async def _ws_connect(self, uri):
"""Establish a connection with a WebSocket."""
return await ws_connect(
uri,
extra_headers={"Origin": f"ws://{self.host}", "Host": f"{self.host}:80"}
extra_headers={"Origin": f"ws://{self.host}", "Host": f"{self.host}:80"},
)

async def connect_handler(self, res):
Expand All @@ -102,14 +111,26 @@ async def connect_handler(self, res):
x = asyncio.create_task(
self.consumer_handler(ws, self._subscriptions, self.futures)
)
self.info, self.sources, self.state, self.play_state, self.now_playing = await asyncio.gather(self.get_info(), self.get_sources(), self.get_state(), self.get_play_state(), self.get_now_playing())
(
self.info,
self.sources,
self.state,
self.play_state,
self.now_playing,
) = await asyncio.gather(
self.get_info(),
self.get_sources(),
self.get_state(),
self.get_play_state(),
self.get_now_playing(),
)
subscribe_state_updates = {
self.subscribe(self._async_handle_info, ep.INFO),
self.subscribe(self._async_handle_sources, ep.SOURCES),
self.subscribe(self._async_handle_zone_state, ep.ZONE_STATE),
self.subscribe(self._async_handle_play_state, ep.PLAY_STATE),
self.subscribe(self._async_handle_position, ep.POSITION),
self.subscribe(self._async_handle_now_playing, ep.NOW_PLAYING)
self.subscribe(self._async_handle_now_playing, ep.NOW_PLAYING),
}
subscribe_tasks = set()
for state_update in subscribe_state_updates:
Expand All @@ -130,8 +151,12 @@ async def subscription_handler(queue, callback):
except asyncio.CancelledError:
pass

async def consumer_handler(self, ws: WebSocketClientProtocol, subscriptions: dict[str, list[Any]],
futures: dict[str, list[asyncio.Future]]):
async def consumer_handler(
self,
ws: WebSocketClientProtocol,
subscriptions: dict[str, list[Any]],
futures: dict[str, list[asyncio.Future]],
):
"""Callback consumer handler."""
subscription_queues = {}
subscription_tasks = {}
Expand All @@ -157,9 +182,9 @@ async def consumer_handler(self, ws: WebSocketClientProtocol, subscriptions: dic
subscription_queues[path].put_nowait(msg)

except (
asyncio.CancelledError,
websockets.exceptions.ConnectionClosedError,
websockets.exceptions.ConnectionClosedOK,
asyncio.CancelledError,
websockets.exceptions.ConnectionClosedError,
websockets.exceptions.ConnectionClosedOK,
):
pass

Expand Down Expand Up @@ -288,25 +313,27 @@ async def power_off(self) -> None:

async def volume_up(self) -> None:
"""Increase the volume of the device by 1."""
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "volume_step_change": 1})
await self.request(
ep.ZONE_STATE, params={"zone": "ZONE1", "volume_step_change": 1}
)

async def volume_down(self) -> None:
"""Increase the volume of the device by -1."""
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "volume_step_change": -1})
await self.request(
ep.ZONE_STATE, params={"zone": "ZONE1", "volume_step_change": -1}
)

async def set_volume(self, volume: int) -> None:
"""Set the volume of the device."""
if not 0 <= volume <= 100:
raise StreamMagicError("Volume must be between 0 and 100")
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "volume_percent": volume})

async def mute(self) -> None:
"""Mute the device."""
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "mute": True})
await self.request(
ep.ZONE_STATE, params={"zone": "ZONE1", "volume_percent": volume}
)

async def unmute(self) -> None:
"""Unmute the device."""
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "mute": False})
async def set_mute(self, mute: bool) -> None:
"""Set the mute of the device."""
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "mute": mute})

async def set_source(self, source: Source) -> None:
"""Set the source of the device."""
Expand All @@ -316,34 +343,54 @@ async def set_source_by_id(self, source_id: str) -> None:
"""Set the source of the device."""
await self.request(ep.ZONE_STATE, params={"zone": "ZONE1", "source": source_id})

# async def media_seek(self, position: int) -> None:
# """Set the media position of the device."""
# await self._request_device('zone/play_control', query=f"position={position}")
async def media_seek(self, position: int) -> None:
"""Set the media position of the device."""
await self.request(
ep.PLAY_CONTROL, params={"zone": "ZONE1", "position": position}
)

async def next_track(self) -> None:
"""Skip the next track."""
await self.request(ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "skip_track": 1})
await self.request(
ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "skip_track": 1}
)

async def previous_track(self) -> None:
"""Skip the next track."""
await self.request(ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "skip_track": -1})
await self.request(
ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "skip_track": -1}
)

async def play_pause(self) -> None:
"""Toggle play/pause."""
await self.request(ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "action": "toggle"})
await self.request(
ep.PLAY_CONTROL,
params={"match": "none", "zone": "ZONE1", "action": "toggle"},
)

async def pause(self) -> None:
"""Pause the device."""
await self.request(ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "action": "pause"})
await self.request(
ep.PLAY_CONTROL,
params={"match": "none", "zone": "ZONE1", "action": "pause"},
)

async def stop(self) -> None:
"""Pause the device."""
await self.request(ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "action": "stop"})

# async def set_shuffle(self, shuffle: str):
# """Set the shuffle of the device."""
# await self._request_device('zone/play_control', query=f"mode_shuffle={shuffle}")
#
# async def set_repeat(self, repeat: str):
# """Set the repeat of the device."""
# await self._request_device('zone/play_control', query=f"mode_repeat={repeat}")
await self.request(
ep.PLAY_CONTROL, params={"match": "none", "zone": "ZONE1", "action": "stop"}
)

async def set_shuffle(self, shuffle: ShuffleMode):
"""Set the shuffle of the device."""
await self.request(
ep.PLAY_CONTROL,
params={"match": "none", "zone": "ZONE1", "mode_shuffle": shuffle},
)

async def set_repeat(self, repeat: RepeatMode):
"""Set the repeat of the device."""
await self.request(
ep.PLAY_CONTROL,
params={"match": "none", "zone": "ZONE1", "mode_repeat": repeat},
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "aiostreammagic"
version = "2.1.0"
version = "2.2.0"
description = "An async python package for interfacing with Cambridge Audio / Stream Magic compatible streamers."
authors = ["Noah Husby <32528627+noahhusby@users.noreply.github.com>"]
maintainers = ["Noah Husby <32528627+noahhusby@users.noreply.github.com>"]
Expand Down

0 comments on commit 135f52a

Please sign in to comment.