Metadata-Version: 2.4
Name: aiodisklavier
Version: 0.3.0
Summary: Async client for the Yamaha Disklavier ENSPIRE: its local HTTP API, and the SMB share that gets music onto it
Author: Reuben Bijl
License-Expression: MIT
Project-URL: Homepage, https://github.com/reubenbijl/aiodisklavier
Project-URL: Changelog, https://github.com/reubenbijl/aiodisklavier/releases
Project-URL: Issues, https://github.com/reubenbijl/aiodisklavier/issues
Project-URL: Protocol reference, https://github.com/reubenbijl/aiodisklavier/blob/main/docs/enspire-api.md
Keywords: yamaha,disklavier,enspire,piano,home-assistant
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aiohttp>=3.9
Requires-Dist: yarl>=1.9
Requires-Dist: pysmb>=1.2.7
Provides-Extra: test
Requires-Dist: pytest>=8.2; extra == "test"
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
Requires-Dist: pytest-cov>=5; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff>=0.6; extra == "lint"
Requires-Dist: mypy>=1.11; extra == "lint"
Provides-Extra: dev
Requires-Dist: aiodisklavier[lint,test]; extra == "dev"
Dynamic: license-file

# aiodisklavier

Async Python client for the **Yamaha Disklavier ENSPIRE** local API — HTTP for control and
state, SMB for getting your own music onto the instrument.

