@bharper/atv-js 0.2.3

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.
Files changed (86) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +80 -0
  3. package/dist/airplay/auth.d.ts +24 -0
  4. package/dist/airplay/auth.d.ts.map +1 -0
  5. package/dist/airplay/auth.js +195 -0
  6. package/dist/airplay/auth.js.map +1 -0
  7. package/dist/bplist.d.ts +19 -0
  8. package/dist/bplist.d.ts.map +1 -0
  9. package/dist/bplist.js +141 -0
  10. package/dist/bplist.js.map +1 -0
  11. package/dist/companion/auth.d.ts +34 -0
  12. package/dist/companion/auth.d.ts.map +1 -0
  13. package/dist/companion/auth.js +119 -0
  14. package/dist/companion/auth.js.map +1 -0
  15. package/dist/companion/connection.d.ts +50 -0
  16. package/dist/companion/connection.d.ts.map +1 -0
  17. package/dist/companion/connection.js +170 -0
  18. package/dist/companion/connection.js.map +1 -0
  19. package/dist/companion/keyboard.d.ts +39 -0
  20. package/dist/companion/keyboard.d.ts.map +1 -0
  21. package/dist/companion/keyboard.js +127 -0
  22. package/dist/companion/keyboard.js.map +1 -0
  23. package/dist/companion/pairing_keepalive.d.ts +4 -0
  24. package/dist/companion/pairing_keepalive.d.ts.map +1 -0
  25. package/dist/companion/pairing_keepalive.js +68 -0
  26. package/dist/companion/pairing_keepalive.js.map +1 -0
  27. package/dist/companion/protocol.d.ts +64 -0
  28. package/dist/companion/protocol.d.ts.map +1 -0
  29. package/dist/companion/protocol.js +246 -0
  30. package/dist/companion/protocol.js.map +1 -0
  31. package/dist/companion/remote.d.ts +75 -0
  32. package/dist/companion/remote.d.ts.map +1 -0
  33. package/dist/companion/remote.js +142 -0
  34. package/dist/companion/remote.js.map +1 -0
  35. package/dist/crypto/chacha20.d.ts +27 -0
  36. package/dist/crypto/chacha20.d.ts.map +1 -0
  37. package/dist/crypto/chacha20.js +88 -0
  38. package/dist/crypto/chacha20.js.map +1 -0
  39. package/dist/crypto/hkdf.d.ts +6 -0
  40. package/dist/crypto/hkdf.d.ts.map +1 -0
  41. package/dist/crypto/hkdf.js +45 -0
  42. package/dist/crypto/hkdf.js.map +1 -0
  43. package/dist/index.d.ts +85 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +191 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/mdns.d.ts +20 -0
  48. package/dist/mdns.d.ts.map +1 -0
  49. package/dist/mdns.js +165 -0
  50. package/dist/mdns.js.map +1 -0
  51. package/dist/opack.d.ts +21 -0
  52. package/dist/opack.d.ts.map +1 -0
  53. package/dist/opack.js +350 -0
  54. package/dist/opack.js.map +1 -0
  55. package/dist/pairing/credentials.d.ts +23 -0
  56. package/dist/pairing/credentials.d.ts.map +1 -0
  57. package/dist/pairing/credentials.js +31 -0
  58. package/dist/pairing/credentials.js.map +1 -0
  59. package/dist/pairing/srp.d.ts +54 -0
  60. package/dist/pairing/srp.d.ts.map +1 -0
  61. package/dist/pairing/srp.js +221 -0
  62. package/dist/pairing/srp.js.map +1 -0
  63. package/dist/pairing/tlv.d.ts +26 -0
  64. package/dist/pairing/tlv.d.ts.map +1 -0
  65. package/dist/pairing/tlv.js +68 -0
  66. package/dist/pairing/tlv.js.map +1 -0
  67. package/examples/pair.ts +103 -0
  68. package/examples/remote.ts +212 -0
  69. package/package.json +33 -0
  70. package/src/airplay/auth.ts +207 -0
  71. package/src/bplist.ts +136 -0
  72. package/src/companion/auth.ts +141 -0
  73. package/src/companion/connection.ts +161 -0
  74. package/src/companion/keyboard.ts +155 -0
  75. package/src/companion/pairing_keepalive.ts +75 -0
  76. package/src/companion/protocol.ts +253 -0
  77. package/src/companion/remote.ts +151 -0
  78. package/src/crypto/chacha20.ts +93 -0
  79. package/src/crypto/hkdf.ts +18 -0
  80. package/src/index.ts +248 -0
  81. package/src/mdns.ts +198 -0
  82. package/src/opack.ts +299 -0
  83. package/src/pairing/credentials.ts +44 -0
  84. package/src/pairing/srp.ts +234 -0
  85. package/src/pairing/tlv.ts +64 -0
  86. package/tsconfig.json +19 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ChaCha20-Poly1305 AEAD encryption with counter-based nonces.
