@homebridge-plugins/homebridge-homepod-radio 3.2.8 → 3.2.10
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 +31 -0
- package/bin/warm-worker.py +220 -0
- package/dist/lib/airplayDevice.d.ts +4 -1
- package/dist/lib/airplayDevice.js +25 -2
- package/dist/lib/airplayDevice.js.map +1 -1
- package/dist/lib/warmPlayer.d.ts +45 -0
- package/dist/lib/warmPlayer.js +197 -0
- package/dist/lib/warmPlayer.js.map +1 -0
- package/dist/platform.d.ts +1 -0
- package/dist/platform.js +13 -1
- package/dist/platform.js.map +1 -1
- package/dist/platformAudioSwitchAccessory.d.ts +3 -1
- package/dist/platformAudioSwitchAccessory.js +4 -2
- package/dist/platformAudioSwitchAccessory.js.map +1 -1
- package/dist/platformConfig.d.ts +1 -0
- package/dist/platformConfig.js +4 -1
- package/dist/platformConfig.js.map +1 -1
- package/dist/platformConstants.d.ts +1 -0
- package/dist/platformConstants.js +6 -0
- package/dist/platformConstants.js.map +1 -1
- package/dist/warm-worker.py +220 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -161,6 +161,37 @@ For Whom The Bell Tolls.mp3
|
|
|
161
161
|
> [!NOTE]
|
|
162
162
|
> Comments starting with `#` and empty lines are ignored.
|
|
163
163
|
|
|
164
|
+
### Low-latency audio buttons
|
|
165
|
+
|
|
166
|
+
By default, every audio-file button press starts a fresh `python3` helper that
|
|
167
|
+
imports pyatv and re-establishes the AirPlay connection before any sound plays.
|
|
168
|
+
For short sound effects (door chimes, alerts, ringtones) that startup cost
|
|
169
|
+
dominates the perceived delay.
|
|
170
|
+
|
|
171
|
+
To avoid it, the plugin keeps a single, long-lived pyatv worker running. The
|
|
172
|
+
worker imports pyatv once and holds the connection open, so button presses skip
|
|
173
|
+
the cold start and play noticeably sooner. This is **on by default** and is only
|
|
174
|
+
started when at least one `audioFiles` entry is configured, so installs without
|
|
175
|
+
audio buttons consume no extra resources.
|
|
176
|
+
|
|
177
|
+
If you ever want to disable it (and fall back to the original spawn-per-press
|
|
178
|
+
behavior), set `keepConnectionWarm` to `false` in your config:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
"keepConnectionWarm": false
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Notes:
|
|
185
|
+
|
|
186
|
+
- The warm path applies to the HomeKit **audio-file switch accessories**. The
|
|
187
|
+
webhook (`/play/...`) endpoint is unaffected and continues to use the standard
|
|
188
|
+
path.
|
|
189
|
+
- If the warm worker is unavailable for any reason, playback automatically falls
|
|
190
|
+
back to the standard spawn path, so a worker failure never silently breaks a
|
|
191
|
+
button.
|
|
192
|
+
- The first press after restart may still be slightly slower while the worker
|
|
193
|
+
finishes its initial warm-up.
|
|
194
|
+
|
|
164
195
|
### Webhook for audio file playback
|
|
165
196
|
|
|
166
197
|
You should use the Homebridge server name (default for Homebridge server is homebridge.local) or IP to invoke playback via URL
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# !/usr/bin/python3
|
|
2
|
+
"""Resident pyatv worker for homebridge-homepod-radio (warm-connection mode).
|
|
3
|
+
|
|
4
|
+
Spawned once by the plugin when ``keepConnectionWarm`` is enabled. It imports
|
|
5
|
+
pyatv a single time, scans for and connects to the HomePod once, and then holds
|
|
6
|
+
that connection warm, reusing it for every audio-button press. This eliminates
|
|
7
|
+
the ~5 s per-press cost (``import pyatv`` cold start + device scan + connect)
|
|
8
|
+
that ``stream.py`` pays each time it is spawned fresh.
|
|
9
|
+
|
|
10
|
+
Protocol (newline-delimited JSON; one object per line):
|
|
11
|
+
|
|
12
|
+
stdin {"id": "<id>", "cmd": "play", "file": "<abs path>",
|
|
13
|
+
"volume": <0-100>, "title": "<text>"}
|
|
14
|
+
{"id": "<id>", "cmd": "ping"}
|
|
15
|
+
|
|
16
|
+
stdout {"event": "ready"} (once, after startup)
|
|
17
|
+
{"id": "<id>", "ok": true}
|
|
18
|
+
{"id": "<id>", "ok": false, "error": "..."}
|
|
19
|
+
|
|
20
|
+
All human-readable logging goes to **stderr** so that stdout stays a clean
|
|
21
|
+
protocol channel. ``volume`` of 0 (or missing) means "do not change volume",
|
|
22
|
+
matching the existing ``stream.py`` semantics.
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import pyatv
|
|
34
|
+
from pyatv.const import Protocol
|
|
35
|
+
from pyatv.interface import MediaMetadata
|
|
36
|
+
_PYATV_IMPORT_ERROR = None
|
|
37
|
+
except ImportError as ex:
|
|
38
|
+
pyatv = None
|
|
39
|
+
Protocol = None
|
|
40
|
+
MediaMetadata = None
|
|
41
|
+
_PYATV_IMPORT_ERROR = ex
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_LOGGER = logging.getLogger("warm-worker")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _out(obj) -> None:
|
|
48
|
+
"""Write one protocol message to stdout and flush immediately."""
|
|
49
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
50
|
+
sys.stdout.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WarmConnection:
|
|
54
|
+
"""Owns a single, reused pyatv connection to the target device."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, identifier: str, loop: asyncio.AbstractEventLoop) -> None:
|
|
57
|
+
self.identifier = identifier
|
|
58
|
+
self.loop = loop
|
|
59
|
+
self.atv = None
|
|
60
|
+
self._lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
async def _scan(self):
|
|
63
|
+
# Mirror stream.py: scan by 12-hex id / MAC first, else by RAOP name.
|
|
64
|
+
ident_regex = re.compile(r"^[0-9A-Fa-f]{12}$")
|
|
65
|
+
mac_regex = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$")
|
|
66
|
+
atvs = []
|
|
67
|
+
if (len(self.identifier) == 12 and ident_regex.match(self.identifier)) or mac_regex.match(
|
|
68
|
+
self.identifier
|
|
69
|
+
):
|
|
70
|
+
_LOGGER.info("scanning by id/MAC: %s", self.identifier)
|
|
71
|
+
atvs = await pyatv.scan(self.loop, identifier=self.identifier, timeout=5)
|
|
72
|
+
if not atvs:
|
|
73
|
+
_LOGGER.info("scanning by name: %s", self.identifier)
|
|
74
|
+
found = await pyatv.scan(self.loop, protocol=Protocol.RAOP, timeout=5)
|
|
75
|
+
atvs = [a for a in found if a.name == self.identifier]
|
|
76
|
+
if not atvs:
|
|
77
|
+
raise RuntimeError("device not found: %s" % self.identifier)
|
|
78
|
+
return atvs[0]
|
|
79
|
+
|
|
80
|
+
async def ensure_connected(self):
|
|
81
|
+
if self.atv is not None:
|
|
82
|
+
return self.atv
|
|
83
|
+
conf = await self._scan()
|
|
84
|
+
_LOGGER.info("connecting to %s", conf.address)
|
|
85
|
+
self.atv = await pyatv.connect(conf, self.loop)
|
|
86
|
+
_LOGGER.info("connected; holding connection warm")
|
|
87
|
+
return self.atv
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
if self.atv is not None:
|
|
91
|
+
try:
|
|
92
|
+
self.atv.close()
|
|
93
|
+
except Exception: # noqa: BLE001 - best-effort teardown
|
|
94
|
+
pass
|
|
95
|
+
self.atv = None
|
|
96
|
+
|
|
97
|
+
async def _expand(self, file_path: str):
|
|
98
|
+
# Mirror stream.py m3u/m3u8 handling so warm playback matches spawn.
|
|
99
|
+
if file_path.endswith(".m3u") or file_path.endswith(".m3u8"):
|
|
100
|
+
folder = os.path.dirname(file_path)
|
|
101
|
+
with open(file_path, "r", encoding="UTF-8") as playlist:
|
|
102
|
+
lines = [ln.strip() for ln in playlist if ln.strip()]
|
|
103
|
+
return [
|
|
104
|
+
os.path.join(folder, ln)
|
|
105
|
+
for ln in lines
|
|
106
|
+
if re.match(r"^[A-Za-z0-9]", ln)
|
|
107
|
+
and not re.match(r"^[A-Za-z]:\\", ln)
|
|
108
|
+
and not re.match(r"^http[s]?://", ln)
|
|
109
|
+
]
|
|
110
|
+
return [file_path]
|
|
111
|
+
|
|
112
|
+
async def play(self, file_path: str, volume, title: str) -> None:
|
|
113
|
+
"""Stream a file (or m3u) on the warm connection, reconnecting once on error."""
|
|
114
|
+
async with self._lock:
|
|
115
|
+
last_err = None
|
|
116
|
+
for attempt in (1, 2):
|
|
117
|
+
try:
|
|
118
|
+
atv = await self.ensure_connected()
|
|
119
|
+
if volume and int(volume) > 0:
|
|
120
|
+
try:
|
|
121
|
+
await atv.audio.set_volume(float(volume))
|
|
122
|
+
except Exception as ex: # noqa: BLE001
|
|
123
|
+
_LOGGER.warning("set_volume(%s) failed: %s", volume, ex)
|
|
124
|
+
metadata = MediaMetadata(title=title, album=title, artist=None, artwork=None)
|
|
125
|
+
for song in await self._expand(file_path):
|
|
126
|
+
_LOGGER.info("streaming %s (attempt %d)", song, attempt)
|
|
127
|
+
await atv.stream.stream_file(song, metadata)
|
|
128
|
+
_LOGGER.info("finished streaming %s", file_path)
|
|
129
|
+
return
|
|
130
|
+
except Exception as ex: # noqa: BLE001
|
|
131
|
+
last_err = ex
|
|
132
|
+
_LOGGER.error("stream attempt %d failed: %s", attempt, ex)
|
|
133
|
+
self.close() # force a fresh connect on retry
|
|
134
|
+
raise last_err if last_err is not None else RuntimeError("play failed")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _read_line(loop: asyncio.AbstractEventLoop) -> str:
|
|
138
|
+
# Blocking stdin read offloaded to a thread so the asyncio loop stays free.
|
|
139
|
+
return await loop.run_in_executor(None, sys.stdin.readline)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def main_async(identifier: str) -> None:
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
conn = WarmConnection(identifier, loop)
|
|
145
|
+
|
|
146
|
+
# Best-effort warmup; a failure here is non-fatal because the first play
|
|
147
|
+
# will retry the scan/connect itself.
|
|
148
|
+
try:
|
|
149
|
+
await conn.ensure_connected()
|
|
150
|
+
except Exception as ex: # noqa: BLE001
|
|
151
|
+
_LOGGER.warning("initial warmup failed (will retry on first play): %s", ex)
|
|
152
|
+
|
|
153
|
+
_out({"event": "ready"})
|
|
154
|
+
|
|
155
|
+
while True:
|
|
156
|
+
line = await _read_line(loop)
|
|
157
|
+
if line == "": # EOF: parent closed our stdin
|
|
158
|
+
_LOGGER.info("stdin closed, exiting")
|
|
159
|
+
break
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
msg = json.loads(line)
|
|
165
|
+
except Exception: # noqa: BLE001
|
|
166
|
+
_LOGGER.warning("ignoring non-JSON line: %s", line)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
req_id = msg.get("id")
|
|
170
|
+
cmd = msg.get("cmd")
|
|
171
|
+
|
|
172
|
+
if cmd == "ping":
|
|
173
|
+
_out({"id": req_id, "ok": True, "pong": True})
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if cmd == "play":
|
|
177
|
+
file_path = msg.get("file")
|
|
178
|
+
volume = msg.get("volume", 0)
|
|
179
|
+
title = msg.get("title") or (os.path.basename(file_path) if file_path else "")
|
|
180
|
+
if not file_path:
|
|
181
|
+
_out({"id": req_id, "ok": False, "error": "missing file"})
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
await conn.play(file_path, volume, title)
|
|
185
|
+
_out({"id": req_id, "ok": True})
|
|
186
|
+
except Exception as ex: # noqa: BLE001
|
|
187
|
+
_out({"id": req_id, "ok": False, "error": str(ex)})
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
_out({"id": req_id, "ok": False, "error": "unknown cmd: %s" % cmd})
|
|
191
|
+
|
|
192
|
+
conn.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> None:
|
|
196
|
+
parser = argparse.ArgumentParser()
|
|
197
|
+
parser.add_argument("-i", "--id", dest="id", required=True, help="device identifier")
|
|
198
|
+
parser.add_argument("-v", "--verbose", action="store_true", dest="verbose")
|
|
199
|
+
args = parser.parse_args()
|
|
200
|
+
|
|
201
|
+
logging.basicConfig(
|
|
202
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
203
|
+
stream=sys.stderr,
|
|
204
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
205
|
+
format="%(asctime)s %(levelname)s [%(name)s]: %(message)s",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if _PYATV_IMPORT_ERROR is not None:
|
|
209
|
+
_LOGGER.error("Required dependency 'pyatv' was not found. Install it with: pip3 install pyatv")
|
|
210
|
+
_LOGGER.error("Import error: %s", _PYATV_IMPORT_ERROR)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
asyncio.run(main_async(args.id))
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
main()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Logger } from 'homebridge';
|
|
2
|
+
import { WarmPlayer } from './warmPlayer.js';
|
|
2
3
|
/**
|
|
3
4
|
* AirPlay device
|
|
4
5
|
*/
|
|
@@ -9,17 +10,19 @@ export declare class AirPlayDevice {
|
|
|
9
10
|
private readonly streamerName;
|
|
10
11
|
private readonly streamMetadataUrl?;
|
|
11
12
|
private readonly streamArtworkUrl?;
|
|
13
|
+
private readonly warmPlayer?;
|
|
12
14
|
private readonly STREAMING_RESTART_TIMEOUT;
|
|
13
15
|
private readonly HEARTBEAT_TIMEOUT;
|
|
14
16
|
private readonly LAST_SEEN_THRESHOLD_MS;
|
|
15
17
|
private readonly DEFAULT_ARTWORK_URL;
|
|
16
18
|
private streaming;
|
|
19
|
+
private warmPlaying;
|
|
17
20
|
private lastSeen;
|
|
18
21
|
private heartbeat;
|
|
19
22
|
private streamingRetries;
|
|
20
23
|
private readonly debug;
|
|
21
24
|
private readonly pluginPath;
|
|
22
|
-
constructor(homepodId: string, logger: Logger, verboseMode: boolean, streamerName: string, streamMetadataUrl?: string | undefined, streamArtworkUrl?: string | undefined);
|
|
25
|
+
constructor(homepodId: string, logger: Logger, verboseMode: boolean, streamerName: string, streamMetadataUrl?: string | undefined, streamArtworkUrl?: string | undefined, warmPlayer?: WarmPlayer | undefined);
|
|
23
26
|
private killProcess;
|
|
24
27
|
getPlaybackTitle(): Promise<string>;
|
|
25
28
|
setVolume(volume: number): Promise<void>;
|
|
@@ -15,24 +15,27 @@ export class AirPlayDevice {
|
|
|
15
15
|
streamerName;
|
|
16
16
|
streamMetadataUrl;
|
|
17
17
|
streamArtworkUrl;
|
|
18
|
+
warmPlayer;
|
|
18
19
|
STREAMING_RESTART_TIMEOUT = 500;
|
|
19
20
|
HEARTBEAT_TIMEOUT = 5000;
|
|
20
21
|
LAST_SEEN_THRESHOLD_MS = 10000;
|
|
21
22
|
DEFAULT_ARTWORK_URL = 'https://www.apple.com/v/apple-music/q/images/shared/og__ckjrh2mu8b2a_image.png';
|
|
22
23
|
streaming;
|
|
24
|
+
warmPlaying = false;
|
|
23
25
|
lastSeen;
|
|
24
26
|
heartbeat;
|
|
25
27
|
streamingRetries = 0;
|
|
26
28
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
29
|
debug;
|
|
28
30
|
pluginPath;
|
|
29
|
-
constructor(homepodId, logger, verboseMode, streamerName, streamMetadataUrl, streamArtworkUrl) {
|
|
31
|
+
constructor(homepodId, logger, verboseMode, streamerName, streamMetadataUrl, streamArtworkUrl, warmPlayer) {
|
|
30
32
|
this.homepodId = homepodId;
|
|
31
33
|
this.logger = logger;
|
|
32
34
|
this.verboseMode = verboseMode;
|
|
33
35
|
this.streamerName = streamerName;
|
|
34
36
|
this.streamMetadataUrl = streamMetadataUrl;
|
|
35
37
|
this.streamArtworkUrl = streamArtworkUrl;
|
|
38
|
+
this.warmPlayer = warmPlayer;
|
|
36
39
|
this.debug = this.verboseMode ? this.logger.info.bind(this.logger) : this.logger.debug.bind(this.logger);
|
|
37
40
|
this.pluginPath = path.resolve(path.dirname(__filename), '..', '..');
|
|
38
41
|
}
|
|
@@ -52,6 +55,25 @@ export class AirPlayDevice {
|
|
|
52
55
|
await execAsync(setVolumeCmd);
|
|
53
56
|
}
|
|
54
57
|
async playFile(filePath, volume) {
|
|
58
|
+
// Prefer the shared warm worker (held pyatv connection) when available,
|
|
59
|
+
// falling back to the per-press spawn path below if it is down or fails.
|
|
60
|
+
if (this.warmPlayer && this.warmPlayer.isReady()) {
|
|
61
|
+
this.warmPlaying = true;
|
|
62
|
+
this.logger.info(`[${this.streamerName}] Started file streaming ${filePath} (warm)`);
|
|
63
|
+
try {
|
|
64
|
+
const ok = await this.warmPlayer.playFile(filePath, volume, this.streamerName);
|
|
65
|
+
this.warmPlaying = false;
|
|
66
|
+
if (ok) {
|
|
67
|
+
this.debug(`[${this.streamerName}] Finished file streaming ${filePath} (warm)`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
this.logger.warn(`[${this.streamerName}] Warm play failed, falling back to spawn`);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
this.warmPlaying = false;
|
|
74
|
+
this.logger.warn(`[${this.streamerName}] Warm play error, falling back to spawn: ${err}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
55
77
|
// create pipe for the command:
|
|
56
78
|
const scriptPath = path.resolve(path.dirname(__filename), '..', 'stream.py');
|
|
57
79
|
const streamParams = [
|
|
@@ -192,6 +214,7 @@ export class AirPlayDevice {
|
|
|
192
214
|
}
|
|
193
215
|
async endStreaming(streamExited = false) {
|
|
194
216
|
try {
|
|
217
|
+
this.warmPlaying = false;
|
|
195
218
|
if (this.streaming === undefined) {
|
|
196
219
|
this.debug(`[${this.streamerName}] End streaming: streaming: ${this.streaming}`);
|
|
197
220
|
return Promise.resolve(true);
|
|
@@ -221,7 +244,7 @@ export class AirPlayDevice {
|
|
|
221
244
|
return true;
|
|
222
245
|
}
|
|
223
246
|
isPlaying() {
|
|
224
|
-
return !!this.streaming;
|
|
247
|
+
return !!this.streaming || this.warmPlaying;
|
|
225
248
|
}
|
|
226
249
|
}
|
|
227
250
|
//# sourceMappingURL=airplayDevice.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"airplayDevice.js","sourceRoot":"","sources":["../../src/lib/airplayDevice.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"airplayDevice.js","sourceRoot":"","sources":["../../src/lib/airplayDevice.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Logger } from 'homebridge';
|
|
2
|
+
/**
|
|
3
|
+
* Supervises a single resident `warm-worker.py` process that holds one warm
|
|
4
|
+
* pyatv connection to the HomePod and replays audio files on it. Shared by all
|
|
5
|
+
* audio-button accessories for a given homepodId.
|
|
6
|
+
*
|
|
7
|
+
* The worker is spawned once and kept alive; if it crashes it is restarted with
|
|
8
|
+
* exponential backoff. Each play is a newline-delimited JSON request/response on
|
|
9
|
+
* the worker's stdin/stdout (logs come back on stderr). When the worker is not
|
|
10
|
+
* ready, `playFile()` resolves `false` so the caller can fall back to the
|
|
11
|
+
* original per-press spawn path — behavior degrades gracefully.
|
|
12
|
+
*/
|
|
13
|
+
export declare class WarmPlayer {
|
|
14
|
+
private readonly homepodId;
|
|
15
|
+
private readonly logger;
|
|
16
|
+
private readonly verboseMode;
|
|
17
|
+
private worker;
|
|
18
|
+
private rl;
|
|
19
|
+
private ready;
|
|
20
|
+
private stopped;
|
|
21
|
+
private restartDisabled;
|
|
22
|
+
private restartDelay;
|
|
23
|
+
private readonly MAX_RESTART_DELAY;
|
|
24
|
+
private restartAttempts;
|
|
25
|
+
private readonly MAX_RESTART_ATTEMPTS;
|
|
26
|
+
private readonly PLAY_TIMEOUT_MS;
|
|
27
|
+
private nextId;
|
|
28
|
+
private readonly pending;
|
|
29
|
+
private readonly scriptPath;
|
|
30
|
+
private readonly debug;
|
|
31
|
+
constructor(homepodId: string, logger: Logger, verboseMode: boolean);
|
|
32
|
+
start(): void;
|
|
33
|
+
private logWorkerStderr;
|
|
34
|
+
private handleLine;
|
|
35
|
+
private scheduleRestart;
|
|
36
|
+
private failAllPending;
|
|
37
|
+
isReady(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Ask the warm worker to play a file. Resolves `true` on success, or `false`
|
|
40
|
+
* if the worker is unavailable or the play failed, so the caller can fall
|
|
41
|
+
* back to spawning stream.py.
|
|
42
|
+
*/
|
|
43
|
+
playFile(filePath: string, volume: number, title: string): Promise<boolean>;
|
|
44
|
+
stop(): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as child from 'child_process';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
/**
|
|
7
|
+
* Supervises a single resident `warm-worker.py` process that holds one warm
|
|
8
|
+
* pyatv connection to the HomePod and replays audio files on it. Shared by all
|
|
9
|
+
* audio-button accessories for a given homepodId.
|
|
10
|
+
*
|
|
11
|
+
* The worker is spawned once and kept alive; if it crashes it is restarted with
|
|
12
|
+
* exponential backoff. Each play is a newline-delimited JSON request/response on
|
|
13
|
+
* the worker's stdin/stdout (logs come back on stderr). When the worker is not
|
|
14
|
+
* ready, `playFile()` resolves `false` so the caller can fall back to the
|
|
15
|
+
* original per-press spawn path — behavior degrades gracefully.
|
|
16
|
+
*/
|
|
17
|
+
export class WarmPlayer {
|
|
18
|
+
homepodId;
|
|
19
|
+
logger;
|
|
20
|
+
verboseMode;
|
|
21
|
+
worker;
|
|
22
|
+
rl;
|
|
23
|
+
ready = false;
|
|
24
|
+
stopped = false;
|
|
25
|
+
restartDisabled = false;
|
|
26
|
+
restartDelay = 1000;
|
|
27
|
+
MAX_RESTART_DELAY = 30000;
|
|
28
|
+
restartAttempts = 0;
|
|
29
|
+
MAX_RESTART_ATTEMPTS = 6;
|
|
30
|
+
PLAY_TIMEOUT_MS = 60000;
|
|
31
|
+
nextId = 1;
|
|
32
|
+
pending = new Map();
|
|
33
|
+
scriptPath;
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
debug;
|
|
36
|
+
constructor(homepodId, logger, verboseMode) {
|
|
37
|
+
this.homepodId = homepodId;
|
|
38
|
+
this.logger = logger;
|
|
39
|
+
this.verboseMode = verboseMode;
|
|
40
|
+
this.debug = this.verboseMode ? this.logger.info.bind(this.logger) : this.logger.debug.bind(this.logger);
|
|
41
|
+
// dist/lib/warmPlayer.js -> ../warm-worker.py == dist/warm-worker.py
|
|
42
|
+
this.scriptPath = path.resolve(path.dirname(__filename), '..', 'warm-worker.py');
|
|
43
|
+
}
|
|
44
|
+
start() {
|
|
45
|
+
if (this.stopped || this.restartDisabled || this.worker) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const args = ['-u', this.scriptPath, '--id', this.homepodId];
|
|
49
|
+
if (this.verboseMode) {
|
|
50
|
+
args.push('--verbose');
|
|
51
|
+
}
|
|
52
|
+
this.logger.info(`Starting warm worker: python3 ${args.join(' ')}`);
|
|
53
|
+
this.worker = child.spawn('python3', args, { env: { ...process.env } });
|
|
54
|
+
this.rl = readline.createInterface({ input: this.worker.stdout });
|
|
55
|
+
this.rl.on('line', (line) => this.handleLine(line));
|
|
56
|
+
this.worker.stderr.on('data', (data) => {
|
|
57
|
+
this.logWorkerStderr(data.toString());
|
|
58
|
+
});
|
|
59
|
+
this.worker.on('error', (err) => {
|
|
60
|
+
this.logger.error(`Warm worker spawn error: ${err}`);
|
|
61
|
+
});
|
|
62
|
+
this.worker.on('exit', (code, signal) => {
|
|
63
|
+
this.logger.warn(`Warm worker exited code=${code} signal=${signal}`);
|
|
64
|
+
this.ready = false;
|
|
65
|
+
this.rl?.close();
|
|
66
|
+
this.rl = undefined;
|
|
67
|
+
this.worker = undefined;
|
|
68
|
+
this.failAllPending();
|
|
69
|
+
this.scheduleRestart();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
logWorkerStderr(output) {
|
|
73
|
+
const lines = output
|
|
74
|
+
.split(/\r?\n/)
|
|
75
|
+
.map((line) => line.trim())
|
|
76
|
+
.filter((line) => line.length > 0);
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (/traceback|error|exception|module not found/i.test(line)) {
|
|
79
|
+
this.logger.error(`Warm worker: ${line}`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.logger.warn(`Warm worker: ${line}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
handleLine(line) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
let msg;
|
|
93
|
+
try {
|
|
94
|
+
msg = JSON.parse(trimmed);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.debug(`Warm worker non-JSON output: ${trimmed}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (msg.event === 'ready') {
|
|
101
|
+
this.ready = true;
|
|
102
|
+
this.restartDelay = 1000; // healthy start resets backoff
|
|
103
|
+
this.restartAttempts = 0;
|
|
104
|
+
this.logger.info('Warm worker ready (connection held warm)');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
108
|
+
const key = String(msg.id);
|
|
109
|
+
const pending = this.pending.get(key);
|
|
110
|
+
if (pending) {
|
|
111
|
+
clearTimeout(pending.timer);
|
|
112
|
+
this.pending.delete(key);
|
|
113
|
+
if (msg.ok === false && msg.error) {
|
|
114
|
+
this.logger.warn(`Warm play failed: ${msg.error}`);
|
|
115
|
+
}
|
|
116
|
+
pending.resolve(msg.ok === true);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
scheduleRestart() {
|
|
121
|
+
if (this.stopped) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.restartAttempts += 1;
|
|
125
|
+
if (this.restartAttempts > this.MAX_RESTART_ATTEMPTS) {
|
|
126
|
+
this.restartDisabled = true;
|
|
127
|
+
this.logger.error(`Warm worker failed to start after ${this.MAX_RESTART_ATTEMPTS} attempts; disabling warm connection until Homebridge restarts`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const delay = this.restartDelay;
|
|
131
|
+
this.restartDelay = Math.min(this.restartDelay * 2, this.MAX_RESTART_DELAY);
|
|
132
|
+
this.logger.info(`Restarting warm worker in ${delay}ms (attempt ${this.restartAttempts}/${this.MAX_RESTART_ATTEMPTS})`);
|
|
133
|
+
setTimeout(() => this.start(), delay);
|
|
134
|
+
}
|
|
135
|
+
failAllPending() {
|
|
136
|
+
for (const pending of this.pending.values()) {
|
|
137
|
+
clearTimeout(pending.timer);
|
|
138
|
+
pending.resolve(false);
|
|
139
|
+
}
|
|
140
|
+
this.pending.clear();
|
|
141
|
+
}
|
|
142
|
+
isReady() {
|
|
143
|
+
return this.ready && !!this.worker;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Ask the warm worker to play a file. Resolves `true` on success, or `false`
|
|
147
|
+
* if the worker is unavailable or the play failed, so the caller can fall
|
|
148
|
+
* back to spawning stream.py.
|
|
149
|
+
*/
|
|
150
|
+
playFile(filePath, volume, title) {
|
|
151
|
+
if (!this.isReady()) {
|
|
152
|
+
return Promise.resolve(false);
|
|
153
|
+
}
|
|
154
|
+
const id = String(this.nextId++);
|
|
155
|
+
const payload = JSON.stringify({ id, cmd: 'play', file: filePath, volume, title }) + '\n';
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
this.pending.delete(id);
|
|
159
|
+
this.logger.warn(`Warm play timed out for ${filePath}`);
|
|
160
|
+
resolve(false);
|
|
161
|
+
}, this.PLAY_TIMEOUT_MS);
|
|
162
|
+
this.pending.set(id, { resolve, timer });
|
|
163
|
+
try {
|
|
164
|
+
this.worker.stdin.write(payload);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
this.pending.delete(id);
|
|
169
|
+
this.logger.warn(`Failed to write to warm worker: ${err}`);
|
|
170
|
+
resolve(false);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
stop() {
|
|
175
|
+
this.stopped = true;
|
|
176
|
+
this.ready = false;
|
|
177
|
+
this.failAllPending();
|
|
178
|
+
if (this.worker) {
|
|
179
|
+
try {
|
|
180
|
+
this.worker.stdin?.end();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// ignore
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
this.worker.kill('SIGTERM');
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// ignore
|
|
190
|
+
}
|
|
191
|
+
this.worker = undefined;
|
|
192
|
+
}
|
|
193
|
+
this.rl?.close();
|
|
194
|
+
this.rl = undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=warmPlayer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"warmPlayer.js","sourceRoot":"","sources":["../../src/lib/warmPlayer.ts"],"names":[],"mappings":"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"}
|
package/dist/platform.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare class HomepodRadioPlatform implements DynamicPlatformPlugin {
|
|
|
12
12
|
private readonly playbackController;
|
|
13
13
|
private readonly httpService;
|
|
14
14
|
private readonly platformActions;
|
|
15
|
+
private warmPlayer;
|
|
15
16
|
readonly Service: typeof Service;
|
|
16
17
|
readonly Characteristic: typeof Characteristic;
|
|
17
18
|
readonly platformConfig: HomepodRadioPlatformConfig;
|
package/dist/platform.js
CHANGED
|
@@ -8,6 +8,7 @@ import { HomepodAudioSwitchAccessory } from './platformAudioSwitchAccessory.js';
|
|
|
8
8
|
import { HomepodVolumeAccessory } from './platformHomepodVolumeAccessory.js';
|
|
9
9
|
import { delay } from './lib/promises.js';
|
|
10
10
|
import { HttpService } from './lib/httpService.js';
|
|
11
|
+
import { WarmPlayer } from './lib/warmPlayer.js';
|
|
11
12
|
let hap;
|
|
12
13
|
/**
|
|
13
14
|
* Platform Accessory
|
|
@@ -21,6 +22,7 @@ export class HomepodRadioPlatform {
|
|
|
21
22
|
playbackController = new PlaybackController();
|
|
22
23
|
httpService;
|
|
23
24
|
platformActions;
|
|
25
|
+
warmPlayer;
|
|
24
26
|
Service;
|
|
25
27
|
Characteristic;
|
|
26
28
|
platformConfig;
|
|
@@ -38,6 +40,15 @@ export class HomepodRadioPlatform {
|
|
|
38
40
|
this.logger.info(`Loaded ${loadedRadios.length} radios: ${loadedRadios}`);
|
|
39
41
|
this.api.on('didFinishLaunching', async () => {
|
|
40
42
|
this.logger.info('Finished initializing platform');
|
|
43
|
+
// Start one shared warm worker (held pyatv connection) before adding
|
|
44
|
+
// audio buttons, so the first press can already use it. On by
|
|
45
|
+
// default, but only when at least one audio button is configured so
|
|
46
|
+
// installs without audio buttons consume no extra resources.
|
|
47
|
+
if (this.platformConfig.keepConnectionWarm && this.platformConfig.audioFiles.length > 0) {
|
|
48
|
+
this.logger.info('Platform: keeping AirPlay connection warm for audio buttons');
|
|
49
|
+
this.warmPlayer = new WarmPlayer(this.platformConfig.homepodId, this.logger, this.platformConfig.verboseMode);
|
|
50
|
+
this.warmPlayer.start();
|
|
51
|
+
}
|
|
41
52
|
this.platformConfig.radios.forEach((radio) => this.addRadioAccessory(radio));
|
|
42
53
|
this.platformConfig.audioFiles.forEach((fileSwitch) => this.addFileSwitchAccessory(fileSwitch));
|
|
43
54
|
await delay(1000, 0);
|
|
@@ -53,6 +64,7 @@ export class HomepodRadioPlatform {
|
|
|
53
64
|
if (this.platformConfig.httpPort > 0) {
|
|
54
65
|
this.httpService.stop();
|
|
55
66
|
}
|
|
67
|
+
this.warmPlayer?.stop();
|
|
56
68
|
});
|
|
57
69
|
}
|
|
58
70
|
/**
|
|
@@ -99,7 +111,7 @@ export class HomepodRadioPlatform {
|
|
|
99
111
|
// Adding Categories.SPEAKER as the category.
|
|
100
112
|
// @see https://github.com/homebridge/homebridge/issues/2553#issuecomment-623675893
|
|
101
113
|
accessory.category = 26 /* Categories.SPEAKER */;
|
|
102
|
-
new HomepodAudioSwitchAccessory(this, accessory, fileSwitch, this.playbackController);
|
|
114
|
+
new HomepodAudioSwitchAccessory(this, accessory, fileSwitch, this.playbackController, this.warmPlayer);
|
|
103
115
|
// SmartSpeaker service must be added as an external accessory.
|
|
104
116
|
// @see https://github.com/homebridge/homebridge/issues/2553#issuecomment-622961035
|
|
105
117
|
// There a no collision issues when calling this multiple times on accessories that already exist.
|
package/dist/platform.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,6BAA6B,EAAE,MAAM,6BAA6B,CAAC;AAC5E,OAAO,EAAe,0BAA0B,EAAe,MAAM,qBAAqB,CAAC;AAC3F,OAAO,EAAE,8BAA8B,EAAE,MAAM,yBAAyB,CAAC;AACzE,OAAO,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAE7E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,15 +2,17 @@ import { AccessoryPlugin, Service, PlatformAccessory } from 'homebridge';
|
|
|
2
2
|
import { HomepodRadioPlatform } from './platform.js';
|
|
3
3
|
import { AudioConfig } from './platformConfig.js';
|
|
4
4
|
import { PlaybackController, PlaybackStreamer } from './lib/playbackController.js';
|
|
5
|
+
import { WarmPlayer } from './lib/warmPlayer.js';
|
|
5
6
|
export declare class HomepodAudioSwitchAccessory implements AccessoryPlugin, PlaybackStreamer {
|
|
6
7
|
private readonly platform;
|
|
7
8
|
private readonly accessory;
|
|
8
9
|
private readonly audioConfig;
|
|
9
10
|
private readonly playbackController;
|
|
11
|
+
private readonly warmPlayer?;
|
|
10
12
|
private readonly device;
|
|
11
13
|
private readonly service;
|
|
12
14
|
private readonly informationService;
|
|
13
|
-
constructor(platform: HomepodRadioPlatform, accessory: PlatformAccessory, audioConfig: AudioConfig, playbackController: PlaybackController);
|
|
15
|
+
constructor(platform: HomepodRadioPlatform, accessory: PlatformAccessory, audioConfig: AudioConfig, playbackController: PlaybackController, warmPlayer?: WarmPlayer | undefined);
|
|
14
16
|
volumeUpdated(homepodId: string, volume: number): Promise<void>;
|
|
15
17
|
stopRequested(source: PlaybackStreamer): Promise<void>;
|
|
16
18
|
shutdownRequested(): Promise<void>;
|
|
@@ -7,15 +7,17 @@ export class HomepodAudioSwitchAccessory {
|
|
|
7
7
|
accessory;
|
|
8
8
|
audioConfig;
|
|
9
9
|
playbackController;
|
|
10
|
+
warmPlayer;
|
|
10
11
|
device;
|
|
11
12
|
service;
|
|
12
13
|
informationService;
|
|
13
|
-
constructor(platform, accessory, audioConfig, playbackController) {
|
|
14
|
+
constructor(platform, accessory, audioConfig, playbackController, warmPlayer) {
|
|
14
15
|
this.platform = platform;
|
|
15
16
|
this.accessory = accessory;
|
|
16
17
|
this.audioConfig = audioConfig;
|
|
17
18
|
this.playbackController = playbackController;
|
|
18
|
-
this.
|
|
19
|
+
this.warmPlayer = warmPlayer;
|
|
20
|
+
this.device = new AirPlayDevice(this.platform.platformConfig.homepodId, platform.logger, platform.platformConfig.verboseMode, this.streamerName(), '', '', this.warmPlayer);
|
|
19
21
|
this.service =
|
|
20
22
|
this.accessory.getService(this.platform.Service.Switch) ||
|
|
21
23
|
this.accessory.addService(this.platform.Service.Switch);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platformAudioSwitchAccessory.js","sourceRoot":"","sources":["../src/platformAudioSwitchAccessory.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3E,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"platformAudioSwitchAccessory.js","sourceRoot":"","sources":["../src/platformAudioSwitchAccessory.ts"],"names":[],"mappings":"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"}
|
package/dist/platformConfig.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare class HomepodRadioPlatformConfig {
|
|
|
27
27
|
readonly httpPort: number;
|
|
28
28
|
readonly enableVolumeControl: boolean;
|
|
29
29
|
readonly volume: number;
|
|
30
|
+
readonly keepConnectionWarm: boolean;
|
|
30
31
|
constructor(config: PlatformConfig);
|
|
31
32
|
private loadAudioConfigs;
|
|
32
33
|
private loadRadioConfigs;
|
package/dist/platformConfig.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PLUGIN_MODEL } from './platformConstants.js';
|
|
1
|
+
import { PLUGIN_MODEL, DEFAULT_KEEP_CONNECTION_WARM } from './platformConstants.js';
|
|
2
2
|
export class HomepodRadioPlatformConfig {
|
|
3
3
|
config;
|
|
4
4
|
name;
|
|
@@ -11,6 +11,7 @@ export class HomepodRadioPlatformConfig {
|
|
|
11
11
|
httpPort;
|
|
12
12
|
enableVolumeControl;
|
|
13
13
|
volume;
|
|
14
|
+
keepConnectionWarm;
|
|
14
15
|
constructor(config) {
|
|
15
16
|
this.config = config;
|
|
16
17
|
this.name = config.name || 'HomePod Mini Radio';
|
|
@@ -26,6 +27,8 @@ export class HomepodRadioPlatformConfig {
|
|
|
26
27
|
this.mediaPath = this.config.mediaPath || '';
|
|
27
28
|
this.enableVolumeControl = (this.config.enableVolumeControl ??= false);
|
|
28
29
|
this.volume = this.config.volume || 25;
|
|
30
|
+
// On by default; an explicit config value (true/false) still wins.
|
|
31
|
+
this.keepConnectionWarm = this.config.keepConnectionWarm ?? DEFAULT_KEEP_CONNECTION_WARM;
|
|
29
32
|
this.loadRadioConfigs();
|
|
30
33
|
this.loadAudioConfigs();
|
|
31
34
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platformConfig.js","sourceRoot":"","sources":["../src/platformConfig.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"platformConfig.js","sourceRoot":"","sources":["../src/platformConfig.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,4BAA4B,EAAE,MAAM,wBAAwB,CAAC;AAoBpF,MAAM,OAAO,0BAA0B;IAcf;IAbJ,IAAI,CAAS;IACb,SAAS,CAAS;IAClB,YAAY,CAAS;IACrB,WAAW,CAAU;IACrB,MAAM,CAAgB;IACtB,UAAU,CAAgB;IAC1B,SAAS,CAAS;IAClB,QAAQ,CAAS;IAEjB,mBAAmB,CAAU;IAC7B,MAAM,CAAS;IACf,kBAAkB,CAAU;IAE5C,YAAoB,MAAsB;QAAtB,WAAM,GAAN,MAAM,CAAgB;QACtC,IAAI,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,IAAI,oBAAoB,CAAC;QAEhD,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;QACjB,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC;YACpB,MAAM,8BAA8B,CAAC;QACzC,CAAC;QACD,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC;QAClC,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC,YAAY,IAAI,OAAO,IAAI,CAAC,SAAS,EAAE,CAAC;QACnE,IAAI,CAAC,WAAW,GAAG,CAAC,MAAM,CAAC,mBAAmB,KAAK,KAAK,CAAC,CAAC;QAE1D,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,IAAI,CAAC;QAC7C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,IAAI,EAAE,CAAC;QAE7C,IAAI,CAAC,mBAAmB,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,mBAAmB,KAAK,KAAK,CAAC,CAAC;QACvE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC;QACvC,mEAAmE;QACnE,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC,MAAM,CAAC,kBAAkB,IAAI,4BAA4B,CAAC;QAEzF,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACxB,IAAI,CAAC,gBAAgB,EAAE,CAAC;IAC5B,CAAC;IAEO,gBAAgB;QACpB,IAAI,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;YACzB,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,EAAE;gBAC3C,MAAM,SAAS,GAAG;oBACd,IAAI,EAAE,WAAW,CAAC,IAAI;oBACtB,QAAQ,EAAE,WAAW,CAAC,QAAQ;oBAC9B,MAAM,EAAE,WAAW,CAAC,MAAM,IAAI,CAAC;iBACnB,CAAC;gBAEjB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACpC,CAAC,CAAC,CAAC;QACP,CAAC;IACL,CAAC;IAEO,gBAAgB;QACpB,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;YACrB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,EAAE;gBACvC,MAAM,KAAK,GAAG;oBACV,IAAI,EAAE,WAAW,CAAC,IAAI;oBACtB,KAAK,EAAE,WAAW,CAAC,KAAK,IAAI,YAAY;oBACxC,QAAQ,EAAE,WAAW,CAAC,QAAQ;oBAC9B,SAAS,EAAE,WAAW,CAAC,SAAS,IAAI,WAAW,CAAC,IAAI;oBACpD,YAAY,EAAE,IAAI,CAAC,YAAY;oBAC/B,UAAU,EAAE,WAAW,CAAC,UAAU,IAAI,KAAK;oBAC3C,WAAW,EAAE,WAAW,CAAC,WAAW,IAAI,EAAE;oBAC1C,UAAU,EAAE,WAAW,CAAC,UAAU,IAAI,EAAE;oBACxC,QAAQ,EAAE,WAAW,CAAC,QAAQ,IAAI,KAAK;oBACvC,MAAM,EAAE,WAAW,CAAC,MAAM,IAAI,CAAC;iBACnB,CAAC;gBAEjB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC,CAAC,CAAC;QACP,CAAC;IACL,CAAC;IAEM,aAAa;QAChB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC1C,CAAC;CACJ"}
|
|
@@ -2,3 +2,4 @@ export declare const PLATFORM_NAME = "HomepodRadioPlatform";
|
|
|
2
2
|
export declare const PLUGIN_NAME = "@homebridge-plugins/homebridge-homepod-radio";
|
|
3
3
|
export declare const PLUGIN_MANUFACTURER = "petro-kushchak";
|
|
4
4
|
export declare const PLUGIN_MODEL = "Homepod Radio";
|
|
5
|
+
export declare const DEFAULT_KEEP_CONNECTION_WARM = true;
|
|
@@ -2,4 +2,10 @@ export const PLATFORM_NAME = 'HomepodRadioPlatform';
|
|
|
2
2
|
export const PLUGIN_NAME = '@homebridge-plugins/homebridge-homepod-radio';
|
|
3
3
|
export const PLUGIN_MANUFACTURER = 'petro-kushchak';
|
|
4
4
|
export const PLUGIN_MODEL = 'Homepod Radio';
|
|
5
|
+
// Internal default for the warm-connection feature. The feature is on by
|
|
6
|
+
// default and only consumes resources when at least one audio button is
|
|
7
|
+
// configured (see platform.ts). It can still be opted out per-install by
|
|
8
|
+
// setting "keepConnectionWarm": false in config.json; flip this constant (or
|
|
9
|
+
// re-expose the option in config.schema.json) to change the default behavior.
|
|
10
|
+
export const DEFAULT_KEEP_CONNECTION_WARM = true;
|
|
5
11
|
//# sourceMappingURL=platformConstants.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platformConstants.js","sourceRoot":"","sources":["../src/platformConstants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,aAAa,GAAG,sBAAsB,CAAC;AACpD,MAAM,CAAC,MAAM,WAAW,GAAG,8CAA8C,CAAC;AAE1E,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD,MAAM,CAAC,MAAM,YAAY,GAAG,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"platformConstants.js","sourceRoot":"","sources":["../src/platformConstants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,aAAa,GAAG,sBAAsB,CAAC;AACpD,MAAM,CAAC,MAAM,WAAW,GAAG,8CAA8C,CAAC;AAE1E,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD,MAAM,CAAC,MAAM,YAAY,GAAG,eAAe,CAAC;AAE5C,yEAAyE;AACzE,wEAAwE;AACxE,yEAAyE;AACzE,6EAA6E;AAC7E,8EAA8E;AAC9E,MAAM,CAAC,MAAM,4BAA4B,GAAG,IAAI,CAAC"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# !/usr/bin/python3
|
|
2
|
+
"""Resident pyatv worker for homebridge-homepod-radio (warm-connection mode).
|
|
3
|
+
|
|
4
|
+
Spawned once by the plugin when ``keepConnectionWarm`` is enabled. It imports
|
|
5
|
+
pyatv a single time, scans for and connects to the HomePod once, and then holds
|
|
6
|
+
that connection warm, reusing it for every audio-button press. This eliminates
|
|
7
|
+
the ~5 s per-press cost (``import pyatv`` cold start + device scan + connect)
|
|
8
|
+
that ``stream.py`` pays each time it is spawned fresh.
|
|
9
|
+
|
|
10
|
+
Protocol (newline-delimited JSON; one object per line):
|
|
11
|
+
|
|
12
|
+
stdin {"id": "<id>", "cmd": "play", "file": "<abs path>",
|
|
13
|
+
"volume": <0-100>, "title": "<text>"}
|
|
14
|
+
{"id": "<id>", "cmd": "ping"}
|
|
15
|
+
|
|
16
|
+
stdout {"event": "ready"} (once, after startup)
|
|
17
|
+
{"id": "<id>", "ok": true}
|
|
18
|
+
{"id": "<id>", "ok": false, "error": "..."}
|
|
19
|
+
|
|
20
|
+
All human-readable logging goes to **stderr** so that stdout stays a clean
|
|
21
|
+
protocol channel. ``volume`` of 0 (or missing) means "do not change volume",
|
|
22
|
+
matching the existing ``stream.py`` semantics.
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import pyatv
|
|
34
|
+
from pyatv.const import Protocol
|
|
35
|
+
from pyatv.interface import MediaMetadata
|
|
36
|
+
_PYATV_IMPORT_ERROR = None
|
|
37
|
+
except ImportError as ex:
|
|
38
|
+
pyatv = None
|
|
39
|
+
Protocol = None
|
|
40
|
+
MediaMetadata = None
|
|
41
|
+
_PYATV_IMPORT_ERROR = ex
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_LOGGER = logging.getLogger("warm-worker")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _out(obj) -> None:
|
|
48
|
+
"""Write one protocol message to stdout and flush immediately."""
|
|
49
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
50
|
+
sys.stdout.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WarmConnection:
|
|
54
|
+
"""Owns a single, reused pyatv connection to the target device."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, identifier: str, loop: asyncio.AbstractEventLoop) -> None:
|
|
57
|
+
self.identifier = identifier
|
|
58
|
+
self.loop = loop
|
|
59
|
+
self.atv = None
|
|
60
|
+
self._lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
async def _scan(self):
|
|
63
|
+
# Mirror stream.py: scan by 12-hex id / MAC first, else by RAOP name.
|
|
64
|
+
ident_regex = re.compile(r"^[0-9A-Fa-f]{12}$")
|
|
65
|
+
mac_regex = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$")
|
|
66
|
+
atvs = []
|
|
67
|
+
if (len(self.identifier) == 12 and ident_regex.match(self.identifier)) or mac_regex.match(
|
|
68
|
+
self.identifier
|
|
69
|
+
):
|
|
70
|
+
_LOGGER.info("scanning by id/MAC: %s", self.identifier)
|
|
71
|
+
atvs = await pyatv.scan(self.loop, identifier=self.identifier, timeout=5)
|
|
72
|
+
if not atvs:
|
|
73
|
+
_LOGGER.info("scanning by name: %s", self.identifier)
|
|
74
|
+
found = await pyatv.scan(self.loop, protocol=Protocol.RAOP, timeout=5)
|
|
75
|
+
atvs = [a for a in found if a.name == self.identifier]
|
|
76
|
+
if not atvs:
|
|
77
|
+
raise RuntimeError("device not found: %s" % self.identifier)
|
|
78
|
+
return atvs[0]
|
|
79
|
+
|
|
80
|
+
async def ensure_connected(self):
|
|
81
|
+
if self.atv is not None:
|
|
82
|
+
return self.atv
|
|
83
|
+
conf = await self._scan()
|
|
84
|
+
_LOGGER.info("connecting to %s", conf.address)
|
|
85
|
+
self.atv = await pyatv.connect(conf, self.loop)
|
|
86
|
+
_LOGGER.info("connected; holding connection warm")
|
|
87
|
+
return self.atv
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
if self.atv is not None:
|
|
91
|
+
try:
|
|
92
|
+
self.atv.close()
|
|
93
|
+
except Exception: # noqa: BLE001 - best-effort teardown
|
|
94
|
+
pass
|
|
95
|
+
self.atv = None
|
|
96
|
+
|
|
97
|
+
async def _expand(self, file_path: str):
|
|
98
|
+
# Mirror stream.py m3u/m3u8 handling so warm playback matches spawn.
|
|
99
|
+
if file_path.endswith(".m3u") or file_path.endswith(".m3u8"):
|
|
100
|
+
folder = os.path.dirname(file_path)
|
|
101
|
+
with open(file_path, "r", encoding="UTF-8") as playlist:
|
|
102
|
+
lines = [ln.strip() for ln in playlist if ln.strip()]
|
|
103
|
+
return [
|
|
104
|
+
os.path.join(folder, ln)
|
|
105
|
+
for ln in lines
|
|
106
|
+
if re.match(r"^[A-Za-z0-9]", ln)
|
|
107
|
+
and not re.match(r"^[A-Za-z]:\\", ln)
|
|
108
|
+
and not re.match(r"^http[s]?://", ln)
|
|
109
|
+
]
|
|
110
|
+
return [file_path]
|
|
111
|
+
|
|
112
|
+
async def play(self, file_path: str, volume, title: str) -> None:
|
|
113
|
+
"""Stream a file (or m3u) on the warm connection, reconnecting once on error."""
|
|
114
|
+
async with self._lock:
|
|
115
|
+
last_err = None
|
|
116
|
+
for attempt in (1, 2):
|
|
117
|
+
try:
|
|
118
|
+
atv = await self.ensure_connected()
|
|
119
|
+
if volume and int(volume) > 0:
|
|
120
|
+
try:
|
|
121
|
+
await atv.audio.set_volume(float(volume))
|
|
122
|
+
except Exception as ex: # noqa: BLE001
|
|
123
|
+
_LOGGER.warning("set_volume(%s) failed: %s", volume, ex)
|
|
124
|
+
metadata = MediaMetadata(title=title, album=title, artist=None, artwork=None)
|
|
125
|
+
for song in await self._expand(file_path):
|
|
126
|
+
_LOGGER.info("streaming %s (attempt %d)", song, attempt)
|
|
127
|
+
await atv.stream.stream_file(song, metadata)
|
|
128
|
+
_LOGGER.info("finished streaming %s", file_path)
|
|
129
|
+
return
|
|
130
|
+
except Exception as ex: # noqa: BLE001
|
|
131
|
+
last_err = ex
|
|
132
|
+
_LOGGER.error("stream attempt %d failed: %s", attempt, ex)
|
|
133
|
+
self.close() # force a fresh connect on retry
|
|
134
|
+
raise last_err if last_err is not None else RuntimeError("play failed")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _read_line(loop: asyncio.AbstractEventLoop) -> str:
|
|
138
|
+
# Blocking stdin read offloaded to a thread so the asyncio loop stays free.
|
|
139
|
+
return await loop.run_in_executor(None, sys.stdin.readline)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def main_async(identifier: str) -> None:
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
conn = WarmConnection(identifier, loop)
|
|
145
|
+
|
|
146
|
+
# Best-effort warmup; a failure here is non-fatal because the first play
|
|
147
|
+
# will retry the scan/connect itself.
|
|
148
|
+
try:
|
|
149
|
+
await conn.ensure_connected()
|
|
150
|
+
except Exception as ex: # noqa: BLE001
|
|
151
|
+
_LOGGER.warning("initial warmup failed (will retry on first play): %s", ex)
|
|
152
|
+
|
|
153
|
+
_out({"event": "ready"})
|
|
154
|
+
|
|
155
|
+
while True:
|
|
156
|
+
line = await _read_line(loop)
|
|
157
|
+
if line == "": # EOF: parent closed our stdin
|
|
158
|
+
_LOGGER.info("stdin closed, exiting")
|
|
159
|
+
break
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
msg = json.loads(line)
|
|
165
|
+
except Exception: # noqa: BLE001
|
|
166
|
+
_LOGGER.warning("ignoring non-JSON line: %s", line)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
req_id = msg.get("id")
|
|
170
|
+
cmd = msg.get("cmd")
|
|
171
|
+
|
|
172
|
+
if cmd == "ping":
|
|
173
|
+
_out({"id": req_id, "ok": True, "pong": True})
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if cmd == "play":
|
|
177
|
+
file_path = msg.get("file")
|
|
178
|
+
volume = msg.get("volume", 0)
|
|
179
|
+
title = msg.get("title") or (os.path.basename(file_path) if file_path else "")
|
|
180
|
+
if not file_path:
|
|
181
|
+
_out({"id": req_id, "ok": False, "error": "missing file"})
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
await conn.play(file_path, volume, title)
|
|
185
|
+
_out({"id": req_id, "ok": True})
|
|
186
|
+
except Exception as ex: # noqa: BLE001
|
|
187
|
+
_out({"id": req_id, "ok": False, "error": str(ex)})
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
_out({"id": req_id, "ok": False, "error": "unknown cmd: %s" % cmd})
|
|
191
|
+
|
|
192
|
+
conn.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> None:
|
|
196
|
+
parser = argparse.ArgumentParser()
|
|
197
|
+
parser.add_argument("-i", "--id", dest="id", required=True, help="device identifier")
|
|
198
|
+
parser.add_argument("-v", "--verbose", action="store_true", dest="verbose")
|
|
199
|
+
args = parser.parse_args()
|
|
200
|
+
|
|
201
|
+
logging.basicConfig(
|
|
202
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
203
|
+
stream=sys.stderr,
|
|
204
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
205
|
+
format="%(asctime)s %(levelname)s [%(name)s]: %(message)s",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if _PYATV_IMPORT_ERROR is not None:
|
|
209
|
+
_LOGGER.error("Required dependency 'pyatv' was not found. Install it with: pip3 install pyatv")
|
|
210
|
+
_LOGGER.error("Import error: %s", _PYATV_IMPORT_ERROR)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
asyncio.run(main_async(args.id))
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
main()
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@homebridge-plugins/homebridge-homepod-radio",
|
|
3
3
|
"displayName": "Homepod Mini Radio",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.
|
|
5
|
+
"version": "3.2.10",
|
|
6
6
|
"description": "Homebridge accessory for streaming radio to Homepod Mini and Apple TV",
|
|
7
7
|
"author": "Petro Kushchak",
|
|
8
8
|
"license": "MIT",
|