Talks to the piano directly over your own network. Verified against firmware **5.24.00** on a
Disklavier ENSPIRE PRO grand — one piano, which is the honest measure of it. If you have
another, [your report](#reporting-from-another-piano) is the most useful thing you can send.

> Not affiliated with, endorsed by, or sponsored by Yamaha Corporation. Yamaha, Disklavier
> and ENSPIRE are trademarks of Yamaha Corporation, used here only to identify the hardware
> this library talks to.

## Install

```bash
pip install aiodisklavier
```

## Use

```python
import asyncio

import aiohttp

from aiodisklavier import Disklavier, SongGroup


async def main() -> None:
    async with aiohttp.ClientSession() as session:
        piano = Disklavier("192.168.1.50", session)

        info = await piano.async_get_current_info()
        print(info.song_title, info.playback_status, info.position_seconds)

        # Fuzzy title search runs on the piano itself.
        await piano.async_play_search("Clair de lune")

        # Play one song and stop, rather than continuing through the library.
        await piano.async_play_song(24, SongGroup.DOWNLOADED_SONGS, single=True)


asyncio.run(main())
```

Finding the piano is a plain SSDP `M-SEARCH` for `urn:schemas-upnp-org:device:Disklavier:1`; the library exposes that device type as `UPNP_DEVICE_TYPE`.

## What it covers

| Area | Methods |
|---|---|
| State | `async_get_static_info`, `async_get_current_info`, `async_get_master_state` |
| Transport | `async_play`, `async_pause`, `async_stop`, `async_play_pause`, `async_next_song`, `async_previous_song`, `async_restart_song`, `async_seek` |
| Volume | `async_set_volume`, `async_volume_up`, `async_volume_down` |
| Power | `async_turn_on`, `async_turn_off`, `async_set_power` |
| Voicing | `async_set_quiet_mode`, `async_set_repeat` |
| Playback | `async_play_song`, `async_play_search`, `async_play_genre`, `async_play_album`, `async_play_playlist`, `async_play_playlist_item` |
| Browsing | `async_get_songs`, `async_get_albums`, `async_get_songs_in_album`, `async_get_playlists`, `async_get_playlist_items` |
| Song database | `async_get_song_db`, `async_lookup_song`, `async_search` |
| Radio | `async_get_radio_channels`, `async_play_radio`, `async_stop_radio` |
| Notifications | `async_notify`, `async_snapshot_playback`, `async_restore_playback`, `async_play_test_chord` |
| Library | `async_refresh_library` |

The song database is the piano's own (`/ctrl/song.json`): one fetch describes every song in
every library, with what the listings leave out — format, composer, performer, length.
`async_search` ranks titles across it and returns candidates, where the piano's own search
can only play its single best guess.

## Firmware behaviours worth knowing

These are properties of the piano, not of this library, and each is easy to get wrong. The
full reasoning, with provenance for every claim, is in
[docs/enspire-api.md](docs/enspire-api.md).

- **There is no stop state.** `stop` leaves `playback_status` reading `pause` at position
  zero. Use `CurrentInfo.is_stopped` rather than looking for a stop constant.
- **Waking takes about twelve seconds**, during which `power_status` reads `wakeup` and the
  piano ignores commands. The HTTP API answers normally while asleep, so reachability tells
  you nothing about power state.
- **Empty libraries are an error, not an empty list** — HTTP 200 carrying
  `{"status": "error", "error_info": "no song"}`. The browse methods translate that
  envelope back into the empty list it denotes, so callers just see `[]`.
- **State reads can come back truncated** while a song is playing, because the daemon
  rewrites those files in place. Reads retry automatically. Payloads may also carry a
  trailing `\n\0`, which is stripped rather than retried.
- **State lags a command.** Reading `current_info` straight after a `load_song` or reselect
  returns the *previous* song. Allow a short settle before trusting a post-command read.
- **The sequencer is deaf while it loads.** `load_song` answers at once and the load takes
  about two seconds, during which a seek or a `play` answers HTTP 200 and is dropped.
  `async_restore_playback` waits the load out and reads the position back; if you cue and
  then seek or play yourself, wait until `playback_status` has left `LOAD`.
- **Radio is a mode, and it takes the piano over.** While a channel plays, `play`, `pause`,
  `stop`, `next_song` and every `load_*` and `play_*` answer HTTP 200 and are ignored, so
  check `CurrentInfo.is_radio` before trusting a transport command. `current_info` goes
  blank; the programme is in `MasterState.radio_channel` / `radio_title`. Only
  `async_stop_radio` ends it, and `async_notify` does that for you and puts the channel back
  afterwards. A paused position does not survive a visit to the radio.
- **Reading the radio channel list stops the music.** `get_radio_channel_list` looks like a
  read and stops the sequencer: a playing song falls silent, a paused one rewinds. So
  `async_get_radio_channels` caches after its first read, and `async_search` leaves radio
  out unless you pass `include_radio=True`. Read the list once, at a quiet moment.
- **A status can be `None`.** `power_status`, `quiet_status` and `playback_status` parse to
  `None`, with one warning logged, for a value this library has no name for — never to a
  plausible guess. `quiet_status` reads `HEADPHONE` while headphones are plugged in, and
  `playback_status` has `LOAD` and `RADIO` as well as the two you would expect.
- **The sequencer daemon can wedge, and only a reboot clears it.** The piano keeps answering
  and keeps accepting selections, but no song finishes loading and nothing plays. What sets
  it off is not established; rapid back-to-back commands are the suspect, so this library
  paces the compound operations it performs, and you should pace yours. See
  [docs/enspire-api.md §7.14](docs/enspire-api.md).
- **`async_play_test_chord` makes a sound** — a C major triad for one second. It goes to the
  MIDI daemon rather than the sequencer, so it will not disturb a loaded song.

## Getting music onto the piano

The piano also exports an SMB share — the *PC Sharing Folder* — and that is the only route
for adding your own MIDI. `DisklavierShare` covers it in the same async, typed idiom, so a
consumer of this library can browse, upload and mirror without shelling out to `mount`.

```python
from aiodisklavier import Disklavier, DisklavierShare, PLAYABLE_SUFFIXES


async with DisklavierShare("192.168.1.50") as share:
    for entry in await share.async_list():
        print(entry.path, entry.size, entry.modified)

    await share.async_upload("doorbell.mid", "ImpromptuApp/doorbell.mid")

    # Mirror a local library. Only what changed is sent, so repeat runs are cheap and an
    # interrupted one resumes.
    result = await share.async_sync_directory(
        "~/Music/disklavier", "ImpromptuApp", suffixes=PLAYABLE_SUFFIXES
    )

if result.changed:
    await piano.async_refresh_library()  # nothing is playable until the piano reindexes
```

| Area | Methods |
|---|---|
| Session | `async_connect`, `async_close` — or use it as an async context manager |
| Browsing | `async_list`, `async_walk`, `async_stat`, `async_exists`, `async_list_shares` |
| Writing | `async_upload`, `async_upload_bytes`, `async_makedirs`, `async_rename` |
| Reading | `async_download`, `async_download_bytes` |
| Removing | `async_delete`, `async_remove_directory`, `async_delete_tree` |
| Mirroring | `async_sync_directory` |

Things worth knowing about the share, all covered in
[docs/enspire-api.md §8](docs/enspire-api.md):

- **It speaks SMB1 and nothing newer.** Samba 3.0.37 is the server *software*; SMB1/NT1 is
  the newest protocol dialect it can offer, because Samba did not gain SMB2 until 3.6. An
  SMB2 negotiate gets the socket closed. This is why the dependency is `pysmb` and not
  `smbprotocol`, which starts at SMB 2.0.2 and cannot connect to the piano at all.
- **The piano indexes exactly two folder levels.** `<folder>/<subfolder>/song.mid` is the
  deepest path it will ever list, and each subfolder holding songs becomes an album.
  Anything below that copies without complaint and then simply is not in the library — no
  error from the write, none from the reindex. `async_sync_directory` logs a warning when it
  uploads past the limit, but it will not restructure a tree for you:

  ```text
  ImpromptuApp/Frédéric Chopin/Ballade No. 1 in G Minor, Op. 23.mid   indexed
  ImpromptuApp/maestro/Frédéric Chopin/Ballade No. 1 in G Minor.mid   invisible
  ```
- **Audio next to a MIDI file is that song's backing track.** `song.mid` + `song.wav` (or
  `.mp3`) with matching basenames is one SMF+Audio song, not two: the keys play the MIDI,
  the speakers play the audio, and the audio's length becomes the song's duration. Filter a
  sync to `{".mid"}` and every transcription still copies, indexes and plays — as a bare
  piano part with the band missing, silently. `PLAYABLE_SUFFIXES` includes audio for exactly
  this reason, and `async_sync_directory` warns if it sends a MIDI whose companion was
  filtered out.