3
+ * Uses @noble/ciphers for pure JS implementation (works in Electron).
4
+ */
5
+
6
+ import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
7
+
8
+ const NONCE_LENGTH = 12;
9
+ const AUTH_TAG_LENGTH = 16;
10
+
11
+ export { AUTH_TAG_LENGTH };
12
+
13
+ export class Chacha20Cipher {
14
+ private outKey: Buffer;
15
+ private inKey: Buffer;
16
+ private outCounter = 0;
17
+ private inCounter = 0;
18
+ private nonceLength: number;
19
+
20
+ constructor(outKey: Buffer, inKey: Buffer, nonceLength = 12) {
21
+ this.outKey = outKey;
22
+ this.inKey = inKey;
23
+ this.nonceLength = nonceLength;
24
+ }
25
+
26
+ private padNonce(nonce: Buffer): Buffer {
27
+ if (nonce.length >= NONCE_LENGTH) return nonce;
28
+ const padded = Buffer.alloc(NONCE_LENGTH);
29
+ nonce.copy(padded, NONCE_LENGTH - nonce.length);
30
+ return padded;
31
+ }
32
+
33
+ private getOutNonce(): Buffer {
34
+ const nonce = Buffer.alloc(this.nonceLength);
35
+ writeLECounter(nonce, this.outCounter, this.nonceLength);
36
+ return this.nonceLength !== NONCE_LENGTH ? this.padNonce(nonce) : nonce;
37
+ }
38
+
39
+ private getInNonce(): Buffer {
40
+ const nonce = Buffer.alloc(this.nonceLength);
41
+ writeLECounter(nonce, this.inCounter, this.nonceLength);
42
+ return this.nonceLength !== NONCE_LENGTH ? this.padNonce(nonce) : nonce;
43
+ }
44
+
45
+ encrypt(data: Buffer, nonce?: Buffer, aad?: Buffer): Buffer {
46
+ let useNonce: Buffer;
47
+ if (nonce === undefined) {
48
+ useNonce = this.getOutNonce();
49
+ this.outCounter++;
50
+ } else {
51
+ useNonce = nonce.length < NONCE_LENGTH ? this.padNonce(nonce) : nonce;
52
+ }
53
+
54
+ // Use @noble/ciphers chacha20-poly1305 (pure JS, works in Electron)
55
+ const cipher = chacha20poly1305(this.outKey, useNonce, aad);
56
+ const encrypted = cipher.encrypt(data);
57
+ return Buffer.from(encrypted);
58
+ }
59
+
60
+ decrypt(data: Buffer, nonce?: Buffer, aad?: Buffer): Buffer {
61
+ let useNonce: Buffer;
62
+ if (nonce === undefined) {
63
+ useNonce = this.getInNonce();
64
+ this.inCounter++;
65
+ } else {
66
+ useNonce = nonce.length < NONCE_LENGTH ? this.padNonce(nonce) : nonce;
67
+ }
68
+
69
+ // Use @noble/ciphers chacha20-poly1305 (pure JS, works in Electron)
70
+ const decipher = chacha20poly1305(this.inKey, useNonce, aad);
71
+ const decrypted = decipher.decrypt(data);
72
+ return Buffer.from(decrypted);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 8-byte counter variant: nonce = [4 zero bytes][8-byte LE counter].
78
+ * Used for pairing steps with explicit nonces like "PS-Msg05", "PV-Msg02".
79
+ */
80
+ export class Chacha20Cipher8byteNonce extends Chacha20Cipher {
81
+ constructor(outKey: Buffer, inKey: Buffer) {
82
+ super(outKey, inKey, 8);
83
+ }
84
+ }
85
+
86
+ function writeLECounter(buf: Buffer, counter: number, length: number): void {
87
+ // Use BigInt to avoid JavaScript's 32-bit limit on bit operations
88
+ let c = BigInt(counter);
89
+ for (let i = 0; i < length && i < 8; i++) {
90
+ buf[i] = Number(c & 0xffn);
91
+ c >>= 8n;
92
+ }
93
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * HKDF-SHA512 key derivation.
3
+ * Port of pyatv/auth/hap_srp.py hkdf_expand().
4
+ */
5
+
6
+ import * as crypto from 'crypto';
7
+
8
+ export function hkdfExpand(salt: string, info: string, sharedSecret: Buffer): Buffer {
9
+ return Buffer.from(
10
+ crypto.hkdfSync(
11
+ 'sha512',
12
+ sharedSecret,
13
+ Buffer.from(salt, 'utf-8'),
14
+ Buffer.from(info, 'utf-8'),
15
+ 32
16
+ )
17
+ );
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * atv-js: Apple TV control library.
3
+ * mDNS discovery, AirPlay/Companion pairing, remote control, keyboard input.
4
+ */
5
+
6
+ export { AppleTVDevice, scan } from './mdns';
7
+ export { HapCredentials, Credentials, parseCredentials, serializeCredentials } from './pairing/credentials';
8
+ export { RemoteKey, HidCommand, MediaControlCommand } from './companion/remote';
9
+ export { KeyboardFocusState } from './companion/keyboard';
10
+
11
+ import { AppleTVDevice } from './mdns';
12
+ import { CompanionConnection } from './companion/connection';
13
+ import { CompanionProtocol } from './companion/protocol';
14
+ import { CompanionPairSetupProcedure } from './companion/auth';
15
+ import {
16
+ getCompanionPairingConnection,
17
+ releaseCompanionPairingConnection,
18
+ } from './companion/pairing_keepalive';
19
+ import { SRPAuthHandler } from './pairing/srp';
20
+ import { HapCredentials, Credentials, serializeCredentials, parseCredentials } from './pairing/credentials';
21
+ import {
22
+ AirPlayPairingSession,
23
+ startAirPlayPairing as _startAirPlayPairing,
24
+ finishAirPlayPairing as _finishAirPlayPairing,
25
+ } from './airplay/auth';
26
+ import { sendKeyPress, sendKeyDown, sendKeyUp, RemoteKey } from './companion/remote';
27
+ import {
28
+ getText as _getText,
29
+ setText as _setText,
30
+ getKeyboardFocus as _getKeyboardFocus,
31
+ watchKeyboardFocus as _watchKeyboardFocus,
32
+ KeyboardFocusState,
33
+ } from './companion/keyboard';
34
+ import { FrameType } from './companion/connection';
35
+ import { opackFloat } from './opack';
36
+
37
+ // ---- Pairing Sessions ----
38
+
39
+ export interface PairingSession {
40
+ /** @internal */
41
+ _type: 'airplay' | 'companion';
42
+ /** @internal */
43
+ _airplay?: AirPlayPairingSession;
44
+ /** @internal */
45
+ _companionProtocol?: CompanionProtocol;
46
+ /** @internal */
47
+ _companionProcedure?: CompanionPairSetupProcedure;
48
+ }
49
+
50
+ /**
51
+ * Start AirPlay pairing with a discovered device.
52
+ * This triggers the PIN display on the Apple TV.
53
+ */
54
+ export async function startAirPlayPairing(device: AppleTVDevice): Promise<PairingSession> {
55
+ const session = await _startAirPlayPairing(device.address, device.airplayPort);
56
+ return { _type: 'airplay', _airplay: session };
57
+ }
58
+
59
+ /**
60
+ * Finish AirPlay pairing with the PIN shown on screen.
61
+ */
62
+ export async function finishAirPlayPairing(
63
+ session: PairingSession,
64
+ pin: string,
65
+ displayName?: string,
66
+ ): Promise<HapCredentials> {
67
+ if (session._type !== 'airplay' || !session._airplay) {
68
+ throw new Error('Not an AirPlay pairing session');
69
+ }
70
+ return _finishAirPlayPairing(session._airplay, pin, displayName);
71
+ }
72
+
73
+ /**
74
+ * Start Companion protocol pairing with a discovered device.
75
+ * This triggers a second PIN display on the Apple TV.
76
+ */
77
+ export async function startCompanionPairing(device: AppleTVDevice): Promise<PairingSession> {
78
+ const connection = getCompanionPairingConnection(device.address, device.port);
79
+ const protocol = new CompanionProtocol(connection, null);
80
+ connection.setListener(protocol);
81
+ const srp = new SRPAuthHandler();
82
+ const procedure = new CompanionPairSetupProcedure(protocol, srp);
83
+
84
+ // Start pairing (connects + sends PS_Start)
85
+ await procedure.startPairing();
86
+
87
+ return {
88
+ _type: 'companion',
89
+ _companionProtocol: protocol,
90
+ _companionProcedure: procedure,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Finish Companion pairing with the PIN shown on screen.
96
+ */
97
+ export async function finishCompanionPairing(
98
+ session: PairingSession,
99
+ pin: string,
100
+ displayName?: string,
101
+ ): Promise<HapCredentials> {
102
+ if (session._type !== 'companion' || !session._companionProcedure) {
103
+ throw new Error('Not a Companion pairing session');
104
+ }
105
+ const creds = await session._companionProcedure.finishPairing(pin.trim(), displayName);
106
+
107
+ // Release the pairing connection back to the keep-alive cache
108
+ if (session._companionProtocol) {
109
+ releaseCompanionPairingConnection(session._companionProtocol.connection);
110
+ }
111
+
112
+ return creds;
113
+ }
114
+
115
+ // ---- Connection ----
116
+
117
+ export interface AppleTVConnection {
118
+ protocol: CompanionProtocol;
119
+ device: AppleTVDevice;
120
+ credentials: Credentials;
121
+ /** @internal */
122
+ _keyboardFocusState: KeyboardFocusState;
123
+ }
124
+
125
+ /**
126
+ * Connect to an Apple TV using stored credentials.
127
+ * Performs pair-verify and sets up encrypted Companion channel.
128
+ */
129
+ export async function connect(
130
+ device: AppleTVDevice,
131
+ credentials: Credentials,
132
+ ): Promise<AppleTVConnection> {
133
+ const connection = new CompanionConnection(device.address, device.port);
134
+ const protocol = new CompanionProtocol(connection, credentials.companion);
135
+ connection.setListener(protocol);
136
+
137
+ // Connect, verify credentials, enable encryption
138
+ await protocol.start();
139
+
140
+ // Post-connection initialization (order matters!)
141
+ // 1. Send system info
142
+ // Client ID is stored hex-encoded in credentials, decode to get the actual UUID bytes
143
+ const clientIdHex = credentials.companion.split(':')[3] || '';
144
+ const clientIdBytes = Buffer.from(clientIdHex, 'hex'); // UUID as bytes
145
+ await protocol.sendCommand('_systemInfo', {
146
+ _bf: 0,
147
+ _cf: 512,
148
+ _clFl: 128,
149
+ _i: null,
150
+ _idsID: clientIdBytes,
151
+ _pubID: 'FF:70:79:61:74:76',
152
+ _sf: 256,
153
+ _sv: '170.18',
154
+ model: 'iPhone10,6',
155
+ name: 'atv-js',
156
+ });
157
+
158
+ // 2. Start touch input (must be before _sessionStart)
159
+ await protocol.sendCommand('_touchStart', {
160
+ _height: opackFloat(1000.0),
161
+ _tFl: 0,
162
+ _width: opackFloat(1000.0),
163
+ });
164
+
165
+ // 3. Start a session
166
+ const sessionId = Math.floor(Math.random() * 0xFFFFFFFF);
167
+ await protocol.sendCommand('_sessionStart', {
168
+ _srvT: 'com.apple.tvremoteservices',
169
+ _sid: sessionId,
170
+ });
171
+
172
+ // 4. Start text input session
173
+ await protocol.sendCommand('_tiStart', {});
174
+
175
+ // 5. Subscribe to media control events
176
+ protocol.subscribeEvent('_iMC');
177
+
178
+ const conn: AppleTVConnection = {
179
+ protocol,
180
+ device,
181
+ credentials,
182
+ _keyboardFocusState: KeyboardFocusState.Unknown,
183
+ };
184
+
185
+ // Watch keyboard focus state
186
+ _watchKeyboardFocus(protocol, (state) => {
187
+ conn._keyboardFocusState = state;
188
+ });
189
+
190
+ return conn;
191
+ }
192
+
193
+ // ---- Remote Control ----
194
+
195
+ /**
196
+ * Send a remote control key press (button down + up).
197
+ */
198
+ export async function sendKey(conn: AppleTVConnection, key: RemoteKey | string): Promise<void> {
199
+ return sendKeyPress(conn.protocol, key);
200
+ }
201
+
202
+ export { sendKeyDown, sendKeyUp };
203
+
204
+ // ---- Keyboard ----
205
+
206
+ /**
207
+ * Check if keyboard is currently focused.
208
+ */
209
+ export async function getKeyboardFocusState(conn: AppleTVConnection): Promise<boolean> {
210
+ return _getKeyboardFocus(conn.protocol);
211
+ }
212
+
213
+ /**
214
+ * Get the current text from a focused keyboard field.
215
+ */
216
+ export async function getText(conn: AppleTVConnection): Promise<string | null> {
217
+ return _getText(conn.protocol);
218
+ }
219
+
220
+ /**
221
+ * Set (replace) the text in a focused keyboard field.
222
+ */
223
+ export async function setText(conn: AppleTVConnection, text: string): Promise<void> {
224
+ return _setText(conn.protocol, text);
225
+ }
226
+
227
+ // ---- Connection Management ----
228
+
229
+ /**
230
+ * Set a handler for connection loss events.
231
+ */
232
+ export function onConnectionLost(conn: AppleTVConnection, handler: (error?: Error) => void): void {
233
+ conn.protocol.onConnectionLost = handler;
234
+ }
235
+
236
+ /**
237
+ * Disconnect from the Apple TV.
238
+ */
239
+ export function disconnect(conn: AppleTVConnection): void {
240
+ conn.protocol.close();
241
+ }
242
+
243
+ /**
244
+ * Check if still connected to the Apple TV.
245
+ */
246
+ export function isConnected(conn: AppleTVConnection): boolean {
247
+ return conn.protocol.connection.isConnected;
248
+ }
package/src/mdns.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * mDNS device scanning for Apple TV discovery.
3
+ * Queries for _companion-link._tcp and _airplay._tcp services.
4
+ */
5
+
6
+ import mdns from 'multicast-dns';
7
+
8
+ export interface AppleTVDevice {
9
+ name: string;
10
+ address: string;
11
+ /** Companion protocol port */
12
+ port: number;
13
+ /** AirPlay port */
14
+ airplayPort: number;
15
+ /** Unique device identifier (from mDNS name or properties) */
16
+ identifier: string;
17
+ /** Device model (e.g. "AppleTV6,2") */
18
+ model: string;
19
+ /** Raw mDNS TXT properties */
20
+ properties: Record<string, string>;
21
+ }
22
+
23
+ interface DiscoveredService {
24
+ name: string;
25
+ address: string;
26
+ port: number;
27
+ properties: Record<string, string>;
28
+ }
29
+
30
+ const APPLE_TV_MODELS = new Set([
31
+ 'J33AP',
32
+ 'J33DAP',
33
+ 'J42dAP',
34
+ 'J105aAP',
35
+ 'J305AP',
36
+ 'J255AP',
37
+ ]);
38
+
39
+ function isAppleTvModel(model: string): boolean {
40
+ if (!model) return false;
41
+ if (model.startsWith('J')) return true;
42
+ return APPLE_TV_MODELS.has(model);
43
+ }
44
+
45
+ export async function scan(timeout = 5000, onlyAppleTV = true): Promise<AppleTVDevice[]> {
46
+ return new Promise((resolve) => {
47
+ const browser = mdns();
48
+ const companionServices = new Map<string, DiscoveredService>();
49
+ const airplayServices = new Map<string, DiscoveredService>();
50
+ const deviceInfoModels = new Map<string, string>(); // device name → model from _device-info
51
+ const addressMap = new Map<string, string>();
52
+
53
+ browser.on('response', (response: any) => {
54
+ // Collect A/AAAA records for hostname→IP resolution
55
+ for (const answer of [...(response.answers || []), ...(response.additionals || [])]) {
56
+ if (answer.type === 'A') {
57
+ addressMap.set(answer.name, answer.data);
58
+ }
59
+ }
60
+
61
+ // Process all records (answers + additionals)
62
+ const allRecords = [...(response.answers || []), ...(response.additionals || [])];
63
+
64
+ for (const answer of allRecords) {
65
+ if (answer.type === 'SRV') {
66
+ const name = answer.name;
67
+ const isCompanion = name.endsWith('._companion-link._tcp.local');
68
+ const isAirplay = name.endsWith('._airplay._tcp.local');
69
+
70
+ if (isCompanion || isAirplay) {
71
+ const deviceName = name.split('.')[0];
72
+ const target = answer.data?.target;
73
+ const port = answer.data?.port;
74
+ const ip = addressMap.get(target) || '';
75
+
76
+ const svc: DiscoveredService = {
77
+ name: deviceName,
78
+ address: ip,
79
+ port,
80
+ properties: {},
81
+ };
82
+
83
+ if (isCompanion) {
84
+ companionServices.set(deviceName, svc);
85
+ } else {
86
+ airplayServices.set(deviceName, svc);
87
+ }
88
+ }
89
+ }
90
+
91
+ if (answer.type === 'TXT') {
92
+ const name = answer.name;
93
+ const isCompanion = name.endsWith('._companion-link._tcp.local');
94
+ const isAirplay = name.endsWith('._airplay._tcp.local');
95
+ const isDeviceInfo = name.endsWith('._device-info._tcp.local');
96
+ const deviceName = name.split('.')[0];
97
+
98
+ const props: Record<string, string> = {};
99
+ if (Array.isArray(answer.data)) {
100
+ for (const entry of answer.data) {
101
+ const str = entry instanceof Buffer ? entry.toString('utf-8') : String(entry);
102
+ const eqIdx = str.indexOf('=');
103
+ if (eqIdx >= 0) {
104
+ props[str.substring(0, eqIdx)] = str.substring(eqIdx + 1);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (isCompanion) {
110
+ const existing = companionServices.get(deviceName);
111
+ if (existing) existing.properties = { ...existing.properties, ...props };
112
+ } else if (isAirplay) {
113
+ const existing = airplayServices.get(deviceName);
114
+ if (existing) existing.properties = { ...existing.properties, ...props };
115
+ } else if (isDeviceInfo && props['model']) {
116
+ // _device-info._tcp.local contains the actual device model
117
+ deviceInfoModels.set(deviceName, props['model']);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Also resolve addresses found in SRV targets
123
+ for (const svc of [...companionServices.values(), ...airplayServices.values()]) {
124
+ if (!svc.address) {
125
+ for (const answer of allRecords) {
126
+ if (answer.type === 'A') {
127
+ addressMap.set(answer.name, answer.data);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ });
133
+
134
+ // Send queries for companion, airplay, and device-info services
135
+ const queryServices = () => {
136
+ browser.query([
137
+ { name: '_companion-link._tcp.local', type: 'PTR' },
138
+ { name: '_airplay._tcp.local', type: 'PTR' },
139
+ { name: '_device-info._tcp.local', type: 'PTR' },
140
+ ]);
141
+ };
142
+
143
+ // Send initial query
144
+ queryServices();
145
+
146
+ // Send a second query midway for better discovery
147
+ setTimeout(queryServices, timeout / 2);
148
+
149
+ setTimeout(() => {
150
+ browser.destroy();
151
+
152
+ // Merge companion and airplay services by device name
153
+ const devices: AppleTVDevice[] = [];
154
+ const allNames = new Set([...companionServices.keys(), ...airplayServices.keys()]);
155
+
156
+ for (const name of allNames) {
157
+ const companion = companionServices.get(name);
158
+ const airplay = airplayServices.get(name);
159
+
160
+ // Need at least companion service for remote control
161
+ if (!companion) continue;
162
+
163
+ // Resolve address from either service or address map
164
+ let address = companion.address || airplay?.address || '';
165
+ if (!address) {
166
+ // Try to find address from addressMap
167
+ for (const [hostname, ip] of addressMap) {
168
+ if (hostname.includes(name.replace(/ /g, '-'))) {
169
+ address = ip;
170
+ break;
171
+ }
172
+ }
173
+ }
174
+
175
+ if (!address) continue;
176
+
177
+ const allProps = { ...companion.properties, ...airplay?.properties };
178
+
179
+ // Model comes from _device-info._tcp.local service, not companion/airplay
180
+ const model = deviceInfoModels.get(name) || allProps['model'] || allProps['rpmd'] || '';
181
+
182
+ if (onlyAppleTV && !isAppleTvModel(model)) continue;
183
+
184
+ devices.push({
185
+ name,
186
+ address,
187
+ port: companion.port,
188
+ airplayPort: airplay?.port || 7000,
189
+ identifier: allProps['deviceid'] || allProps['MACAddress'] || name,
190
+ model,
191
+ properties: allProps,
192
+ });
193
+ }
194
+
195
+ resolve(devices);
196
+ }, timeout);
197
+ });
198
+ }