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.
@@ -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,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Tool caches
10
+ .ruff_cache/
11
+ .ty_cache/
12
+
13
+ # Virtual environments
14
+ .venv
@@ -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" }]