- **Names on the share ignore case.** Uploading `Clair de Lune.mid` over a stored
  `clair de lune.mid` overwrites it under its old name. `async_sync_directory` therefore
  matches remote paths case-insensitively, so a file renamed only in its capitalisation keeps
  the name the share already has — rather than being re-sent and then pruned under the old
  name, which would delete the only copy.
- **Never copy to the share from macOS directly.** Finder leaves a `._` AppleDouble stub
  beside every file, the piano indexes those as songs in their own right, and loading one
  silently resets the piano to the first built-in song. `async_sync_directory` excludes them
  — along with `.DS_Store` and friends — by default.
- **Nothing you upload is playable until you reindex** with `async_refresh_library`, and the
  reindex reassigns song ids, so resolve songs by title afterwards rather than reusing an id.
- **The share is served to guests** on stock firmware — no password, full write access. Same
  trust model as the HTTP API: the LAN is the security boundary.
- **Transfers are serial.** One SMB session carries one request, so `DisklavierShare`
  serialises operations behind a lock. Use separate instances if you want parallelism.

## Two APIs, one preferred

The piano exposes a versioned open API at `/api/1.0/<command>` and an internal, unversioned
set of endpoints under `/ctrl/` that its own web UI drives. This library uses the open API
wherever possible and drops to `/ctrl/` only for what the open API cannot do: seeking, repeat
and shuffle, the extended state block, reindexing, and the test chord.

The open API takes some finding: nothing the piano normally serves links to it, and neither
the phone app nor the piano's own web UI calls it. The one client-side trail is
`/ctrl/api_test.html`, a test harness Yamaha ships on the device — that is where the
`/api/1.0/` form is visible. `/api/api.php?_com=<command>` is the same surface by another
name, verified equivalent down to the error codes.

## Security

The piano's API is plaintext HTTP with no authentication (unless a passcode is set on the
piano), and SSDP discovery answers are unauthenticated multicast — any host on the LAN can
observe or impersonate the piano. The client hardens itself against a hostile device:
response bodies are read against a size ceiling, redirects are refused, and device-supplied
strings are treated as data. The transport itself still has no confidentiality or
integrity, so keep this traffic on a trusted network and do not expose the piano or this
client across an untrusted one.

