wiim-cli 0.1.0__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.
- wiim_cli-0.1.0/.gitignore +7 -0
- wiim_cli-0.1.0/PKG-INFO +51 -0
- wiim_cli-0.1.0/README.md +40 -0
- wiim_cli-0.1.0/SKILL.md +66 -0
- wiim_cli-0.1.0/pyproject.toml +19 -0
- wiim_cli-0.1.0/src/wiim_cli/__init__.py +3 -0
- wiim_cli-0.1.0/src/wiim_cli/cli.py +380 -0
- wiim_cli-0.1.0/src/wiim_cli/device.py +108 -0
- wiim_cli-0.1.0/uv.lock +894 -0
wiim_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wiim-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for controlling WiiM devices on your local network
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: pywiim>=1.0.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: typer>=0.9.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# wiim-cli
|
|
13
|
+
|
|
14
|
+
CLI tool for controlling WiiM and LinkPlay audio devices on your local network.
|
|
15
|
+
|
|
16
|
+
Built with [pywiim](https://github.com/mjcumming/pywiim) and [typer](https://typer.tiangolo.com/).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install wiim-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or run directly:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx --from wiim-cli wiim --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python >=3.11.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
wiim discover # Find devices
|
|
36
|
+
wiim status # Show what's playing
|
|
37
|
+
wiim play / pause / stop # Playback control
|
|
38
|
+
wiim next / prev # Track navigation
|
|
39
|
+
wiim volume [0-100] # Get/set volume
|
|
40
|
+
wiim mute / unmute # Mute control
|
|
41
|
+
wiim play-url <url> # Play audio from URL
|
|
42
|
+
wiim play-preset <n> # Play saved preset
|
|
43
|
+
wiim seek <seconds> # Seek in track
|
|
44
|
+
wiim shuffle true/false # Toggle shuffle
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
All commands accept `--host <ip>` to target a specific device. If omitted and a single device is found on the network, it's used automatically.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
wiim_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# wiim-cli
|
|
2
|
+
|
|
3
|
+
CLI tool for controlling WiiM and LinkPlay audio devices on your local network.
|
|
4
|
+
|
|
5
|
+
Built with [pywiim](https://github.com/mjcumming/pywiim) and [typer](https://typer.tiangolo.com/).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv tool install wiim-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or run directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uvx --from wiim-cli wiim --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python >=3.11.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
wiim discover # Find devices
|
|
25
|
+
wiim status # Show what's playing
|
|
26
|
+
wiim play / pause / stop # Playback control
|
|
27
|
+
wiim next / prev # Track navigation
|
|
28
|
+
wiim volume [0-100] # Get/set volume
|
|
29
|
+
wiim mute / unmute # Mute control
|
|
30
|
+
wiim play-url <url> # Play audio from URL
|
|
31
|
+
wiim play-preset <n> # Play saved preset
|
|
32
|
+
wiim seek <seconds> # Seek in track
|
|
33
|
+
wiim shuffle true/false # Toggle shuffle
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
All commands accept `--host <ip>` to target a specific device. If omitted and a single device is found on the network, it's used automatically.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT
|
wiim_cli-0.1.0/SKILL.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wiim
|
|
3
|
+
description: Control WiiM audio devices (play, pause, stop, next, prev, volume, mute, play URLs, presets). Use when the user wants to control music playback, adjust volume, discover WiiM/LinkPlay speakers on the network, or play audio from a URL on a WiiM device.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# WiiM CLI
|
|
7
|
+
|
|
8
|
+
Control WiiM and LinkPlay audio devices from the command line.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# Install globally
|
|
14
|
+
uv tool install wiim-cli
|
|
15
|
+
|
|
16
|
+
# Or run directly without installing
|
|
17
|
+
uvx --from wiim-cli wiim --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python >=3.11.
|
|
21
|
+
|
|
22
|
+
## Quick Reference
|
|
23
|
+
|
|
24
|
+
All commands accept `--host <ip>` to target a specific device. If omitted and only one device is on the network, it auto-discovers.
|
|
25
|
+
|
|
26
|
+
### Discovery
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
wiim discover # Find devices on the network
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Playback
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
wiim status # Show what's playing
|
|
36
|
+
wiim play # Resume
|
|
37
|
+
wiim pause # Pause
|
|
38
|
+
wiim stop # Stop
|
|
39
|
+
wiim next # Next track
|
|
40
|
+
wiim prev # Previous track
|
|
41
|
+
wiim seek 90 # Seek to 1:30
|
|
42
|
+
wiim shuffle true # Enable shuffle
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Volume
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
wiim volume # Show current volume
|
|
49
|
+
wiim volume 50 # Set to 50%
|
|
50
|
+
wiim mute # Mute
|
|
51
|
+
wiim unmute # Unmute
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Play Media
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
wiim play-url "https://example.com/stream.mp3" # Play a URL
|
|
58
|
+
wiim play-preset 1 # Play saved preset #1
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- The WiiM must be on the same local network as the machine running the CLI.
|
|
64
|
+
- Discovery uses SSDP/UPnP — may not work across subnets/VLANs.
|
|
65
|
+
- Spotify, AirPlay, and other streaming services are controlled from their own apps. Once playing on the WiiM, this CLI can pause/play/skip/adjust volume.
|
|
66
|
+
- `play-url` works with direct audio URLs (MP3, FLAC, M3U streams, etc.).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wiim-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI tool for controlling WiiM devices on your local network"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"typer>=0.9.0",
|
|
10
|
+
"pywiim>=1.0.0",
|
|
11
|
+
"rich>=13.0.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
wiim = "wiim_cli.cli:app"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""WiiM CLI — control WiiM devices from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from wiim_cli.device import connect, disconnect, find_devices
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="wiim",
|
|
16
|
+
help="Control WiiM audio devices on your local network.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
console = Console()
|
|
20
|
+
err_console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(coro):
|
|
24
|
+
"""Run an async coroutine, handling event loop edge cases."""
|
|
25
|
+
return asyncio.run(coro)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _resolve_host(host: str | None) -> str:
|
|
29
|
+
"""Return the host or auto-discover a single device."""
|
|
30
|
+
if host:
|
|
31
|
+
return host
|
|
32
|
+
devices = await find_devices()
|
|
33
|
+
if len(devices) == 0:
|
|
34
|
+
err_console.print("[red]No WiiM devices found on the network.[/red]")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
if len(devices) > 1:
|
|
37
|
+
err_console.print(
|
|
38
|
+
f"[yellow]Multiple devices found ({len(devices)}). "
|
|
39
|
+
"Use --host to pick one:[/yellow]"
|
|
40
|
+
)
|
|
41
|
+
for d in devices:
|
|
42
|
+
err_console.print(f" {d.host} {d.name or '(unknown)'}")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
console.print(f"[dim]Auto-discovered: {devices[0].name or devices[0].host}[/dim]")
|
|
45
|
+
return devices[0].host
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# discover
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def discover(
|
|
54
|
+
timeout: int = typer.Option(5, help="SSDP discovery timeout in seconds."),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Discover WiiM devices on the local network."""
|
|
57
|
+
|
|
58
|
+
async def _discover():
|
|
59
|
+
devices = await find_devices(timeout=timeout)
|
|
60
|
+
if not devices:
|
|
61
|
+
console.print("[yellow]No devices found.[/yellow]")
|
|
62
|
+
raise typer.Exit(0)
|
|
63
|
+
|
|
64
|
+
table = Table(title="WiiM Devices")
|
|
65
|
+
table.add_column("Name", style="cyan")
|
|
66
|
+
table.add_column("Host", style="green")
|
|
67
|
+
table.add_column("Model")
|
|
68
|
+
table.add_column("Firmware")
|
|
69
|
+
table.add_column("UUID", style="dim")
|
|
70
|
+
|
|
71
|
+
for d in devices:
|
|
72
|
+
table.add_row(
|
|
73
|
+
d.name or "—",
|
|
74
|
+
d.host,
|
|
75
|
+
d.model or "—",
|
|
76
|
+
d.firmware or "—",
|
|
77
|
+
d.uuid or "—",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
console.print(table)
|
|
81
|
+
|
|
82
|
+
_run(_discover())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# status
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def status(
|
|
91
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Show the current playback status."""
|
|
94
|
+
|
|
95
|
+
async def _status():
|
|
96
|
+
resolved = await _resolve_host(host)
|
|
97
|
+
player = await connect(resolved)
|
|
98
|
+
try:
|
|
99
|
+
table = Table(title=f"{player.name or player.host} — Status")
|
|
100
|
+
table.add_column("Property", style="cyan")
|
|
101
|
+
table.add_column("Value")
|
|
102
|
+
|
|
103
|
+
state = player.play_state or "unknown"
|
|
104
|
+
table.add_row("State", state)
|
|
105
|
+
table.add_row("Volume", f"{int((player.volume_level or 0) * 100)}%")
|
|
106
|
+
table.add_row("Muted", str(player.is_muted or False))
|
|
107
|
+
table.add_row("Source", player.source or "—")
|
|
108
|
+
table.add_row("Title", player.media_title or "—")
|
|
109
|
+
table.add_row("Artist", player.media_artist or "—")
|
|
110
|
+
table.add_row("Album", player.media_album or "—")
|
|
111
|
+
|
|
112
|
+
pos = player.media_position
|
|
113
|
+
dur = player.media_duration
|
|
114
|
+
if pos is not None and dur is not None:
|
|
115
|
+
table.add_row(
|
|
116
|
+
"Position",
|
|
117
|
+
f"{_fmt_time(pos)} / {_fmt_time(dur)}",
|
|
118
|
+
)
|
|
119
|
+
elif pos is not None:
|
|
120
|
+
table.add_row("Position", _fmt_time(pos))
|
|
121
|
+
|
|
122
|
+
table.add_row("Shuffle", str(player.shuffle or False))
|
|
123
|
+
table.add_row("Repeat", player.repeat or "off")
|
|
124
|
+
|
|
125
|
+
console.print(table)
|
|
126
|
+
finally:
|
|
127
|
+
await disconnect(player)
|
|
128
|
+
|
|
129
|
+
_run(_status())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fmt_time(seconds: int) -> str:
|
|
133
|
+
m, s = divmod(seconds, 60)
|
|
134
|
+
h, m = divmod(m, 60)
|
|
135
|
+
if h:
|
|
136
|
+
return f"{h}:{m:02d}:{s:02d}"
|
|
137
|
+
return f"{m}:{s:02d}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# play / pause / stop / next / prev
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def play(
|
|
146
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Resume playback."""
|
|
149
|
+
|
|
150
|
+
async def _play():
|
|
151
|
+
resolved = await _resolve_host(host)
|
|
152
|
+
player = await connect(resolved)
|
|
153
|
+
try:
|
|
154
|
+
await player.play()
|
|
155
|
+
console.print("[green]▶ Playing[/green]")
|
|
156
|
+
finally:
|
|
157
|
+
await disconnect(player)
|
|
158
|
+
|
|
159
|
+
_run(_play())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def pause(
|
|
164
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Pause playback."""
|
|
167
|
+
|
|
168
|
+
async def _pause():
|
|
169
|
+
resolved = await _resolve_host(host)
|
|
170
|
+
player = await connect(resolved)
|
|
171
|
+
try:
|
|
172
|
+
await player.pause()
|
|
173
|
+
console.print("[yellow]⏸ Paused[/yellow]")
|
|
174
|
+
finally:
|
|
175
|
+
await disconnect(player)
|
|
176
|
+
|
|
177
|
+
_run(_pause())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def stop(
|
|
182
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Stop playback."""
|
|
185
|
+
|
|
186
|
+
async def _stop():
|
|
187
|
+
resolved = await _resolve_host(host)
|
|
188
|
+
player = await connect(resolved)
|
|
189
|
+
try:
|
|
190
|
+
await player.stop()
|
|
191
|
+
console.print("[red]⏹ Stopped[/red]")
|
|
192
|
+
finally:
|
|
193
|
+
await disconnect(player)
|
|
194
|
+
|
|
195
|
+
_run(_stop())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command(name="next")
|
|
199
|
+
def next_track(
|
|
200
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Skip to the next track."""
|
|
203
|
+
|
|
204
|
+
async def _next():
|
|
205
|
+
resolved = await _resolve_host(host)
|
|
206
|
+
player = await connect(resolved)
|
|
207
|
+
try:
|
|
208
|
+
await player.next_track()
|
|
209
|
+
console.print("[green]⏭ Next track[/green]")
|
|
210
|
+
finally:
|
|
211
|
+
await disconnect(player)
|
|
212
|
+
|
|
213
|
+
_run(_next())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command(name="prev")
|
|
217
|
+
def prev_track(
|
|
218
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Skip to the previous track."""
|
|
221
|
+
|
|
222
|
+
async def _prev():
|
|
223
|
+
resolved = await _resolve_host(host)
|
|
224
|
+
player = await connect(resolved)
|
|
225
|
+
try:
|
|
226
|
+
await player.previous_track()
|
|
227
|
+
console.print("[green]⏮ Previous track[/green]")
|
|
228
|
+
finally:
|
|
229
|
+
await disconnect(player)
|
|
230
|
+
|
|
231
|
+
_run(_prev())
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# volume
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
@app.command()
|
|
239
|
+
def volume(
|
|
240
|
+
level: Optional[int] = typer.Argument(None, help="Volume 0-100. Omit to show current."),
|
|
241
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Get or set the volume (0-100)."""
|
|
244
|
+
|
|
245
|
+
async def _volume():
|
|
246
|
+
resolved = await _resolve_host(host)
|
|
247
|
+
player = await connect(resolved)
|
|
248
|
+
try:
|
|
249
|
+
if level is None:
|
|
250
|
+
current = int((player.volume_level or 0) * 100)
|
|
251
|
+
console.print(f"🔊 Volume: {current}%")
|
|
252
|
+
else:
|
|
253
|
+
clamped = max(0, min(100, level))
|
|
254
|
+
await player.set_volume(clamped / 100.0)
|
|
255
|
+
console.print(f"🔊 Volume set to {clamped}%")
|
|
256
|
+
finally:
|
|
257
|
+
await disconnect(player)
|
|
258
|
+
|
|
259
|
+
_run(_volume())
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.command()
|
|
263
|
+
def mute(
|
|
264
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Mute the device."""
|
|
267
|
+
|
|
268
|
+
async def _mute():
|
|
269
|
+
resolved = await _resolve_host(host)
|
|
270
|
+
player = await connect(resolved)
|
|
271
|
+
try:
|
|
272
|
+
await player.set_mute(True)
|
|
273
|
+
console.print("[yellow]🔇 Muted[/yellow]")
|
|
274
|
+
finally:
|
|
275
|
+
await disconnect(player)
|
|
276
|
+
|
|
277
|
+
_run(_mute())
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.command()
|
|
281
|
+
def unmute(
|
|
282
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Unmute the device."""
|
|
285
|
+
|
|
286
|
+
async def _unmute():
|
|
287
|
+
resolved = await _resolve_host(host)
|
|
288
|
+
player = await connect(resolved)
|
|
289
|
+
try:
|
|
290
|
+
await player.set_mute(False)
|
|
291
|
+
console.print("[green]🔊 Unmuted[/green]")
|
|
292
|
+
finally:
|
|
293
|
+
await disconnect(player)
|
|
294
|
+
|
|
295
|
+
_run(_unmute())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# play-url / play-preset
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
@app.command(name="play-url")
|
|
303
|
+
def play_url(
|
|
304
|
+
url: str = typer.Argument(help="URL to play (MP3, FLAC, M3U, etc.)."),
|
|
305
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Play audio from a URL."""
|
|
308
|
+
|
|
309
|
+
async def _play_url():
|
|
310
|
+
resolved = await _resolve_host(host)
|
|
311
|
+
player = await connect(resolved)
|
|
312
|
+
try:
|
|
313
|
+
await player.play_url(url)
|
|
314
|
+
console.print(f"[green]▶ Playing URL:[/green] {url}")
|
|
315
|
+
finally:
|
|
316
|
+
await disconnect(player)
|
|
317
|
+
|
|
318
|
+
_run(_play_url())
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command(name="play-preset")
|
|
322
|
+
def play_preset(
|
|
323
|
+
preset: int = typer.Argument(help="Preset number (1-20)."),
|
|
324
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Play a saved preset by number."""
|
|
327
|
+
|
|
328
|
+
async def _play_preset():
|
|
329
|
+
resolved = await _resolve_host(host)
|
|
330
|
+
player = await connect(resolved)
|
|
331
|
+
try:
|
|
332
|
+
await player.play_preset(preset)
|
|
333
|
+
console.print(f"[green]▶ Playing preset {preset}[/green]")
|
|
334
|
+
finally:
|
|
335
|
+
await disconnect(player)
|
|
336
|
+
|
|
337
|
+
_run(_play_preset())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@app.command()
|
|
341
|
+
def seek(
|
|
342
|
+
position: int = typer.Argument(help="Position in seconds."),
|
|
343
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Seek to a position in the current track."""
|
|
346
|
+
|
|
347
|
+
async def _seek():
|
|
348
|
+
resolved = await _resolve_host(host)
|
|
349
|
+
player = await connect(resolved)
|
|
350
|
+
try:
|
|
351
|
+
await player.seek(position)
|
|
352
|
+
console.print(f"[green]⏩ Seeked to {_fmt_time(position)}[/green]")
|
|
353
|
+
finally:
|
|
354
|
+
await disconnect(player)
|
|
355
|
+
|
|
356
|
+
_run(_seek())
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@app.command()
|
|
360
|
+
def shuffle(
|
|
361
|
+
enabled: bool = typer.Argument(help="true or false."),
|
|
362
|
+
host: Optional[str] = typer.Option(None, help="Device IP or hostname."),
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Enable or disable shuffle."""
|
|
365
|
+
|
|
366
|
+
async def _shuffle():
|
|
367
|
+
resolved = await _resolve_host(host)
|
|
368
|
+
player = await connect(resolved)
|
|
369
|
+
try:
|
|
370
|
+
await player.set_shuffle(enabled)
|
|
371
|
+
state = "on" if enabled else "off"
|
|
372
|
+
console.print(f"[green]🔀 Shuffle {state}[/green]")
|
|
373
|
+
finally:
|
|
374
|
+
await disconnect(player)
|
|
375
|
+
|
|
376
|
+
_run(_shuffle())
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
app()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Device connection and discovery helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from pywiim import Player, WiiMClient
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Device:
|
|
18
|
+
"""A discovered WiiM device."""
|
|
19
|
+
|
|
20
|
+
host: str
|
|
21
|
+
name: str | None = None
|
|
22
|
+
model: str | None = None
|
|
23
|
+
firmware: str | None = None
|
|
24
|
+
uuid: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def find_devices(timeout: int = 5) -> list[Device]:
|
|
28
|
+
"""Discover WiiM/LinkPlay devices via SSDP then validate with the API."""
|
|
29
|
+
hosts = await _ssdp_search(timeout)
|
|
30
|
+
devices: list[Device] = []
|
|
31
|
+
tasks = [_probe_device(host) for host in hosts]
|
|
32
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
33
|
+
for result in results:
|
|
34
|
+
if isinstance(result, Device):
|
|
35
|
+
devices.append(result)
|
|
36
|
+
return devices
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _ssdp_search(timeout: int = 5) -> list[str]:
|
|
40
|
+
"""Raw SSDP M-SEARCH returning unique host IPs."""
|
|
41
|
+
msg = (
|
|
42
|
+
"M-SEARCH * HTTP/1.1\r\n"
|
|
43
|
+
"HOST: 239.255.255.250:1900\r\n"
|
|
44
|
+
'MAN: "ssdp:discover"\r\n'
|
|
45
|
+
f"MX: {timeout}\r\n"
|
|
46
|
+
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n"
|
|
47
|
+
"\r\n"
|
|
48
|
+
)
|
|
49
|
+
loop = asyncio.get_event_loop()
|
|
50
|
+
hosts: set[str] = set()
|
|
51
|
+
|
|
52
|
+
def _do_search() -> set[str]:
|
|
53
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
54
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
55
|
+
sock.settimeout(timeout + 1)
|
|
56
|
+
try:
|
|
57
|
+
sock.sendto(msg.encode(), ("239.255.255.250", 1900))
|
|
58
|
+
found: set[str] = set()
|
|
59
|
+
while True:
|
|
60
|
+
try:
|
|
61
|
+
data, addr = sock.recvfrom(4096)
|
|
62
|
+
found.add(addr[0])
|
|
63
|
+
except socket.timeout:
|
|
64
|
+
break
|
|
65
|
+
return found
|
|
66
|
+
finally:
|
|
67
|
+
sock.close()
|
|
68
|
+
|
|
69
|
+
hosts = await loop.run_in_executor(None, _do_search)
|
|
70
|
+
return list(hosts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _probe_device(host: str) -> Device | None:
|
|
74
|
+
"""Check if a host is a WiiM/LinkPlay device via its HTTP API."""
|
|
75
|
+
client = WiiMClient(host)
|
|
76
|
+
try:
|
|
77
|
+
info = await client.get_device_info()
|
|
78
|
+
if not info:
|
|
79
|
+
return None
|
|
80
|
+
return Device(
|
|
81
|
+
host=host,
|
|
82
|
+
name=info.get("DeviceName") or info.get("device_name"),
|
|
83
|
+
model=info.get("project") or info.get("priv_prj"),
|
|
84
|
+
firmware=info.get("firmware"),
|
|
85
|
+
uuid=info.get("uuid"),
|
|
86
|
+
)
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
finally:
|
|
90
|
+
await client.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def connect(host: str, timeout: float = 5.0) -> Player:
|
|
94
|
+
"""Create a Player connected to the given host."""
|
|
95
|
+
client = WiiMClient(host, timeout=timeout)
|
|
96
|
+
player = Player(client)
|
|
97
|
+
await player.refresh()
|
|
98
|
+
return player
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def disconnect(player: Player) -> None:
|
|
102
|
+
"""Close a player connection."""
|
|
103
|
+
await player.client.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run(coro):
|
|
107
|
+
"""Run an async coroutine synchronously."""
|
|
108
|
+
return asyncio.run(coro)
|