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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .eggs/
@@ -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
@@ -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
@@ -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,3 @@
1
+ """CLI tool for controlling WiiM devices."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)