The SMB share is the same picture, and a little worse: stock firmware serves it to guests
with full write access, and SMB1 has no meaningful integrity protection. `DisklavierShare`
refuses paths containing `..` or control characters before they reach the wire, so a
device- or config-supplied path cannot be steered outside the share, and it never follows
a name the server invents. Treat write access to the share as equivalent to LAN access.

## Stability

The version is still below 1.0, deliberately: the last incompatible changes are being made
now, while they are cheap, so that they need not be made later — the
[changelog](CHANGELOG.md) lists them. From 1.0.0 the project follows
[Semantic Versioning](https://semver.org), and this is what the number will and will not
promise.

**What is covered.** The names in `aiodisklavier.__all__`, imported from `aiodisklavier`
itself: their signatures, the fields of the models, the members of the enumerations, and the
exception each failure raises. Within a major version those only grow. Models are
keyword-only and new fields arrive with defaults, so code that constructs one — a test
fixture, say — keeps working.

**What is not.**

- *Submodule paths.* `aiodisklavier.client`, `.const`, `.models`, `.share` and `.exceptions`
  are an arrangement of files, and may be rearranged. Import from the top level. Anything
  spelled with a leading underscore is private, `python -m aiodisklavier`'s output included.
- *The values of tuning constants.* `DEFAULT_TIMEOUT`, `MAX_RESPONSE_BYTES` and their kind
  keep their names and may change their numbers in a minor release, as may the settles and
  polling intervals behind `async_notify` and `async_restore_playback`.
- *New enumeration members.* A firmware state this library learns to name stops parsing to
  `None` and starts parsing to a new member, in a minor release. Write your `match` with a
  default arm.
- *The piano.* Features built on the internal `/ctrl/` endpoints — seeking, repeat, the
  extended state, the song database, reindexing, restoring a position — depend on an
  interface Yamaha has never published or versioned. They are best-effort: a firmware update
  can break them, and a fix for that is a patch release, not a breach of this policy.

**What 1.0 will claim**, and no more: that the API above is stable, and that it was verified
on an ENSPIRE PRO grand running 5.24.00. Not that it has been tried on other models, regions
or firmware — it has not.

**Deprecation.** Something on its way out keeps working, and warns with
`DeprecationWarning`, for at least one minor release before it is removed in the next major.

**Python.** Every version CI tests, currently 3.11 to 3.14. Dropping one that has reached its
end of life is a minor release.

## Reporting from another piano

Everything here was learned from one instrument, so what yours says is news. One command
describes it, read-only and silent:

```bash
python -m aiodisklavier 192.168.1.50
```

It prints which fields your piano reports and which values its status fields take, flagging
anything this library does not recognise, and it is made to be pasted into
[an issue](https://github.com/reubenbijl/aiodisklavier/issues/new/choose) as it stands. It
reports shapes rather than contents: no song titles, file names, ids or addresses, and
nothing from the account and passcode fields the piano keeps in the same state file. It
never asks for the radio channel list, so it will not interrupt anything that is playing.

The [Home Assistant integration](https://github.com/reubenbijl/ha-disklavier) is built on
this library; a problem you meet there that traces back to the piano belongs here too.

## Development

```bash
python3 -m venv .venv
.venv/bin/pip install -e ".[test]"
.venv/bin/python -m pytest
```

Tests run against a real `aiohttp` test server that imitates the piano, so no hardware is
needed and the suite does not depend on any mocking library's grip on aiohttp internals.
Several tests encode behaviour found only on real hardware — those are commented as such,
because they look arbitrary otherwise.

A fake can only repeat what one piano once did, so there is also an opt-in suite that runs
the same library against a real one — read-only unless you ask for more:

```bash
DISKLAVIER_HOST=192.168.1.50 .venv/bin/python -m pytest -m hardware
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for its three levels, and for the caution that goes
with the third.

## Licence

MIT — see [LICENSE](LICENSE).

This project documents and interoperates with a local network interface exposed by hardware
the owner already has on their own network. It contains no Yamaha source code, firmware or
musical content, and circumvents no access control: the piano's local API is unauthenticated
as shipped. The protocol was derived by observing traffic, not by decompiling anything.

Yamaha, Disklavier and ENSPIRE are trademarks of Yamaha Corporation. This project is not
affiliated with, endorsed by, or sponsored by Yamaha Corporation.
