@basmilius/apple-common 0.9.18 → 0.10.0

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/dist/index.mjs CHANGED
@@ -61,6 +61,11 @@ function v4(options, buf, offset) {
61
61
 
62
62
  //#endregion
63
63
  //#region src/airplayFeatures.ts
64
+ /**
65
+ * AirPlay feature flags as a bitmask of bigint values. Each flag corresponds to a specific
66
+ * capability advertised by an AirPlay device in its mDNS TXT record "features" field.
67
+ * The bit positions match Apple's internal AirPlayFeatureFlags enum.
68
+ */
64
69
  const AirPlayFeatureFlags = {
65
70
  SupportsAirPlayVideoV1: 1n << 0n,
66
71
  SupportsAirPlayPhoto: 1n << 1n,
@@ -107,9 +112,21 @@ const AirPlayFeatureFlags = {
107
112
  SupportsAudioMetadataControl: 1n << 60n,
108
113
  SupportsRFC2198Redundancy: 1n << 61n
109
114
  };
115
+ /** Bitmask in the "sf" (status flags) TXT field indicating password protection. */
110
116
  const PASSWORD_BIT = 128n;
117
+ /** Bitmask in the "sf" TXT field indicating legacy pairing (PIN required). */
111
118
  const LEGACY_PAIRING_BIT = 512n;
119
+ /** Bitmask in the "sf" TXT field indicating a PIN is required. */
112
120
  const PIN_REQUIRED_BIT = 8n;
121
+ /**
122
+ * Parses an AirPlay features string into a single bigint bitmask.
123
+ * Features are advertised as either a single hex value or two comma-separated
124
+ * 32-bit hex values (low,high) which are combined into a 64-bit bitmask.
125
+ *
126
+ * @param features - The features string from the mDNS TXT record.
127
+ * @returns The combined feature flags as a bigint.
128
+ * @throws If the features string has an unexpected format.
129
+ */
113
130
  function parseFeatures(features) {
114
131
  const parts = features.split(",").map((part) => part.trim());
115
132
  if (parts.length === 1) return BigInt(parts[0]);
@@ -119,14 +136,36 @@ function parseFeatures(features) {
119
136
  }
120
137
  throw new Error(`Invalid features format: ${features}`);
121
138
  }
139
+ /**
140
+ * Checks whether a specific feature flag is set in a features bitmask.
141
+ *
142
+ * @param features - The combined feature flags bitmask.
143
+ * @param flag - The specific flag to check for.
144
+ * @returns True if the flag is set.
145
+ */
122
146
  function hasFeatureFlag(features, flag) {
123
147
  return (features & flag) !== 0n;
124
148
  }
149
+ /**
150
+ * Returns the names of all feature flags that are set in the given bitmask.
151
+ * Useful for debugging and diagnostics output.
152
+ *
153
+ * @param features - The combined feature flags bitmask.
154
+ * @returns An array of feature flag names that are active.
155
+ */
125
156
  function describeFlags(features) {
126
157
  const result = [];
127
158
  for (const [name, flag] of Object.entries(AirPlayFeatureFlags)) if (hasFeatureFlag(features, flag)) result.push(name);
128
159
  return result;
129
160
  }
161
+ /**
162
+ * Determines the AirPlay protocol version supported by a device based on its
163
+ * mDNS TXT record properties. AirPlay 2 is indicated by the presence of
164
+ * SupportsUnifiedMediaControl or SupportsCoreUtilsPairingAndEncryption flags.
165
+ *
166
+ * @param txt - The key-value properties from the device's mDNS TXT record.
167
+ * @returns 1 for legacy AirPlay, 2 for AirPlay 2.
168
+ */
130
169
  function getProtocolVersion(txt) {
131
170
  const featuresStr = txt.features ?? txt.ft;
132
171
  if (!featuresStr) return 1;
@@ -135,6 +174,14 @@ function getProtocolVersion(txt) {
135
174
  if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsCoreUtilsPairingAndEncryption)) return 2;
136
175
  return 1;
137
176
  }
177
+ /**
178
+ * Determines the pairing requirement for an AirPlay device based on its
179
+ * feature flags and status flags. The hierarchy is:
180
+ * HomeKit pairing > PIN required > Transient (system) pairing > Legacy PIN > None.
181
+ *
182
+ * @param txt - The key-value properties from the device's mDNS TXT record.
183
+ * @returns The pairing requirement type.
184
+ */
138
185
  function getPairingRequirement(txt) {
139
186
  const featuresStr = txt.features ?? txt.ft;
140
187
  if (!featuresStr) return "none";
@@ -146,10 +193,24 @@ function getPairingRequirement(txt) {
146
193
  if ((sf & LEGACY_PAIRING_BIT) !== 0n) return "pin";
147
194
  return "none";
148
195
  }
196
+ /**
197
+ * Checks whether the AirPlay device requires a password to connect.
198
+ * Determined by the "pw" TXT field or the password bit in the "sf" status flags.
199
+ *
200
+ * @param txt - The key-value properties from the device's mDNS TXT record.
201
+ * @returns True if a password is required.
202
+ */
149
203
  function isPasswordRequired(txt) {
150
204
  if (txt.pw === "true") return true;
151
205
  return ((txt.sf ? BigInt(txt.sf) : 0n) & PASSWORD_BIT) !== 0n;
152
206
  }
207
+ /**
208
+ * Checks whether the AirPlay device supports remote control (Hangdog protocol).
209
+ * Only devices with the SupportsHangdogRemoteControl flag can receive HID events.
210
+ *
211
+ * @param txt - The key-value properties from the device's mDNS TXT record.
212
+ * @returns True if remote control is supported (typically Apple TV only).
213
+ */
153
214
  function isRemoteControlSupported(txt) {
154
215
  const featuresStr = txt.features ?? txt.ft;
155
216
  if (!featuresStr) return false;
@@ -158,7 +219,20 @@ function isRemoteControlSupported(txt) {
158
219
 
159
220
  //#endregion
160
221
  //#region src/storage.ts
222
+ /**
223
+ * Builds the composite key for credential storage lookup.
224
+ *
225
+ * @param deviceId - The device identifier.
226
+ * @param protocol - The protocol type.
227
+ * @returns A composite key in the format "deviceId:protocol".
228
+ */
161
229
  const credentialKey = (deviceId, protocol) => `${deviceId}:${protocol}`;
230
+ /**
231
+ * Converts credential buffers to base64 strings for JSON serialization.
232
+ *
233
+ * @param credentials - The credentials to serialize.
234
+ * @returns A JSON-safe serialized form.
235
+ */
162
236
  const serializeCredentials = (credentials) => ({
163
237
  accessoryIdentifier: credentials.accessoryIdentifier,
164
238
  accessoryLongTermPublicKey: credentials.accessoryLongTermPublicKey.toString("base64"),
@@ -166,6 +240,12 @@ const serializeCredentials = (credentials) => ({
166
240
  publicKey: credentials.publicKey.toString("base64"),
167
241
  secretKey: credentials.secretKey.toString("base64")
168
242
  });
243
+ /**
244
+ * Restores credential buffers from base64-encoded strings.
245
+ *
246
+ * @param stored - The serialized credentials from storage.
247
+ * @returns Fully hydrated credentials with Buffer fields.
248
+ */
169
249
  const deserializeCredentials = (stored) => ({
170
250
  accessoryIdentifier: stored.accessoryIdentifier,
171
251
  accessoryLongTermPublicKey: Buffer.from(stored.accessoryLongTermPublicKey, "base64"),
@@ -173,68 +253,150 @@ const deserializeCredentials = (stored) => ({
173
253
  publicKey: Buffer.from(stored.publicKey, "base64"),
174
254
  secretKey: Buffer.from(stored.secretKey, "base64")
175
255
  });
256
+ /**
257
+ * Creates an empty storage data structure with version 1 schema.
258
+ *
259
+ * @returns A fresh empty storage data object.
260
+ */
176
261
  const createEmptyData = () => ({
177
262
  version: 1,
178
263
  devices: {},
179
264
  credentials: {}
180
265
  });
266
+ /**
267
+ * Abstract base class for persistent storage of device registrations and
268
+ * pairing credentials. Subclasses implement the actual load/save mechanism.
269
+ *
270
+ * Credentials are stored keyed by "deviceId:protocol" to support per-protocol
271
+ * pairing (a device can have separate AirPlay and Companion Link credentials).
272
+ */
181
273
  var Storage = class {
182
274
  #data = createEmptyData();
275
+ /** The current storage data. */
183
276
  get data() {
184
277
  return this.#data;
185
278
  }
279
+ /**
280
+ * Replaces the internal data with the given storage data.
281
+ * Used by subclasses during load.
282
+ *
283
+ * @param data - The loaded storage data to set.
284
+ */
186
285
  setData(data) {
187
286
  this.#data = data;
188
287
  }
288
+ /**
289
+ * Retrieves a stored device by its identifier.
290
+ *
291
+ * @param identifier - The device identifier.
292
+ * @returns The stored device, or undefined if not found.
293
+ */
189
294
  getDevice(identifier) {
190
295
  return this.#data.devices[identifier];
191
296
  }
297
+ /**
298
+ * Stores or updates a device registration.
299
+ *
300
+ * @param identifier - The device identifier.
301
+ * @param device - The device data to store.
302
+ */
192
303
  setDevice(identifier, device) {
193
304
  this.#data.devices[identifier] = device;
194
305
  }
306
+ /**
307
+ * Removes a device and all its associated credentials from storage.
308
+ *
309
+ * @param identifier - The device identifier to remove.
310
+ */
195
311
  removeDevice(identifier) {
196
312
  delete this.#data.devices[identifier];
197
313
  for (const key of Object.keys(this.#data.credentials)) if (key.startsWith(`${identifier}:`)) delete this.#data.credentials[key];
198
314
  }
315
+ /**
316
+ * Returns all stored devices.
317
+ *
318
+ * @returns An array of all stored device records.
319
+ */
199
320
  listDevices() {
200
321
  return Object.values(this.#data.devices);
201
322
  }
323
+ /**
324
+ * Retrieves pairing credentials for a device and protocol combination.
325
+ *
326
+ * @param deviceId - The device identifier.
327
+ * @param protocol - The protocol type.
328
+ * @returns The deserialized credentials, or undefined if not found.
329
+ */
202
330
  getCredentials(deviceId, protocol) {
203
331
  const stored = this.#data.credentials[credentialKey(deviceId, protocol)];
204
332
  if (!stored) return;
205
333
  return deserializeCredentials(stored);
206
334
  }
335
+ /**
336
+ * Stores pairing credentials for a device and protocol combination.
337
+ *
338
+ * @param deviceId - The device identifier.
339
+ * @param protocol - The protocol type.
340
+ * @param credentials - The credentials to store.
341
+ */
207
342
  setCredentials(deviceId, protocol, credentials) {
208
343
  this.#data.credentials[credentialKey(deviceId, protocol)] = serializeCredentials(credentials);
209
344
  }
345
+ /**
346
+ * Removes pairing credentials for a device and protocol combination.
347
+ *
348
+ * @param deviceId - The device identifier.
349
+ * @param protocol - The protocol type.
350
+ */
210
351
  removeCredentials(deviceId, protocol) {
211
352
  delete this.#data.credentials[credentialKey(deviceId, protocol)];
212
353
  }
213
354
  };
355
+ /**
356
+ * JSON file-based storage implementation. Persists data to a JSON file on disk,
357
+ * defaulting to `~/.config/apple-protocols/storage.json`.
358
+ */
214
359
  var JsonStorage = class extends Storage {
215
360
  #path;
361
+ /**
362
+ * @param path - Optional custom file path. Defaults to `~/.config/apple-protocols/storage.json`.
363
+ */
216
364
  constructor(path) {
217
365
  super();
218
366
  this.#path = path ?? join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".config", "apple-protocols", "storage.json");
219
367
  }
368
+ /** Loads storage data from the JSON file, if it exists. */
220
369
  async load() {
221
370
  if (!existsSync(this.#path)) return;
222
371
  const raw = readFileSync(this.#path, "utf-8");
223
372
  const json = JSON.parse(raw);
224
373
  if (json.version === 1) this.setData(json);
225
374
  }
375
+ /** Saves the current storage data to the JSON file, creating directories as needed. */
226
376
  async save() {
227
377
  mkdirSync(dirname(this.#path), { recursive: true });
228
378
  writeFileSync(this.#path, JSON.stringify(this.data, null, 2), "utf-8");
229
379
  }
230
380
  };
381
+ /**
382
+ * In-memory storage implementation. Data is not persisted between sessions.
383
+ * Useful for testing or environments without filesystem access.
384
+ */
231
385
  var MemoryStorage = class extends Storage {
386
+ /** No-op: memory storage has nothing to load. */
232
387
  async load() {}
388
+ /** No-op: memory storage has nothing to persist. */
233
389
  async save() {}
234
390
  };
235
391
 
236
392
  //#endregion
237
393
  //#region src/cli.ts
394
+ /**
395
+ * Prompts the user for input via stdin and returns their response.
396
+ *
397
+ * @param message - The message to display before the input cursor.
398
+ * @returns The user's input string.
399
+ */
238
400
  async function prompt(message) {
239
401
  const cli = createInterface({
240
402
  input: process.stdin,
@@ -244,25 +406,173 @@ async function prompt(message) {
244
406
  cli.close();
245
407
  return answer;
246
408
  }
409
+ /**
410
+ * Returns a promise that resolves after the specified delay.
411
+ * Commonly used for timing gaps in HID press/release sequences.
412
+ *
413
+ * @param ms - The delay in milliseconds.
414
+ */
247
415
  async function waitFor(ms) {
248
416
  return new Promise((resolve) => setTimeout(resolve, ms));
249
417
  }
250
418
 
251
419
  //#endregion
252
420
  //#region src/const.ts
421
+ /** Default PIN used for transient (non-persistent) AirPlay pairing sessions. */
253
422
  const AIRPLAY_TRANSIENT_PIN = "3939";
423
+ /** Timeout in milliseconds for HTTP requests during setup and control. */
254
424
  const HTTP_TIMEOUT = 6e3;
425
+ /** Timeout in milliseconds for TCP socket connections during initial connect. */
255
426
  const SOCKET_TIMEOUT = 1e4;
427
+ /** mDNS service type for AirPlay device discovery. */
256
428
  const AIRPLAY_SERVICE = "_airplay._tcp.local";
429
+ /** mDNS service type for Companion Link (remote control protocol) device discovery. */
257
430
  const COMPANION_LINK_SERVICE = "_companion-link._tcp.local";
431
+ /** mDNS service type for RAOP (Remote Audio Output Protocol) device discovery. */
258
432
  const RAOP_SERVICE = "_raop._tcp.local";
259
433
 
434
+ //#endregion
435
+ //#region src/errors.ts
436
+ /**
437
+ * Base error class for all Apple protocol errors.
438
+ * All domain-specific errors in this library extend from this class,
439
+ * enabling consumers to catch any protocol error with a single handler.
440
+ */
441
+ var AppleProtocolError = class extends Error {
442
+ /** @param message - Human-readable description of the error. */
443
+ constructor(message) {
444
+ super(message);
445
+ this.name = "AppleProtocolError";
446
+ }
447
+ };
448
+ /**
449
+ * Thrown when a TCP connection cannot be established or encounters a fatal error.
450
+ * Parent class for more specific connection errors.
451
+ */
452
+ var ConnectionError = class extends AppleProtocolError {
453
+ /** @param message - Human-readable description of the connection failure. */
454
+ constructor(message) {
455
+ super(message);
456
+ this.name = "ConnectionError";
457
+ }
458
+ };
459
+ /** Thrown when a TCP connection attempt exceeds the configured socket timeout. */
460
+ var ConnectionTimeoutError = class extends ConnectionError {
461
+ /** @param message - Optional custom message; defaults to a standard timeout message. */
462
+ constructor(message = "Connection timed out.") {
463
+ super(message);
464
+ this.name = "ConnectionTimeoutError";
465
+ }
466
+ };
467
+ /** Thrown when a TCP connection is closed unexpectedly by the remote end or the OS. */
468
+ var ConnectionClosedError = class extends ConnectionError {
469
+ /** @param message - Optional custom message; defaults to a standard closed message. */
470
+ constructor(message = "Connection closed unexpectedly.") {
471
+ super(message);
472
+ this.name = "ConnectionClosedError";
473
+ }
474
+ };
475
+ /**
476
+ * Thrown when a pairing operation fails during the HAP pair-setup or pair-verify flow.
477
+ * Parent class for authentication and credentials errors.
478
+ */
479
+ var PairingError = class extends AppleProtocolError {
480
+ /** @param message - Human-readable description of the pairing failure. */
481
+ constructor(message) {
482
+ super(message);
483
+ this.name = "PairingError";
484
+ }
485
+ };
486
+ /**
487
+ * Thrown when the accessory's identity verification fails, such as an invalid
488
+ * Ed25519 signature or mismatched accessory identifier during pair-verify.
489
+ */
490
+ var AuthenticationError = class extends PairingError {
491
+ /** @param message - Human-readable description of the authentication failure. */
492
+ constructor(message) {
493
+ super(message);
494
+ this.name = "AuthenticationError";
495
+ }
496
+ };
497
+ /**
498
+ * Thrown when stored credentials are invalid, missing, or incompatible
499
+ * with the accessory (e.g. after a factory reset).
500
+ */
501
+ var CredentialsError = class extends PairingError {
502
+ /** @param message - Human-readable description of the credentials issue. */
503
+ constructor(message) {
504
+ super(message);
505
+ this.name = "CredentialsError";
506
+ }
507
+ };
508
+ /** Thrown when a protocol command fails to execute on the target device. */
509
+ var CommandError = class extends AppleProtocolError {
510
+ /** @param message - Human-readable description of the command failure. */
511
+ constructor(message) {
512
+ super(message);
513
+ this.name = "CommandError";
514
+ }
515
+ };
516
+ /** Thrown when a protocol setup step fails (e.g. RTSP SETUP, AirPlay stream setup). */
517
+ var SetupError = class extends AppleProtocolError {
518
+ /** @param message - Human-readable description of the setup failure. */
519
+ constructor(message) {
520
+ super(message);
521
+ this.name = "SetupError";
522
+ }
523
+ };
524
+ /** Thrown when mDNS device discovery fails or a device cannot be found after retries. */
525
+ var DiscoveryError = class extends AppleProtocolError {
526
+ /** @param message - Human-readable description of the discovery failure. */
527
+ constructor(message) {
528
+ super(message);
529
+ this.name = "DiscoveryError";
530
+ }
531
+ };
532
+ /** Thrown when an encryption or decryption operation fails (e.g. ChaCha20 auth tag mismatch). */
533
+ var EncryptionError = class extends AppleProtocolError {
534
+ /** @param message - Human-readable description of the encryption failure. */
535
+ constructor(message) {
536
+ super(message);
537
+ this.name = "EncryptionError";
538
+ }
539
+ };
540
+ /** Thrown when a response from the accessory is malformed or has an unexpected format. */
541
+ var InvalidResponseError = class extends AppleProtocolError {
542
+ /** @param message - Human-readable description of the invalid response. */
543
+ constructor(message) {
544
+ super(message);
545
+ this.name = "InvalidResponseError";
546
+ }
547
+ };
548
+ /** Thrown when a protocol operation exceeds its expected time limit. */
549
+ var TimeoutError = class extends AppleProtocolError {
550
+ /** @param message - Human-readable description of the timeout. */
551
+ constructor(message) {
552
+ super(message);
553
+ this.name = "TimeoutError";
554
+ }
555
+ };
556
+ /** Thrown when a media playback operation fails on the target device. */
557
+ var PlaybackError = class extends AppleProtocolError {
558
+ /** @param message - Human-readable description of the playback failure. */
559
+ constructor(message) {
560
+ super(message);
561
+ this.name = "PlaybackError";
562
+ }
563
+ };
564
+
260
565
  //#endregion
261
566
  //#region src/mdns.ts
567
+ /** IPv4 multicast address for mDNS (RFC 6762). */
262
568
  const MDNS_ADDRESS = "224.0.0.251";
569
+ /** Standard mDNS port. */
263
570
  const MDNS_PORT = 5353;
571
+ /** Query ID used in outgoing mDNS queries. */
264
572
  const QUERY_ID = 13823;
573
+ /** Maximum number of service queries to pack into a single DNS message. */
265
574
  const SERVICES_PER_MSG = 3;
575
+ /** DNS record type codes used in mDNS queries and responses. */
266
576
  const QueryType = {
267
577
  A: 1,
268
578
  PTR: 12,
@@ -271,6 +581,12 @@ const QueryType = {
271
581
  SRV: 33,
272
582
  ANY: 255
273
583
  };
584
+ /**
585
+ * Encodes a domain name into DNS wire format (length-prefixed labels terminated by 0x00).
586
+ *
587
+ * @param name - The domain name to encode (e.g. "_airplay._tcp.local").
588
+ * @returns A buffer containing the encoded QNAME.
589
+ */
274
590
  const encodeQName = (name) => {
275
591
  const parts = [];
276
592
  const labels = splitServiceName(name);
@@ -282,11 +598,25 @@ const encodeQName = (name) => {
282
598
  parts.push(Buffer.from([0]));
283
599
  return Buffer.concat(parts);
284
600
  };
601
+ /**
602
+ * Splits a service name into DNS labels, handling instance names that may contain dots.
603
+ * For example, "Living Room._airplay._tcp.local" splits into
604
+ * ["Living Room", "_airplay", "_tcp", "local"].
605
+ *
606
+ * @param name - The full service name.
607
+ * @returns An array of DNS labels.
608
+ */
285
609
  const splitServiceName = (name) => {
286
610
  const match = name.match(/\._[a-z]+\._(?:tcp|udp)\.local$/);
287
611
  if (match) return [name.substring(0, match.index), ...match[0].substring(1).split(".")];
288
612
  return name.split(".");
289
613
  };
614
+ /**
615
+ * Encodes a DNS header into a 12-byte buffer.
616
+ *
617
+ * @param header - The header fields to encode.
618
+ * @returns A 12-byte buffer containing the encoded DNS header.
619
+ */
290
620
  const encodeDnsHeader = (header) => {
291
621
  const buf = Buffer.allocUnsafe(12);
292
622
  buf.writeUInt16BE(header.id, 0);
@@ -297,6 +627,14 @@ const encodeDnsHeader = (header) => {
297
627
  buf.writeUInt16BE(header.arcount, 10);
298
628
  return buf;
299
629
  };
630
+ /**
631
+ * Encodes a single DNS question section entry.
632
+ *
633
+ * @param name - The domain name to query.
634
+ * @param qtype - The query type (e.g. PTR, SRV).
635
+ * @param unicastResponse - Whether to request a unicast response (QU bit).
636
+ * @returns A buffer containing the encoded question.
637
+ */
300
638
  const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
301
639
  const qname = encodeQName(name);
302
640
  const suffix = Buffer.allocUnsafe(4);
@@ -304,6 +642,15 @@ const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
304
642
  suffix.writeUInt16BE(unicastResponse ? 32769 : 1, 2);
305
643
  return Buffer.concat([qname, suffix]);
306
644
  };
645
+ /**
646
+ * Creates one or more DNS query packets for the given service types.
647
+ * Services are batched into groups of {@link SERVICES_PER_MSG} per packet.
648
+ *
649
+ * @param services - The mDNS service types to query (e.g. '_airplay._tcp.local').
650
+ * @param qtype - The DNS query type. Defaults to PTR.
651
+ * @param unicastResponse - Whether to set the QU (unicast response) bit.
652
+ * @returns An array of encoded DNS query packets.
653
+ */
307
654
  function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = false) {
308
655
  const packets = [];
309
656
  for (let i = 0; i < services.length; i += SERVICES_PER_MSG) {
@@ -321,10 +668,20 @@ function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = f
321
668
  }
322
669
  return packets;
323
670
  }
671
+ /** Maximum number of pointer jumps allowed during name decompression to prevent infinite loops. */
672
+ const MAX_POINTER_JUMPS = 128;
673
+ /**
674
+ * Decodes a DNS QNAME from a buffer, handling name compression pointers (RFC 1035 section 4.1.4).
675
+ *
676
+ * @param buf - The DNS packet buffer.
677
+ * @param offset - The byte offset where the QNAME starts.
678
+ * @returns A tuple of [decoded name string, next offset after the QNAME].
679
+ */
324
680
  const decodeQName = (buf, offset) => {
325
681
  const labels = [];
326
682
  let currentOffset = offset;
327
683
  let jumped = false;
684
+ let jumps = 0;
328
685
  let returnOffset = offset;
329
686
  while (currentOffset < buf.byteLength) {
330
687
  const length = buf[currentOffset];
@@ -333,6 +690,7 @@ const decodeQName = (buf, offset) => {
333
690
  break;
334
691
  }
335
692
  if ((length & 192) === 192) {
693
+ if (++jumps > MAX_POINTER_JUMPS) break;
336
694
  const pointer = (length & 63) << 8 | buf[currentOffset + 1];
337
695
  if (!jumped) returnOffset = currentOffset + 2;
338
696
  currentOffset = pointer;
@@ -340,12 +698,19 @@ const decodeQName = (buf, offset) => {
340
698
  continue;
341
699
  }
342
700
  currentOffset++;
701
+ if (currentOffset + length > buf.byteLength) break;
343
702
  labels.push(buf.toString("utf-8", currentOffset, currentOffset + length));
344
703
  currentOffset += length;
345
704
  if (!jumped) returnOffset = currentOffset;
346
705
  }
347
706
  return [labels.join("."), returnOffset];
348
707
  };
708
+ /**
709
+ * Decodes a 12-byte DNS header from a buffer.
710
+ *
711
+ * @param buf - The DNS packet buffer (must be at least 12 bytes).
712
+ * @returns The parsed DNS header.
713
+ */
349
714
  const decodeDnsHeader = (buf) => ({
350
715
  id: buf.readUInt16BE(0),
351
716
  flags: buf.readUInt16BE(2),
@@ -354,6 +719,13 @@ const decodeDnsHeader = (buf) => ({
354
719
  nscount: buf.readUInt16BE(8),
355
720
  arcount: buf.readUInt16BE(10)
356
721
  });
722
+ /**
723
+ * Decodes a DNS question section entry.
724
+ *
725
+ * @param buf - The DNS packet buffer.
726
+ * @param offset - The byte offset where the question starts.
727
+ * @returns A tuple of [parsed question, next offset].
728
+ */
357
729
  const decodeQuestion = (buf, offset) => {
358
730
  const [qname, newOffset] = decodeQName(buf, offset);
359
731
  return [{
@@ -362,6 +734,15 @@ const decodeQuestion = (buf, offset) => {
362
734
  qclass: buf.readUInt16BE(newOffset + 2)
363
735
  }, newOffset + 4];
364
736
  };
737
+ /**
738
+ * Decodes a DNS TXT record into key-value pairs. Each entry is a length-prefixed
739
+ * UTF-8 string in "key=value" format.
740
+ *
741
+ * @param buf - The DNS packet buffer.
742
+ * @param offset - The byte offset where the TXT rdata starts.
743
+ * @param length - The total length of the TXT rdata.
744
+ * @returns A record of key-value pairs.
745
+ */
365
746
  const decodeTxtRecord = (buf, offset, length) => {
366
747
  const properties = {};
367
748
  let pos = offset;
@@ -378,6 +759,13 @@ const decodeTxtRecord = (buf, offset, length) => {
378
759
  }
379
760
  return properties;
380
761
  };
762
+ /**
763
+ * Decodes a DNS SRV record containing service priority, weight, port, and target hostname.
764
+ *
765
+ * @param buf - The DNS packet buffer.
766
+ * @param offset - The byte offset where the SRV rdata starts.
767
+ * @returns The parsed SRV record.
768
+ */
381
769
  const decodeSrvRecord = (buf, offset) => {
382
770
  const priority = buf.readUInt16BE(offset);
383
771
  const weight = buf.readUInt16BE(offset + 2);
@@ -390,6 +778,14 @@ const decodeSrvRecord = (buf, offset) => {
390
778
  target
391
779
  };
392
780
  };
781
+ /**
782
+ * Decodes a DNS resource record (answer, authority, or additional section).
783
+ * Dispatches to type-specific decoders for A, AAAA, PTR, SRV, and TXT records.
784
+ *
785
+ * @param buf - The DNS packet buffer.
786
+ * @param offset - The byte offset where the resource record starts.
787
+ * @returns A tuple of [parsed resource record, next offset].
788
+ */
393
789
  const decodeResource = (buf, offset) => {
394
790
  const [qname, nameEnd] = decodeQName(buf, offset);
395
791
  const qtype = buf.readUInt16BE(nameEnd);
@@ -429,6 +825,13 @@ const decodeResource = (buf, offset) => {
429
825
  rdata
430
826
  }, rdOffset + rdLength];
431
827
  };
828
+ /**
829
+ * Decodes a complete DNS response packet into its header, answer records,
830
+ * and additional resource records. Skips question and authority sections.
831
+ *
832
+ * @param buf - The raw DNS response packet.
833
+ * @returns The parsed header, answer records, and additional resource records.
834
+ */
432
835
  const decodeDnsResponse = (buf) => {
433
836
  const header = decodeDnsHeader(buf);
434
837
  let offset = 12;
@@ -458,11 +861,27 @@ const decodeDnsResponse = (buf) => {
458
861
  resources
459
862
  };
460
863
  };
864
+ /**
865
+ * Aggregates DNS records from multiple mDNS responses and resolves them
866
+ * into complete service descriptions. Correlates PTR, SRV, TXT, and A records
867
+ * to produce fully-resolved {@link MdnsService} instances.
868
+ */
461
869
  var ServiceCollector = class {
870
+ /** Maps service types to sets of instance QNAMEs (from PTR records). */
462
871
  #ptrMap = /* @__PURE__ */ new Map();
872
+ /** Maps instance QNAMEs to their SRV records (port and target host). */
463
873
  #srvMap = /* @__PURE__ */ new Map();
874
+ /** Maps instance QNAMEs to their TXT record properties. */
464
875
  #txtMap = /* @__PURE__ */ new Map();
876
+ /** Maps hostnames to IPv4 addresses (from A records). */
465
877
  #addressMap = /* @__PURE__ */ new Map();
878
+ /**
879
+ * Ingests DNS answer and additional resource records, categorizing them
880
+ * by type into the internal maps.
881
+ *
882
+ * @param answers - Answer section records.
883
+ * @param resources - Additional section records.
884
+ */
466
885
  addRecords(answers, resources) {
467
886
  for (const record of [...answers, ...resources]) switch (record.qtype) {
468
887
  case QueryType.PTR: {
@@ -482,6 +901,10 @@ var ServiceCollector = class {
482
901
  break;
483
902
  }
484
903
  }
904
+ /**
905
+ * Resolves all collected records into complete service descriptions.
906
+ * Only returns services where all required data (PTR, SRV with non-zero port, A record) is present.
907
+ */
485
908
  get services() {
486
909
  const results = [];
487
910
  for (const [serviceType, instanceNames] of this.#ptrMap) for (const instanceQName of instanceNames) {
@@ -503,12 +926,20 @@ var ServiceCollector = class {
503
926
  return results;
504
927
  }
505
928
  };
929
+ /** Well-known ports used to wake sleeping Apple devices via TCP SYN. */
506
930
  const WAKE_PORTS$1 = [
507
931
  7e3,
508
932
  3689,
509
933
  49152,
510
934
  32498
511
935
  ];
936
+ /**
937
+ * Sends TCP connection attempts ("knocks") to well-known Apple service ports
938
+ * to wake a sleeping device before querying it.
939
+ *
940
+ * @param address - The IP address of the device to wake.
941
+ * @returns A promise that resolves when all knock attempts complete (success or failure).
942
+ */
512
943
  const knock = (address) => {
513
944
  const promises = WAKE_PORTS$1.map((port) => new Promise((resolve) => {
514
945
  const socket = createConnection({
@@ -531,6 +962,16 @@ const knock = (address) => {
531
962
  }));
532
963
  return Promise.all(promises).then(() => {});
533
964
  };
965
+ /**
966
+ * Performs unicast DNS-SD queries to specific hosts. First wakes the devices
967
+ * via TCP knocking, then repeatedly sends DNS queries via UDP to port 5353 on
968
+ * each host, collecting responses for the specified duration.
969
+ *
970
+ * @param hosts - IP addresses of the specific devices to query.
971
+ * @param services - mDNS service types to discover.
972
+ * @param timeout - Discovery duration in seconds. Defaults to 4.
973
+ * @returns An array of resolved mDNS services.
974
+ */
534
975
  function unicast(hosts, services, timeout = 4) {
535
976
  return new Promise((resolve) => {
536
977
  const queries = createQueryPackets(services);
@@ -568,6 +1009,19 @@ function unicast(hosts, services, timeout = 4) {
568
1009
  });
569
1010
  });
570
1011
  }
1012
+ /**
1013
+ * Performs multicast DNS-SD discovery on the local network. Creates UDP sockets
1014
+ * on all network interfaces, joins the mDNS multicast group (224.0.0.251),
1015
+ * and sends periodic queries for the specified duration.
1016
+ *
1017
+ * Creates two types of sockets:
1018
+ * - One on 0.0.0.0:5353 to receive multicast responses (may fail on some platforms)
1019
+ * - One per local network interface on a random port with multicast membership
1020
+ *
1021
+ * @param services - mDNS service types to discover.
1022
+ * @param timeout - Discovery duration in seconds. Defaults to 4.
1023
+ * @returns An array of resolved mDNS services found on the network.
1024
+ */
571
1025
  function multicast(services, timeout = 4) {
572
1026
  return new Promise((resolve) => {
573
1027
  const collector = new ServiceCollector();
@@ -644,15 +1098,195 @@ function multicast(services, timeout = 4) {
644
1098
  });
645
1099
  }
646
1100
 
1101
+ //#endregion
1102
+ //#region src/reporter.ts
1103
+ /**
1104
+ * Scoped logger instance tagged with a device or component identifier.
1105
+ * All log output is gated by the global {@link reporter} singleton — messages
1106
+ * are only printed when the corresponding debug group is enabled.
1107
+ */
1108
+ var Logger = class {
1109
+ /** The identifier this logger is scoped to. */
1110
+ get id() {
1111
+ return this.#id;
1112
+ }
1113
+ /** ANSI-colored label prefix used in log output. */
1114
+ get label() {
1115
+ return this.#label;
1116
+ }
1117
+ #id;
1118
+ #label;
1119
+ /**
1120
+ * @param id - Identifier used as a prefix in log output (typically a device ID or component name).
1121
+ */
1122
+ constructor(id) {
1123
+ this.#id = id;
1124
+ this.#label = `\u001b[36m[${id}]\u001b[39m`;
1125
+ }
1126
+ /**
1127
+ * Logs a debug-level message (cyan). Only printed when the 'debug' group is enabled.
1128
+ *
1129
+ * @param data - Values to log.
1130
+ */
1131
+ debug(...data) {
1132
+ debug(this.#label, ...data);
1133
+ }
1134
+ /**
1135
+ * Logs an error-level message (red). Only printed when the 'error' group is enabled.
1136
+ *
1137
+ * @param data - Values to log.
1138
+ */
1139
+ error(...data) {
1140
+ error(this.#label, ...data);
1141
+ }
1142
+ /**
1143
+ * Logs an info-level message (green). Only printed when the 'info' group is enabled.
1144
+ *
1145
+ * @param data - Values to log.
1146
+ */
1147
+ info(...data) {
1148
+ info(this.#label, ...data);
1149
+ }
1150
+ /**
1151
+ * Logs a network-level message (yellow). Only printed when the 'net' group is enabled.
1152
+ *
1153
+ * @param data - Values to log.
1154
+ */
1155
+ net(...data) {
1156
+ net(this.#label, ...data);
1157
+ }
1158
+ /**
1159
+ * Logs a raw data message (blue). Only printed when the 'raw' group is enabled.
1160
+ * Typically used for hex dumps and binary protocol data.
1161
+ *
1162
+ * @param data - Values to log.
1163
+ */
1164
+ raw(...data) {
1165
+ raw(this.#label, ...data);
1166
+ }
1167
+ /**
1168
+ * Logs a warning-level message (yellow). Only printed when the 'warn' group is enabled.
1169
+ *
1170
+ * @param data - Values to log.
1171
+ */
1172
+ warn(...data) {
1173
+ warn(this.#label, ...data);
1174
+ }
1175
+ };
1176
+ /**
1177
+ * Global log output controller that manages which debug groups are active.
1178
+ * All {@link Logger} instances check the singleton {@link reporter} before printing.
1179
+ */
1180
+ var Reporter = class {
1181
+ #enabled = [];
1182
+ /** Enables all debug groups (except 'raw' which is very verbose). */
1183
+ all() {
1184
+ this.#enabled = [
1185
+ "debug",
1186
+ "error",
1187
+ "info",
1188
+ "net",
1189
+ "warn"
1190
+ ];
1191
+ }
1192
+ /** Disables all debug groups, silencing all log output. */
1193
+ none() {
1194
+ this.#enabled = [];
1195
+ }
1196
+ /**
1197
+ * Disables a specific debug group.
1198
+ *
1199
+ * @param group - The debug group to disable.
1200
+ */
1201
+ disable(group) {
1202
+ if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
1203
+ }
1204
+ /**
1205
+ * Enables a specific debug group.
1206
+ *
1207
+ * @param group - The debug group to enable.
1208
+ */
1209
+ enable(group) {
1210
+ if (!this.#enabled.includes(group)) this.#enabled.push(group);
1211
+ }
1212
+ /**
1213
+ * Checks whether a specific debug group is currently enabled.
1214
+ *
1215
+ * @param group - The debug group to check.
1216
+ * @returns True if the group is enabled.
1217
+ */
1218
+ isEnabled(group) {
1219
+ return this.#enabled.includes(group);
1220
+ }
1221
+ };
1222
+ /**
1223
+ * Logs a debug-level message if the 'debug' group is enabled.
1224
+ *
1225
+ * @param data - Values to log.
1226
+ */
1227
+ function debug(...data) {
1228
+ reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
1229
+ }
1230
+ /**
1231
+ * Logs an error-level message if the 'error' group is enabled.
1232
+ *
1233
+ * @param data - Values to log.
1234
+ */
1235
+ function error(...data) {
1236
+ reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
1237
+ }
1238
+ /**
1239
+ * Logs an info-level message if the 'info' group is enabled.
1240
+ *
1241
+ * @param data - Values to log.
1242
+ */
1243
+ function info(...data) {
1244
+ reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
1245
+ }
1246
+ /**
1247
+ * Logs a network-level message if the 'net' group is enabled.
1248
+ *
1249
+ * @param data - Values to log.
1250
+ */
1251
+ function net(...data) {
1252
+ reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
1253
+ }
1254
+ /**
1255
+ * Logs a raw data message if the 'raw' group is enabled.
1256
+ *
1257
+ * @param data - Values to log.
1258
+ */
1259
+ function raw(...data) {
1260
+ reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
1261
+ }
1262
+ /**
1263
+ * Logs a warning-level message if the 'warn' group is enabled.
1264
+ *
1265
+ * @param data - Values to log.
1266
+ */
1267
+ function warn(...data) {
1268
+ reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
1269
+ }
1270
+ /** Global reporter singleton controlling which debug groups produce output. */
1271
+ const reporter = new Reporter();
1272
+
647
1273
  //#endregion
648
1274
  //#region src/discovery.ts
1275
+ /** Cache time-to-live in milliseconds. */
649
1276
  const CACHE_TTL = 3e4;
1277
+ /** Ports used for wake-on-network "knocking" to wake sleeping Apple devices. */
650
1278
  const WAKE_PORTS = [
651
1279
  7e3,
652
1280
  3689,
653
1281
  49152,
654
1282
  32498
655
1283
  ];
1284
+ /**
1285
+ * Converts a raw mDNS service record into a {@link DiscoveryResult}.
1286
+ *
1287
+ * @param service - The mDNS service record.
1288
+ * @returns A normalized discovery result.
1289
+ */
656
1290
  const toDiscoveryResult = (service) => {
657
1291
  const txt = service.properties;
658
1292
  const featuresStr = txt.features ?? txt.ft;
@@ -675,6 +1309,12 @@ const toDiscoveryResult = (service) => {
675
1309
  packet: null
676
1310
  };
677
1311
  };
1312
+ /**
1313
+ * Safely parses a features string, returning undefined on failure.
1314
+ *
1315
+ * @param features - The features string to parse.
1316
+ * @returns The parsed feature bitmask, or undefined if parsing fails.
1317
+ */
678
1318
  const tryParseFeatures = (features) => {
679
1319
  try {
680
1320
  return parseFeatures(features);
@@ -682,12 +1322,32 @@ const tryParseFeatures = (features) => {
682
1322
  return;
683
1323
  }
684
1324
  };
1325
+ const logger = new Logger("discovery");
1326
+ /**
1327
+ * mDNS service discovery for Apple devices on the local network.
1328
+ *
1329
+ * Supports discovering AirPlay, Companion Link, and RAOP services via multicast DNS.
1330
+ * Results are cached for 30 seconds to avoid excessive network traffic.
1331
+ * Use the static factory methods {@link Discovery.airplay}, {@link Discovery.companionLink},
1332
+ * and {@link Discovery.raop} for convenience.
1333
+ */
685
1334
  var Discovery = class Discovery {
1335
+ /** Shared cache of discovery results, keyed by service type. */
686
1336
  static #cache = /* @__PURE__ */ new Map();
687
1337
  #service;
1338
+ /**
1339
+ * @param service - The mDNS service type to discover (e.g. '_airplay._tcp.local').
1340
+ */
688
1341
  constructor(service) {
689
1342
  this.#service = service;
690
1343
  }
1344
+ /**
1345
+ * Discovers devices advertising this service type via mDNS multicast.
1346
+ * Returns cached results if available and not expired.
1347
+ *
1348
+ * @param useCache - Whether to use cached results. Defaults to true.
1349
+ * @returns An array of discovered devices.
1350
+ */
691
1351
  async find(useCache = true) {
692
1352
  if (useCache) {
693
1353
  const cached = Discovery.#cache.get(this.#service);
@@ -702,22 +1362,37 @@ var Discovery = class Discovery {
702
1362
  });
703
1363
  return mapped;
704
1364
  }
1365
+ /**
1366
+ * Repeatedly searches for a specific device by ID until found or retries are exhausted.
1367
+ * Does not use the cache to ensure fresh results on each attempt.
1368
+ *
1369
+ * @param id - The device ID to search for.
1370
+ * @param tries - Maximum number of discovery attempts. Defaults to 10.
1371
+ * @param timeout - Delay in milliseconds between attempts. Defaults to 1000.
1372
+ * @returns The discovered device.
1373
+ * @throws {DiscoveryError} If the device is not found after all attempts.
1374
+ */
705
1375
  async findUntil(id, tries = 10, timeout = 1e3) {
706
1376
  while (tries > 0) {
707
1377
  const devices = await this.find(false);
708
1378
  const device = devices.find((device) => device.id === id);
709
1379
  if (device) return device;
710
- console.log();
711
- console.log(`Device not found, retrying in ${timeout}ms...`);
712
- console.log(devices.map((d) => ` ● ${d.id} (${d.fqdn})`).join("\n"));
1380
+ logger.debug(`Device '${id}' not found, retrying in ${timeout}ms...`, devices.map((d) => d.id));
713
1381
  tries--;
714
1382
  await waitFor(timeout);
715
1383
  }
716
- throw new Error("Device not found after several tries, aborting.");
1384
+ throw new DiscoveryError(`Device '${id}' not found after several tries, aborting.`);
717
1385
  }
1386
+ /** Clears all cached discovery results across all service types. */
718
1387
  static clearCache() {
719
1388
  Discovery.#cache.clear();
720
1389
  }
1390
+ /**
1391
+ * Attempts to wake a sleeping Apple device by knocking on well-known ports.
1392
+ * Sends TCP connection attempts to ports 7000, 3689, 49152, and 32498.
1393
+ *
1394
+ * @param address - The IP address of the device to wake.
1395
+ */
721
1396
  static async wake(address) {
722
1397
  const promises = WAKE_PORTS.map((port) => new Promise((resolve) => {
723
1398
  const socket = createConnection({
@@ -740,6 +1415,13 @@ var Discovery = class Discovery {
740
1415
  }));
741
1416
  await Promise.all(promises);
742
1417
  }
1418
+ /**
1419
+ * Discovers all Apple devices on the network across all supported service types
1420
+ * (AirPlay, Companion Link, RAOP) and merges them by device ID.
1421
+ *
1422
+ * @returns An array of combined results, each representing a single physical device
1423
+ * with its available service endpoints.
1424
+ */
743
1425
  static async discoverAll() {
744
1426
  const allServices = await multicast([
745
1427
  AIRPLAY_SERVICE,
@@ -765,12 +1447,27 @@ var Discovery = class Discovery {
765
1447
  }
766
1448
  return [...devices.values()];
767
1449
  }
1450
+ /**
1451
+ * Creates a Discovery instance for AirPlay services.
1452
+ *
1453
+ * @returns A new Discovery instance targeting `_airplay._tcp.local`.
1454
+ */
768
1455
  static airplay() {
769
1456
  return new Discovery(AIRPLAY_SERVICE);
770
1457
  }
1458
+ /**
1459
+ * Creates a Discovery instance for Companion Link services.
1460
+ *
1461
+ * @returns A new Discovery instance targeting `_companion-link._tcp.local`.
1462
+ */
771
1463
  static companionLink() {
772
1464
  return new Discovery(COMPANION_LINK_SERVICE);
773
1465
  }
1466
+ /**
1467
+ * Creates a Discovery instance for RAOP services.
1468
+ *
1469
+ * @returns A new Discovery instance targeting `_raop._tcp.local`.
1470
+ */
774
1471
  static raop() {
775
1472
  return new Discovery(RAOP_SERVICE);
776
1473
  }
@@ -778,23 +1475,42 @@ var Discovery = class Discovery {
778
1475
 
779
1476
  //#endregion
780
1477
  //#region src/connection.ts
1478
+ /** No-op promise handler used as a fallback when no connect promise is active. */
781
1479
  const NOOP_PROMISE_HANDLER = {
782
1480
  resolve: () => {},
783
1481
  reject: (_) => {}
784
1482
  };
1483
+ /**
1484
+ * TCP socket connection wrapper with built-in retry logic and typed events.
1485
+ *
1486
+ * Manages a single TCP socket to an Apple device, providing automatic reconnection
1487
+ * on failure (configurable attempts and interval), keep-alive, and no-delay settings.
1488
+ * Subclasses can extend the event map with protocol-specific events.
1489
+ *
1490
+ * Default retry behavior: 3 attempts with 3-second intervals between retries.
1491
+ */
785
1492
  var Connection = class extends EventEmitter {
1493
+ /** The remote IP address this connection targets. */
786
1494
  get address() {
787
1495
  return this.#address;
788
1496
  }
1497
+ /** The shared context carrying device identity and logger. */
789
1498
  get context() {
790
1499
  return this.#context;
791
1500
  }
1501
+ /** The remote port this connection targets. */
792
1502
  get port() {
793
1503
  return this.#port;
794
1504
  }
1505
+ /** Whether the connection is currently established and open. */
795
1506
  get isConnected() {
796
1507
  return this.#state === "connected";
797
1508
  }
1509
+ /** The local IP address of the socket, or '0.0.0.0' if not connected. */
1510
+ get localAddress() {
1511
+ return this.#socket?.localAddress ?? "0.0.0.0";
1512
+ }
1513
+ /** The current connection state, derived from both internal state and socket readyState. */
798
1514
  get state() {
799
1515
  if (this.#state === "closing" || this.#state === "failed") return this.#state;
800
1516
  if (!this.#socket) return "disconnected";
@@ -807,7 +1523,6 @@ var Connection = class extends EventEmitter {
807
1523
  #address;
808
1524
  #port;
809
1525
  #context;
810
- #emitInternal = (event, ...args) => this.emit(event, ...args);
811
1526
  #debug = false;
812
1527
  #retryAttempt = 0;
813
1528
  #retryAttempts = 3;
@@ -817,23 +1532,45 @@ var Connection = class extends EventEmitter {
817
1532
  #socket;
818
1533
  #state;
819
1534
  #connectPromise;
1535
+ /**
1536
+ * @param context - Shared context with device identity and logger.
1537
+ * @param address - The remote IP address to connect to.
1538
+ * @param port - The remote port to connect to.
1539
+ */
820
1540
  constructor(context, address, port) {
821
1541
  super();
822
1542
  this.#address = address;
823
1543
  this.#port = port;
824
1544
  this.#context = context;
825
1545
  this.#state = "disconnected";
1546
+ this.onClose = this.onClose.bind(this);
1547
+ this.onConnect = this.onConnect.bind(this);
1548
+ this.onData = this.onData.bind(this);
1549
+ this.onEnd = this.onEnd.bind(this);
1550
+ this.onError = this.onError.bind(this);
1551
+ this.onTimeout = this.onTimeout.bind(this);
826
1552
  }
1553
+ /**
1554
+ * Establishes a TCP connection to the remote address. If already connected,
1555
+ * returns immediately. Enables retry logic for the duration of this connection.
1556
+ *
1557
+ * @throws {ConnectionError} If already connecting or all retry attempts are exhausted.
1558
+ */
827
1559
  async connect() {
828
1560
  if (this.#state === "connected") return;
829
- if (this.#state === "connecting") throw new Error("A connection is already being established.");
1561
+ if (this.#state === "connecting") throw new ConnectionError("A connection is already being established.");
830
1562
  this.#retryEnabled = true;
831
1563
  this.#retryAttempt = 0;
832
1564
  return this.#attemptConnect();
833
1565
  }
1566
+ /** Immediately destroys the underlying socket without graceful shutdown. */
834
1567
  destroy() {
835
1568
  this.#socket?.destroy();
836
1569
  }
1570
+ /**
1571
+ * Gracefully disconnects by ending the socket and waiting for the 'close' event.
1572
+ * Disables retry logic so the connection does not automatically reconnect.
1573
+ */
837
1574
  async disconnect() {
838
1575
  if (this.#retryTimeout) {
839
1576
  clearTimeout(this.#retryTimeout);
@@ -841,6 +1578,10 @@ var Connection = class extends EventEmitter {
841
1578
  }
842
1579
  this.#retryEnabled = false;
843
1580
  if (!this.#socket || this.#state === "disconnected") return;
1581
+ if (this.#socket.destroyed || this.#socket.readyState === "closed") {
1582
+ this.#cleanup();
1583
+ return;
1584
+ }
844
1585
  return new Promise((resolve) => {
845
1586
  this.#state = "closing";
846
1587
  this.#socket.once("close", () => {
@@ -850,18 +1591,37 @@ var Connection = class extends EventEmitter {
850
1591
  this.#socket.end();
851
1592
  });
852
1593
  }
1594
+ /**
1595
+ * Enables or disables debug logging for incoming data (hex + ASCII dumps).
1596
+ *
1597
+ * @param enabled - Whether to enable debug output.
1598
+ * @returns This connection instance for chaining.
1599
+ */
853
1600
  debug(enabled) {
854
1601
  this.#debug = enabled;
855
1602
  return this;
856
1603
  }
1604
+ /**
1605
+ * Configures the retry behavior for connection attempts.
1606
+ *
1607
+ * @param attempts - Maximum number of retry attempts.
1608
+ * @param interval - Delay in milliseconds between retry attempts.
1609
+ * @returns This connection instance for chaining.
1610
+ */
857
1611
  retry(attempts, interval = 3e3) {
858
1612
  this.#retryAttempts = attempts;
859
1613
  this.#retryInterval = interval;
860
1614
  return this;
861
1615
  }
1616
+ /**
1617
+ * Writes data to the underlying TCP socket.
1618
+ * Emits an error event if the socket is not writable.
1619
+ *
1620
+ * @param data - The data to send.
1621
+ */
862
1622
  write(data) {
863
1623
  if (!this.#socket || this.state !== "connected" || !this.#socket.writable) {
864
- this.#emitInternal("error", /* @__PURE__ */ new Error("Cannot write to a disconnected connection."));
1624
+ this.#emitInternal("error", new ConnectionClosedError("Cannot write to a disconnected connection."));
865
1625
  return;
866
1626
  }
867
1627
  this.#socket.write(data, (err) => {
@@ -870,6 +1630,10 @@ var Connection = class extends EventEmitter {
870
1630
  this.#emitInternal("error", err);
871
1631
  });
872
1632
  }
1633
+ /**
1634
+ * Creates a new socket and attempts to connect. The returned promise resolves
1635
+ * on successful connect or rejects after all retries are exhausted.
1636
+ */
873
1637
  async #attemptConnect() {
874
1638
  return new Promise((resolve, reject) => {
875
1639
  this.#state = "connecting";
@@ -878,16 +1642,17 @@ var Connection = class extends EventEmitter {
878
1642
  reject
879
1643
  };
880
1644
  this.#socket?.removeAllListeners();
1645
+ this.#socket?.destroy();
881
1646
  this.#socket = void 0;
882
1647
  this.#socket = new Socket();
883
1648
  this.#socket.setNoDelay(true);
884
1649
  this.#socket.setTimeout(SOCKET_TIMEOUT);
885
- this.#socket.on("close", this.#onClose.bind(this));
886
- this.#socket.on("connect", this.#onConnect.bind(this));
887
- this.#socket.on("data", this.#onData.bind(this));
888
- this.#socket.on("end", this.#onEnd.bind(this));
889
- this.#socket.on("error", this.#onError.bind(this));
890
- this.#socket.on("timeout", this.#onTimeout.bind(this));
1650
+ this.#socket.on("close", this.onClose);
1651
+ this.#socket.on("connect", this.onConnect);
1652
+ this.#socket.on("data", this.onData);
1653
+ this.#socket.on("end", this.onEnd);
1654
+ this.#socket.on("error", this.onError);
1655
+ this.#socket.on("timeout", this.onTimeout);
891
1656
  this.#context.logger.net(`Connecting to ${this.#address}:${this.#port}...`);
892
1657
  this.#socket.connect({
893
1658
  host: this.#address,
@@ -895,6 +1660,7 @@ var Connection = class extends EventEmitter {
895
1660
  });
896
1661
  });
897
1662
  }
1663
+ /** Removes all socket listeners, destroys the socket, and resets connection state. */
898
1664
  #cleanup() {
899
1665
  if (this.#retryTimeout) {
900
1666
  clearTimeout(this.#retryTimeout);
@@ -908,6 +1674,12 @@ var Connection = class extends EventEmitter {
908
1674
  this.#state = "disconnected";
909
1675
  this.#connectPromise = void 0;
910
1676
  }
1677
+ /**
1678
+ * Schedules a retry attempt after a failed connection. If all attempts are exhausted,
1679
+ * transitions to 'failed' state and rejects the original connect promise.
1680
+ *
1681
+ * @param err - The error that triggered the retry.
1682
+ */
911
1683
  #scheduleRetry(err) {
912
1684
  if (!this.#retryEnabled || this.#retryAttempt >= this.#retryAttempts) {
913
1685
  this.#state = "failed";
@@ -932,19 +1704,31 @@ var Connection = class extends EventEmitter {
932
1704
  };
933
1705
  await this.#attemptConnect();
934
1706
  resolve();
935
- } catch (retryErr) {}
1707
+ } catch (retryErr) {
1708
+ reject(retryErr instanceof Error ? retryErr : new ConnectionError(String(retryErr)));
1709
+ }
936
1710
  }, this.#retryInterval);
937
1711
  }
938
- #onClose(hadError) {
1712
+ /**
1713
+ * Handles the socket 'close' event. If the connection was active and closed
1714
+ * unexpectedly with an error, triggers a retry.
1715
+ *
1716
+ * @param hadError - Whether the close was caused by an error.
1717
+ */
1718
+ onClose(hadError) {
939
1719
  const wasConnected = this.#state === "connected";
940
1720
  if (this.#state !== "closing") {
941
1721
  this.#state = "disconnected";
942
1722
  this.#context.logger.net(`Connection closed (${hadError ? "with error" : "normally"}).`);
943
1723
  }
944
1724
  this.#emitInternal("close", hadError);
945
- if (wasConnected && this.#retryEnabled && hadError) this.#scheduleRetry(/* @__PURE__ */ new Error("Connection closed unexpectedly."));
1725
+ if (wasConnected && this.#retryEnabled && hadError) this.#scheduleRetry(new ConnectionClosedError());
946
1726
  }
947
- #onConnect() {
1727
+ /**
1728
+ * Handles successful TCP connection. Enables keep-alive (10s interval),
1729
+ * disables the connection timeout, resets retry counter, and resolves the connect promise.
1730
+ */
1731
+ onConnect() {
948
1732
  this.#state = "connected";
949
1733
  this.#retryAttempt = 0;
950
1734
  this.#socket.setKeepAlive(true, 1e4);
@@ -953,7 +1737,13 @@ var Connection = class extends EventEmitter {
953
1737
  this.#connectPromise?.resolve();
954
1738
  this.#connectPromise = void 0;
955
1739
  }
956
- #onData(data) {
1740
+ /**
1741
+ * Handles incoming data from the socket. When debug mode is enabled,
1742
+ * logs a hex and ASCII dump of the first 64 bytes.
1743
+ *
1744
+ * @param data - The received data buffer.
1745
+ */
1746
+ onData(data) {
957
1747
  if (this.#debug) {
958
1748
  const cutoff = Math.min(data.byteLength, 64);
959
1749
  this.#context.logger.debug(`Received ${data.byteLength} bytes of data.`);
@@ -962,19 +1752,29 @@ var Connection = class extends EventEmitter {
962
1752
  }
963
1753
  this.#emitInternal("data", data);
964
1754
  }
965
- #onEnd() {
1755
+ /** Handles the socket 'end' event (remote end sent FIN). */
1756
+ onEnd() {
966
1757
  this.#emitInternal("end");
967
1758
  }
968
- #onError(err) {
1759
+ /**
1760
+ * Handles socket errors. If connecting, schedules a retry; otherwise marks
1761
+ * the connection as failed. Warns if no error listener is registered.
1762
+ *
1763
+ * @param err - The socket error.
1764
+ */
1765
+ onError(err) {
969
1766
  this.#context.logger.error(`Connection error: ${err.message}`);
970
1767
  if (this.listenerCount("error") > 0) this.#emitInternal("error", err);
971
- else this.#context.logger.warn("No error handler registered. This is likely a bug.", this.constructor.name, "#onError");
1768
+ else this.#context.logger.warn("No error handler registered. This is likely a bug.", this.constructor.name, "onError");
972
1769
  if (this.#state === "connecting") this.#scheduleRetry(err);
973
- else this.#state = "failed";
974
1770
  }
975
- #onTimeout() {
1771
+ /**
1772
+ * Handles socket timeout. If connecting, schedules a retry;
1773
+ * otherwise destroys the socket and marks the connection as failed.
1774
+ */
1775
+ onTimeout() {
976
1776
  this.#context.logger.error("Connection timed out.");
977
- const err = /* @__PURE__ */ new Error("Connection timed out.");
1777
+ const err = new ConnectionTimeoutError();
978
1778
  this.#emitInternal("timeout");
979
1779
  if (this.#state === "connecting") this.#scheduleRetry(err);
980
1780
  else {
@@ -982,21 +1782,60 @@ var Connection = class extends EventEmitter {
982
1782
  this.#socket?.destroy();
983
1783
  }
984
1784
  }
1785
+ /**
1786
+ * Type-safe internal emit that narrows the event to ConnectionEventMap keys.
1787
+ * Needed because the merged TEventMap makes the standard emit signature too broad.
1788
+ *
1789
+ * @param event - The event name to emit.
1790
+ * @param args - The event arguments.
1791
+ * @returns Whether any listeners were called.
1792
+ */
1793
+ #emitInternal(event, ...args) {
1794
+ return this.emit(event, ...args);
1795
+ }
985
1796
  };
1797
+ /**
1798
+ * Connection subclass that adds optional ChaCha20-Poly1305 encryption.
1799
+ * Once encryption is enabled, subclasses use the {@link EncryptionState}
1800
+ * to encrypt outgoing data and decrypt incoming data with per-message nonce counters.
1801
+ */
986
1802
  var EncryptionAwareConnection = class extends Connection {
1803
+ /** Whether encryption has been enabled on this connection. */
987
1804
  get isEncrypted() {
988
1805
  return !!this._encryption;
989
1806
  }
1807
+ /** The current encryption state, or undefined if encryption is not enabled. */
990
1808
  _encryption;
1809
+ /**
1810
+ * Enables ChaCha20-Poly1305 encryption for this connection.
1811
+ * After calling this, all subsequent data must be encrypted/decrypted
1812
+ * using the provided keys.
1813
+ *
1814
+ * @param readKey - The 32-byte key for decrypting incoming data.
1815
+ * @param writeKey - The 32-byte key for encrypting outgoing data.
1816
+ */
991
1817
  enableEncryption(readKey, writeKey) {
992
1818
  this._encryption = new EncryptionState(readKey, writeKey);
993
1819
  }
994
1820
  };
1821
+ /**
1822
+ * Holds the symmetric encryption keys and nonce counters for a single
1823
+ * encrypted connection. Each message increments the corresponding counter
1824
+ * to ensure unique nonces for ChaCha20-Poly1305.
1825
+ */
995
1826
  var EncryptionState = class {
1827
+ /** The 32-byte key used to decrypt incoming data. */
996
1828
  readKey;
1829
+ /** Monotonically increasing counter used as part of the read nonce. */
997
1830
  readCount;
1831
+ /** The 32-byte key used to encrypt outgoing data. */
998
1832
  writeKey;
1833
+ /** Monotonically increasing counter used as part of the write nonce. */
999
1834
  writeCount;
1835
+ /**
1836
+ * @param readKey - The 32-byte decryption key.
1837
+ * @param writeKey - The 32-byte encryption key.
1838
+ */
1000
1839
  constructor(readKey, writeKey) {
1001
1840
  this.readCount = 0;
1002
1841
  this.readKey = readKey;
@@ -1005,98 +1844,50 @@ var EncryptionState = class {
1005
1844
  }
1006
1845
  };
1007
1846
 
1008
- //#endregion
1009
- //#region src/reporter.ts
1010
- var Logger = class {
1011
- get id() {
1012
- return this.#id;
1013
- }
1014
- get label() {
1015
- return this.#label;
1016
- }
1017
- #id;
1018
- #label;
1019
- constructor(id) {
1020
- this.#id = id;
1021
- this.#label = `\u001b[36m[${id}]\u001b[39m`;
1022
- }
1023
- debug(...data) {
1024
- debug(this.#label, ...data);
1025
- }
1026
- error(...data) {
1027
- error(this.#label, ...data);
1028
- }
1029
- info(...data) {
1030
- info(this.#label, ...data);
1031
- }
1032
- net(...data) {
1033
- net(this.#label, ...data);
1034
- }
1035
- raw(...data) {
1036
- raw(this.#label, ...data);
1037
- }
1038
- warn(...data) {
1039
- warn(this.#label, ...data);
1040
- }
1041
- };
1042
- var Reporter = class {
1043
- #enabled = [];
1044
- all() {
1045
- this.#enabled = [
1046
- "debug",
1047
- "error",
1048
- "info",
1049
- "net",
1050
- "raw",
1051
- "warn"
1052
- ];
1053
- }
1054
- none() {
1055
- this.#enabled = [];
1056
- }
1057
- disable(group) {
1058
- if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
1059
- }
1060
- enable(group) {
1061
- if (!this.#enabled.includes(group)) this.#enabled.push(group);
1062
- }
1063
- isEnabled(group) {
1064
- return this.#enabled.includes(group);
1065
- }
1066
- };
1067
- function debug(...data) {
1068
- reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
1069
- }
1070
- function error(...data) {
1071
- reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
1072
- }
1073
- function info(...data) {
1074
- reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
1075
- }
1076
- function net(...data) {
1077
- reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
1078
- }
1079
- function raw(...data) {
1080
- reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
1081
- }
1082
- function warn(...data) {
1083
- reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
1084
- }
1085
- const reporter = new Reporter();
1086
-
1087
1847
  //#endregion
1088
1848
  //#region src/context.ts
1849
+ /** Default identity mimicking an iPhone running the Apple TV Remote app. */
1850
+ const DEFAULT_IDENTITY = {
1851
+ name: "apple-protocols",
1852
+ model: "iPhone17,3",
1853
+ osName: "iPhone OS",
1854
+ osVersion: "26.3",
1855
+ osBuildVersion: "25D63",
1856
+ sourceVersion: "935.7.1",
1857
+ applicationBundleIdentifier: "com.apple.TVRemote",
1858
+ applicationBundleVersion: "700"
1859
+ };
1860
+ /**
1861
+ * Shared context for a device connection, carrying the device identifier,
1862
+ * the client identity presented to the accessory, and a scoped logger.
1863
+ * Passed through the entire protocol stack for consistent identification and logging.
1864
+ */
1089
1865
  var Context = class {
1866
+ /** Unique identifier of the target device (typically its mDNS hostname). */
1090
1867
  get deviceId() {
1091
1868
  return this.#deviceId;
1092
1869
  }
1870
+ /** The identity this client presents to the Apple device. */
1871
+ get identity() {
1872
+ return this.#identity;
1873
+ }
1874
+ /** Scoped logger instance tagged with the device identifier. */
1093
1875
  get logger() {
1094
1876
  return this.#logger;
1095
1877
  }
1096
1878
  #deviceId;
1879
+ #identity;
1097
1880
  #logger;
1098
- constructor(deviceId) {
1881
+ /**
1882
+ * @param deviceId - Unique identifier of the target device.
1883
+ * @param identity - Optional partial override of the default device identity.
1884
+ */
1885
+ constructor(deviceId, identity) {
1099
1886
  this.#deviceId = deviceId;
1887
+ this.#identity = {
1888
+ ...DEFAULT_IDENTITY,
1889
+ ...identity
1890
+ };
1100
1891
  this.#logger = new Logger(deviceId);
1101
1892
  }
1102
1893
  };
@@ -3125,14 +3916,30 @@ var require_srp = /* @__PURE__ */ __commonJSMin(((exports) => {
3125
3916
  //#endregion
3126
3917
  //#region src/pairing.ts
3127
3918
  var import_srp = require_srp();
3919
+ /**
3920
+ * Abstract base class for HAP pairing operations. Provides shared TLV8
3921
+ * decoding with automatic error checking.
3922
+ */
3128
3923
  var BasePairing = class {
3924
+ /** The shared context carrying device identity and logger. */
3129
3925
  get context() {
3130
3926
  return this.#context;
3131
3927
  }
3132
3928
  #context;
3929
+ /**
3930
+ * @param context - Shared context for logging and device identity.
3931
+ */
3133
3932
  constructor(context) {
3134
3933
  this.#context = context;
3135
3934
  }
3935
+ /**
3936
+ * Decodes a TLV8 buffer and checks for error codes. If the TLV contains
3937
+ * an error value, it throws via TLV8.bail.
3938
+ *
3939
+ * @param buffer - The raw TLV8-encoded response.
3940
+ * @returns A map of TLV type numbers to their buffer values.
3941
+ * @throws If the TLV contains an error value.
3942
+ */
3136
3943
  tlv(buffer) {
3137
3944
  const data = TLV8.decode(buffer);
3138
3945
  if (data.has(TLV8.Value.Error)) TLV8.bail(data);
@@ -3152,23 +3959,43 @@ var BasePairing = class {
3152
3959
  * For transient pairing (HomePod), only M1-M4 are needed — no long-term credentials are stored.
3153
3960
  */
3154
3961
  var AccessoryPair = class extends BasePairing {
3962
+ /** Human-readable name sent to the accessory during pairing. */
3155
3963
  #name;
3964
+ /** Unique pairing identifier (UUID) for this controller. */
3156
3965
  #pairingId;
3966
+ /** Protocol-specific callback for sending TLV8-encoded pair-setup requests. */
3157
3967
  #requestHandler;
3968
+ /** Ed25519 public key for long-term identity. */
3158
3969
  #publicKey;
3970
+ /** Ed25519 secret key for signing identity proofs. */
3159
3971
  #secretKey;
3972
+ /** SRP client instance used for PIN-based authentication. */
3160
3973
  #srp;
3974
+ /**
3975
+ * @param context - Shared context for logging and device identity.
3976
+ * @param requestHandler - Callback that sends TLV8-encoded data and returns the response.
3977
+ */
3161
3978
  constructor(context, requestHandler) {
3162
3979
  super(context);
3163
3980
  this.#name = "basmilius/apple-protocols";
3164
3981
  this.#pairingId = Buffer.from(v4().toUpperCase());
3165
3982
  this.#requestHandler = requestHandler;
3166
3983
  }
3984
+ /** Generates a new Ed25519 key pair for long-term identity. Must be called before pin() or transient(). */
3167
3985
  async start() {
3168
3986
  const keyPair = Ed25519.generateKeyPair();
3169
3987
  this.#publicKey = Buffer.from(keyPair.publicKey);
3170
3988
  this.#secretKey = Buffer.from(keyPair.secretKey);
3171
3989
  }
3990
+ /**
3991
+ * Performs the full PIN-based pair-setup flow (M1 through M6).
3992
+ * Prompts for a PIN via the provided callback, then completes SRP authentication
3993
+ * and Ed25519 credential exchange with the accessory.
3994
+ *
3995
+ * @param askPin - Async callback that returns the user-entered PIN.
3996
+ * @returns Long-term credentials for future pair-verify sessions.
3997
+ * @throws {PairingError} If any step fails.
3998
+ */
3172
3999
  async pin(askPin) {
3173
4000
  const m1 = await this.m1();
3174
4001
  const m2 = await this.m2(m1, await askPin());
@@ -3176,9 +4003,18 @@ var AccessoryPair = class extends BasePairing {
3176
4003
  const m4 = await this.m4(m3);
3177
4004
  const m5 = await this.m5(m4);
3178
4005
  const m6 = await this.m6(m4, m5);
3179
- if (!m6) throw new Error("Pairing failed, could not get accessory keys.");
4006
+ if (!m6) throw new PairingError("Pairing failed, could not get accessory keys.");
3180
4007
  return m6;
3181
4008
  }
4009
+ /**
4010
+ * Performs the transient (non-persistent) pair-setup flow (M1 through M4 only).
4011
+ * Uses the default transient PIN ('3939'). No long-term credentials are stored;
4012
+ * session keys are derived directly from the SRP shared secret.
4013
+ *
4014
+ * Primarily used for HomePod connections where persistent pairing is not required.
4015
+ *
4016
+ * @returns Session keys for the current connection (not reusable across sessions).
4017
+ */
3182
4018
  async transient() {
3183
4019
  const m1 = await this.m1([[TLV8.Value.Flags, TLV8.Flags.TransientPairing]]);
3184
4020
  const m2 = await this.m2(m1);
@@ -3337,7 +4173,7 @@ var AccessoryPair = class extends BasePairing {
3337
4173
  accessoryIdentifier,
3338
4174
  accessoryLongTermPublicKey
3339
4175
  ]);
3340
- if (!Ed25519.verify(accessoryInfo, accessorySignature, accessoryLongTermPublicKey)) throw new Error("Invalid accessory signature.");
4176
+ if (!Ed25519.verify(accessoryInfo, accessorySignature, accessoryLongTermPublicKey)) throw new AuthenticationError("Invalid accessory signature.");
3341
4177
  return {
3342
4178
  accessoryIdentifier: accessoryIdentifier.toString(),
3343
4179
  accessoryLongTermPublicKey,
@@ -3357,13 +4193,28 @@ var AccessoryPair = class extends BasePairing {
3357
4193
  * → M3 (controller proof) → M4 (session key derivation)
3358
4194
  */
3359
4195
  var AccessoryVerify = class extends BasePairing {
4196
+ /** Ephemeral Curve25519 key pair for forward-secrecy ECDH exchange. */
3360
4197
  #ephemeralKeyPair;
4198
+ /** Protocol-specific callback for sending TLV8-encoded pair-verify requests. */
3361
4199
  #requestHandler;
4200
+ /**
4201
+ * @param context - Shared context for logging and device identity.
4202
+ * @param requestHandler - Callback that sends TLV8-encoded data and returns the response.
4203
+ */
3362
4204
  constructor(context, requestHandler) {
3363
4205
  super(context);
3364
4206
  this.#ephemeralKeyPair = Curve25519.generateKeyPair();
3365
4207
  this.#requestHandler = requestHandler;
3366
4208
  }
4209
+ /**
4210
+ * Performs the full pair-verify flow (M1 through M4) using stored credentials
4211
+ * from a previous pair-setup. Establishes a new session with forward secrecy
4212
+ * via ephemeral Curve25519 key exchange.
4213
+ *
4214
+ * @param credentials - Long-term credentials from a previous pair-setup.
4215
+ * @returns Session keys derived from the ECDH shared secret.
4216
+ * @throws {AuthenticationError} If the accessory's identity cannot be verified.
4217
+ */
3367
4218
  async start(credentials) {
3368
4219
  const m1 = await this.#m1();
3369
4220
  const m2 = await this.#m2(credentials.accessoryIdentifier, credentials.accessoryLongTermPublicKey, m1);
@@ -3410,13 +4261,13 @@ var AccessoryVerify = class extends BasePairing {
3410
4261
  const tlv = TLV8.decode(data);
3411
4262
  const accessoryIdentifier = tlv.get(TLV8.Value.Identifier);
3412
4263
  const accessorySignature = tlv.get(TLV8.Value.Signature);
3413
- if (accessoryIdentifier.toString() !== localAccessoryIdentifier) throw new Error(`Invalid accessory identifier. Expected ${accessoryIdentifier.toString()} to be ${localAccessoryIdentifier}.`);
4264
+ if (accessoryIdentifier.toString() !== localAccessoryIdentifier) throw new AuthenticationError(`Invalid accessory identifier. Expected ${accessoryIdentifier.toString()} to be ${localAccessoryIdentifier}.`);
3414
4265
  const accessoryInfo = Buffer.concat([
3415
4266
  m1.serverPublicKey,
3416
4267
  accessoryIdentifier,
3417
4268
  this.#ephemeralKeyPair.publicKey
3418
4269
  ]);
3419
- if (!Ed25519.verify(accessoryInfo, accessorySignature, longTermPublicKey)) throw new Error("Invalid accessory signature.");
4270
+ if (!Ed25519.verify(accessoryInfo, accessorySignature, longTermPublicKey)) throw new AuthenticationError("Invalid accessory signature.");
3420
4271
  return {
3421
4272
  serverEphemeralPublicKey: m1.serverPublicKey,
3422
4273
  sessionKey,
@@ -3461,11 +4312,173 @@ var AccessoryVerify = class extends BasePairing {
3461
4312
 
3462
4313
  //#endregion
3463
4314
  //#region src/symbols.ts
4315
+ /**
4316
+ * Unique symbol used as a key for accessing encryption state on connection instances.
4317
+ * Provides type-safe access to internal encryption fields without exposing them publicly.
4318
+ */
3464
4319
  const ENCRYPTION = Symbol();
3465
4320
 
4321
+ //#endregion
4322
+ //#region src/recovery.ts
4323
+ /**
4324
+ * Manages automatic connection recovery with exponential backoff.
4325
+ *
4326
+ * When an unexpected disconnect occurs, this class schedules retry attempts
4327
+ * with configurable delay (exponential backoff by default: base=1s, max=30s).
4328
+ * Emits 'recovering', 'recovered', and 'failed' events for monitoring.
4329
+ *
4330
+ * Optionally supports a periodic reconnect interval for proactive
4331
+ * connection health checks (failures are silent; unexpected disconnects
4332
+ * trigger the full recovery flow).
4333
+ */
4334
+ var ConnectionRecovery = class extends EventEmitter {
4335
+ #options;
4336
+ #attempt = 0;
4337
+ #errors = [];
4338
+ #isRecovering = false;
4339
+ #isScheduledReconnecting = false;
4340
+ #retryTimeout;
4341
+ #reconnectInterval;
4342
+ #disposed = false;
4343
+ /**
4344
+ * @param options - Recovery configuration including the reconnect callback.
4345
+ */
4346
+ constructor(options) {
4347
+ super();
4348
+ this.#options = {
4349
+ maxAttempts: 3,
4350
+ baseDelay: 1e3,
4351
+ maxDelay: 3e4,
4352
+ useExponentialBackoff: true,
4353
+ reconnectInterval: 0,
4354
+ ...options
4355
+ };
4356
+ if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
4357
+ }
4358
+ /** Whether a recovery attempt is currently in progress. */
4359
+ get isRecovering() {
4360
+ return this.#isRecovering;
4361
+ }
4362
+ /** The current retry attempt number (0 when not recovering). */
4363
+ get attempt() {
4364
+ return this.#attempt;
4365
+ }
4366
+ /**
4367
+ * Called when a disconnect is detected. Only triggers recovery for
4368
+ * unexpected disconnects; intentional disconnects are ignored.
4369
+ *
4370
+ * @param unexpected - Whether the disconnect was unexpected (e.g. socket error).
4371
+ */
4372
+ handleDisconnect(unexpected) {
4373
+ if (this.#disposed || !unexpected) return;
4374
+ this.#stopReconnectInterval();
4375
+ this.#recover();
4376
+ }
4377
+ /** Resets the recovery state and restarts the periodic reconnect interval if configured. */
4378
+ reset() {
4379
+ this.#attempt = 0;
4380
+ this.#errors = [];
4381
+ this.#isRecovering = false;
4382
+ if (this.#retryTimeout) {
4383
+ clearTimeout(this.#retryTimeout);
4384
+ this.#retryTimeout = void 0;
4385
+ }
4386
+ if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
4387
+ }
4388
+ /** Permanently disposes this recovery instance, cancelling all timers and removing listeners. */
4389
+ dispose() {
4390
+ this.#disposed = true;
4391
+ this.#isRecovering = false;
4392
+ this.#isScheduledReconnecting = false;
4393
+ if (this.#retryTimeout) {
4394
+ clearTimeout(this.#retryTimeout);
4395
+ this.#retryTimeout = void 0;
4396
+ }
4397
+ this.#stopReconnectInterval();
4398
+ this.removeAllListeners();
4399
+ }
4400
+ /**
4401
+ * Initiates a recovery attempt. If max attempts are reached, emits 'failed'
4402
+ * with all collected errors. Otherwise, schedules a delayed reconnect.
4403
+ */
4404
+ #recover() {
4405
+ if (this.#isRecovering || this.#disposed) return;
4406
+ if (this.#attempt >= this.#options.maxAttempts) {
4407
+ this.emit("failed", this.#errors);
4408
+ return;
4409
+ }
4410
+ this.#isRecovering = true;
4411
+ this.#attempt++;
4412
+ this.emit("recovering", this.#attempt);
4413
+ const delay = this.#calculateDelay();
4414
+ this.#retryTimeout = setTimeout(async () => {
4415
+ this.#retryTimeout = void 0;
4416
+ try {
4417
+ await this.#options.onReconnect();
4418
+ this.#isRecovering = false;
4419
+ this.#attempt = 0;
4420
+ this.#errors = [];
4421
+ this.emit("recovered");
4422
+ if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
4423
+ } catch (err) {
4424
+ this.#isRecovering = false;
4425
+ this.#errors.push(err instanceof Error ? err : new Error(String(err)));
4426
+ this.#recover();
4427
+ }
4428
+ }, delay);
4429
+ }
4430
+ /**
4431
+ * Calculates the delay before the next retry attempt.
4432
+ * Uses exponential backoff (base * 2^(attempt-1)) capped at maxDelay,
4433
+ * or a flat baseDelay if exponential backoff is disabled.
4434
+ *
4435
+ * @returns The delay in milliseconds.
4436
+ */
4437
+ #calculateDelay() {
4438
+ if (!this.#options.useExponentialBackoff) return this.#options.baseDelay;
4439
+ const delay = this.#options.baseDelay * Math.pow(2, this.#attempt - 1);
4440
+ return Math.min(delay, this.#options.maxDelay);
4441
+ }
4442
+ /**
4443
+ * Starts the periodic reconnect interval. Silently calls onReconnect at the
4444
+ * configured interval; failures do not trigger recovery (that happens via handleDisconnect).
4445
+ */
4446
+ #startReconnectInterval() {
4447
+ this.#stopReconnectInterval();
4448
+ this.#reconnectInterval = setInterval(async () => {
4449
+ if (this.#isRecovering || this.#disposed || this.#isScheduledReconnecting) return;
4450
+ this.#isScheduledReconnecting = true;
4451
+ try {
4452
+ await this.#options.onReconnect();
4453
+ } catch (_) {} finally {
4454
+ this.#isScheduledReconnecting = false;
4455
+ }
4456
+ }, this.#options.reconnectInterval);
4457
+ }
4458
+ /** Stops the periodic reconnect interval timer. */
4459
+ #stopReconnectInterval() {
4460
+ if (this.#reconnectInterval) {
4461
+ clearInterval(this.#reconnectInterval);
4462
+ this.#reconnectInterval = void 0;
4463
+ }
4464
+ }
4465
+ };
4466
+
3466
4467
  //#endregion
3467
4468
  //#region src/timing.ts
4469
+ /**
4470
+ * NTP timing server for AirPlay audio synchronization.
4471
+ *
4472
+ * Apple devices send NTP timing requests to synchronize their clocks with the
4473
+ * controller during audio streaming. This server responds with the current
4474
+ * wall-clock time using NTP packet format, enabling the device to calculate
4475
+ * the correct playback offset for synchronized multi-room audio.
4476
+ *
4477
+ * Must use wall-clock time (Date.now), not monotonic time (process.hrtime),
4478
+ * because NTP timestamps are anchored to the Unix epoch.
4479
+ */
3468
4480
  var TimingServer = class {
4481
+ /** The UDP port the timing server is listening on, or 0 if not yet bound. */
3469
4482
  get port() {
3470
4483
  return this.#port;
3471
4484
  }
@@ -3475,37 +4488,71 @@ var TimingServer = class {
3475
4488
  constructor() {
3476
4489
  this.#logger = new Logger("timing-server");
3477
4490
  this.#socket = createSocket("udp4");
3478
- this.#socket.on("connect", this.#onConnect.bind(this));
3479
- this.#socket.on("error", this.#onError.bind(this));
3480
- this.#socket.on("message", this.#onMessage.bind(this));
3481
- }
4491
+ this.onConnect = this.onConnect.bind(this);
4492
+ this.onError = this.onError.bind(this);
4493
+ this.onMessage = this.onMessage.bind(this);
4494
+ this.#socket.on("connect", this.onConnect);
4495
+ this.#socket.on("error", this.onError);
4496
+ this.#socket.on("message", this.onMessage);
4497
+ }
4498
+ /** Closes the UDP socket and resets the port. */
3482
4499
  close() {
3483
4500
  this.#socket.close();
3484
4501
  this.#port = 0;
3485
4502
  }
4503
+ /**
4504
+ * Binds the UDP socket to a random available port and starts listening
4505
+ * for NTP timing requests.
4506
+ *
4507
+ * @throws If the socket fails to bind.
4508
+ */
3486
4509
  listen() {
3487
4510
  return new Promise((resolve, reject) => {
3488
- this.#socket.once("error", reject);
3489
- this.#socket.once("listening", () => {
3490
- this.#socket.removeListener("error", reject);
4511
+ const onError = (err) => {
4512
+ this.#socket.removeListener("listening", onListening);
4513
+ reject(err);
4514
+ };
4515
+ const onListening = () => {
4516
+ this.#socket.removeListener("error", onError);
3491
4517
  this.#onListening();
3492
4518
  resolve();
3493
- });
3494
- this.#socket.bind(0, resolve);
4519
+ };
4520
+ this.#socket.once("error", onError);
4521
+ this.#socket.once("listening", onListening);
4522
+ this.#socket.bind(0);
3495
4523
  });
3496
4524
  }
3497
- #onConnect() {
4525
+ /**
4526
+ * Handles the socket 'connect' event by configuring buffer sizes.
4527
+ */
4528
+ onConnect() {
3498
4529
  this.#socket.setRecvBufferSize(16384);
3499
4530
  this.#socket.setSendBufferSize(16384);
3500
4531
  }
3501
- #onError(err) {
4532
+ /**
4533
+ * Handles socket errors by logging them.
4534
+ *
4535
+ * @param err - The error that occurred.
4536
+ */
4537
+ onError(err) {
3502
4538
  this.#logger.error("Timing server error", err);
3503
4539
  }
4540
+ /**
4541
+ * Records the bound port after the socket starts listening.
4542
+ */
3504
4543
  #onListening() {
3505
4544
  const { port } = this.#socket.address();
3506
4545
  this.#port = port;
3507
4546
  }
3508
- #onMessage(data, info) {
4547
+ /**
4548
+ * Handles incoming NTP timing requests from Apple devices.
4549
+ * Decodes the request, captures the current NTP timestamp, and sends back
4550
+ * a response with reference, receive, and send timestamps populated.
4551
+ *
4552
+ * @param data - The raw UDP packet data.
4553
+ * @param info - Remote address information of the sender.
4554
+ */
4555
+ onMessage(data, info) {
3509
4556
  try {
3510
4557
  const request = NTP.decode(data);
3511
4558
  const ntp = NTP.now();
@@ -3535,15 +4582,36 @@ var TimingServer = class {
3535
4582
 
3536
4583
  //#endregion
3537
4584
  //#region src/utils.ts
4585
+ /**
4586
+ * Generates a random Active-Remote identifier for DACP (Digital Audio Control Protocol).
4587
+ * Used in RTSP headers to identify the remote controller.
4588
+ *
4589
+ * @returns A random 32-bit unsigned integer as a decimal string.
4590
+ */
3538
4591
  function generateActiveRemoteId() {
3539
4592
  return Math.floor(Math.random() * 2 ** 32).toString(10);
3540
4593
  }
4594
+ /**
4595
+ * Generates a random DACP-ID for identifying this controller in DACP sessions.
4596
+ *
4597
+ * @returns A random 64-bit integer as an uppercase hexadecimal string.
4598
+ */
3541
4599
  function generateDacpId() {
3542
4600
  return Math.floor(Math.random() * 2 ** 64).toString(16).toUpperCase();
3543
4601
  }
4602
+ /**
4603
+ * Generates a random session identifier for RTSP and AirPlay sessions.
4604
+ *
4605
+ * @returns A random 32-bit unsigned integer as a decimal string.
4606
+ */
3544
4607
  function generateSessionId() {
3545
4608
  return Math.floor(Math.random() * 2 ** 32).toString(10);
3546
4609
  }
4610
+ /**
4611
+ * Finds the first non-internal IPv4 address on this machine.
4612
+ *
4613
+ * @returns The local IPv4 address, or null if none is found.
4614
+ */
3547
4615
  function getLocalIP() {
3548
4616
  const interfaces = networkInterfaces();
3549
4617
  for (const iface of Object.values(interfaces)) {
@@ -3555,6 +4623,11 @@ function getLocalIP() {
3555
4623
  }
3556
4624
  return null;
3557
4625
  }
4626
+ /**
4627
+ * Finds the first non-internal MAC address on this machine.
4628
+ *
4629
+ * @returns The MAC address in uppercase colon-separated format, or '00:00:00:00:00:00' if none is found.
4630
+ */
3558
4631
  function getMacAddress() {
3559
4632
  const interfaces = networkInterfaces();
3560
4633
  for (const iface of Object.values(interfaces)) {
@@ -3566,17 +4639,41 @@ function getMacAddress() {
3566
4639
  }
3567
4640
  return "00:00:00:00:00:00";
3568
4641
  }
4642
+ /**
4643
+ * Generates a cryptographically random 32-bit unsigned integer.
4644
+ *
4645
+ * @returns A random unsigned 32-bit integer.
4646
+ */
3569
4647
  function randomInt32() {
3570
4648
  return randomBytes(4).readUInt32BE(0);
3571
4649
  }
4650
+ /**
4651
+ * Generates a cryptographically random 64-bit unsigned integer.
4652
+ *
4653
+ * @returns A random unsigned 64-bit bigint.
4654
+ */
3572
4655
  function randomInt64() {
3573
4656
  return randomBytes(8).readBigUint64LE(0);
3574
4657
  }
4658
+ /**
4659
+ * Encodes a 16-bit unsigned integer into a big-endian buffer.
4660
+ *
4661
+ * @param value - The 16-bit unsigned integer to encode.
4662
+ * @returns A 2-byte buffer containing the value in big-endian byte order.
4663
+ */
3575
4664
  function uint16ToBE(value) {
3576
4665
  const buffer = Buffer.allocUnsafe(2);
3577
4666
  buffer.writeUInt16BE(value, 0);
3578
4667
  return buffer;
3579
4668
  }
4669
+ /**
4670
+ * Encodes a 53-bit unsigned integer into an 8-byte little-endian buffer.
4671
+ * Useful for encoding JavaScript-safe integers into 64-bit wire formats.
4672
+ *
4673
+ * @param value - The unsigned integer to encode (must be in range [0, 2^53-1]).
4674
+ * @returns An 8-byte buffer containing the value in little-endian byte order.
4675
+ * @throws If the value is out of range or not an integer.
4676
+ */
3580
4677
  function uint53ToLE(value) {
3581
4678
  const [upper, lower] = splitUInt53(value);
3582
4679
  const buffer = Buffer.allocUnsafe(8);
@@ -3584,6 +4681,13 @@ function uint53ToLE(value) {
3584
4681
  buffer.writeUInt32LE(upper, 4);
3585
4682
  return buffer;
3586
4683
  }
4684
+ /**
4685
+ * Splits a 53-bit unsigned integer into upper and lower 32-bit halves.
4686
+ *
4687
+ * @param number - The integer to split (must be in range [0, 2^53-1]).
4688
+ * @returns A tuple of [upper32, lower32].
4689
+ * @throws If the number is out of range or not an integer.
4690
+ */
3587
4691
  function splitUInt53(number) {
3588
4692
  const MAX_UINT32 = 4294967295;
3589
4693
  if (number <= -1 || number > 9007199254740991) throw new Error("Number out of range.");
@@ -3596,4 +4700,131 @@ function splitUInt53(number) {
3596
4700
  }
3597
4701
 
3598
4702
  //#endregion
3599
- export { AIRPLAY_SERVICE, AIRPLAY_TRANSIENT_PIN, AccessoryPair, AccessoryVerify, AirPlayFeatureFlags, COMPANION_LINK_SERVICE, Connection, Context, Discovery, ENCRYPTION, EncryptionAwareConnection, EncryptionState, HTTP_TIMEOUT, JsonStorage, MemoryStorage, RAOP_SERVICE, Storage, TimingServer, describeFlags, generateActiveRemoteId, generateDacpId, generateSessionId, getLocalIP, getMacAddress, getPairingRequirement, getProtocolVersion, hasFeatureFlag, isPasswordRequired, isRemoteControlSupported, multicast as mdnsMulticast, unicast as mdnsUnicast, parseFeatures, prompt, randomInt32, randomInt64, reporter, uint16ToBE, uint53ToLE, v4 as uuid, waitFor };
4703
+ //#region src/deviceModel.ts
4704
+ /** Known Apple device models that can be discovered and controlled via AirPlay/Companion Link. */
4705
+ let DeviceModel = /* @__PURE__ */ function(DeviceModel) {
4706
+ DeviceModel[DeviceModel["Unknown"] = 0] = "Unknown";
4707
+ DeviceModel[DeviceModel["AppleTVGen2"] = 1] = "AppleTVGen2";
4708
+ DeviceModel[DeviceModel["AppleTVGen3"] = 2] = "AppleTVGen3";
4709
+ DeviceModel[DeviceModel["AppleTVHD"] = 3] = "AppleTVHD";
4710
+ DeviceModel[DeviceModel["AppleTV4K"] = 4] = "AppleTV4K";
4711
+ DeviceModel[DeviceModel["AppleTV4KGen2"] = 5] = "AppleTV4KGen2";
4712
+ DeviceModel[DeviceModel["AppleTV4KGen3"] = 6] = "AppleTV4KGen3";
4713
+ DeviceModel[DeviceModel["HomePod"] = 10] = "HomePod";
4714
+ DeviceModel[DeviceModel["HomePodMini"] = 11] = "HomePodMini";
4715
+ DeviceModel[DeviceModel["HomePodGen2"] = 12] = "HomePodGen2";
4716
+ DeviceModel[DeviceModel["AirPortExpress"] = 20] = "AirPortExpress";
4717
+ DeviceModel[DeviceModel["AirPortExpressGen2"] = 21] = "AirPortExpressGen2";
4718
+ return DeviceModel;
4719
+ }({});
4720
+ /** High-level device categories derived from the specific device model. */
4721
+ let DeviceType = /* @__PURE__ */ function(DeviceType) {
4722
+ DeviceType[DeviceType["Unknown"] = 0] = "Unknown";
4723
+ DeviceType[DeviceType["AppleTV"] = 1] = "AppleTV";
4724
+ DeviceType[DeviceType["HomePod"] = 2] = "HomePod";
4725
+ DeviceType[DeviceType["AirPort"] = 3] = "AirPort";
4726
+ return DeviceType;
4727
+ }({});
4728
+ /** Maps Apple model identifiers (e.g. "AppleTV6,2") to known device models. */
4729
+ const MODEL_IDENTIFIERS = {
4730
+ "AppleTV2,1": DeviceModel.AppleTVGen2,
4731
+ "AppleTV3,1": DeviceModel.AppleTVGen3,
4732
+ "AppleTV3,2": DeviceModel.AppleTVGen3,
4733
+ "AppleTV5,3": DeviceModel.AppleTVHD,
4734
+ "AppleTV6,2": DeviceModel.AppleTV4K,
4735
+ "AppleTV11,1": DeviceModel.AppleTV4KGen2,
4736
+ "AppleTV14,1": DeviceModel.AppleTV4KGen3,
4737
+ "AudioAccessory1,1": DeviceModel.HomePod,
4738
+ "AudioAccessory1,2": DeviceModel.HomePod,
4739
+ "AudioAccessory5,1": DeviceModel.HomePodMini,
4740
+ "AudioAccessorySingle5,1": DeviceModel.HomePodMini,
4741
+ "AudioAccessory6,1": DeviceModel.HomePodGen2,
4742
+ "AirPort4,107": DeviceModel.AirPortExpress,
4743
+ "AirPort10,115": DeviceModel.AirPortExpressGen2
4744
+ };
4745
+ /** Maps Apple internal code names (e.g. "J305AP") to known device models. */
4746
+ const INTERNAL_NAMES = {
4747
+ "K66AP": DeviceModel.AppleTVGen2,
4748
+ "J33AP": DeviceModel.AppleTVGen3,
4749
+ "J33IAP": DeviceModel.AppleTVGen3,
4750
+ "J42dAP": DeviceModel.AppleTVHD,
4751
+ "J105aAP": DeviceModel.AppleTV4K,
4752
+ "J305AP": DeviceModel.AppleTV4KGen2,
4753
+ "J255AP": DeviceModel.AppleTV4KGen3,
4754
+ "B520AP": DeviceModel.HomePodMini
4755
+ };
4756
+ /** Human-readable display names for each device model. */
4757
+ const MODEL_NAMES = {
4758
+ [DeviceModel.Unknown]: "Unknown",
4759
+ [DeviceModel.AppleTVGen2]: "Apple TV (2nd generation)",
4760
+ [DeviceModel.AppleTVGen3]: "Apple TV (3rd generation)",
4761
+ [DeviceModel.AppleTVHD]: "Apple TV HD",
4762
+ [DeviceModel.AppleTV4K]: "Apple TV 4K (1st generation)",
4763
+ [DeviceModel.AppleTV4KGen2]: "Apple TV 4K (2nd generation)",
4764
+ [DeviceModel.AppleTV4KGen3]: "Apple TV 4K (3rd generation)",
4765
+ [DeviceModel.HomePod]: "HomePod",
4766
+ [DeviceModel.HomePodMini]: "HomePod mini",
4767
+ [DeviceModel.HomePodGen2]: "HomePod (2nd generation)",
4768
+ [DeviceModel.AirPortExpress]: "AirPort Express",
4769
+ [DeviceModel.AirPortExpressGen2]: "AirPort Express (2nd generation)"
4770
+ };
4771
+ /** Maps each device model to its high-level device type category. */
4772
+ const MODEL_TYPES = {
4773
+ [DeviceModel.Unknown]: DeviceType.Unknown,
4774
+ [DeviceModel.AppleTVGen2]: DeviceType.AppleTV,
4775
+ [DeviceModel.AppleTVGen3]: DeviceType.AppleTV,
4776
+ [DeviceModel.AppleTVHD]: DeviceType.AppleTV,
4777
+ [DeviceModel.AppleTV4K]: DeviceType.AppleTV,
4778
+ [DeviceModel.AppleTV4KGen2]: DeviceType.AppleTV,
4779
+ [DeviceModel.AppleTV4KGen3]: DeviceType.AppleTV,
4780
+ [DeviceModel.HomePod]: DeviceType.HomePod,
4781
+ [DeviceModel.HomePodMini]: DeviceType.HomePod,
4782
+ [DeviceModel.HomePodGen2]: DeviceType.HomePod,
4783
+ [DeviceModel.AirPortExpress]: DeviceType.AirPort,
4784
+ [DeviceModel.AirPortExpressGen2]: DeviceType.AirPort
4785
+ };
4786
+ /**
4787
+ * Resolves an Apple model identifier or internal code name to a known device model.
4788
+ *
4789
+ * @param identifier - Model identifier (e.g. "AppleTV6,2") or internal name (e.g. "J305AP").
4790
+ * @returns The matching device model, or {@link DeviceModel.Unknown} if unrecognized.
4791
+ */
4792
+ const lookupDeviceModel = (identifier) => MODEL_IDENTIFIERS[identifier] ?? INTERNAL_NAMES[identifier] ?? DeviceModel.Unknown;
4793
+ /**
4794
+ * Returns the human-readable display name for a device model.
4795
+ *
4796
+ * @param model - The device model to look up.
4797
+ * @returns A display name like "Apple TV 4K (2nd generation)", or "Unknown".
4798
+ */
4799
+ const getDeviceModelName = (model) => MODEL_NAMES[model] ?? "Unknown";
4800
+ /**
4801
+ * Returns the high-level device type category for a device model.
4802
+ *
4803
+ * @param model - The device model to categorize.
4804
+ * @returns The device type (AppleTV, HomePod, AirPort, or Unknown).
4805
+ */
4806
+ const getDeviceType = (model) => MODEL_TYPES[model] ?? DeviceType.Unknown;
4807
+ /**
4808
+ * Checks whether the given model is an Apple TV.
4809
+ *
4810
+ * @param model - The device model to check.
4811
+ * @returns True if the model is any Apple TV generation.
4812
+ */
4813
+ const isAppleTV = (model) => getDeviceType(model) === DeviceType.AppleTV;
4814
+ /**
4815
+ * Checks whether the given model is a HomePod.
4816
+ *
4817
+ * @param model - The device model to check.
4818
+ * @returns True if the model is any HomePod variant.
4819
+ */
4820
+ const isHomePod = (model) => getDeviceType(model) === DeviceType.HomePod;
4821
+ /**
4822
+ * Checks whether the given model is an AirPort Express.
4823
+ *
4824
+ * @param model - The device model to check.
4825
+ * @returns True if the model is any AirPort Express generation.
4826
+ */
4827
+ const isAirPort = (model) => getDeviceType(model) === DeviceType.AirPort;
4828
+
4829
+ //#endregion
4830
+ export { AIRPLAY_SERVICE, AIRPLAY_TRANSIENT_PIN, AccessoryPair, AccessoryVerify, AirPlayFeatureFlags, AppleProtocolError, AuthenticationError, COMPANION_LINK_SERVICE, CommandError, Connection, ConnectionClosedError, ConnectionError, ConnectionRecovery, ConnectionTimeoutError, Context, CredentialsError, DeviceModel, DeviceType, Discovery, DiscoveryError, ENCRYPTION, EncryptionAwareConnection, EncryptionError, EncryptionState, HTTP_TIMEOUT, InvalidResponseError, JsonStorage, MemoryStorage, PairingError, PlaybackError, RAOP_SERVICE, SetupError, Storage, TimeoutError, TimingServer, describeFlags, generateActiveRemoteId, generateDacpId, generateSessionId, getDeviceModelName, getDeviceType, getLocalIP, getMacAddress, getPairingRequirement, getProtocolVersion, hasFeatureFlag, isAirPort, isAppleTV, isHomePod, isPasswordRequired, isRemoteControlSupported, lookupDeviceModel, multicast as mdnsMulticast, unicast as mdnsUnicast, parseFeatures, prompt, randomInt32, randomInt64, reporter, uint16ToBE, uint53ToLE, v4 as uuid, waitFor };