Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 15 additions & 98 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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="<xml/>")
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.
98 changes: 87 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,108 @@
[![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 `<host>` with your player's hostname or IP address:

```python
import asyncio

from pyblu import Player


async def main():
async with Player("<host>") 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.
Loading
Loading