diff --git a/AGENTS.md b/AGENTS.md index e8ee845..d3e7eba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,98 +1,15 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -Keep this file up to date as the codebase evolves — update it when commands, architecture, or conventions change. - -## Project Overview - -`pyblu` is an async Python library for controlling BluOS players via their HTTP API (port 11000). No authentication is required. The library is published to PyPI and uses `uv` for dependency management. - -## BluOS API Documentation - -Use the official BluOS Custom Integration API PDF linked near the top of `README.md` as the source of truth for endpoints and response formats. Download that document directly instead of searching the web. To make it searchable locally: - -```bash -api_url=$(grep -o 'https://[^)]*\.pdf' README.md | head -1) -curl -fL "$api_url" -o /tmp/bluos-api.pdf -pdftotext -layout /tmp/bluos-api.pdf /tmp/bluos-api.txt -rg -n -C 10 '/Playlist|/Delete|/Move|/Save' /tmp/bluos-api.txt -``` - -The PDF and extracted text are temporary reference files; do not commit them. - -## Commands - -```bash -uv sync # Install all dependencies (including dev) -uv run pytest # Run all tests -uv run pytest tests/test_parse.py::test_parse_status # Run a single test -uv run pylint src tests # Lint -uv run black src tests # Format (line length: 160) -uv run black --check src tests # Check formatting without modifying -uv run mypy src # Type check - -# Invoke tasks (wrappers around the above) -uv run invoke format-and-lint # black + pylint together -uv run invoke test # pytest -uv run invoke mypy # mypy -uv run invoke build-docs # sphinx docs → _site/ -uv run invoke release # select a stable/dev release (requires GITHUB_TOKEN_PYBLU env var) -``` - -## Architecture - -The library has five main modules with a clear separation of concerns: - -- **`player.py`** — `Player` class: the public API. Async endpoint methods use `_get()` to make HTTP GET requests, then delegate the raw response bytes to parse functions. `_get()` centralizes transport error handling. - -- **`settings.py`** — Settings API exposed through `Player.settings`. Uses the player's `_get()` callable for requests, sharing its session, timeouts, and transport error handling. - -- **`parse.py`** — Stateless XML parsing functions. Each takes `bytes` from the HTTP response and returns a typed entity. Uses `lxml.etree` for parsing. All public parse functions are decorated with `@_wrap_in_unxpected_response_error`. - -- **`entities.py`** — Pure `@dataclass` types for player state, play queues, and media browsing, including `PlayQueue`, `PlayQueueTrack`, `BrowseResult`, `BrowseItem`, and `ContextMenuAction`. No logic. - -- **`errors.py`** — Exception hierarchy (`PlayerError` → `PlayerUnreachableError` / `PlayerUnexpectedResponseError` / `PlayerCommandError` / `PlayerBrowseError`) and the decorator for translating parser failures. - -### Key Conventions - -**Centralized error handling**: `Player._get()` catches `TimeoutError` and `aiohttp.ClientConnectionError` and raises `PlayerUnreachableError`. `_wrap_in_unxpected_response_error` on parse functions preserves existing `PlayerError` exceptions and wraps other exceptions in `PlayerUnexpectedResponseError`. Parsers raise `PlayerCommandError` or `PlayerBrowseError` for structured player errors. Do not duplicate try/except handling in endpoint methods or parse functions. - -**BluOS API quirks**: -- All operations use HTTP GET, including mutations (play, pause, volume set). -- `inputs()` calls `/RadioBrowse?service=Capture`, not a dedicated inputs endpoint. -- `play_url()` and `play()` both map to the `/Play` endpoint. -- Browse keys, `playURL` / `autoplayURL`, and context-menu action URLs are opaque. They map to `BrowseItem.play_action_url` / `autoplay_action_url` and `ContextMenuAction.action_url`; pass them unchanged to `Player.execute_action()`, never to `Player.play_url()`. Resolve context-menu keys through `context_menu()`; actions may mutate playback, the queue, presets, or service favorites. `execute_action()` passes a relative `yarl.URL(encoded=True)` to `_get()` to preserve its encoding. `_get()` accepts relative strings or `URL` objects, rejects URIs with a scheme or host, and resolves them against the player's base URL; normal endpoint query parameters still use aiohttp's encoding. -- `/Playlist` returns queue metadata as child elements for `length=1`, but as attributes for full and paginated listings; `parse_play_queue()` supports both forms. Optional metadata varies by response and player state: `name`, `modified`, `shuffle`, and `repeat` may be absent and are exposed as `None`. -- The API uses "master/slave" terminology; the library exposes this as "leader/follower". - -**Long polling**: `status()` and `sync_status()` accept an `etag` parameter. When provided, `poll_timeout` must be strictly less than `timeout` — the Player method validates this and raises `ValueError` if violated. - -**Session ownership**: `Player` creates and owns its `aiohttp.ClientSession` unless one is passed in. If an external session is passed, the caller is responsible for closing it. Use `async with Player(...) as player:` in normal usage. - -### Testing Pattern - -Tests use `mocket` to mock HTTP calls and `pytest-asyncio` with `asyncio_mode = "auto"` (no `@pytest.mark.asyncio` decorator needed). `pythonpath = "src"` is set in `pyproject.toml` so imports work without installation. - -`mocket` patches the OS socket layer, but aiohttp drives I/O through asyncio transports, so the session must be built with `MocketTCPConnector` and passed into `Player(session=...)` (the test's `async with` owns/closes that session — `Player` only closes sessions it created itself). Decorate each test with `@async_mocketize(strict_mode=True)` so any request without a matching registered entry raises `StrictMocketException` instead of falling through to the real network. Registering the full URL **including the query string** preserves query-param verification: `Entry` matches the querystring exactly by default, so a mismatched param means no entry is served and the request errors out in strict mode. - -```python -@async_mocketize(strict_mode=True) -async def test_example(): - Entry.single_register(Entry.GET, "http://node:11000/SomeEndpoint?foo=1", status=200, body="") - async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: - async with Player("node", session=session) as client: - result = await client.some_method() - - assert len(Mocket.request_list()) == 1 - assert result.field == expected -``` - -The action-URL encoding regression test uses a loopback aiohttp server to assert the raw request path, since query-parsing mocks can hide URL canonicalization. It never contacts a real player. - -### Adding a New Player Method - -1. Add the entity dataclass to `entities.py` if needed, and export it from `__init__.py`. -2. Add a `parse_*` function in `parse.py` decorated with `@_wrap_in_unxpected_response_error`. -3. Add the async method to `Player` in `player.py`, using `_get()` for the request and delegating the response to the parser. -4. Add tests in `tests/test_player.py` (HTTP mock) and `tests/test_parse.py` (parse logic) as appropriate. +# AGENTS.md + +Keep this file minimal: record only guidance that cannot be inferred from code, +tests, or existing documentation. + +- See `development.md` for setup, checks, and releases; do not duplicate commands here. +- For BluOS endpoint and response specifications, download the official API PDF + linked in `README.md` directly rather than searching the web. Extract searchable + text with `pdftotext -layout`; do not commit downloaded reference files. +- Never run setters or other mutating actions against a real player without + explicit permission. Tests must use mocks or loopback servers, not live hardware. +- Keep audio setting classes independent; do not introduce setting inheritance, + parser callbacks, or configuration-driven abstractions. Share XML parsing instead. +- For URL-encoding regressions, verify the raw request path with a loopback server: + query-parsing HTTP mocks can hide canonicalization bugs. diff --git a/README.md b/README.md index 328c943..52b3830 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,26 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyblu)](https://pypi.org/project/pyblu/) [![PyPI - License](https://img.shields.io/pypi/l/pyblu)](https://github.com/LouisChrist/pyblu/blob/main/LICENSE) -This is a Python library for interfacing with BluOS players. -It uses the -[BluOS API](https://bluos.io/wp-content/uploads/2025/06/BluOS-Custom-Integration-API_v1.7.pdf) -to control and query the status of BluOS players. -Authentication is not required. +pyblu is an async Python library for controlling BluOS players and reading their +status. It connects directly to a player using the +[BluOS HTTP API](https://bluos.io/wp-content/uploads/2025/06/BluOS-Custom-Integration-API_v1.7.pdf), +with no authentication required. -Documentation is available at [here](https://louischrist.github.io/pyblu/) +## Installation + +Requires Python 3.12 or newer. + +```bash +pip install pyblu +``` + +## Quick start + +Replace `` with your player's hostname or IP address: ```python +import asyncio + from pyblu import Player @@ -20,16 +31,81 @@ async def main(): async with Player("") as player: status = await player.status() print(status) + + +asyncio.run(main()) ``` -## Installation +The connection uses port 11000 by default. Using `async with` closes the client's +HTTP session when you're done. -```bash -pip install pyblu +See the [documentation](https://louischrist.github.io/pyblu/) for playback, +volume, browsing, play queues, and player grouping. + +## Audio settings + +Use `player.settings` to read or change audio settings. Which settings are +available depends on your player. + +| Settings | `get()` result | How to change it | +| --- | --- | --- | +| `listening_mode`, `subwoofer_mode` (legacy) | Active display name | `set(name)` | +| `replay_gain`, `output_mode` | Raw active name | `set(name)` | +| `tone_controls`, `centre_channel`, `stereo_surround`, `digital_passthrough`, `fixed_volume`, `audio_clock_trim` | `bool` | `set(True)` / `set(False)` | +| `treble`, `bass`, `balance`, `centre_volume_trim`, `crossover` | `float` | `set(value)` | +| `volume_limits` | `(minimum, maximum)` in dB | `set(minimum, maximum)` | + +Inside the `async with` block, you can read settings and their available values: + +```python +controls_enabled = await player.settings.tone_controls.get() +bass_limits = await player.settings.bass.range() +replay_gain_choices = await player.settings.replay_gain.choices() ``` -## Development +- `get()` returns `None` if the setting is missing. For `replay_gain` and + `output_mode`, it returns the raw active name even if not listed in `choices()`. +- `is_available()` checks whether the player lists the setting. For listening + and subwoofer modes, it also requires at least one choice. +- For `replay_gain` and `output_mode`, `choices()` returns entries with `name`, + `display_name`, and `active`. Both `get()` and `set()` use raw names; + use `display_name` for presentation. +- The legacy `listening_mode` and `subwoofer_mode` APIs retain `values()` and + display-name getters. Their `get()` also returns `None` if no choice matches; + pass a `values()` entry's `name` to `set()`, not the display name. +- For numeric settings, `range()` returns a `SettingRange`, or `None` if the + setting is missing. It contains `minimum`, `maximum`, and optional `step`, + `units`, and `minimum_range` (for volume limits). Check these limits before + changing a value: `set()` does not fetch or enforce them. + +All settings methods accept a `timeout` in seconds. + +### Legacy choice settings -For information on contributing and releasing new versions, see the [Development Guide](development.md). +`listening_mode` and `subwoofer_mode` preserve their original API for backward +compatibility. They differ from the newer choice settings: +| Behavior | `listening_mode`, `subwoofer_mode` | `replay_gain`, `output_mode` | +| --- | --- | --- | +| `get()` | Display label, e.g. `"Movie"` or `"Off"` | Raw name, e.g. `"none"` or `"default"` | +| `set(name)` | Raw name, **not** the display label | Raw name, same representation as `get()` | +| List choices | `values()` | `choices()` | +| Active name not in choices | `get()` returns `None` | `get()` returns the raw name | +| `is_available()` | Requires at least one choice | Requires the setting to be present | + +**Do not pass a legacy setting's `get()` result to `set()`.** Obtain its raw +name from the active entry in `values()` instead: + +```python +choices = await player.settings.subwoofer_mode.values() +original_name = next((choice.name for choice in choices if choice.active), None) +# If restoring later, pass original_name to set(), not the display label "Off". +``` + +Legacy listening-mode choices are `ListeningModeValue` objects (including an +`icon`); subwoofer choices are `SubwooferModeValue` objects. New choice settings +return `SettingValue` objects. All three expose `name`, `display_name`, and `active`. + +## Development +See the [development guide](development.md) for setup, checks, and releases. diff --git a/development.md b/development.md index afc7b26..0fd4c94 100644 --- a/development.md +++ b/development.md @@ -1,237 +1,169 @@ -# Development Guide +# Development guide -This guide covers the development workflow for pyblu, including how to release new versions and handle issues that may arise. +## Setup -## Development Setup - -Development requires Python 3.12 or newer and [uv](https://docs.astral.sh/uv/). - -1. Clone the repository: +You'll need Python 3.12 or newer and [uv](https://docs.astral.sh/uv/). ```bash git clone https://github.com/LouisChrist/pyblu.git cd pyblu -``` - -2. Install the project and development dependencies: - -```bash uv sync ``` -3. Run tests and checks: - -```bash -uv run pytest # Run tests -uv run pylint src tests # Run linting -uv run black --check src tests # Check formatting -uv run mypy src # Type check -``` - -To apply formatting, run `uv run black src tests`. - -## Release Process - -The project uses an automated CI/CD pipeline for building and publishing releases to PyPI. The process is triggered when you create and push a version tag. - -### Prerequisites - -1. **GitHub Token**: Set the `GITHUB_TOKEN_PYBLU` environment variable. A fine-grained personal access token needs access to this repository and read/write **Contents** permission; a classic token needs the `repo` scope. The token is used to create the GitHub release. Git pushes use the credentials configured for the `origin` remote. +If you use [devenv](https://devenv.sh/), run `devenv shell` before `uv sync`. +The environment provides Python 3.14, uv, and the native XML libraries. -2. **PyPI Trusted Publisher**: The project uses PyPI's Trusted Publisher feature (OIDC authentication) instead of API tokens. +## Tests and checks -### Creating a Release - -1. Ensure you're on the latest `main` branch, the working tree is clean, and the local checks pass: +Run these before opening a pull request or making a release: ```bash -git switch main -git pull --ff-only -git status # Should show a clean working tree uv run pytest uv run pylint src tests uv run black --check src tests uv run mypy src ``` -2. Run the release task: - -```bash -uv run invoke release -``` +To apply formatting, run `uv run black src tests`. You can also use +`uv run invoke format-and-lint` to format the code and then run pylint. -3. The script will: - - Display an interactive list of stable and development version bumps, including their resulting versions - - Update the version in `pyproject.toml` - - Create a git commit with message `Release v{version}` - - Create a git tag `v{version}` - - Push the commit and tag to GitHub - - Create a GitHub release with auto-generated release notes +Tests must use mocks or loopback servers, not real players. For URL-encoding +tests, check the raw request path with a loopback server; query-parsing mocks +can hide encoding bugs. Don't change settings or run other mutating commands +on a real player without explicit permission. -4. Once the tag is pushed, the CI/CD pipeline automatically: - - Runs all quality gates (lint, typecheck, test) - - Builds the package (sdist and wheel) - - Publishes to PyPI using Trusted Publisher authentication +For BluOS API details, download the official PDF linked in the +[README](README.md) and extract its text with `pdftotext -layout`. Keep downloaded +reference files out of git. -5. Monitor the release workflow at: https://github.com/LouisChrist/pyblu/actions +## Documentation -### Development Releases - -PyPI supports PEP 440 development releases. The normal release command presents stable patch, minor, and major releases alongside their development equivalents, with a preview of every resulting version: - -```text -Patch release 2.0.9 -Minor release 2.1.0 -Major release 3.0.0 -Patch development release 2.0.9.dev1 -Minor development release 2.1.0.dev1 -Major development release 3.0.0.dev1 +```bash +uv run invoke build-docs ``` -Use the arrow keys to select a release and Enter to confirm it. Press Ctrl+C to abort without changing the version. - -When the current version is already a development release, the selector only offers to increment its development number (`2.0.9.dev1` → `2.0.9.dev2`) or promote it to stable (`2.0.9.dev2` → `2.0.9`). Patch, minor, and major bumps are unavailable until the development version has been promoted, preventing the current development target from being skipped. Development versions are published by the normal release workflow and marked as pre-releases on GitHub. - -Users can opt into development releases with `pip install --pre pyblu`, or install an exact version with `pip install pyblu==2.0.9.dev2`. +The HTML output is in `_site/`. The docs workflow builds on matching branch +pushes and `v*` tags; only tags deploy to +[GitHub Pages](https://louischrist.github.io/pyblu/). -### Dry-Run Testing +## Making a release -To test the build process without actually publishing to PyPI: +### Access -1. Go to the GitHub Actions page: https://github.com/LouisChrist/pyblu/actions/workflows/release.yml -2. Click "Run workflow" -3. Check the "Run build without publishing (dry-run)" checkbox -4. Select the branch (usually `main`) -5. Click "Run workflow" +Set the `GITHUB_TOKEN_PYBLU` environment variable to a GitHub personal access +token. A fine-grained token needs access to `LouisChrist/pyblu` with read/write +**Contents** permission; a classic token needs the `repo` scope. The release +task uses this token to create the GitHub release. Git pushes use your normal +credentials for `origin`. -This will run all quality gates and build the package, but skip the PyPI publish step. You can download the built artifacts to inspect them. +PyPI publishing uses Trusted Publisher authentication, not a PyPI API token. +If publishing fails because of authentication, check the project's +[Trusted Publisher settings](https://pypi.org/manage/project/pyblu/settings/publishing/): -## PyPI Trusted Publisher Configuration +| Field | Value | +| --- | --- | +| Owner | `LouisChrist` | +| Repository | `pyblu` | +| Workflow | `release.yml` | +| Environment | `pypi` | -Trusted Publisher must be configured once on PyPI. This allows GitHub Actions to publish packages without using API tokens. +### Release steps -### Initial Setup - -1. Go to https://pypi.org/manage/account/publishing/ -2. If the project already exists on PyPI: - - Go to https://pypi.org/manage/project/pyblu/settings/publishing/ - - Add a new "Trusted Publisher" -3. If this is the first release: - - Use "Pending Publisher" at https://pypi.org/manage/account/publishing/ - -4. Configure with these details: - - **PyPI Project Name**: `pyblu` - - **Owner**: `LouisChrist` - - **Repository name**: `pyblu` - - **Workflow name**: `release.yml` - - **Environment name**: `pypi` - -5. Save the configuration - -The GitHub Actions workflow is already configured with the necessary permissions (`id-token: write`) to authenticate using OIDC. - -## Handling Release Failures - -First inspect the failed workflow. If no source change is needed—for example, a transient failure or corrected Trusted Publisher configuration—rerun the failed jobs in GitHub Actions. - -Before rolling anything back, check whether the version was published to PyPI. The tag push starts the workflow before the release task creates the GitHub release, so a later local or GitHub API failure does not prove that publication failed. - -### Unpublished Version - -If a source change is required and the version is **not** on PyPI: - -1. Cancel any running workflow for the tag. -2. Delete the GitHub release, if it was created, at https://github.com/LouisChrist/pyblu/releases. -3. Delete the tag remotely and locally: +Start from an up-to-date `main` branch with a clean working tree: ```bash -git push origin :refs/tags/v{version} -git tag -d v{version} +git switch main +git pull --ff-only +git status ``` -4. Revert the release commit because it has already been pushed to `main`: +Run the [tests and checks](#tests-and-checks), then: ```bash -git log --oneline -n 5 -# Verify that this is the "Release v{version}" commit before reverting it. -git revert -git push origin main +uv run invoke release ``` -5. Apply and commit the fix, run the local checks, and run `uv run invoke release` again. The reverted project version allows the same target version to be selected again. - -### Published Version +Choose a version with the arrow keys and press Enter. The menu shows the +resulting version for each choice. Press Ctrl+C at the menu to cancel without +changing anything. -PyPI versions are immutable. If the version was published, do not try to delete or reuse it. Apply the fix and make a new release. For a development release, select the next development number (for example, `1.2.3.dev1` → `1.2.3.dev2`); after a stable release, select the next appropriate stable version. +The task updates `pyproject.toml`, creates a `Release v{version}` commit and an +annotated `v{version}` tag, pushes them, and creates a GitHub release with +automatically generated notes. It does not run the local checks for you. -## Troubleshooting +Pushing the tag starts the [release workflow](https://github.com/LouisChrist/pyblu/actions/workflows/release.yml). +It runs lint, formatting, type checks, and tests, then builds the source archive +and wheel and publishes them to PyPI. Check the workflow result before treating +the release as complete. -### Release Workflow Fails Quality Gates +### Development releases -If lint, type checking, or tests fail, the workflow stops before building and publishing. Because a source change is required, follow the **Unpublished Version** procedure above, then fix the issue and release again. +The same release menu offers patch, minor, and major versions as either stable +or development releases. If the current version is a development release, you +can only increment its development number or promote it to stable. -### Trusted Publisher Authentication Fails +Development releases use the same publishing workflow and are marked as +pre-releases on GitHub. To install one: -Error: `Authentication failed: OIDC token is invalid or expired` - -**Solutions**: -- Verify the Trusted Publisher is configured correctly on PyPI -- Check that the workflow name is exactly `release.yml` -- Ensure the environment name is exactly `pypi` -- Verify the repository owner and name match exactly - -### Build Artifacts Are Missing +```bash +pip install --pre pyblu +``` -If the publish step can't find the built packages: -- Check the `build` job completed successfully -- Verify artifacts were uploaded (check the build job logs) -- Ensure artifact name matches in download step (`dist`) +### Testing the release build -### Version Already Exists on PyPI +Open the [release workflow](https://github.com/LouisChrist/pyblu/actions/workflows/release.yml), +choose **Run workflow**, select the branch, and check **Run build without +publishing (dry-run)**. -PyPI rejects reused versions. Make a new release instead: select the next development number for an existing development release, or the next appropriate stable version for an existing stable release. The release task does not currently offer post releases. +This runs the checks and builds the packages without publishing to PyPI. The +workflow's `dist` artifact contains the packages for inspection. -### GitHub Token Issues +## If a release fails -Error: `GITHUB_TOKEN_PYBLU environment variable is required` +Check the task output and GitHub Actions logs to see which step failed. If no +code change is needed, fix the cause and rerun the failed jobs. -**Solutions**: -- Set the environment variable: `export GITHUB_TOKEN_PYBLU=...` -- Generate a token at: https://github.com/settings/tokens -- For a fine-grained token, grant this repository read/write **Contents** permission; for a classic token, grant the `repo` scope +**Check PyPI before rolling anything back.** The tag push starts publishing +before the task creates the GitHub release, so a failure in that last step +doesn't mean the package wasn't published. If only the GitHub release is +missing, create it for the existing tag rather than running the release task +again. -### Uncommitted Changes +### The version is already on PyPI -Error: `There are uncommitted changes` +Don't delete or reuse the version. If the package needs a fix, commit it, run +the checks, and make a new release. For a development version, choose the next +development number; for a stable version, choose the appropriate version bump. -**Solutions**: -- Commit or stash your changes before releasing -- Use `git status` to see what's uncommitted -- The release must be from a clean working directory +### The version is not on PyPI -## Additional Tasks +If you need to change the code and retry the same version after pushing the +release commit and tag: -### Documentation +1. Cancel any running release workflow for the tag, then confirm the version + hasn't appeared on PyPI. +2. Delete the [GitHub release](https://github.com/LouisChrist/pyblu/releases), + if one exists. +3. Delete the remote and local tags, replacing `{version}` with the version + you're retrying: -Build documentation locally: -```bash -uv run invoke build-docs -``` + ```bash + git push origin :refs/tags/v{version} + git tag -d v{version} + ``` -Documentation is automatically deployed to GitHub Pages when a version tag is pushed. +4. Find and revert the release commit. Because it was pushed to `main`, use a + revert rather than rewriting history: -### Formatting and Linting + ```bash + git log --oneline -n 5 + git revert + git push origin main + ``` -Format code and run linters: -```bash -uv run invoke format-and-lint # Formats with black and runs pylint -``` - -### Retroactive Tagging - -If commits with version changes are missing tags: -```bash -uv run invoke add-missing-tags -``` +5. Apply and commit the fix, run the checks, and run `uv run invoke release` + again. Reverting the version bump lets you select the same target version. -This scans git history and creates annotated **local** tags for version bumps that were not tagged. Review the created tags before pushing them to `origin`. +If the task failed before pushing, inspect `git status`, the local commits, and +the tags first. Don't follow the remote rollback steps for changes that only +exist locally. diff --git a/docs/api.rst b/docs/api.rst index a2c4ec4..60c191a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -8,9 +8,87 @@ Main Classes :members: :special-members: __init__ +Audio Settings +-------------- + +Access these through ``player.settings``; do not instantiate them directly. +See :class:`pyblu.settings.Settings` for shared timeout and availability semantics. + +.. important:: + + ``listening_mode`` and ``subwoofer_mode`` are legacy exceptions: their + ``get()`` methods return display labels, but ``set()`` requires raw names + from ``values()``. Do not round-trip their getters directly into setters. + The newer ``replay_gain`` and ``output_mode`` use raw names for both + ``get()`` and ``set()``, and expose ``choices()`` instead of ``values()``. + See :doc:`usage` for the full legacy comparison and a save/restore example. + +.. autoclass:: pyblu.settings.Settings + :members: + +.. autoclass:: pyblu.settings.ListeningMode + :members: + +.. autoclass:: pyblu.settings.SubwooferMode + :members: + +.. autoclass:: pyblu.settings.ToneControls + :members: + +.. autoclass:: pyblu.settings.Treble + :members: + +.. autoclass:: pyblu.settings.Bass + :members: + +.. autoclass:: pyblu.settings.Balance + :members: + +.. autoclass:: pyblu.settings.CentreChannel + :members: + +.. autoclass:: pyblu.settings.CentreVolumeTrim + :members: + +.. autoclass:: pyblu.settings.Crossover + :members: + +.. autoclass:: pyblu.settings.ReplayGain + :members: + +.. autoclass:: pyblu.settings.OutputMode + :members: + +.. autoclass:: pyblu.settings.StereoSurround + :members: + +.. autoclass:: pyblu.settings.DigitalPassthrough + :members: + +.. autoclass:: pyblu.settings.FixedVolume + :members: + +.. autoclass:: pyblu.settings.VolumeLimits + :members: + +.. autoclass:: pyblu.settings.AudioClockTrim + :members: + Data Classes ------------ +.. autoclass:: pyblu.SettingValue + :members: + +.. autoclass:: pyblu.SettingRange + :members: + +.. autoclass:: pyblu.ListeningModeValue + :members: + +.. autoclass:: pyblu.SubwooferModeValue + :members: + .. autoclass:: pyblu.Status :members: diff --git a/docs/conf.py b/docs/conf.py index 3eaef80..a4c48ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,4 +24,4 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'alabaster' -html_static_path = ['_static'] +html_static_path = [] diff --git a/docs/usage.rst b/docs/usage.rst index 74e0dca..c98558d 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -92,3 +92,64 @@ Alternatively, request inline actions while browsing: Browse keys and all action URLs are opaque. Do not parse, decode, reconstruct, or otherwise modify them before passing them back to the same player that returned them. + +Audio settings +-------------- + +Audio settings are available through ``player.settings``. Each read fetches fresh +state; availability means the player advertises the setting. Unsupported settings +return ``None`` from ``get()``. + +.. code-block:: python + + if await player.settings.treble.is_available(): + print(await player.settings.treble.get()) + print(await player.settings.treble.range()) # Bounds, step, and units. + + for choice in await player.settings.output_mode.choices(): + print(choice.name, choice.display_name, choice.active) + +For ``replay_gain`` and ``output_mode``, getters and setters both use raw names. +Their getters preserve unlisted active names and return ``None`` only when the +setting is absent. ``choices()`` returns a list of :class:`~pyblu.SettingValue` +entries, empty when unavailable; use ``display_name`` for presentation. +Numeric ``range()`` methods return :class:`~pyblu.SettingRange` or ``None``. + +Legacy choice settings +~~~~~~~~~~~~~~~~~~~~~~ + +``listening_mode`` and ``subwoofer_mode`` retain their original behavior for +backward compatibility. Unlike ``replay_gain`` and ``output_mode``: + +* ``get()`` returns a display label, such as ``"Movie"`` or ``"Off"``, not a raw name. +* ``set(name)`` still requires the raw name, such as ``"MOVIE"`` or ``"default"``. +* Choices are read through ``values()``, not ``choices()``. +* An unlisted active name produces ``None`` from ``get()``, rather than the raw name. +* ``is_available()`` requires at least one choice, rather than just a present setting. + +.. warning:: + + Do not pass a legacy setting's ``get()`` result directly to ``set()``. + To save a value for later restoration, read the active choice's raw name: + + .. code-block:: python + + choices = await player.settings.subwoofer_mode.values() + original_name = next((choice.name for choice in choices if choice.active), None) + # Restore with set(original_name) only if original_name is not None. + +``listening_mode.values()`` returns :class:`~pyblu.ListeningModeValue` entries, +which include an ``icon``. ``subwoofer_mode.values()`` returns +:class:`~pyblu.SubwooferModeValue` entries. The newer ``choices()`` methods return +:class:`~pyblu.SettingValue` entries. All expose ``name``, ``display_name``, and +``active``. + +Writing settings +~~~~~~~~~~~~~~~~ + +Setters mutate the player. They do not check advertised choices/ranges or read +back the result. Numeric setters reject booleans and non-finite numbers; volume +limits must also be in ascending order. + +All methods accept ``timeout`` in seconds, defaulting to the player's timeout. +See :class:`~pyblu.settings.Settings` and the :doc:`api` for all setting classes. diff --git a/src/pyblu/__init__.py b/src/pyblu/__init__.py index fc7a8e0..68dce64 100644 --- a/src/pyblu/__init__.py +++ b/src/pyblu/__init__.py @@ -11,6 +11,8 @@ PlayQueue, PlayQueueTrack, Preset, + SettingRange, + SettingValue, Status, SubwooferModeValue, SyncStatus, @@ -30,6 +32,8 @@ "PlayQueueTrack", "Player", "Preset", + "SettingRange", + "SettingValue", "Status", "SubwooferModeValue", "SyncStatus", diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 4394527..fde9afa 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -1,6 +1,35 @@ from dataclasses import dataclass, field +@dataclass +class SettingValue: + """A selectable setting value; pass name (not display_name) to set().""" + + name: str + display_name: str + active: bool + + +@dataclass +class SettingRange: + """Player-advertised limits for a range or dual-range setting.""" + + minimum: float + maximum: float + step: float | None = None + units: str | None = None + minimum_range: float | None = None + + +@dataclass +class _AudioSetting: + """Internal parsed audio setting.""" + + value: str | bool | float | tuple[float, float] + values: list[SettingValue] = field(default_factory=list) + range: SettingRange | None = None + + @dataclass class Status: etag: str diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 128c701..a519196 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -1,8 +1,13 @@ +from math import isfinite +from typing import Literal from urllib.parse import unquote from lxml import etree from pyblu.entities import ( + _AudioSetting, + SettingRange, + SettingValue, BrowseCategory, BrowseItem, BrowseResult, @@ -449,6 +454,78 @@ def parse_inputs(response: bytes) -> list[Input]: return inputs +def _parse_setting_range(choices: list[etree._Element]) -> SettingRange: # pylint: disable=protected-access + """Parse and validate the player's range metadata.""" + if len(choices) != 1: + raise ValueError("Expected exactly one range definition") + bounds = choices[0].attrib + limits = SettingRange( + minimum=float(bounds["min"]), + maximum=float(bounds["max"]), + step=float(bounds["step"]) if "step" in bounds else None, + units=bounds.get("units"), + minimum_range=float(bounds["minRange"]) if "minRange" in bounds else None, + ) + if not all(isfinite(number) for number in (limits.minimum, limits.maximum)) or limits.minimum > limits.maximum: + raise ValueError("Invalid range bounds") + if limits.step is not None and (not isfinite(limits.step) or limits.step <= 0): + raise ValueError("Invalid range step") + if limits.minimum_range is not None and (not isfinite(limits.minimum_range) or not 0 <= limits.minimum_range <= limits.maximum - limits.minimum): + raise ValueError("Invalid minimum range") + return limits + + +@_wrap_in_unxpected_response_error +def parse_audio_setting(response: bytes, setting_id: str, expected_kind: Literal["boolean", "range", "dual-range", "list"]) -> _AudioSetting | None: + """Parse an expected audio setting kind; absent settings return None. + + Validate untrusted XML before exposing values or metadata to setting classes. + Unexpected classes, missing attributes, and malformed numbers raise + PlayerUnexpectedResponseError. Boolean modes with named choices are handled + separately by parse_subwoofer_modes(). + """ + tree = etree.fromstring(response) # pylint: disable=c-extension-no-member + elements = tree.xpath("/settings/menuGroup/setting[@id=$setting_id]", setting_id=setting_id) + if not elements: + return None + if len(elements) != 1: + raise ValueError("Duplicate audio setting") + element = elements[0] + kind = element.attrib["class"] + if kind != expected_kind: + raise ValueError(f"Expected {expected_kind} setting, got {kind}") + raw = element.attrib["value"] + value: str | bool | float | tuple[float, float] = raw + choices = element.findall("value") + limits = None + if kind == "boolean": + if choices: + raise ValueError("Expected an ON/OFF boolean, not a selectable mode") + if raw not in ("ON", "OFF"): + raise ValueError("Invalid boolean setting value") + value = raw == "ON" + elif kind in ("range", "dual-range"): + if kind == "range": + value = float(raw) + if not isfinite(value): + raise ValueError("Non-finite setting value") + else: + lower, upper = raw.split(",") + value = (float(lower), float(upper)) + if not all(isfinite(number) for number in value) or value[0] > value[1]: + raise ValueError("Invalid dual-range setting value") + limits = _parse_setting_range(choices) + return _AudioSetting( + value=value, + values=( + [SettingValue(name=val.attrib["name"], display_name=val.attrib["displayName"], active=val.attrib["name"] == raw) for val in choices] + if kind == "list" + else [] + ), + range=limits, + ) + + @_wrap_in_unxpected_response_error def parse_listening_modes(response: bytes) -> list[ListeningModeValue]: """ @@ -463,7 +540,7 @@ def parse_listening_modes(response: bytes) -> list[ListeningModeValue]: mode_active = mode_elements[0].attrib["value"] modes = [ ListeningModeValue(name=val.attrib["name"], display_name=val.attrib["displayName"], icon=val.attrib["icon"], active=val.attrib["name"] == mode_active) - for val in mode_elements[0] + for val in mode_elements[0].findall("value") ] return modes @@ -482,6 +559,6 @@ def parse_subwoofer_modes(response: bytes) -> list[SubwooferModeValue]: mode_active = mode_elements[0].attrib["value"] modes = [ SubwooferModeValue(name=val.attrib["name"], display_name=val.attrib["displayName"], active=val.attrib["name"] == mode_active) - for val in mode_elements[0] + for val in mode_elements[0].findall("value") ] return modes diff --git a/src/pyblu/settings.py b/src/pyblu/settings.py index ed37fdd..17417c1 100644 --- a/src/pyblu/settings.py +++ b/src/pyblu/settings.py @@ -1,11 +1,35 @@ -from collections.abc import Awaitable, Callable +from math import isfinite +from typing import Protocol -from pyblu.entities import ListeningModeValue, SubwooferModeValue -from pyblu.parse import parse_listening_modes, parse_subwoofer_modes +from pyblu.entities import _AudioSetting, ListeningModeValue, SettingRange, SettingValue, SubwooferModeValue +from pyblu.errors import PlayerUnexpectedResponseError +from pyblu.parse import parse_audio_setting, parse_listening_modes, parse_subwoofer_modes + +type QueryParams = dict[str, str | int] + + +class _HttpGet(Protocol): # pylint: disable=too-few-public-methods + async def __call__( + self, + path: str, + params: QueryParams | None = None, + timeout: float | None = None, + ) -> bytes: ... class ListeningMode: - def __init__(self, get: Callable[..., Awaitable[bytes]]): + """Listening mode; accessed through ``player.settings.listening_mode``. + + Legacy API: get() returns a display label, but set() requires a raw name. + Do not pass get() directly to set(); use the active values() entry's name. + Choices use values(), not choices(), and include an icon. Unlike newer + choice settings, get() returns None for an unlisted active name, and + is_available() requires at least one choice. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): self._get = get async def _query_endpoint(self, timeout: float | None = None) -> list[ListeningModeValue]: @@ -13,23 +37,41 @@ async def _query_endpoint(self, timeout: float | None = None) -> list[ListeningM return parse_listening_modes(data) async def get(self, timeout: float | None = None) -> str | None: + """Return the active display name, or None if absent or no choice matches.""" for val in await self._query_endpoint(timeout): if val.active: return val.display_name return None async def set(self, mode: str, timeout: float | None = None) -> None: + """Set a raw choice name, not the display name returned by get(). + + Choices are not checked locally. + """ await self._get("/alsa_setting", params={"preset": mode}, timeout=timeout) async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" return len(await self._query_endpoint(timeout)) > 0 async def values(self, timeout: float | None = None) -> list[ListeningModeValue]: + """Return choices with raw names for set(), or an empty list if absent.""" return await self._query_endpoint(timeout) class SubwooferMode: - def __init__(self, get: Callable[..., Awaitable[bytes]]): + """Subwoofer mode; accessed through ``player.settings.subwoofer_mode``. + + Legacy API: get() returns a display label, but set() requires a raw name. + Do not pass get() directly to set(); use the active values() entry's name. + Choices use values(), not choices(). Unlike newer choice settings, get() + returns None for an unlisted active name, and is_available() requires at + least one choice. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): self._get = get async def _query_endpoint(self, timeout: float | None = None) -> list[SubwooferModeValue]: @@ -37,22 +79,612 @@ async def _query_endpoint(self, timeout: float | None = None) -> list[SubwooferM return parse_subwoofer_modes(data) async def get(self, timeout: float | None = None) -> str | None: + """Return the active display name, or None if absent or no choice matches.""" for val in await self._query_endpoint(timeout): if val.active: return val.display_name return None async def set(self, mode: str, timeout: float | None = None) -> None: + """Set a raw choice name, not the display name returned by get(). + + Choices are not checked locally. + """ await self._get("/audiomodes", params={"subwoofer": mode}, timeout=timeout) async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" return len(await self._query_endpoint(timeout)) > 0 async def values(self, timeout: float | None = None) -> list[SubwooferModeValue]: + """Return choices with raw names for set(), or an empty list if absent.""" return await self._query_endpoint(timeout) -class Settings: # pylint: disable=too-few-public-methods - def __init__(self, get: Callable[..., Awaitable[bytes]]): +class ToneControls: + """Tone controls; accessed through ``player.settings.tone_controls``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "eq-switch", "boolean") + + async def get(self, timeout: float | None = None) -> bool | None: + """Return whether enabled, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, bool): + raise PlayerUnexpectedResponseError("Expected boolean setting value") + return setting.value + + async def set(self, enabled: bool, timeout: float | None = None) -> None: + """Enable or disable this setting. + + :raises ValueError: If enabled is not a bool. + """ + if not isinstance(enabled, bool): + raise ValueError("enabled must be a bool") + await self._get("/alsa_setting", params={"eq-switch": "ON" if enabled else "OFF"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + +class Treble: + """Treble level in dB; accessed through ``player.settings.treble``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "eq-treble", "range") + + async def get(self, timeout: float | None = None) -> float | None: + """Return the current numeric value, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, float): + raise PlayerUnexpectedResponseError("Expected range setting value") + return setting.value + + async def set(self, value: float, timeout: float | None = None) -> None: + """Set a finite numeric value. + + Player-advertised bounds and step sizes are not checked locally. + + :raises ValueError: If value is a bool or is not finite. + """ + if isinstance(value, bool) or not isfinite(value): + raise ValueError("value must be a finite number") + await self._get("/alsa_setting", params={"eq-treble": f"{value:g}"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def range(self, timeout: float | None = None) -> SettingRange | None: + """Return advertised bounds, or None if absent; these are not enforced by set().""" + setting = await self._query_endpoint(timeout) + return setting.range if setting else None + + +class Bass: + """Bass level in dB; accessed through ``player.settings.bass``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "eq-bass", "range") + + async def get(self, timeout: float | None = None) -> float | None: + """Return the current numeric value, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, float): + raise PlayerUnexpectedResponseError("Expected range setting value") + return setting.value + + async def set(self, value: float, timeout: float | None = None) -> None: + """Set a finite numeric value. + + Player-advertised bounds and step sizes are not checked locally. + + :raises ValueError: If value is a bool or is not finite. + """ + if isinstance(value, bool) or not isfinite(value): + raise ValueError("value must be a finite number") + await self._get("/alsa_setting", params={"eq-bass": f"{value:g}"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def range(self, timeout: float | None = None) -> SettingRange | None: + """Return advertised bounds, or None if absent; these are not enforced by set().""" + setting = await self._query_endpoint(timeout) + return setting.range if setting else None + + +class Balance: + """Left/right balance; accessed through ``player.settings.balance``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "eq-balance", "range") + + async def get(self, timeout: float | None = None) -> float | None: + """Return the current numeric value, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, float): + raise PlayerUnexpectedResponseError("Expected range setting value") + return setting.value + + async def set(self, value: float, timeout: float | None = None) -> None: + """Set a finite numeric value. + + Player-advertised bounds and step sizes are not checked locally. + + :raises ValueError: If value is a bool or is not finite. + """ + if isinstance(value, bool) or not isfinite(value): + raise ValueError("value must be a finite number") + await self._get("/alsa_setting", params={"eq-balance": f"{value:g}"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def range(self, timeout: float | None = None) -> SettingRange | None: + """Return advertised bounds, or None if absent; these are not enforced by set().""" + setting = await self._query_endpoint(timeout) + return setting.range if setting else None + + +class CentreChannel: + """Centre channel; accessed through ``player.settings.centre_channel``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "centre", "boolean") + + async def get(self, timeout: float | None = None) -> bool | None: + """Return whether enabled, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, bool): + raise PlayerUnexpectedResponseError("Expected boolean setting value") + return setting.value + + async def set(self, enabled: bool, timeout: float | None = None) -> None: + """Enable or disable this setting. + + :raises ValueError: If enabled is not a bool. + """ + if not isinstance(enabled, bool): + raise ValueError("enabled must be a bool") + await self._get("/setting", params={"centre": "ON" if enabled else "OFF"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + +class CentreVolumeTrim: + """Centre-channel volume trim; accessed through ``player.settings.centre_volume_trim``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "eq-centre-trim", "range") + + async def get(self, timeout: float | None = None) -> float | None: + """Return the current numeric value, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, float): + raise PlayerUnexpectedResponseError("Expected range setting value") + return setting.value + + async def set(self, value: float, timeout: float | None = None) -> None: + """Set a finite numeric value. + + Player-advertised bounds and step sizes are not checked locally. + + :raises ValueError: If value is a bool or is not finite. + """ + if isinstance(value, bool) or not isfinite(value): + raise ValueError("value must be a finite number") + await self._get("/alsa_setting", params={"eq-centre-trim": f"{value:g}"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def range(self, timeout: float | None = None) -> SettingRange | None: + """Return advertised bounds, or None if absent; these are not enforced by set().""" + setting = await self._query_endpoint(timeout) + return setting.range if setting else None + + +class Crossover: + """Subwoofer crossover frequency in Hz; accessed through ``player.settings.crossover``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "eq-crossover", "range") + + async def get(self, timeout: float | None = None) -> float | None: + """Return the current numeric value, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, float): + raise PlayerUnexpectedResponseError("Expected range setting value") + return setting.value + + async def set(self, value: float, timeout: float | None = None) -> None: + """Set a finite numeric value. + + Player-advertised bounds and step sizes are not checked locally. + + :raises ValueError: If value is a bool or is not finite. + """ + if isinstance(value, bool) or not isfinite(value): + raise ValueError("value must be a finite number") + await self._get("/alsa_setting", params={"eq-crossover": f"{value:g}"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def range(self, timeout: float | None = None) -> SettingRange | None: + """Return advertised bounds, or None if absent; these are not enforced by set().""" + setting = await self._query_endpoint(timeout) + return setting.range if setting else None + + +class ReplayGain: + """Replay-gain mode; accessed through ``player.settings.replay_gain``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "replayGainMode", "list") + + async def get(self, timeout: float | None = None) -> str | None: + """Return the raw active name, even if unlisted, or None if absent.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, str): + raise PlayerUnexpectedResponseError("Expected list setting value") + return setting.value + + async def set(self, mode: str, timeout: float | None = None) -> None: + """Set a raw choice name, as returned by get(). + + Choices are not checked locally. + """ + await self._get("/audiomodes", params={"replayGainMode": mode}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def choices(self, timeout: float | None = None) -> list[SettingValue]: + """Return choices with raw names for set(), or an empty list if absent.""" + setting = await self._query_endpoint(timeout) + return setting.values if setting else [] + + +class OutputMode: + """Output channel mode; accessed through ``player.settings.output_mode``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "channelMode", "list") + + async def get(self, timeout: float | None = None) -> str | None: + """Return the raw active name, even if unlisted, or None if absent.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, str): + raise PlayerUnexpectedResponseError("Expected list setting value") + return setting.value + + async def set(self, mode: str, timeout: float | None = None) -> None: + """Set a raw choice name, as returned by get(). + + Choices are not checked locally. + """ + await self._get("/audiomodes", params={"channelMode": mode}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def choices(self, timeout: float | None = None) -> list[SettingValue]: + """Return choices with raw names for set(), or an empty list if absent.""" + setting = await self._query_endpoint(timeout) + return setting.values if setting else [] + + +class StereoSurround: + """Stereo surround; accessed through ``player.settings.stereo_surround``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "ears", "boolean") + + async def get(self, timeout: float | None = None) -> bool | None: + """Return whether enabled, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, bool): + raise PlayerUnexpectedResponseError("Expected boolean setting value") + return setting.value + + async def set(self, enabled: bool, timeout: float | None = None) -> None: + """Enable or disable this setting. + + :raises ValueError: If enabled is not a bool. + """ + if not isinstance(enabled, bool): + raise ValueError("enabled must be a bool") + await self._get("/audiomodes", params={"ears": "ON" if enabled else "OFF"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + +class DigitalPassthrough: + """Digital passthrough; accessed through ``player.settings.digital_passthrough``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "mqaDisable", "boolean") + + async def get(self, timeout: float | None = None) -> bool | None: + """Return whether enabled, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, bool): + raise PlayerUnexpectedResponseError("Expected boolean setting value") + return setting.value + + async def set(self, enabled: bool, timeout: float | None = None) -> None: + """Enable or disable this setting. + + :raises ValueError: If enabled is not a bool. + """ + if not isinstance(enabled, bool): + raise ValueError("enabled must be a bool") + await self._get("/audiomodes", params={"mqaDisable": "ON" if enabled else "OFF"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + +class FixedVolume: + """Fixed output level; accessed through ``player.settings.fixed_volume``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "fixedVolume", "boolean") + + async def get(self, timeout: float | None = None) -> bool | None: + """Return whether enabled, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, bool): + raise PlayerUnexpectedResponseError("Expected boolean setting value") + return setting.value + + async def set(self, enabled: bool, timeout: float | None = None) -> None: + """Enable or disable this setting. + + :raises ValueError: If enabled is not a bool. + """ + if not isinstance(enabled, bool): + raise ValueError("enabled must be a bool") + # No setting URL: use the parent menuGroup URL. + await self._get("/audiomodes", params={"fixedVolume": "ON" if enabled else "OFF"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + +class VolumeLimits: + """Volume limits in dB; accessed through ``player.settings.volume_limits``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "volumeLimits", "dual-range") + + async def get(self, timeout: float | None = None) -> tuple[float, float] | None: + """Return (minimum, maximum) in dB, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, tuple): + raise PlayerUnexpectedResponseError("Expected dual-range setting value") + return setting.value + + async def set(self, minimum: float, maximum: float, timeout: float | None = None) -> None: + """Set volume limits in dB. + + Player-advertised bounds and minimum span are not checked locally. + + :raises ValueError: If limits are bools, non-finite, or not ascending. + """ + if any(isinstance(val, bool) or not isfinite(val) for val in (minimum, maximum)) or minimum > maximum: + raise ValueError("limits must be finite numbers in ascending order") + # No setting URL: use the parent menuGroup URL. + await self._get("/audiomodes", params={"volumeLimits": f"{minimum:g},{maximum:g}"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + async def range(self, timeout: float | None = None) -> SettingRange | None: + """Return advertised bounds, or None if absent; these are not enforced by set().""" + setting = await self._query_endpoint(timeout) + return setting.range if setting else None + + +class AudioClockTrim: + """Audio clock trim; accessed through ``player.settings.audio_clock_trim``. + + See :class:`Settings` for request and availability semantics. + """ + + def __init__(self, get: _HttpGet): + self._get = get + + async def _query_endpoint(self, timeout: float | None = None) -> _AudioSetting | None: + data = await self._get("/Settings?id=audio", timeout=timeout) + return parse_audio_setting(data, "enableClockTrim", "boolean") + + async def get(self, timeout: float | None = None) -> bool | None: + """Return whether enabled, or None if absent from the player.""" + setting = await self._query_endpoint(timeout) + if setting is None: + return None + if not isinstance(setting.value, bool): + raise PlayerUnexpectedResponseError("Expected boolean setting value") + return setting.value + + async def set(self, enabled: bool, timeout: float | None = None) -> None: + """Enable or disable this setting. + + :raises ValueError: If enabled is not a bool. + """ + if not isinstance(enabled, bool): + raise ValueError("enabled must be a bool") + await self._get("/audiomodes", params={"enableClockTrim": "ON" if enabled else "OFF"}, timeout=timeout) + + async def is_available(self, timeout: float | None = None) -> bool: + """Return whether the player advertises this setting.""" + return await self._query_endpoint(timeout) is not None + + +class Settings: # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Audio settings accessed through ``player.settings``. + + Each get(), choices(), range(), values(), or is_available() call fetches a + fresh audio settings response. New choice settings return raw names from + get() and expose choices(); numeric settings expose range(). ListeningMode + and SubwooferMode retain display-name getters and values(). Availability means the player advertises the setting, not that it + is currently enabled. Setters send one command and do not validate against + advertised choices/ranges or read back the result. + + All methods accept an optional timeout in seconds; None uses the player's + default. Transport failures raise :class:`~pyblu.errors.PlayerUnreachableError`; + malformed setting responses raise + :class:`~pyblu.errors.PlayerUnexpectedResponseError`. + """ + + def __init__(self, get: _HttpGet): self.listening_mode = ListeningMode(get) self.subwoofer_mode = SubwooferMode(get) + self.tone_controls = ToneControls(get) + self.treble = Treble(get) + self.bass = Bass(get) + self.balance = Balance(get) + self.centre_channel = CentreChannel(get) + self.centre_volume_trim = CentreVolumeTrim(get) + self.crossover = Crossover(get) + self.replay_gain = ReplayGain(get) + self.output_mode = OutputMode(get) + self.stereo_surround = StereoSurround(get) + self.digital_passthrough = DigitalPassthrough(get) + self.fixed_volume = FixedVolume(get) + self.volume_limits = VolumeLimits(get) + self.audio_clock_trim = AudioClockTrim(get) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..b2f08c8 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,394 @@ +"""Settings tests use saved responses and strict mocks, never a real player.""" + +from dataclasses import dataclass +from pathlib import Path +from unittest.mock import AsyncMock + +import aiohttp +import pytest +from mocket import Mocket, async_mocketize +from mocket.mocks.mockhttp import Entry +from mocket.plugins.aiohttp_connector import MocketTCPConnector + +from pyblu import ListeningModeValue, Player, SettingRange, SettingValue, SubwooferModeValue +from pyblu.errors import PlayerUnexpectedResponseError, PlayerUnreachableError +from pyblu.parse import parse_audio_setting +from pyblu.settings import ListeningMode, Settings, SubwooferMode + +RESPONSES = Path(__file__).resolve().parents[1] / "api-responses/settings/audio" + + +@dataclass(frozen=True) +class SettingCase: + name: str + setting_id: str + endpoint: str + set_args: tuple[str | bool | float, ...] + encoded_value: str + n130_value: str | bool | float | tuple[float, float] | None + n331_value: str | bool | float | tuple[float, float] | None + + +SETTINGS = [ + SettingCase( + name="listening_mode", + setting_id="preset", + endpoint="/alsa_setting", + set_args=("MUSIC",), + encoded_value="MUSIC", + n130_value=None, + n331_value="Movie", + ), + SettingCase( + name="subwoofer_mode", + setting_id="subwoofer", + endpoint="/audiomodes", + set_args=("withsub",), + encoded_value="withsub", + n130_value="Off", + n331_value="On", + ), + SettingCase( + name="tone_controls", + setting_id="eq-switch", + endpoint="/alsa_setting", + set_args=(True,), + encoded_value="ON", + n130_value=False, + n331_value=False, + ), + SettingCase( + name="treble", + setting_id="eq-treble", + endpoint="/alsa_setting", + set_args=(1.5,), + encoded_value="1.5", + n130_value=0.0, + n331_value=0.0, + ), + SettingCase( + name="bass", + setting_id="eq-bass", + endpoint="/alsa_setting", + set_args=(-2.5,), + encoded_value="-2.5", + n130_value=0.0, + n331_value=0.0, + ), + SettingCase( + name="balance", + setting_id="eq-balance", + endpoint="/alsa_setting", + set_args=(-1,), + encoded_value="-1", + n130_value=None, + n331_value=0.0, + ), + SettingCase( + name="centre_channel", + setting_id="centre", + endpoint="/setting", + set_args=(False,), + encoded_value="OFF", + n130_value=None, + n331_value=True, + ), + SettingCase( + name="centre_volume_trim", + setting_id="eq-centre-trim", + endpoint="/alsa_setting", + set_args=(0.5,), + encoded_value="0.5", + n130_value=None, + n331_value=0.0, + ), + SettingCase( + name="crossover", + setting_id="eq-crossover", + endpoint="/alsa_setting", + set_args=(100,), + encoded_value="100", + n130_value=80.0, + n331_value=80.0, + ), + SettingCase( + name="replay_gain", + setting_id="replayGainMode", + endpoint="/audiomodes", + set_args=("album",), + encoded_value="album", + n130_value="none", + n331_value="none", + ), + SettingCase( + name="output_mode", + setting_id="channelMode", + endpoint="/audiomodes", + set_args=("mono",), + encoded_value="mono", + n130_value="default", + n331_value="default", + ), + SettingCase( + name="stereo_surround", + setting_id="ears", + endpoint="/audiomodes", + set_args=(True,), + encoded_value="ON", + n130_value=None, + n331_value=False, + ), + SettingCase( + name="digital_passthrough", + setting_id="mqaDisable", + endpoint="/audiomodes", + set_args=(True,), + encoded_value="ON", + n130_value=False, + n331_value=None, + ), + SettingCase( + name="fixed_volume", + setting_id="fixedVolume", + endpoint="/audiomodes", + set_args=(True,), + encoded_value="ON", + n130_value=False, + n331_value=None, + ), + SettingCase( + name="volume_limits", + setting_id="volumeLimits", + endpoint="/audiomodes", + set_args=(-60, -10), + encoded_value="-60,-10", + n130_value=(-44.0, 0.0), + n331_value=(-90.0, 0.0), + ), + SettingCase( + name="audio_clock_trim", + setting_id="enableClockTrim", + endpoint="/audiomodes", + set_args=(False,), + encoded_value="OFF", + n130_value=True, + n331_value=None, + ), +] + + +@pytest.mark.parametrize("case", SETTINGS, ids=[case.name for case in SETTINGS]) +@pytest.mark.parametrize("model", ["n130", "n331"]) +@pytest.mark.parametrize("operation", ["get", "is_available"]) +@async_mocketize(strict_mode=True) +async def test_read_settings(case: SettingCase, model, operation): + expected = case.n130_value if model == "n130" else case.n331_value + body = (RESPONSES / f"bluesound_{model}.xml").read_bytes() + Entry.single_register(Entry.GET, "http://node:11000/Settings?id=audio", status=200, body=body) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as player: + setting = getattr(player.settings, case.name) + if operation == "is_available": + assert await setting.is_available() is (expected is not None) + else: + assert await setting.get() == expected + assert len(Mocket.request_list()) == 1 + + +@pytest.mark.parametrize("case", SETTINGS, ids=[case.name for case in SETTINGS]) +@async_mocketize(strict_mode=True) +async def test_set_settings(case): + Entry.single_register(Entry.GET, f"http://node:11000{case.endpoint}?{case.setting_id}={case.encoded_value}", status=200, body="") + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as player: + setting = getattr(player.settings, case.name) + await setting.set(*case.set_args, timeout=3) + assert len(Mocket.request_list()) == 1 + + +@pytest.mark.parametrize("case", SETTINGS, ids=[case.name for case in SETTINGS]) +async def test_timeouts_and_errors(case): + get = AsyncMock(return_value=(RESPONSES / "bluesound_n331.xml").read_bytes()) + setting = getattr(Settings(get), case.name) + await setting.is_available(timeout=2) + get.assert_awaited_once_with("/Settings?id=audio", timeout=2) + get.reset_mock() + await setting.set(*case.set_args, timeout=3) + get.assert_awaited_once_with(case.endpoint, params={case.setting_id: case.encoded_value}, timeout=3) + get.side_effect = PlayerUnreachableError("offline") + with pytest.raises(PlayerUnreachableError): + await setting.is_available() + + +@pytest.mark.parametrize("case", SETTINGS, ids=[case.name for case in SETTINGS]) +async def test_missing_settings(case): + setting = getattr(Settings(AsyncMock(return_value=b"")), case.name) + assert not await setting.is_available() + assert await setting.get() is None + for operation in ("values", "choices", "range"): + if hasattr(setting, operation): + assert await getattr(setting, operation)() in (None, []) + + +@pytest.mark.parametrize("case", [case for case in SETTINGS if isinstance(case.set_args[0], bool)], ids=lambda case: case.name) +@pytest.mark.parametrize("enabled", [True, False]) +async def test_boolean_round_trip(case, enabled): + value = "ON" if enabled else "OFF" + body = f''.encode() + get = AsyncMock(return_value=body) + setting = getattr(Settings(get), case.name) + assert await setting.get(timeout=2) is enabled + get.assert_awaited_once_with("/Settings?id=audio", timeout=2) + get.reset_mock() + await setting.set(enabled, timeout=3) + get.assert_awaited_once_with(case.endpoint, params={case.setting_id: value}, timeout=3) + + +@pytest.mark.parametrize("name,setting_id", [("replay_gain", "replayGainMode"), ("output_mode", "channelMode")]) +async def test_unknown_active_choice(name, setting_id): + body = ( + f'' '' + ).encode() + setting = getattr(Settings(AsyncMock(return_value=body)), name) + assert await setting.get() == "new" + assert await setting.is_available() + assert await setting.choices() == [SettingValue("old", "Old", False)] + + +@pytest.mark.parametrize( + "name,setting_id,endpoint,expected", + [ + ("listening_mode", "preset", "/alsa_setting", ListeningModeValue("raw", "Display", "/icon.png", True)), + ("subwoofer_mode", "subwoofer", "/audiomodes", SubwooferModeValue("raw", "Display", True)), + ], +) +async def test_original_mode_api(name, setting_id, endpoint, expected): + # No class attribute was required by the original parsers. Preserve that too. + body = ( + f'' + '' + '' + ).encode() + get = AsyncMock(return_value=body) + setting = getattr(Settings(get), name) + assert isinstance(setting, ListeningMode if name == "listening_mode" else SubwooferMode) + values = await setting.values(timeout=2) + assert values == [expected] + assert type(values[0]) is type(expected) + get.assert_awaited_once_with("/Settings?id=audio", timeout=2) + assert await setting.get(timeout=2) == "Display" + assert await setting.is_available(timeout=2) + get.reset_mock() + await setting.set(mode="raw", timeout=3) + get.assert_awaited_once_with(endpoint, params={setting_id: "raw"}, timeout=3) + + get.return_value = body.replace(b'value="raw"', b'value="unknown"') + assert await setting.get() is None + assert await setting.is_available() + assert not (await setting.values())[0].active + + get.return_value = f''.encode() + assert await setting.values() == [] + assert await setting.get() is None + assert not await setting.is_available() + + +@pytest.mark.parametrize("name,setting_id", [("replay_gain", "replayGainMode"), ("output_mode", "channelMode")]) +@pytest.mark.parametrize("raw", ["old", "new"]) +async def test_choice_get_set_round_trip(name, setting_id, raw): + body = ( + f'' + '' + ).encode() + get = AsyncMock(return_value=body) + setting = getattr(Settings(get), name) + original = await setting.get(timeout=2) + assert original == raw + get.assert_awaited_once_with("/Settings?id=audio", timeout=2) + get.reset_mock() + await setting.set(original, timeout=3) + get.assert_awaited_once_with("/audiomodes", params={setting_id: raw}, timeout=3) + + +@pytest.mark.parametrize("case", SETTINGS, ids=[case.name for case in SETTINGS]) +async def test_metadata_api_and_timeout(case): + get = AsyncMock(return_value=(RESPONSES / "bluesound_n331.xml").read_bytes()) + setting = getattr(Settings(get), case.name) + if case.name in ("listening_mode", "subwoofer_mode"): + expected = {"values"} + elif case.name in ("replay_gain", "output_mode"): + expected = {"choices"} + elif isinstance(case.set_args[0], bool): + expected = set() + else: + expected = {"range"} + assert {method for method in ("values", "choices", "range") if hasattr(setting, method)} == expected + for method in expected: + await getattr(setting, method)(timeout=2) + get.assert_awaited_once_with("/Settings?id=audio", timeout=2) + get.reset_mock() + get.side_effect = PlayerUnreachableError("offline") + with pytest.raises(PlayerUnreachableError): + await getattr(setting, method)() + + +async def test_choices_and_ranges(): + settings = Settings(AsyncMock(return_value=(RESPONSES / "bluesound_n130.xml").read_bytes())) + assert await settings.replay_gain.choices() == [ + SettingValue("none", "Disabled", True), + SettingValue("track", "Track gain", False), + SettingValue("album", "Album gain", False), + SettingValue("smart", "Smart gain", False), + ] + assert await settings.output_mode.choices() == [ + SettingValue("default", "Stereo", True), + SettingValue("left", "Left", False), + SettingValue("right", "Right", False), + SettingValue("mono", "Mono", False), + ] + assert await settings.treble.range() == SettingRange(-6, 6, 0.5, "dB") + assert await settings.bass.range() == SettingRange(-6, 6, 0.5, "dB") + assert await settings.crossover.range() == SettingRange(40, 200, 10, "Hz") + assert await settings.volume_limits.range() == SettingRange(-90, 0, units="dB", minimum_range=30) + settings = Settings(AsyncMock(return_value=(RESPONSES / "bluesound_n331.xml").read_bytes())) + assert await settings.balance.range() == SettingRange(-7, 7, 0.5) + assert await settings.centre_volume_trim.range() == SettingRange(-10, 10, 0.5) + + +def test_non_choice_elements_are_ignored(): + setting = parse_audio_setting((RESPONSES / "bluesound_n130.xml").read_bytes(), "replayGainMode", "list") + assert setting is not None + assert len(setting.values) == 4 + + +@pytest.mark.parametrize( + "kind,body", + [ + ("boolean", b"not xml"), + ("boolean", b''), + ("range", b''), + ("dual-range", b''), + ("list", b''), + ], +) +def test_malformed_settings(kind, body): + with pytest.raises(PlayerUnexpectedResponseError): + parse_audio_setting(body, "test", kind) + + +@pytest.mark.parametrize( + "name,args", + [ + ("tone_controls", ("OFF",)), + ("treble", (float("nan"),)), + ("bass", (float("inf"),)), + ("balance", (True,)), + ("volume_limits", (0, -90)), + ("volume_limits", (float("-inf"), 0)), + ], +) +async def test_invalid_values_do_not_send_requests(name, args): + get = AsyncMock() + with pytest.raises(ValueError): + await getattr(Settings(get), name).set(*args) + get.assert_not_awaited() diff --git a/tests/test_settings_validation.py b/tests/test_settings_validation.py new file mode 100644 index 0000000..0fe4af8 --- /dev/null +++ b/tests/test_settings_validation.py @@ -0,0 +1,111 @@ +"""Validate untrusted settings responses without contacting a player.""" + +from unittest.mock import AsyncMock + +import pytest + +import pyblu +from pyblu.errors import PlayerUnexpectedResponseError +from pyblu.parse import parse_audio_setting +from pyblu.settings import Settings + + +@pytest.mark.parametrize( + "name,setting_id", + [ + ("tone_controls", "eq-switch"), + ("treble", "eq-treble"), + ("bass", "eq-bass"), + ("balance", "eq-balance"), + ("centre_channel", "centre"), + ("centre_volume_trim", "eq-centre-trim"), + ("crossover", "eq-crossover"), + ("replay_gain", "replayGainMode"), + ("output_mode", "channelMode"), + ("stereo_surround", "ears"), + ("digital_passthrough", "mqaDisable"), + ("fixed_volume", "fixedVolume"), + ("volume_limits", "volumeLimits"), + ("audio_clock_trim", "enableClockTrim"), + ], +) +async def test_wrong_setting_class_is_not_exposed(name, setting_id): + # Well-formed XML and a value, but not the class promised by the public API. + body = f''.encode() + setting = getattr(Settings(AsyncMock(return_value=body)), name) + for operation in ("get", "is_available", "choices", "range"): + if hasattr(setting, operation): + with pytest.raises(PlayerUnexpectedResponseError, match="Expected .* setting, got text"): + await getattr(setting, operation)() + + +@pytest.mark.parametrize( + "kind,value,children", + [ + ("boolean", "ON", ''), + ("range", "nan", ''), + ("range", "inf", ''), + ("range", "-inf", ''), + ("range", "0", ""), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("range", "0", ''), + ("dual-range", "0,-90", ''), + ("dual-range", "nan,0", ''), + ("dual-range", "-90,inf", ''), + ("dual-range", "-90", ''), + ("dual-range", "-90,0", ''), + ("dual-range", "-90,0", ''), + ("dual-range", "-90,0", ''), + ("list", "raw", ''), + ("list", "raw", ''), + ], +) +def test_invalid_values_and_metadata(kind, value, children): + body = f'{children}'.encode() + with pytest.raises(PlayerUnexpectedResponseError): + parse_audio_setting(body, "test", kind) + + +@pytest.mark.parametrize( + "element", + [ + '', + '', + '' * 2, + ], +) +def test_missing_attributes_and_duplicate_settings(element): + body = f"{element}".encode() + with pytest.raises(PlayerUnexpectedResponseError): + parse_audio_setting(body, "test", "boolean") + + +def test_audio_setting_is_not_public(): + assert "AudioSetting" not in pyblu.__all__ + assert not hasattr(pyblu, "AudioSetting") + + +@pytest.mark.parametrize("name", ["treble", "bass", "balance", "centre_volume_trim", "crossover"]) +@pytest.mark.parametrize("value", [True, False, float("nan"), float("inf"), float("-inf")]) +async def test_numeric_setter_rejects_values_not_excluded_by_float_annotation(name, value): + get = AsyncMock() + with pytest.raises(ValueError): + await getattr(Settings(get), name).set(value) + get.assert_not_awaited() + + +@pytest.mark.parametrize("value", [True, False, float("nan"), float("inf"), float("-inf")]) +@pytest.mark.parametrize("position", ["minimum", "maximum"]) +async def test_volume_limits_reject_invalid_numbers(position, value): + get = AsyncMock() + limits = {"minimum": -90.0, "maximum": 0.0, position: value} + with pytest.raises(ValueError): + await Settings(get).volume_limits.set(**limits) + get.assert_not_awaited()