@azurestacknerd/roon-mcp 1.0.1
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.
- package/README.md +180 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +37 -0
- package/build/roon-connection.d.ts +21 -0
- package/build/roon-connection.js +202 -0
- package/build/tools/browse.d.ts +2 -0
- package/build/tools/browse.js +482 -0
- package/build/tools/playback.d.ts +2 -0
- package/build/tools/playback.js +166 -0
- package/build/tools/volume.d.ts +2 -0
- package/build/tools/volume.js +115 -0
- package/build/tools/zone.d.ts +2 -0
- package/build/tools/zone.js +153 -0
- package/package.json +40 -0
- package/scripts/patch-roon-api.js +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Roon MCP Server
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that lets Claude Desktop control [Roon](https://roon.app/) music playback. Search your library, play music, control volume, and manage zones — all through natural language.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **20 MCP tools** for full Roon control
|
|
8
|
+
- Search and play artists, albums, tracks, and playlists
|
|
9
|
+
- Playback controls: play, pause, stop, skip, seek, shuffle, loop
|
|
10
|
+
- Volume control with mute support
|
|
11
|
+
- Zone management and now-playing info
|
|
12
|
+
- Queue management (add tracks, albums, playlists)
|
|
13
|
+
- Direct WebSocket connection to Roon Core (no discovery needed)
|
|
14
|
+
- Auto-reconnect on connection loss
|
|
15
|
+
- Persistent pairing (only authorize once in Roon)
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- [Node.js](https://nodejs.org/) 18 or later
|
|
20
|
+
- A [Roon](https://roon.app/) Core running on your network
|
|
21
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/AzureStackNerd/roon-mcp.git
|
|
27
|
+
cd roon-mcp
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Add the server to your Claude Desktop configuration file:
|
|
35
|
+
|
|
36
|
+
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
37
|
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"roon": {
|
|
43
|
+
"command": "node",
|
|
44
|
+
"args": ["C:\\path\\to\\roon-mcp\\build\\index.js"],
|
|
45
|
+
"env": {
|
|
46
|
+
"ROON_HOST": "192.168.1.100",
|
|
47
|
+
"ROON_PORT": "9330"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Environment Variables
|
|
55
|
+
|
|
56
|
+
| Variable | Default | Description |
|
|
57
|
+
| ----------- | --------------- | -------------------------------- |
|
|
58
|
+
| `ROON_HOST` | `192.168.1.100` | IP address of your Roon Core |
|
|
59
|
+
| `ROON_PORT` | `9100` | WebSocket port of your Roon Core |
|
|
60
|
+
|
|
61
|
+
> **Finding your Roon Core port**: In Roon, go to **Settings > General** and look for the HTTP port displayed under your Core name. The default is 9100, but it may differ.
|
|
62
|
+
|
|
63
|
+
## First-Time Setup
|
|
64
|
+
|
|
65
|
+
1. Start Claude Desktop with the MCP server configured
|
|
66
|
+
2. In Roon, go to **Settings > Extensions**
|
|
67
|
+
3. Find **Roon MCP for Claude** and click **Enable**
|
|
68
|
+
4. The extension will remember its authorization for future restarts
|
|
69
|
+
|
|
70
|
+
## Available Tools
|
|
71
|
+
|
|
72
|
+
### Zone & Status
|
|
73
|
+
|
|
74
|
+
| Tool | Parameters | Description |
|
|
75
|
+
| ------------- | ---------- | -------------------------------------------------------- |
|
|
76
|
+
| `list_zones` | — | List all zones with current playback status |
|
|
77
|
+
| `now_playing` | `zone?` | Get current track info for a zone (or all playing zones) |
|
|
78
|
+
| `get_queue` | `zone` | Get the play queue for a zone |
|
|
79
|
+
|
|
80
|
+
### Playback Controls
|
|
81
|
+
|
|
82
|
+
| Tool | Parameters | Description |
|
|
83
|
+
| ---------------- | ------------------------------ | ------------------------------------------------------ |
|
|
84
|
+
| `play` | `zone` | Start playback |
|
|
85
|
+
| `pause` | `zone` | Pause playback |
|
|
86
|
+
| `play_pause` | `zone` | Toggle play/pause |
|
|
87
|
+
| `stop` | `zone` | Stop playback and release audio device |
|
|
88
|
+
| `next_track` | `zone` | Skip to next track |
|
|
89
|
+
| `previous_track` | `zone` | Go to previous track |
|
|
90
|
+
| `seek` | `zone`, `seconds`, `relative?` | Seek to position (absolute or relative) |
|
|
91
|
+
| `shuffle` | `zone`, `enabled` | Enable or disable shuffle |
|
|
92
|
+
| `loop` | `zone`, `mode` | Set loop mode (`loop`, `loop_one`, `disabled`, `next`) |
|
|
93
|
+
|
|
94
|
+
### Volume
|
|
95
|
+
|
|
96
|
+
| Tool | Parameters | Description |
|
|
97
|
+
| --------------- | ----------------------- | ------------------------------------------------------- |
|
|
98
|
+
| `change_volume` | `zone`, `value`, `how?` | Change volume (`absolute`, `relative`, `relative_step`) |
|
|
99
|
+
| `mute` | `zone`, `mute` | Mute or unmute |
|
|
100
|
+
| `get_volume` | `zone` | Get current volume level and mute status |
|
|
101
|
+
|
|
102
|
+
### Search & Play
|
|
103
|
+
|
|
104
|
+
| Tool | Parameters | Description |
|
|
105
|
+
| --------------- | ---------------------------- | --------------------------------------------------------------- |
|
|
106
|
+
| `search` | `query`, `zone?` | Search the library (returns artists, albums, tracks, playlists) |
|
|
107
|
+
| `play_artist` | `artist`, `zone` | Search and play an artist |
|
|
108
|
+
| `play_album` | `album`, `zone` | Search and play an album |
|
|
109
|
+
| `play_playlist` | `playlist`, `zone` | Search and play a playlist |
|
|
110
|
+
| `play_track` | `track`, `zone` | Search and play a specific track |
|
|
111
|
+
| `add_to_queue` | `query`, `zone`, `category?` | Search and add to the queue |
|
|
112
|
+
|
|
113
|
+
## Usage Examples
|
|
114
|
+
|
|
115
|
+
Once configured, you can ask Claude things like:
|
|
116
|
+
|
|
117
|
+
- _"What zones are available?"_
|
|
118
|
+
- _"What's playing right now?"_
|
|
119
|
+
- _"Play Blue by Joni Mitchell in the living room"_
|
|
120
|
+
- _"Play the album Bad by Michael Jackson in Bedroom"_
|
|
121
|
+
- _"Skip to the next track"_
|
|
122
|
+
- _"Turn the volume down a bit in the kitchen"_
|
|
123
|
+
- _"Add Bohemian Rhapsody to the queue"_
|
|
124
|
+
- _"Enable shuffle mode"_
|
|
125
|
+
- _"Search for Miles Davis"_
|
|
126
|
+
|
|
127
|
+
## Architecture
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
src/
|
|
131
|
+
index.ts # Entry point, MCP server setup
|
|
132
|
+
roon-connection.ts # Roon connection management, zone cache
|
|
133
|
+
tools/
|
|
134
|
+
zone.ts # list_zones, now_playing, get_queue
|
|
135
|
+
playback.ts # play, pause, stop, seek, shuffle, loop
|
|
136
|
+
volume.ts # change_volume, mute, get_volume
|
|
137
|
+
browse.ts # search, play_artist/album/playlist/track, add_to_queue
|
|
138
|
+
types/
|
|
139
|
+
node-roon-api.d.ts # Type declarations for node-roon-api
|
|
140
|
+
node-roon-api-transport.d.ts # Type declarations for node-roon-api-transport
|
|
141
|
+
node-roon-api-browse.d.ts # Type declarations for node-roon-api-browse
|
|
142
|
+
node-roon-api-status.d.ts # Type declarations for node-roon-api-status
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Build
|
|
149
|
+
npm run build
|
|
150
|
+
|
|
151
|
+
# Run directly
|
|
152
|
+
npm start
|
|
153
|
+
|
|
154
|
+
# Test interactively with MCP Inspector
|
|
155
|
+
npx @modelcontextprotocol/inspector node build/index.js
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Troubleshooting
|
|
159
|
+
|
|
160
|
+
### "Not connected to Roon"
|
|
161
|
+
|
|
162
|
+
- Verify `ROON_HOST` and `ROON_PORT` point to your Roon Core
|
|
163
|
+
- Check that the extension is enabled in Roon > Settings > Extensions
|
|
164
|
+
|
|
165
|
+
### Extension requires re-enabling after every restart
|
|
166
|
+
|
|
167
|
+
- The pairing token is stored in `roon-mcp/config.json`. After the first authorization it should persist across restarts. If you still need to re-enable, check that the `config.json` file exists in the project root after the first pairing.
|
|
168
|
+
|
|
169
|
+
### Search finds wrong version of a track/album
|
|
170
|
+
|
|
171
|
+
- Include the artist name in your query for better matching, e.g. _"Dirty Diana Michael Jackson"_ instead of just _"Dirty Diana"_
|
|
172
|
+
|
|
173
|
+
### Playback doesn't start after search
|
|
174
|
+
|
|
175
|
+
- Check the Roon Core logs for errors
|
|
176
|
+
- Ensure the zone is available and not in use by another controller
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/build/index.d.ts
ADDED
package/build/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { roonConnection } from "./roon-connection.js";
|
|
5
|
+
import { registerZoneTools } from "./tools/zone.js";
|
|
6
|
+
import { registerPlaybackTools } from "./tools/playback.js";
|
|
7
|
+
import { registerVolumeTools } from "./tools/volume.js";
|
|
8
|
+
import { registerBrowseTools } from "./tools/browse.js";
|
|
9
|
+
// Prevent process crashes from unhandled errors in node-roon-api's WebSocket.
|
|
10
|
+
// The ws library emits EventEmitter 'error' events, but node-roon-api only sets
|
|
11
|
+
// DOM-style .onerror handlers, leaving EventEmitter errors unhandled.
|
|
12
|
+
process.on("uncaughtException", (error) => {
|
|
13
|
+
console.error("[roon-mcp] Uncaught exception (kept alive):", error.message);
|
|
14
|
+
});
|
|
15
|
+
process.on("unhandledRejection", (reason) => {
|
|
16
|
+
console.error("[roon-mcp] Unhandled rejection (kept alive):", reason);
|
|
17
|
+
});
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: "roon-mcp",
|
|
20
|
+
version: "1.0.1",
|
|
21
|
+
});
|
|
22
|
+
registerZoneTools(server);
|
|
23
|
+
registerPlaybackTools(server);
|
|
24
|
+
registerVolumeTools(server);
|
|
25
|
+
registerBrowseTools(server);
|
|
26
|
+
async function main() {
|
|
27
|
+
// Start Roon connection (runs in background with auto-reconnect)
|
|
28
|
+
roonConnection.connect();
|
|
29
|
+
// Start MCP server on stdio
|
|
30
|
+
const transport = new StdioServerTransport();
|
|
31
|
+
await server.connect(transport);
|
|
32
|
+
console.error("[roon-mcp] MCP server running on stdio");
|
|
33
|
+
}
|
|
34
|
+
main().catch((error) => {
|
|
35
|
+
console.error("[roon-mcp] Fatal error:", error);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import RoonApiTransport from "node-roon-api-transport";
|
|
2
|
+
import RoonApiBrowse from "node-roon-api-browse";
|
|
3
|
+
import type { Zone } from "node-roon-api-transport";
|
|
4
|
+
export declare class RoonConnection {
|
|
5
|
+
private roon;
|
|
6
|
+
private status;
|
|
7
|
+
private core;
|
|
8
|
+
private zones;
|
|
9
|
+
constructor();
|
|
10
|
+
connect(): void;
|
|
11
|
+
private subscribeZones;
|
|
12
|
+
private getTransportUnsafe;
|
|
13
|
+
getTransport(): RoonApiTransport;
|
|
14
|
+
private getBrowseUnsafe;
|
|
15
|
+
getBrowse(): RoonApiBrowse;
|
|
16
|
+
isConnected(): boolean;
|
|
17
|
+
getZones(): Zone[];
|
|
18
|
+
findZone(nameOrId: string): Zone | null;
|
|
19
|
+
findZoneOrThrow(nameOrId: string): Zone;
|
|
20
|
+
}
|
|
21
|
+
export declare const roonConnection: RoonConnection;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import RoonApi from "node-roon-api";
|
|
2
|
+
import RoonApiTransport from "node-roon-api-transport";
|
|
3
|
+
import RoonApiBrowse from "node-roon-api-browse";
|
|
4
|
+
import RoonApiStatus from "node-roon-api-status";
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
const ROON_HOST = process.env.ROON_HOST || "192.168.1.100";
|
|
9
|
+
const ROON_PORT = (() => {
|
|
10
|
+
const port = parseInt(process.env.ROON_PORT || "9100", 10);
|
|
11
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
12
|
+
console.error(`[roon-mcp] Invalid ROON_PORT: ${process.env.ROON_PORT}. Using default 9100.`);
|
|
13
|
+
return 9100;
|
|
14
|
+
}
|
|
15
|
+
return port;
|
|
16
|
+
})();
|
|
17
|
+
// Fixed path for config.json so pairing token persists across restarts
|
|
18
|
+
// regardless of CWD when Claude Desktop launches the process
|
|
19
|
+
const CONFIG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
21
|
+
function loadPersistedState() {
|
|
22
|
+
try {
|
|
23
|
+
const content = readFileSync(CONFIG_PATH, { encoding: "utf8" });
|
|
24
|
+
return JSON.parse(content)?.roonstate || {};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function savePersistedState(state) {
|
|
31
|
+
try {
|
|
32
|
+
let config = {};
|
|
33
|
+
try {
|
|
34
|
+
const content = readFileSync(CONFIG_PATH, { encoding: "utf8" });
|
|
35
|
+
config = JSON.parse(content) || {};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// file doesn't exist yet
|
|
39
|
+
}
|
|
40
|
+
config.roonstate = state;
|
|
41
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
42
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, " "));
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
console.error("[roon-mcp] Failed to save config:", e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class RoonConnection {
|
|
49
|
+
roon;
|
|
50
|
+
status;
|
|
51
|
+
core = null;
|
|
52
|
+
zones = new Map();
|
|
53
|
+
constructor() {
|
|
54
|
+
this.roon = new RoonApi({
|
|
55
|
+
extension_id: "com.roon-mcp.claude",
|
|
56
|
+
display_name: "Roon MCP for Claude",
|
|
57
|
+
display_version: "1.0.1",
|
|
58
|
+
publisher: "roon-mcp",
|
|
59
|
+
email: "noreply@roon-mcp.local",
|
|
60
|
+
log_level: "none",
|
|
61
|
+
get_persisted_state: loadPersistedState,
|
|
62
|
+
set_persisted_state: savePersistedState,
|
|
63
|
+
core_paired: (core) => {
|
|
64
|
+
console.error(`[roon-mcp] Paired with core: ${core.display_name}`);
|
|
65
|
+
this.core = core;
|
|
66
|
+
this.subscribeZones();
|
|
67
|
+
},
|
|
68
|
+
core_unpaired: (core) => {
|
|
69
|
+
console.error(`[roon-mcp] Unpaired from core: ${core.display_name}`);
|
|
70
|
+
this.core = null;
|
|
71
|
+
this.zones.clear();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
this.status = new RoonApiStatus(this.roon);
|
|
75
|
+
this.roon.init_services({
|
|
76
|
+
required_services: [RoonApiTransport, RoonApiBrowse],
|
|
77
|
+
provided_services: [this.status],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
connect() {
|
|
81
|
+
console.error(`[roon-mcp] Connecting to Roon Core at ${ROON_HOST}:${ROON_PORT}...`);
|
|
82
|
+
this.status.set_status("Connecting...", false);
|
|
83
|
+
const doConnect = () => {
|
|
84
|
+
this.roon.ws_connect({
|
|
85
|
+
host: ROON_HOST,
|
|
86
|
+
port: ROON_PORT,
|
|
87
|
+
onclose: () => {
|
|
88
|
+
console.error("[roon-mcp] Connection lost, reconnecting in 3s...");
|
|
89
|
+
this.core = null;
|
|
90
|
+
this.zones.clear();
|
|
91
|
+
setTimeout(doConnect, 3000);
|
|
92
|
+
},
|
|
93
|
+
onerror: () => {
|
|
94
|
+
console.error("[roon-mcp] WebSocket error");
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
doConnect();
|
|
99
|
+
}
|
|
100
|
+
subscribeZones() {
|
|
101
|
+
const transport = this.getTransportUnsafe();
|
|
102
|
+
if (!transport)
|
|
103
|
+
return;
|
|
104
|
+
transport.subscribe_zones((response, msg) => {
|
|
105
|
+
if (response === "Subscribed" && msg.zones) {
|
|
106
|
+
this.zones.clear();
|
|
107
|
+
for (const zone of msg.zones) {
|
|
108
|
+
this.zones.set(zone.zone_id, zone);
|
|
109
|
+
}
|
|
110
|
+
console.error(`[roon-mcp] Subscribed to ${this.zones.size} zone(s)`);
|
|
111
|
+
this.status.set_status("Connected", false);
|
|
112
|
+
}
|
|
113
|
+
else if (response === "Changed") {
|
|
114
|
+
if (msg.zones_removed) {
|
|
115
|
+
for (const id of msg.zones_removed) {
|
|
116
|
+
this.zones.delete(id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (msg.zones_added) {
|
|
120
|
+
for (const zone of msg.zones_added) {
|
|
121
|
+
this.zones.set(zone.zone_id, zone);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (msg.zones_changed) {
|
|
125
|
+
for (const zone of msg.zones_changed) {
|
|
126
|
+
this.zones.set(zone.zone_id, zone);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (msg.zones_seek_changed) {
|
|
130
|
+
for (const update of msg.zones_seek_changed) {
|
|
131
|
+
const zone = this.zones.get(update.zone_id);
|
|
132
|
+
if (zone) {
|
|
133
|
+
if (zone.now_playing) {
|
|
134
|
+
zone.now_playing.seek_position = update.seek_position;
|
|
135
|
+
}
|
|
136
|
+
zone.queue_time_remaining = update.queue_time_remaining;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
getTransportUnsafe() {
|
|
144
|
+
if (!this.core)
|
|
145
|
+
return null;
|
|
146
|
+
return this.core.services.RoonApiTransport ?? null;
|
|
147
|
+
}
|
|
148
|
+
getTransport() {
|
|
149
|
+
const transport = this.getTransportUnsafe();
|
|
150
|
+
if (!transport) {
|
|
151
|
+
throw new Error("Not connected to Roon. Please approve the extension in Roon Settings > Extensions.");
|
|
152
|
+
}
|
|
153
|
+
return transport;
|
|
154
|
+
}
|
|
155
|
+
getBrowseUnsafe() {
|
|
156
|
+
if (!this.core)
|
|
157
|
+
return null;
|
|
158
|
+
return this.core.services.RoonApiBrowse ?? null;
|
|
159
|
+
}
|
|
160
|
+
getBrowse() {
|
|
161
|
+
const browse = this.getBrowseUnsafe();
|
|
162
|
+
if (!browse) {
|
|
163
|
+
throw new Error("Not connected to Roon. Please approve the extension in Roon Settings > Extensions.");
|
|
164
|
+
}
|
|
165
|
+
return browse;
|
|
166
|
+
}
|
|
167
|
+
isConnected() {
|
|
168
|
+
return this.core !== null;
|
|
169
|
+
}
|
|
170
|
+
getZones() {
|
|
171
|
+
return Array.from(this.zones.values());
|
|
172
|
+
}
|
|
173
|
+
findZone(nameOrId) {
|
|
174
|
+
// Try exact zone_id match first
|
|
175
|
+
const byId = this.zones.get(nameOrId);
|
|
176
|
+
if (byId)
|
|
177
|
+
return byId;
|
|
178
|
+
// Try case-insensitive display_name match
|
|
179
|
+
const lower = nameOrId.toLowerCase();
|
|
180
|
+
for (const zone of this.zones.values()) {
|
|
181
|
+
if (zone.display_name.toLowerCase() === lower)
|
|
182
|
+
return zone;
|
|
183
|
+
}
|
|
184
|
+
// Try partial match
|
|
185
|
+
for (const zone of this.zones.values()) {
|
|
186
|
+
if (zone.display_name.toLowerCase().includes(lower))
|
|
187
|
+
return zone;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
findZoneOrThrow(nameOrId) {
|
|
192
|
+
const zone = this.findZone(nameOrId);
|
|
193
|
+
if (!zone) {
|
|
194
|
+
const available = this.getZones()
|
|
195
|
+
.map((z) => z.display_name)
|
|
196
|
+
.join(", ");
|
|
197
|
+
throw new Error(`Zone '${nameOrId}' not found. Available zones: ${available || "(none - is Roon paired?)"}`);
|
|
198
|
+
}
|
|
199
|
+
return zone;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export const roonConnection = new RoonConnection();
|