edifier-es300 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- edifier_es300-0.1.1/.bumpversion.cfg +14 -0
- edifier_es300-0.1.1/.github/workflows/publish.yml +30 -0
- edifier_es300-0.1.1/.gitignore +14 -0
- edifier_es300-0.1.1/.pre-commit-config.yaml +29 -0
- edifier_es300-0.1.1/.python-version +1 -0
- edifier_es300-0.1.1/CLAUDE.md +79 -0
- edifier_es300-0.1.1/PKG-INFO +149 -0
- edifier_es300-0.1.1/README.md +141 -0
- edifier_es300-0.1.1/edifier_es300/__init__.py +264 -0
- edifier_es300-0.1.1/edifier_es300/__main__.py +209 -0
- edifier_es300-0.1.1/edifier_es300/discovery.py +146 -0
- edifier_es300-0.1.1/edifier_es300/typing_.py +114 -0
- edifier_es300-0.1.1/pyproject.toml +28 -0
- edifier_es300-0.1.1/uv.lock +35 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[bumpversion]
|
|
2
|
+
current_version = 0.1.1
|
|
3
|
+
commit = True
|
|
4
|
+
tag = True
|
|
5
|
+
tag_name = v{new_version}
|
|
6
|
+
message = Bump version: {current_version} -> {new_version}
|
|
7
|
+
|
|
8
|
+
[bumpversion:file:pyproject.toml]
|
|
9
|
+
search = version = "{current_version}"
|
|
10
|
+
replace = version = "{new_version}"
|
|
11
|
+
|
|
12
|
+
[bumpversion:file:uv.lock]
|
|
13
|
+
search = version = "{current_version}"
|
|
14
|
+
replace = version = "{new_version}"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v[0-9]+.[0-9]+.[0-9]+"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
# Gates each release behind the `pypi` environment's protection rules
|
|
12
|
+
# (e.g. required reviewers). Must match the environment on the PyPI publisher.
|
|
13
|
+
environment: pypi
|
|
14
|
+
# Required for PyPI Trusted Publishing (OIDC): uv exchanges this token for a
|
|
15
|
+
# short-lived PyPI credential — no stored secret.
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v6
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.13"
|
|
25
|
+
|
|
26
|
+
- name: Build
|
|
27
|
+
run: uv build
|
|
28
|
+
|
|
29
|
+
- name: Publish
|
|
30
|
+
run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
default_language_version:
|
|
2
|
+
python: python3.13
|
|
3
|
+
|
|
4
|
+
repos:
|
|
5
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
6
|
+
rev: v6.0.0
|
|
7
|
+
hooks:
|
|
8
|
+
- id: end-of-file-fixer
|
|
9
|
+
- id: trailing-whitespace
|
|
10
|
+
- id: check-yaml
|
|
11
|
+
- id: check-toml
|
|
12
|
+
- id: check-added-large-files
|
|
13
|
+
- id: check-merge-conflict
|
|
14
|
+
- id: mixed-line-ending
|
|
15
|
+
- id: debug-statements
|
|
16
|
+
|
|
17
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
18
|
+
rev: v0.15.20
|
|
19
|
+
hooks:
|
|
20
|
+
# Lint + import sorting (fixes what it can, incl. isort-style I rules).
|
|
21
|
+
- id: ruff
|
|
22
|
+
args: [--fix]
|
|
23
|
+
# Formatting.
|
|
24
|
+
- id: ruff-format
|
|
25
|
+
|
|
26
|
+
- repo: https://github.com/astral-sh/ty-pre-commit
|
|
27
|
+
rev: v0.0.56
|
|
28
|
+
hooks:
|
|
29
|
+
- id: ty
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# CLAUDE.md — working in `edifier_es300`
|
|
2
|
+
|
|
3
|
+
Guidance for working in this Python package. Read `README.md` for user-facing usage.
|
|
4
|
+
This file is about the *code*: how it's organized, the conventions to follow, and the
|
|
5
|
+
Python-specific traps hit while building it.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
An `asyncio` package to control an Edifier ES300 speaker on the local network, plus a
|
|
10
|
+
`click` CLI. The library is stdlib-only; only the CLI needs `click`. Python 3.13+.
|
|
11
|
+
|
|
12
|
+
## Module layout
|
|
13
|
+
|
|
14
|
+
- `__init__.py` — the `ES300` async client: connection lifecycle (async context
|
|
15
|
+
manager), the request/response plumbing, the public command methods (volume,
|
|
16
|
+
transport, light, EQ, input source), and the `discover()` classmethod. Stdlib-only.
|
|
17
|
+
- `typing.py` — **all shared types**: `FrameData`, the `Status` dataclass,
|
|
18
|
+
`CommandResult`, and every device enum (`Source`, `EqPreset`, `LightEffect`,
|
|
19
|
+
`LightColor`, `BatteryStatus`, `PlayerStatus`). It imports nothing from
|
|
20
|
+
the package, so it can never be part of an import cycle.
|
|
21
|
+
- `__main__.py` — the `click` CLI (`python -m edifier_es300`). A thin wrapper:
|
|
22
|
+
resolve a target (explicit `--host/--port` or auto-discover), open one connection
|
|
23
|
+
per command, format output with `click.echo`.
|
|
24
|
+
- `discovery.py` — network discovery of speakers on the LAN; returns
|
|
25
|
+
`DiscoveredDevice`s. Uses `logging`, not `print`.
|
|
26
|
+
|
|
27
|
+
## Conventions
|
|
28
|
+
|
|
29
|
+
- **Types live in `typing.py`.** If a type is used by more than one module — or would
|
|
30
|
+
cause a circular import if defined in `__init__` — define it there. `__init__`
|
|
31
|
+
re-exports the ones it uses so `from edifier_es300 import Source` keeps working.
|
|
32
|
+
(Enums not referenced by `__init__`, like `BatteryStatus`, are imported from
|
|
33
|
+
`edifier_es300.typing` to avoid an unused import.)
|
|
34
|
+
- **Type aliases use the `type` keyword** (PEP 695): `type FrameData = dict[str, Any]`.
|
|
35
|
+
- **Name collisions get a trailing underscore.** Prefer a descriptive name first
|
|
36
|
+
(this is why the JSON-dict alias is `FrameData`, not `json_`); fall back to a `_`
|
|
37
|
+
suffix only for a genuine clash with a stdlib module or builtin.
|
|
38
|
+
- **No 1–2 character variable names.** Spell them out (`header_pos`, `payload_len`,
|
|
39
|
+
`device`, `frame`, `chunk`).
|
|
40
|
+
- **Device settings are enums.** `IntEnum` for index-valued settings (`Source`,
|
|
41
|
+
`EqPreset`, `LightEffect`, `BatteryStatus`, `PlayerStatus`); a plain `Enum` for `LightColor` (its
|
|
42
|
+
value is the RGB payload). For display, format members with `repr` (`%r` / `{x!r}`),
|
|
43
|
+
which gives `<Source.AIRPLAY: 3>`; plain `str` on an `IntEnum` is just the number.
|
|
44
|
+
Index enums also accept a raw `int` in method signatures (`Source | int`).
|
|
45
|
+
- **CLI:** one `click` command per action; enum args use `click.Choice(...)` resolved
|
|
46
|
+
via `Enum[name.upper()]`. Omitting `--host` triggers auto-discovery.
|
|
47
|
+
- **Logging, not print,** in library/discovery code. Only the CLI emits output
|
|
48
|
+
(through `click.echo`).
|
|
49
|
+
|
|
50
|
+
## Python gotchas hit here
|
|
51
|
+
|
|
52
|
+
- **`IntEnum.__str__` returns the number** in 3.11+ (`str(Source.AIRPLAY)` → `3`, not
|
|
53
|
+
`Source.AIRPLAY`). Use `repr` for display (`%r`) — `repr(Source.AIRPLAY)` →
|
|
54
|
+
`<Source.AIRPLAY: 3>` — which is why `Status.__str__` formats enum fields with `%r`.
|
|
55
|
+
- **An `IntEnum` member with value `0` is falsy** (`EqPreset.CLASSIC`). Never test an
|
|
56
|
+
enum lookup with truthiness — use `is None`.
|
|
57
|
+
- **`LightColor`'s value is a dict** (unhashable). `LightColor(some_dict)` still works
|
|
58
|
+
because `Enum.__new__` falls back to a linear value search, but the hashed
|
|
59
|
+
value→member map isn't built for it.
|
|
60
|
+
- **PEP 695 `type` aliases are lazy** — the right-hand side isn't evaluated at import.
|
|
61
|
+
That's what lets `typing.py` hold `CommandResult = tuple[bool, Status | None]` and be
|
|
62
|
+
imported by `__init__` without an import cycle.
|
|
63
|
+
|
|
64
|
+
## Testing
|
|
65
|
+
|
|
66
|
+
- **Verify against the real speaker.** This project favors empirical checks over specs:
|
|
67
|
+
after a change, run the CLI live — typically `status`, then the command you changed,
|
|
68
|
+
then `status` again to confirm the effect landed.
|
|
69
|
+
- Use `uv` + the project venv for tooling. The library itself needs no dependencies.
|
|
70
|
+
|
|
71
|
+
## Running
|
|
72
|
+
|
|
73
|
+
The package modules sit at the top of this directory, so run from the directory that
|
|
74
|
+
*contains* `edifier_es300/`:
|
|
75
|
+
|
|
76
|
+
- CLI: `python -m edifier_es300 <command>`
|
|
77
|
+
- Library: `from edifier_es300 import ES300`
|
|
78
|
+
|
|
79
|
+
The interpreter needs `click` importable for the CLI.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edifier-es300
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: click>=8.4.2
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# edifier_es300
|
|
10
|
+
|
|
11
|
+
Control an **Edifier ES300** speaker over Wi-Fi, without the Edifier Home app.
|
|
12
|
+
Ships an `asyncio` library and a `click`-based command-line interface.
|
|
13
|
+
|
|
14
|
+
- **Library** (`edifier_es300`): stdlib-only, fully async.
|
|
15
|
+
- **CLI** (`python -m edifier_es300`): thin wrapper over the library; needs `click`.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Python 3.13+
|
|
20
|
+
- `click` (CLI only — the library has no third-party dependencies)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv sync # or: pip install click
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Library usage
|
|
27
|
+
|
|
28
|
+
Everything is async. The `ES300` connection is an async context manager — reuse a
|
|
29
|
+
single connection for a burst of commands (the device drops an idle socket after a
|
|
30
|
+
few seconds).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from edifier_es300 import ES300, Source, EqPreset, LightEffect, LightColor
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
async with ES300("192.168.1.123", 8080) as device:
|
|
38
|
+
print(await device.status()) # parsed Status (see below)
|
|
39
|
+
|
|
40
|
+
await device.volume(20) # 0..30
|
|
41
|
+
await device.play() # resume
|
|
42
|
+
await device.pause() # pause
|
|
43
|
+
await device.play_pause_toggle() # toggle
|
|
44
|
+
await device.next_track()
|
|
45
|
+
await device.prev_track()
|
|
46
|
+
|
|
47
|
+
await device.input_source(Source.AIRPLAY)
|
|
48
|
+
|
|
49
|
+
await device.light_switch(True)
|
|
50
|
+
await device.brightness(60) # 0..100
|
|
51
|
+
await device.light_effect(LightEffect.BREATHING)
|
|
52
|
+
await device.light_color(LightColor.YELLOW)
|
|
53
|
+
|
|
54
|
+
await device.eq_preset(EqPreset.VOCAL)
|
|
55
|
+
await device.eq_custom([10, 5, 0, 0, 0, -5]) # tenths of a dB (-30..30)
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Discovery
|
|
61
|
+
|
|
62
|
+
`ES300.discover()` broadcasts on the LAN and returns a list of ready-to-use
|
|
63
|
+
`ES300` objects (host, port, and name filled in):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
speakers = await ES300.discover(seconds=3.0)
|
|
67
|
+
for speaker in speakers:
|
|
68
|
+
print(speaker) # "EDIFIER ES300 192.168.1.123:8080"
|
|
69
|
+
|
|
70
|
+
async with speakers[0] as device:
|
|
71
|
+
await device.volume(15)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Return values
|
|
75
|
+
|
|
76
|
+
- Command methods (`volume`, `play`, `input_source`, `eq_custom`, …) return
|
|
77
|
+
`CommandResult`, a `tuple[bool, Status | None]` of `(acknowledged, new_state)`.
|
|
78
|
+
- `status()` returns a `Status | None` (`None` only if the device stays silent).
|
|
79
|
+
|
|
80
|
+
### `Status`
|
|
81
|
+
|
|
82
|
+
`str(status)` renders a human-readable dump (this is what the CLI `status` prints):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
playing: - / - (status 0)
|
|
86
|
+
volume : 6 / 30
|
|
87
|
+
source : Source.AIRPLAY
|
|
88
|
+
effect : LightEffect.STATIC
|
|
89
|
+
color : LightColor.YELLOW
|
|
90
|
+
eq : EqPreset.CLASSIC gains=[0, 0, 0, 0, 0, 0]
|
|
91
|
+
battery: 100% (BatteryStatus.CONNECTED)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Fields: `volume`, `max_volume`, `song`, `lyric`, `player_status`, `input_source`,
|
|
95
|
+
`light_effect`, `sound_index`, `eq_selected_index`, `eq_gains`, `battery`, and
|
|
96
|
+
`raw` (the full status frame for anything not surfaced).
|
|
97
|
+
|
|
98
|
+
### Enums
|
|
99
|
+
|
|
100
|
+
| Enum | Values | Notes |
|
|
101
|
+
|------|--------|-------|
|
|
102
|
+
| `Source` | `BLUETOOTH=0`, `AUX=1`, `USB=2`, `AIRPLAY=3` | input source |
|
|
103
|
+
| `EqPreset` | `CLASSIC=0`, `MONITOR=1`, `GAME=2`, `VOCAL=3`, `CUSTOMIZED=4` | `CUSTOMIZED` is the editable slot |
|
|
104
|
+
| `LightEffect` | `STATIC=1`, `BREATHING=2`, `WATERFLOW=3` | ambient LED effect |
|
|
105
|
+
| `LightColor` | `YELLOW`, `WHITE` | value is the RGB dict; hardware only does these two |
|
|
106
|
+
| `BatteryStatus` | `CONNECTED=1`, `DISCONNECTED=2` | external power state (read-only) |
|
|
107
|
+
|
|
108
|
+
`Source`, `EqPreset`, and `LightEffect` are `IntEnum`s, so methods also accept a
|
|
109
|
+
plain `int`. Setting `eq_custom` gains automatically selects `EqPreset.CUSTOMIZED`.
|
|
110
|
+
|
|
111
|
+
## CLI usage
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python -m edifier_es300 [--host IP] [--port N] COMMAND [ARGS]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- `--host` — speaker IP. Omit it to **auto-discover** the first speaker on the LAN.
|
|
118
|
+
- `--port` — control-channel TCP port (default `8080`).
|
|
119
|
+
|
|
120
|
+
| Command | Args | Description |
|
|
121
|
+
|---------|------|-------------|
|
|
122
|
+
| `discover` | — | list speakers on the LAN (`name ip:port`) |
|
|
123
|
+
| `status` | — | dump volume / source / light / EQ / battery |
|
|
124
|
+
| `vol` | `LEVEL` (0..30) | set volume |
|
|
125
|
+
| `play` / `pause` | — | resume / pause playback |
|
|
126
|
+
| `play-pause` | — | toggle play/pause |
|
|
127
|
+
| `next` / `prev` | — | skip track |
|
|
128
|
+
| `light` | `on` \| `off` | LED strip on/off |
|
|
129
|
+
| `light-brightness` | `LEVEL` (0..100) | LED brightness |
|
|
130
|
+
| `light-effect` | `static` \| `breathing` \| `waterflow` | LED effect |
|
|
131
|
+
| `light-color` | `yellow` \| `white` | LED color |
|
|
132
|
+
| `source` | `bluetooth` \| `aux` \| `usb` \| `airplay` | input source |
|
|
133
|
+
| `preset` | `classic` \| `monitor` \| `game` \| `vocal` \| `customized` | EQ preset |
|
|
134
|
+
| `eq` | `GAINS...` | 6 custom gains, tenths of a dB (-30..30 = -3.0..+3.0 dB) |
|
|
135
|
+
|
|
136
|
+
### Examples
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
python -m edifier_es300 discover
|
|
140
|
+
python -m edifier_es300 status # auto-discover, then dump state
|
|
141
|
+
python -m edifier_es300 --host 192.168.1.123 vol 22
|
|
142
|
+
python -m edifier_es300 source airplay
|
|
143
|
+
python -m edifier_es300 light-effect breathing
|
|
144
|
+
python -m edifier_es300 light-color yellow
|
|
145
|
+
python -m edifier_es300 preset vocal
|
|
146
|
+
python -m edifier_es300 eq -- 10 5 0 0 0 -5 # use -- so negatives aren't read as options
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> Note: negative EQ gains look like CLI options, so prefix the gain list with `--`.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# edifier_es300
|
|
2
|
+
|
|
3
|
+
Control an **Edifier ES300** speaker over Wi-Fi, without the Edifier Home app.
|
|
4
|
+
Ships an `asyncio` library and a `click`-based command-line interface.
|
|
5
|
+
|
|
6
|
+
- **Library** (`edifier_es300`): stdlib-only, fully async.
|
|
7
|
+
- **CLI** (`python -m edifier_es300`): thin wrapper over the library; needs `click`.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Python 3.13+
|
|
12
|
+
- `click` (CLI only — the library has no third-party dependencies)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv sync # or: pip install click
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Library usage
|
|
19
|
+
|
|
20
|
+
Everything is async. The `ES300` connection is an async context manager — reuse a
|
|
21
|
+
single connection for a burst of commands (the device drops an idle socket after a
|
|
22
|
+
few seconds).
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from edifier_es300 import ES300, Source, EqPreset, LightEffect, LightColor
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
async with ES300("192.168.1.123", 8080) as device:
|
|
30
|
+
print(await device.status()) # parsed Status (see below)
|
|
31
|
+
|
|
32
|
+
await device.volume(20) # 0..30
|
|
33
|
+
await device.play() # resume
|
|
34
|
+
await device.pause() # pause
|
|
35
|
+
await device.play_pause_toggle() # toggle
|
|
36
|
+
await device.next_track()
|
|
37
|
+
await device.prev_track()
|
|
38
|
+
|
|
39
|
+
await device.input_source(Source.AIRPLAY)
|
|
40
|
+
|
|
41
|
+
await device.light_switch(True)
|
|
42
|
+
await device.brightness(60) # 0..100
|
|
43
|
+
await device.light_effect(LightEffect.BREATHING)
|
|
44
|
+
await device.light_color(LightColor.YELLOW)
|
|
45
|
+
|
|
46
|
+
await device.eq_preset(EqPreset.VOCAL)
|
|
47
|
+
await device.eq_custom([10, 5, 0, 0, 0, -5]) # tenths of a dB (-30..30)
|
|
48
|
+
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Discovery
|
|
53
|
+
|
|
54
|
+
`ES300.discover()` broadcasts on the LAN and returns a list of ready-to-use
|
|
55
|
+
`ES300` objects (host, port, and name filled in):
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
speakers = await ES300.discover(seconds=3.0)
|
|
59
|
+
for speaker in speakers:
|
|
60
|
+
print(speaker) # "EDIFIER ES300 192.168.1.123:8080"
|
|
61
|
+
|
|
62
|
+
async with speakers[0] as device:
|
|
63
|
+
await device.volume(15)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Return values
|
|
67
|
+
|
|
68
|
+
- Command methods (`volume`, `play`, `input_source`, `eq_custom`, …) return
|
|
69
|
+
`CommandResult`, a `tuple[bool, Status | None]` of `(acknowledged, new_state)`.
|
|
70
|
+
- `status()` returns a `Status | None` (`None` only if the device stays silent).
|
|
71
|
+
|
|
72
|
+
### `Status`
|
|
73
|
+
|
|
74
|
+
`str(status)` renders a human-readable dump (this is what the CLI `status` prints):
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
playing: - / - (status 0)
|
|
78
|
+
volume : 6 / 30
|
|
79
|
+
source : Source.AIRPLAY
|
|
80
|
+
effect : LightEffect.STATIC
|
|
81
|
+
color : LightColor.YELLOW
|
|
82
|
+
eq : EqPreset.CLASSIC gains=[0, 0, 0, 0, 0, 0]
|
|
83
|
+
battery: 100% (BatteryStatus.CONNECTED)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Fields: `volume`, `max_volume`, `song`, `lyric`, `player_status`, `input_source`,
|
|
87
|
+
`light_effect`, `sound_index`, `eq_selected_index`, `eq_gains`, `battery`, and
|
|
88
|
+
`raw` (the full status frame for anything not surfaced).
|
|
89
|
+
|
|
90
|
+
### Enums
|
|
91
|
+
|
|
92
|
+
| Enum | Values | Notes |
|
|
93
|
+
|------|--------|-------|
|
|
94
|
+
| `Source` | `BLUETOOTH=0`, `AUX=1`, `USB=2`, `AIRPLAY=3` | input source |
|
|
95
|
+
| `EqPreset` | `CLASSIC=0`, `MONITOR=1`, `GAME=2`, `VOCAL=3`, `CUSTOMIZED=4` | `CUSTOMIZED` is the editable slot |
|
|
96
|
+
| `LightEffect` | `STATIC=1`, `BREATHING=2`, `WATERFLOW=3` | ambient LED effect |
|
|
97
|
+
| `LightColor` | `YELLOW`, `WHITE` | value is the RGB dict; hardware only does these two |
|
|
98
|
+
| `BatteryStatus` | `CONNECTED=1`, `DISCONNECTED=2` | external power state (read-only) |
|
|
99
|
+
|
|
100
|
+
`Source`, `EqPreset`, and `LightEffect` are `IntEnum`s, so methods also accept a
|
|
101
|
+
plain `int`. Setting `eq_custom` gains automatically selects `EqPreset.CUSTOMIZED`.
|
|
102
|
+
|
|
103
|
+
## CLI usage
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python -m edifier_es300 [--host IP] [--port N] COMMAND [ARGS]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `--host` — speaker IP. Omit it to **auto-discover** the first speaker on the LAN.
|
|
110
|
+
- `--port` — control-channel TCP port (default `8080`).
|
|
111
|
+
|
|
112
|
+
| Command | Args | Description |
|
|
113
|
+
|---------|------|-------------|
|
|
114
|
+
| `discover` | — | list speakers on the LAN (`name ip:port`) |
|
|
115
|
+
| `status` | — | dump volume / source / light / EQ / battery |
|
|
116
|
+
| `vol` | `LEVEL` (0..30) | set volume |
|
|
117
|
+
| `play` / `pause` | — | resume / pause playback |
|
|
118
|
+
| `play-pause` | — | toggle play/pause |
|
|
119
|
+
| `next` / `prev` | — | skip track |
|
|
120
|
+
| `light` | `on` \| `off` | LED strip on/off |
|
|
121
|
+
| `light-brightness` | `LEVEL` (0..100) | LED brightness |
|
|
122
|
+
| `light-effect` | `static` \| `breathing` \| `waterflow` | LED effect |
|
|
123
|
+
| `light-color` | `yellow` \| `white` | LED color |
|
|
124
|
+
| `source` | `bluetooth` \| `aux` \| `usb` \| `airplay` | input source |
|
|
125
|
+
| `preset` | `classic` \| `monitor` \| `game` \| `vocal` \| `customized` | EQ preset |
|
|
126
|
+
| `eq` | `GAINS...` | 6 custom gains, tenths of a dB (-30..30 = -3.0..+3.0 dB) |
|
|
127
|
+
|
|
128
|
+
### Examples
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python -m edifier_es300 discover
|
|
132
|
+
python -m edifier_es300 status # auto-discover, then dump state
|
|
133
|
+
python -m edifier_es300 --host 192.168.1.123 vol 22
|
|
134
|
+
python -m edifier_es300 source airplay
|
|
135
|
+
python -m edifier_es300 light-effect breathing
|
|
136
|
+
python -m edifier_es300 light-color yellow
|
|
137
|
+
python -m edifier_es300 preset vocal
|
|
138
|
+
python -m edifier_es300 eq -- 10 5 0 0 0 -5 # use -- so negatives aren't read as options
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
> Note: negative EQ gains look like CLI options, so prefix the gain list with `--`.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Edifier ES300 local control over Wi-Fi (reverse-engineered from Edifier Home 3.3.9).
|
|
3
|
+
|
|
4
|
+
Transport: raw TCP to <ip>:8080
|
|
5
|
+
outbound (client->device): XOR_0xA5( json_utf8 ) -- no header
|
|
6
|
+
inbound (device->client): EE DD FF EE | len(2,BE) | json , whole frame XOR_0xA5
|
|
7
|
+
|
|
8
|
+
Every command carries an "id". The device echoes that id back in:
|
|
9
|
+
- a `settings` ack frame {payload:"settings", id:<id>, message:"success"}
|
|
10
|
+
- a change-triggered status {payload:"status_query", id:<id>, ...full state...}
|
|
11
|
+
both ~1s later. We read until the matching id arrives instead of polling on a timer.
|
|
12
|
+
|
|
13
|
+
Envelope: {"id":"<ms-timestamp>","payload":"settings","<field>":<obj>}
|
|
14
|
+
Note: the device drops a session after ~5s of silence, so reuse one connection --
|
|
15
|
+
the async context manager holds a single connection open for its lifetime.
|
|
16
|
+
|
|
17
|
+
Verified live: volume, transport, light (on/off, mode, brightness, warm/cool),
|
|
18
|
+
EQ (preset + 6-band custom), input source.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
async with ES300("192.168.1.123") as device:
|
|
22
|
+
status = await device.status()
|
|
23
|
+
await device.volume(20)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import AsyncIterator
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from edifier_es300.typing_ import (
|
|
33
|
+
CommandResult,
|
|
34
|
+
EqPreset,
|
|
35
|
+
FrameData,
|
|
36
|
+
LightColor,
|
|
37
|
+
LightEffect,
|
|
38
|
+
PlayerStatus,
|
|
39
|
+
Source,
|
|
40
|
+
Status,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
KEY: int = 0xA5
|
|
44
|
+
FRAME_HEADER: bytes = b"\xee\xdd\xff\xee"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _xor(data: bytes) -> bytes:
|
|
48
|
+
return bytes(byte ^ KEY for byte in data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _uid() -> str:
|
|
52
|
+
return str(int(time.time() * 1000))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ES300:
|
|
56
|
+
def __init__(self, host, port, name: str | None = None) -> None:
|
|
57
|
+
self.name: str | None = name # from discovery; refreshed by status if present
|
|
58
|
+
self._host: str = host
|
|
59
|
+
self._port: int = port
|
|
60
|
+
self._reader: asyncio.StreamReader | None = None
|
|
61
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
62
|
+
self._buffer = bytearray() # decoded receive buffer; consumed bytes are trimmed
|
|
63
|
+
self._offset: int = 0 # scan cursor into _buffer
|
|
64
|
+
|
|
65
|
+
def __str__(self) -> str:
|
|
66
|
+
return "%s %s:%s" % (self.name or "?", self._host, self._port)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
async def discover(cls, seconds: float = 3.0) -> list["ES300"]:
|
|
70
|
+
"""Broadcast-discover ES300 speakers on the LAN; one ES300 per device."""
|
|
71
|
+
from .discovery import discover
|
|
72
|
+
|
|
73
|
+
found = await discover(timeout=seconds)
|
|
74
|
+
return [
|
|
75
|
+
cls(
|
|
76
|
+
host=device.host or device.address,
|
|
77
|
+
port=device.port,
|
|
78
|
+
name=device.name,
|
|
79
|
+
)
|
|
80
|
+
for device in found
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def _absorb_name(self, status: "Status | None") -> "Status | None":
|
|
84
|
+
"""Let a received status override the discovery name (when it carries one)."""
|
|
85
|
+
if status is not None:
|
|
86
|
+
name = status.raw.get("name")
|
|
87
|
+
if name:
|
|
88
|
+
self.name = name
|
|
89
|
+
return status
|
|
90
|
+
|
|
91
|
+
# --- connection lifecycle ---
|
|
92
|
+
async def __aenter__(self) -> "ES300":
|
|
93
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
94
|
+
self._host, self._port
|
|
95
|
+
)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
99
|
+
if self._writer is not None:
|
|
100
|
+
self._writer.close()
|
|
101
|
+
try:
|
|
102
|
+
await self._writer.wait_closed()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
self._reader = self._writer = None
|
|
106
|
+
|
|
107
|
+
# --- framing ---
|
|
108
|
+
async def _send_raw(self, message: FrameData) -> None:
|
|
109
|
+
assert self._writer is not None, "not connected (use 'async with')"
|
|
110
|
+
self._writer.write(_xor(json.dumps(message).encode()))
|
|
111
|
+
await self._writer.drain()
|
|
112
|
+
|
|
113
|
+
async def _frames(self, seconds: float) -> AsyncIterator[FrameData]:
|
|
114
|
+
"""Yield inbound JSON frames as they arrive, up to `seconds` seconds."""
|
|
115
|
+
assert self._reader is not None, "not connected (use 'async with')"
|
|
116
|
+
loop = asyncio.get_running_loop()
|
|
117
|
+
deadline = loop.time() + seconds
|
|
118
|
+
# Outer loop: keep pulling bytes off the socket until the deadline passes.
|
|
119
|
+
while True:
|
|
120
|
+
# Inner loop: drain every complete frame already sitting in the buffer.
|
|
121
|
+
# A frame is HDR + 2-byte big-endian length + that many payload bytes;
|
|
122
|
+
# we stop as soon as the next frame is missing or only partially received.
|
|
123
|
+
while True:
|
|
124
|
+
header_pos = self._buffer.find(FRAME_HEADER, self._offset)
|
|
125
|
+
if header_pos < 0 or header_pos + 6 > len(self._buffer):
|
|
126
|
+
break # no header yet, or length bytes not fully received
|
|
127
|
+
payload_len = (self._buffer[header_pos + 4] << 8) | self._buffer[
|
|
128
|
+
header_pos + 5
|
|
129
|
+
]
|
|
130
|
+
payload_start = header_pos + 6
|
|
131
|
+
payload_end = payload_start + payload_len
|
|
132
|
+
if payload_end > len(self._buffer):
|
|
133
|
+
break # payload still arriving; wait for more bytes
|
|
134
|
+
frame = self._buffer[payload_start:payload_end]
|
|
135
|
+
self._offset = payload_end
|
|
136
|
+
try:
|
|
137
|
+
yield json.loads(frame)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
# Drop consumed frames so the buffer only holds the unparsed tail --
|
|
141
|
+
# otherwise it would grow for the life of a long-running connection.
|
|
142
|
+
if self._offset:
|
|
143
|
+
del self._buffer[: self._offset]
|
|
144
|
+
self._offset = 0
|
|
145
|
+
remaining = deadline - loop.time()
|
|
146
|
+
if remaining <= 0:
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
chunk = await asyncio.wait_for(
|
|
150
|
+
self._reader.read(8192), timeout=remaining
|
|
151
|
+
)
|
|
152
|
+
except TimeoutError:
|
|
153
|
+
return
|
|
154
|
+
if not chunk:
|
|
155
|
+
return
|
|
156
|
+
self._buffer += _xor(chunk)
|
|
157
|
+
|
|
158
|
+
# --- core request/response ---
|
|
159
|
+
async def _command(
|
|
160
|
+
self, field: str, obj: Any, payload: str = "settings", seconds: float = 5.0
|
|
161
|
+
) -> CommandResult:
|
|
162
|
+
"""Send a setting and wait for the id-matched ack + status.
|
|
163
|
+
Returns (ok: bool, status: Status|None)."""
|
|
164
|
+
message_id = _uid()
|
|
165
|
+
await self._send_raw({"id": message_id, "payload": payload, field: obj})
|
|
166
|
+
acked: bool | None = None
|
|
167
|
+
status: Status | None = None
|
|
168
|
+
async for frame in self._frames(seconds):
|
|
169
|
+
if frame.get("id") != message_id:
|
|
170
|
+
continue
|
|
171
|
+
if frame.get("payload") == "settings":
|
|
172
|
+
acked = frame.get("message") == "success"
|
|
173
|
+
elif frame.get("payload") == "status_query":
|
|
174
|
+
status = self._absorb_name(Status.from_frame(frame))
|
|
175
|
+
if acked is not None and status is not None:
|
|
176
|
+
break
|
|
177
|
+
return bool(acked), status
|
|
178
|
+
|
|
179
|
+
# --- commands ---
|
|
180
|
+
async def status(self, seconds: float = 6.0) -> Status | None:
|
|
181
|
+
"""Request full state; return the parsed status (id-matched if possible)."""
|
|
182
|
+
message_id = _uid()
|
|
183
|
+
await self._send_raw({"id": message_id, "payload": "status_query"})
|
|
184
|
+
fallback: Status | None = None
|
|
185
|
+
async for frame in self._frames(seconds):
|
|
186
|
+
if frame.get("payload") == "status_query":
|
|
187
|
+
parsed = self._absorb_name(Status.from_frame(frame))
|
|
188
|
+
if frame.get("id") == message_id:
|
|
189
|
+
return parsed
|
|
190
|
+
fallback = parsed
|
|
191
|
+
return fallback
|
|
192
|
+
|
|
193
|
+
async def volume(self, level: int) -> CommandResult:
|
|
194
|
+
return await self._command("player", {"volume": level}) # 0..30
|
|
195
|
+
|
|
196
|
+
async def play(self) -> CommandResult:
|
|
197
|
+
return await self._command(
|
|
198
|
+
"player", {"playerStatus": int(PlayerStatus.PLAYING)}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def pause(self) -> CommandResult:
|
|
202
|
+
return await self._command(
|
|
203
|
+
"player", {"playerStatus": int(PlayerStatus.STOPPED)}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def play_pause_toggle(self) -> CommandResult:
|
|
207
|
+
# Toggle by sending the opposite of the current state, like the app's button.
|
|
208
|
+
current = await self.status()
|
|
209
|
+
playing = current is not None and current.player_status is PlayerStatus.PLAYING
|
|
210
|
+
target = PlayerStatus.STOPPED if playing else PlayerStatus.PLAYING
|
|
211
|
+
return await self._command("player", {"playerStatus": int(target)})
|
|
212
|
+
|
|
213
|
+
async def next_track(self) -> CommandResult:
|
|
214
|
+
return await self._command("player", {"next": 1})
|
|
215
|
+
|
|
216
|
+
async def prev_track(self) -> CommandResult:
|
|
217
|
+
return await self._command("player", {"previous": 1})
|
|
218
|
+
|
|
219
|
+
async def brightness(self, level: int) -> CommandResult:
|
|
220
|
+
return await self._command("lightEffect", {"brightness": level}) # 0..100
|
|
221
|
+
|
|
222
|
+
async def light_switch(self, enabled: bool) -> CommandResult:
|
|
223
|
+
return await self._command("lightEffect", {"lightSwitch": int(enabled)})
|
|
224
|
+
|
|
225
|
+
async def light_effect(self, effect: LightEffect | int) -> CommandResult:
|
|
226
|
+
return await self._command("lightEffect", {"selectedIndex": int(effect)})
|
|
227
|
+
|
|
228
|
+
async def light_color(self, color: LightColor) -> CommandResult:
|
|
229
|
+
return await self._command("lightEffect", {"color": color.value})
|
|
230
|
+
|
|
231
|
+
async def input_source(self, source: Source | int) -> CommandResult:
|
|
232
|
+
return await self._command("inputSource", {"selectedIndex": int(source)})
|
|
233
|
+
|
|
234
|
+
# EQ. Active EQ is chosen by selectedIndex (index into the speaker's preset list;
|
|
235
|
+
# the last entry is the editable custom slot, auto-selected when you set gains).
|
|
236
|
+
async def eq_preset(self, preset: EqPreset | int) -> CommandResult:
|
|
237
|
+
current = await self.status()
|
|
238
|
+
sound_index = current.sound_index if current else 2
|
|
239
|
+
return await self._command(
|
|
240
|
+
"soundEffect",
|
|
241
|
+
{"soundIndex": sound_index, "selectedIndex": int(preset)},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def eq_custom(
|
|
245
|
+
self, gains: list[int]
|
|
246
|
+
) -> CommandResult: # up to 6 ints in tenths of a dB (-30..30 = -3.0..+3.0 dB), for 62/250/1k/4k/8k/16k Hz
|
|
247
|
+
current = await self.status()
|
|
248
|
+
if current is None:
|
|
249
|
+
return (False, None)
|
|
250
|
+
sound_effect = current.raw["soundEffect"]
|
|
251
|
+
diy = sound_effect["soundEffectDIY"]
|
|
252
|
+
for index, gain in enumerate(gains):
|
|
253
|
+
if index < len(diy["diyData"]):
|
|
254
|
+
diy["diyData"][index]["gain"]["value"] = gain
|
|
255
|
+
return await self._command(
|
|
256
|
+
"soundEffect",
|
|
257
|
+
{
|
|
258
|
+
"soundIndex": sound_effect["soundIndex"],
|
|
259
|
+
"selectedIndex": int(
|
|
260
|
+
EqPreset.CUSTOMIZED
|
|
261
|
+
), # editing gains selects the custom slot
|
|
262
|
+
"soundEffectDIY": diy,
|
|
263
|
+
},
|
|
264
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from edifier_es300 import ES300, EqPreset, LightColor, LightEffect, Source, Status
|
|
8
|
+
from edifier_es300.typing_ import CommandResult
|
|
9
|
+
|
|
10
|
+
DEFAULT_PORT: int = 8080
|
|
11
|
+
|
|
12
|
+
type Action = Callable[[ES300], Awaitable]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Target(NamedTuple):
|
|
16
|
+
"""Where to reach the speaker; host None means auto-discover on the LAN."""
|
|
17
|
+
|
|
18
|
+
host: str | None
|
|
19
|
+
port: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _resolve(target: Target) -> ES300:
|
|
23
|
+
"""Return the target speaker: the given host, or the first one discovered."""
|
|
24
|
+
if target.host:
|
|
25
|
+
return ES300(target.host, target.port)
|
|
26
|
+
speakers = await ES300.discover()
|
|
27
|
+
if not speakers:
|
|
28
|
+
raise click.ClickException("no ES300 speakers found on the network")
|
|
29
|
+
return speakers[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _execute(target: Target, action: Action):
|
|
33
|
+
"""Open a short-lived connection and run one coroutine against the device."""
|
|
34
|
+
|
|
35
|
+
async def runner():
|
|
36
|
+
device = await _resolve(target)
|
|
37
|
+
async with device:
|
|
38
|
+
return await action(device)
|
|
39
|
+
|
|
40
|
+
return asyncio.run(runner())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _report(label: str, result: CommandResult) -> Status | None:
|
|
44
|
+
acked, status = result
|
|
45
|
+
click.echo(("OK " if acked else "?? ") + label)
|
|
46
|
+
return status
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group()
|
|
50
|
+
@click.option(
|
|
51
|
+
"--host", default=None, help="Speaker IP (default: auto-discover on the LAN)."
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--port", default=DEFAULT_PORT, show_default=True, help="Control-channel TCP port."
|
|
55
|
+
)
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def cli(ctx: click.Context, host: str | None, port: int) -> None:
|
|
58
|
+
"""Control an Edifier ES300 speaker over Wi-Fi."""
|
|
59
|
+
ctx.obj = Target(host, port)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@cli.command()
|
|
63
|
+
def discover() -> None:
|
|
64
|
+
"""List ES300 speakers found on the local network."""
|
|
65
|
+
speakers = asyncio.run(ES300.discover())
|
|
66
|
+
if not speakers:
|
|
67
|
+
click.echo("no ES300 speakers found (same Wi-Fi as the speaker?)")
|
|
68
|
+
return
|
|
69
|
+
for speaker in speakers:
|
|
70
|
+
click.echo(speaker)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cli.command()
|
|
74
|
+
@click.pass_obj
|
|
75
|
+
def status(target: Target) -> None:
|
|
76
|
+
"""Dump volume / source / light / EQ / battery."""
|
|
77
|
+
current = _execute(target, lambda device: device.status())
|
|
78
|
+
click.echo(current or "no status (device idle; retry)")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cli.command()
|
|
82
|
+
@click.argument("level", type=click.IntRange(0, 30))
|
|
83
|
+
@click.pass_obj
|
|
84
|
+
def volume(target: Target, level: int) -> None:
|
|
85
|
+
"""Set volume (0..30)."""
|
|
86
|
+
current = _report(
|
|
87
|
+
f"volume={level}", _execute(target, lambda device: device.volume(level))
|
|
88
|
+
)
|
|
89
|
+
click.echo("now %s" % (current.volume if current else "?"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cli.command()
|
|
93
|
+
@click.pass_obj
|
|
94
|
+
def play(target: Target) -> None:
|
|
95
|
+
"""Resume playback."""
|
|
96
|
+
_report("play", _execute(target, lambda device: device.play()))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cli.command()
|
|
100
|
+
@click.pass_obj
|
|
101
|
+
def pause(target: Target) -> None:
|
|
102
|
+
"""Pause playback."""
|
|
103
|
+
_report("pause", _execute(target, lambda device: device.pause()))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@cli.command()
|
|
107
|
+
@click.pass_obj
|
|
108
|
+
def play_pause(target: Target) -> None:
|
|
109
|
+
"""Toggle play/pause."""
|
|
110
|
+
_report("play-pause", _execute(target, lambda device: device.play_pause_toggle()))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@cli.command()
|
|
114
|
+
@click.pass_obj
|
|
115
|
+
def next(target: Target) -> None:
|
|
116
|
+
"""Next track."""
|
|
117
|
+
_report("next", _execute(target, lambda device: device.next_track()))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@cli.command()
|
|
121
|
+
@click.pass_obj
|
|
122
|
+
def previous(target: Target) -> None:
|
|
123
|
+
"""Previous track."""
|
|
124
|
+
_report("prev", _execute(target, lambda device: device.prev_track()))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@cli.command()
|
|
128
|
+
@click.argument("level", type=click.IntRange(0, 100))
|
|
129
|
+
@click.pass_obj
|
|
130
|
+
def light_brightness(target: Target, level: int) -> None:
|
|
131
|
+
"""Set LED brightness (0..100)."""
|
|
132
|
+
_report(
|
|
133
|
+
f"brightness={level}", _execute(target, lambda device: device.brightness(level))
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
@click.argument("state", type=click.Choice(["on", "off"]))
|
|
139
|
+
@click.pass_obj
|
|
140
|
+
def light(target: Target, state: str) -> None:
|
|
141
|
+
"""Turn the LED strip on/off."""
|
|
142
|
+
_report(
|
|
143
|
+
f"light {state}",
|
|
144
|
+
_execute(target, lambda device: device.light_switch(state == "on")),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
@click.argument("name", type=click.Choice(["static", "breathing", "waterflow"]))
|
|
150
|
+
@click.pass_obj
|
|
151
|
+
def light_effect(target: Target, name: str) -> None:
|
|
152
|
+
"""Set light effect."""
|
|
153
|
+
chosen = LightEffect[name.upper()]
|
|
154
|
+
_report(
|
|
155
|
+
f"effect {name}", _execute(target, lambda device: device.light_effect(chosen))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@cli.command()
|
|
160
|
+
@click.argument("name", type=click.Choice(["yellow", "white"]))
|
|
161
|
+
@click.pass_obj
|
|
162
|
+
def light_color(target: Target, name: str) -> None:
|
|
163
|
+
"""Set light color (yellow or white)."""
|
|
164
|
+
chosen = LightColor[name.upper()]
|
|
165
|
+
_report(
|
|
166
|
+
f"color {name}",
|
|
167
|
+
_execute(target, lambda device: device.light_color(chosen)),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@cli.command()
|
|
172
|
+
@click.argument("name", type=click.Choice(["bluetooth", "aux", "usb", "airplay"]))
|
|
173
|
+
@click.pass_obj
|
|
174
|
+
def source(target: Target, name: str) -> None:
|
|
175
|
+
"""Select input source."""
|
|
176
|
+
chosen = Source[name.upper()]
|
|
177
|
+
_report(
|
|
178
|
+
f"source {name}", _execute(target, lambda device: device.input_source(chosen))
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@cli.command()
|
|
183
|
+
@click.argument(
|
|
184
|
+
"name", type=click.Choice(["classic", "monitor", "game", "vocal", "customized"])
|
|
185
|
+
)
|
|
186
|
+
@click.pass_obj
|
|
187
|
+
def preset(target: Target, name: str) -> None:
|
|
188
|
+
"""Select an EQ preset."""
|
|
189
|
+
chosen = EqPreset[name.upper()]
|
|
190
|
+
current = _report(
|
|
191
|
+
f"preset {name}", _execute(target, lambda device: device.eq_preset(chosen))
|
|
192
|
+
)
|
|
193
|
+
click.echo("selectedIndex %s" % (current.eq_selected_index if current else "?"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.argument("gains", type=int, nargs=-1)
|
|
198
|
+
@click.pass_obj
|
|
199
|
+
def eq(target: Target, gains: tuple[int, ...]) -> None:
|
|
200
|
+
"""Set custom 6-band gains in tenths of a dB (-30..30 = -3.0..+3.0 dB), for 62/250/1k/4k/8k/16k Hz."""
|
|
201
|
+
label = "eq %s" % " ".join(str(gain) for gain in gains)
|
|
202
|
+
current = _report(
|
|
203
|
+
label, _execute(target, lambda device: device.eq_custom(list(gains)))
|
|
204
|
+
)
|
|
205
|
+
click.echo("gains %s" % (current.eq_gains if current else "?"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
cli()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-discovery for Edifier ES300 speakers on the local network.
|
|
3
|
+
|
|
4
|
+
The Edifier Home app finds speakers with a UDP broadcast handshake on port 6000
|
|
5
|
+
(not mDNS -- the app's Bonjour libs are only for AirPlay):
|
|
6
|
+
|
|
7
|
+
1. The app broadcasts, to 255.255.255.255:6000, the plaintext query:
|
|
8
|
+
{"firm":"EDF","phoneOS":1,"protocol":1}
|
|
9
|
+
2. Each speaker replies (on port 6000) with a plaintext `AudioWlan` JSON:
|
|
10
|
+
{"encryption":<xor key>, "firm":"EDF", "protocol":1, "product":..., "player":...,
|
|
11
|
+
"info":{"host":<ip>, "port":8080, "name":..., "uuid":..., "bluetoothMac":..., "wifiMac":...}}
|
|
12
|
+
3. The app then opens the TCP control channel at info.host:info.port.
|
|
13
|
+
|
|
14
|
+
Unlike the control channel, discovery frames are NOT XOR-obfuscated -- both the
|
|
15
|
+
query and the reply are plain UTF-8 JSON.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
devices = await discover()
|
|
19
|
+
if devices:
|
|
20
|
+
async with ES300(devices[0].host, devices[0].port) as speaker:
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
Run standalone:
|
|
24
|
+
python -m edifier_es300.discovery
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from edifier_es300.typing_ import FrameData
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
DISCOVERY_PORT: int = 6000
|
|
37
|
+
BROADCAST_ADDR: str = "255.255.255.255"
|
|
38
|
+
DISCOVERY_QUERY: FrameData = {"firm": "EDF", "phoneOS": 1, "protocol": 1}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class DiscoveredDevice:
|
|
43
|
+
"""A speaker that answered the discovery broadcast."""
|
|
44
|
+
|
|
45
|
+
name: str | None
|
|
46
|
+
host: str | None # info.host -- the control-channel IP
|
|
47
|
+
port: int | None # info.port -- the control-channel TCP port (8080)
|
|
48
|
+
uuid: int | None
|
|
49
|
+
bluetooth_mac: str | None
|
|
50
|
+
wifi_mac: str | None
|
|
51
|
+
encryption: int | None # XOR key the control channel expects (0xA5 on ES300)
|
|
52
|
+
address: str # UDP source IP of the reply (host fallback)
|
|
53
|
+
raw: FrameData # the full AudioWlan reply
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_reply(cls, data: FrameData, source_ip: str) -> "DiscoveredDevice":
|
|
57
|
+
info = data.get("info") or {}
|
|
58
|
+
return cls(
|
|
59
|
+
name=info.get("name"),
|
|
60
|
+
host=info.get("host") or source_ip,
|
|
61
|
+
port=info.get("port"),
|
|
62
|
+
uuid=info.get("uuid"),
|
|
63
|
+
bluetooth_mac=info.get("bluetoothMac"),
|
|
64
|
+
wifi_mac=info.get("wifiMac"),
|
|
65
|
+
encryption=data.get("encryption"),
|
|
66
|
+
address=source_ip,
|
|
67
|
+
raw=data,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
return "%s %s:%s uuid=%s mac address=%s encryption=%s" % (
|
|
72
|
+
self.name or "?",
|
|
73
|
+
self.host,
|
|
74
|
+
self.port,
|
|
75
|
+
self.uuid,
|
|
76
|
+
self.wifi_mac,
|
|
77
|
+
self.encryption,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
|
82
|
+
"""Collects AudioWlan replies, ignoring our own echoed broadcast."""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self.replies: list[tuple[FrameData, str]] = []
|
|
86
|
+
|
|
87
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
88
|
+
try:
|
|
89
|
+
obj = json.loads(data.decode("utf-8"))
|
|
90
|
+
except Exception:
|
|
91
|
+
return
|
|
92
|
+
# Our own broadcast (the query) has no "info"; only real replies do.
|
|
93
|
+
if isinstance(obj, dict) and obj.get("info"):
|
|
94
|
+
self.replies.append((obj, addr[0]))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def discover(timeout: float = 3.0, broadcasts: int = 3) -> list[DiscoveredDevice]:
|
|
98
|
+
"""Broadcast the discovery query and collect replies for `timeout` seconds.
|
|
99
|
+
|
|
100
|
+
Sends the query `broadcasts` times (spread across the window) since UDP is
|
|
101
|
+
lossy. Returns one DiscoveredDevice per speaker, de-duplicated by host.
|
|
102
|
+
"""
|
|
103
|
+
loop = asyncio.get_running_loop()
|
|
104
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
105
|
+
_DiscoveryProtocol,
|
|
106
|
+
local_addr=("0.0.0.0", DISCOVERY_PORT),
|
|
107
|
+
allow_broadcast=True,
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
payload = json.dumps(DISCOVERY_QUERY).encode("utf-8")
|
|
111
|
+
deadline = loop.time() + timeout
|
|
112
|
+
gap = timeout / max(1, broadcasts)
|
|
113
|
+
for _ in range(max(1, broadcasts)):
|
|
114
|
+
transport.sendto(payload, (BROADCAST_ADDR, DISCOVERY_PORT))
|
|
115
|
+
await asyncio.sleep(min(gap, max(0.0, deadline - loop.time())))
|
|
116
|
+
remaining = deadline - loop.time()
|
|
117
|
+
if remaining > 0:
|
|
118
|
+
await asyncio.sleep(remaining)
|
|
119
|
+
finally:
|
|
120
|
+
transport.close()
|
|
121
|
+
|
|
122
|
+
devices: dict[str, DiscoveredDevice] = {}
|
|
123
|
+
for reply, source_ip in protocol.replies:
|
|
124
|
+
device = DiscoveredDevice.from_reply(reply, source_ip)
|
|
125
|
+
devices[device.host or source_ip] = device # dedupe by host
|
|
126
|
+
return list(devices.values())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def discover_one(timeout: float = 3.0) -> DiscoveredDevice | None:
|
|
130
|
+
"""Return the first speaker found, or None."""
|
|
131
|
+
devices = await discover(timeout=timeout)
|
|
132
|
+
return devices[0] if devices else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _main() -> None:
|
|
136
|
+
devices = await discover()
|
|
137
|
+
if not devices:
|
|
138
|
+
logger.warning("no ES300 speakers found (same Wi-Fi as the speaker?)")
|
|
139
|
+
return
|
|
140
|
+
for device in devices:
|
|
141
|
+
logger.info("%s", device)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
146
|
+
asyncio.run(_main())
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Shared types for the edifier_es300 package."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, IntEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
type FrameData = dict[str, Any] # a decoded JSON protocol frame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Source(IntEnum):
|
|
11
|
+
"""Input sources; value is inputSource.selectedIndex (all verified live)."""
|
|
12
|
+
|
|
13
|
+
BLUETOOTH = 0
|
|
14
|
+
AUX = 1
|
|
15
|
+
USB = 2
|
|
16
|
+
AIRPLAY = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EqPreset(IntEnum):
|
|
20
|
+
"""EQ presets; value is soundEffect.selectedIndex (CUSTOMIZED is the custom slot)."""
|
|
21
|
+
|
|
22
|
+
CLASSIC = 0
|
|
23
|
+
MONITOR = 1
|
|
24
|
+
GAME = 2
|
|
25
|
+
VOCAL = 3
|
|
26
|
+
CUSTOMIZED = 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LightColor(Enum):
|
|
30
|
+
"""Ambient LED colors; value is the RGB the app sends (ES300 only does these two)."""
|
|
31
|
+
|
|
32
|
+
YELLOW = {"r": 255, "g": 170, "b": 60}
|
|
33
|
+
WHITE = {"r": 255, "g": 255, "b": 255}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LightEffect(IntEnum):
|
|
37
|
+
"""Ambient LED effects; value is lightEffect.selectedIndex."""
|
|
38
|
+
|
|
39
|
+
STATIC = 1
|
|
40
|
+
BREATHING = 2
|
|
41
|
+
WATERFLOW = 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BatteryStatus(IntEnum):
|
|
45
|
+
"""Battery power state; value is battery.status."""
|
|
46
|
+
|
|
47
|
+
CONNECTED = 1 # external power connected
|
|
48
|
+
DISCONNECTED = 2 # running on battery
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PlayerStatus(IntEnum):
|
|
52
|
+
"""Playback state; value is player.playerStatus."""
|
|
53
|
+
|
|
54
|
+
STOPPED = 0
|
|
55
|
+
PLAYING = 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Status:
|
|
60
|
+
"""Parsed device state from a `status_query` frame."""
|
|
61
|
+
|
|
62
|
+
volume: int
|
|
63
|
+
max_volume: int
|
|
64
|
+
song: str | None
|
|
65
|
+
lyric: str | None
|
|
66
|
+
player_status: PlayerStatus
|
|
67
|
+
input_source: FrameData
|
|
68
|
+
light_effect: FrameData
|
|
69
|
+
sound_index: int
|
|
70
|
+
eq_selected_index: int
|
|
71
|
+
eq_gains: list[int]
|
|
72
|
+
battery: Any
|
|
73
|
+
raw: FrameData # the original frame, for fields not surfaced above
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_frame(cls, frame: FrameData) -> "Status":
|
|
77
|
+
player = frame["player"]
|
|
78
|
+
sound_effect = frame["soundEffect"]
|
|
79
|
+
return cls(
|
|
80
|
+
volume=player["volume"],
|
|
81
|
+
max_volume=player["maxVolume"],
|
|
82
|
+
song=player.get("song"),
|
|
83
|
+
lyric=player.get("lyric"),
|
|
84
|
+
player_status=PlayerStatus(player["playerStatus"]),
|
|
85
|
+
input_source=frame["inputSource"],
|
|
86
|
+
light_effect=frame["lightEffect"],
|
|
87
|
+
sound_index=sound_effect["soundIndex"],
|
|
88
|
+
eq_selected_index=sound_effect["selectedIndex"],
|
|
89
|
+
eq_gains=[
|
|
90
|
+
band["gain"]["value"]
|
|
91
|
+
for band in sound_effect["soundEffectDIY"]["diyData"]
|
|
92
|
+
],
|
|
93
|
+
battery=frame.get("battery"),
|
|
94
|
+
raw=frame,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
return "\n".join(
|
|
99
|
+
(
|
|
100
|
+
"playing: %s / %s (status %r)"
|
|
101
|
+
% (self.song or "-", self.lyric or "-", self.player_status),
|
|
102
|
+
"volume : %s / %s" % (self.volume, self.max_volume),
|
|
103
|
+
"source : %r" % Source(self.input_source["selectedIndex"]),
|
|
104
|
+
"effect : %r" % LightEffect(self.light_effect["selectedIndex"]),
|
|
105
|
+
"color : %r" % LightColor(self.light_effect["color"]),
|
|
106
|
+
"eq : %r gains=%s"
|
|
107
|
+
% (EqPreset(self.eq_selected_index), self.eq_gains),
|
|
108
|
+
"battery: %s%% (%r)"
|
|
109
|
+
% (self.battery["box"], BatteryStatus(self.battery["status"])),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
type CommandResult = tuple[bool, Status | None] # (acked, status)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "edifier-es300"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.4.2",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
edifier-es300 = "edifier_es300.__main__:cli"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.ruff]
|
|
19
|
+
target-version = "py313"
|
|
20
|
+
|
|
21
|
+
[tool.ruff.lint]
|
|
22
|
+
# E/F = pyflakes + pycodestyle (defaults), I = isort import sorting.
|
|
23
|
+
# E501 (line length) is left to the formatter, which won't reflow comments.
|
|
24
|
+
select = ["E", "F", "I"]
|
|
25
|
+
ignore = ["E501"]
|
|
26
|
+
|
|
27
|
+
[tool.ty.environment]
|
|
28
|
+
python-version = "3.13"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.13"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "click"
|
|
7
|
+
version = "8.4.2"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
11
|
+
]
|
|
12
|
+
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
|
13
|
+
wheels = [
|
|
14
|
+
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "colorama"
|
|
19
|
+
version = "0.4.6"
|
|
20
|
+
source = { registry = "https://pypi.org/simple" }
|
|
21
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
22
|
+
wheels = [
|
|
23
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "edifier-es300"
|
|
28
|
+
version = "0.1.1"
|
|
29
|
+
source = { editable = "." }
|
|
30
|
+
dependencies = [
|
|
31
|
+
{ name = "click" },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[package.metadata]
|
|
35
|
+
requires-dist = [{ name = "click", specifier = ">=8.4.2" }]
|