Metadata-Version: 2.4
Name: edifier-es300
Version: 1.0.1
Summary: Add your description here
Requires-Python: >=3.13
Requires-Dist: click>=8.4.2
Description-Content-Type: text/markdown

# edifier_es300

Control an **Edifier ES300** speaker over Wi-Fi, without the Edifier Home app.
Ships an `asyncio` library and a `click`-based command-line interface.

- **Library** (`edifier_es300`): stdlib-only, fully async.
- **CLI** (`python -m edifier_es300`): thin wrapper over the library; needs `click`.

## Requirements

- Python 3.13+
- `click` (CLI only — the library has no third-party dependencies)

```bash
uv sync        # or: pip install click
```

## Library usage

Everything is async. The `ES300` connection is an async context manager — reuse a
single connection for a burst of commands (the device drops an idle socket after a
few seconds).

```python
import asyncio
from edifier_es300 import ES300, Source, EqPreset, LightEffect, LightColor


async def main():
  async with ES300("192.168.1.123", 8080) as device:
    print(await device.status())  # parsed Status (see below)

    await device.volume(20)  # 0..30
    await device.play()  # resume
    await device.pause()  # pause
    await device.play_pause_toggle()  # toggle
    await device.next_track()
    await device.previous_track()

    await device.input_source(Source.AIRPLAY)

    await device.light_switch(True)
    await device.brightness(60)  # 0..100
    await device.light_effect(LightEffect.BREATHING)
    await device.light_color(LightColor.YELLOW)

    await device.eq_preset(EqPreset.VOCAL)
    await device.eq_custom((10, 5, 0, 0, 0, -5))  # 6 gains, tenths of a dB (-30..30)

    await device.timer_shutdown(30)  # sleep timer in minutes (0 = off)
    await device.shutdown()  # power off (no remote power-on; physical button to wake)


asyncio.run(main())
```

Internally the context manager holds the socket open and runs a background task that
reads every inbound frame. Each command carries a generated `id`; the device echoes
it in its reply, and the background task hands the reply to the awaiting call — so
`await device.volume(20)` returns only once that command's ack comes back.

### Discovery

`ES300.discover()` broadcasts on the LAN and returns a list of ready-to-use
`ES300` objects (host, port, and name filled in):

```python
speakers = await ES300.discover(seconds=3.0)
for speaker in speakers:
    print(speaker)            # "EDIFIER ES300  192.168.1.123:8080"

async with speakers[0] as device:
    await device.volume(15)
```

### Return values & errors

- Command methods (`volume`, `play`, `input_source`, `eq_custom`, …) return the
  device's raw ack frame (a `FrameData` dict). They raise `CommandFailed` if the
  device acks with a non-`success` message, or `EndOfStream` if the socket drops
  before the ack arrives.
- `status()` returns a `Status | None` (`None` only if the device stays silent).

### Live callbacks

Register callbacks to react to unsolicited frames the device pushes while a
connection is held open. Both are usable as decorators and hold the callback by
**weak reference** (drop your reference and it stops firing):

```python
async with ES300("192.168.1.123") as device:
    @device.status_callback
    async def on_status(status: Status):
        print("state changed:", status.volume)

    @device.heartbeat_callback
    async def on_heartbeat(frame):
        print("heartbeat")

    await asyncio.sleep(60)  # callbacks fire as frames arrive
```

### `Status`

`str(status)` renders a human-readable dump (this is what the CLI `status` prints):

```
playing: - / - (status <PlayerStatus.PLAYING: 1>)
volume : 6 / 30
source : <Source.USB: 2>
effect : <LightEffect.STATIC: 1>
color  : <LightColor.YELLOW: {'r': 255, 'g': 170, 'b': 60}>
eq     : <EqPreset.CLASSIC: 0> gains=[20, 10, 0, -5, 5, 10]
battery: 40% (<BatteryStatus.DISCONNECTED: 2>)
timer  : off
```

Fields: `volume`, `max_volume`, `song`, `lyric`, `player_status`, `input_source`,
`light_effect`, `sound_index`, `eq_selected_index`, `eq_gains`, `battery`, and
`raw` (the full status frame for anything not surfaced).

### Enums

| Enum | Values | Notes |
|------|--------|-------|
| `Source` | `BLUETOOTH=0`, `AUX=1`, `USB=2`, `AIRPLAY=3` | input source |
| `EqPreset` | `CLASSIC=0`, `MONITOR=1`, `GAME=2`, `VOCAL=3`, `CUSTOMIZED=4` | `CUSTOMIZED` is the editable slot |
| `LightEffect` | `STATIC=1`, `BREATHING=2`, `WATERFLOW=3` | ambient LED effect |
| `LightColor` | `YELLOW`, `WHITE` | value is the RGB dict; hardware only does these two |
| `BatteryStatus` | `CONNECTED=1`, `DISCONNECTED=2` | external power state (read-only) |

`Source`, `EqPreset`, and `LightEffect` are `IntEnum`s, so methods also accept a
plain `int`. Setting `eq_custom` gains automatically selects `EqPreset.CUSTOMIZED`.

## CLI usage

```bash
python -m edifier_es300 [--host IP] [--port N] COMMAND [ARGS]
```

- `--host` — speaker IP. Omit it to **auto-discover** the first speaker on the LAN.
- `--port` — control-channel TCP port (default `8080`).

| Command | Args | Description |
|---------|------|-------------|
| `discover` | — | list speakers on the LAN (`name  ip:port`) |
| `status` | — | dump volume / source / light / EQ / battery |
| `volume` | `LEVEL` (0..30) | set volume |
| `play` / `pause` | — | resume / pause playback |
| `play-pause` | — | toggle play/pause |
| `next-track` / `previous-track` | — | skip track |
| `shutdown` | — | power the speaker off (no remote power-on) |
| `timer-shutdown` | `MINUTES` (0..1440) | sleep timer (0 = off; app presets 5/15/30/60/180) |
| `light` | `on` \| `off` | LED strip on/off |
| `light-brightness` | `LEVEL` (0..100) | LED brightness |
| `light-effect` | `static` \| `breathing` \| `waterflow` | LED effect |
| `light-color` | `yellow` \| `white` | LED color |
| `source` | `bluetooth` \| `aux` \| `usb` \| `airplay` | input source |
| `eq-preset` | `classic` \| `monitor` \| `game` \| `vocal` \| `customized` | EQ preset |
| `eq` | `G1..G6` | exactly 6 custom gains, tenths of a dB (-30..30 = -3.0..+3.0 dB) |

### Examples

```bash
python -m edifier_es300 discover
python -m edifier_es300 status                       # auto-discover, then dump state
python -m edifier_es300 --host 192.168.1.123 volume 22
python -m edifier_es300 source airplay
python -m edifier_es300 light-effect breathing
python -m edifier_es300 light-color yellow
python -m edifier_es300 eq-preset vocal
python -m edifier_es300 eq -- 10 5 0 0 0 -5          # use -- so negatives aren't read as options
```

> Note: negative EQ gains look like CLI options, so prefix the gain list with `--`.
