TypeScript implementations of AirPlay 2, RAOP and Companion Link, with shared encoding, encryption and discovery packages.
Most of this code comes from studying network traffic and other implementations of Apple's undocumented protocols. I started the TypeScript rewrite to run it on a Homey Pro without a Python runtime. Claude Code helped with handshake debugging and the 117 protobuf definitions.
- Discover, pair, and control Apple TV and HomePod devices from TypeScript/Node/Bun.
- Stream audio (MP3, FLAC, OGG, WAV) to AirPlay 2 devices, including multi-room.
- Read now-playing state, control playback, manage apps and accounts.
- Use the diagnostics tool for interactive testing and debugging.
bun install
bash build.shbuild.sh builds the libraries and diagnostics in dependency order. Library packages run tsgo --noEmit && tsdown. Diagnostics runs its typechecks and an Electron Vite build.
| Package | Path | Description |
|---|---|---|
@basmilius/apple-encoding |
packages/encoding |
Plist, OPack, TLV8, DAAP, NTP encoding/decoding |
@basmilius/apple-encryption |
packages/encryption |
Ed25519, Curve25519, ChaCha20-Poly1305, HKDF, SRP |
@basmilius/apple-common |
packages/common |
mDNS discovery, HAP pairing (M1-M6 + verify), credential storage |
@basmilius/apple-audio-source |
packages/audio-source |
Audio decoders: MP3, OGG, WAV, FLAC, QOA, PCM, FFmpeg, URL |
@basmilius/apple-rtsp |
packages/rtsp |
RTSP client with encryption support |
@basmilius/apple-airplay |
packages/airplay |
AirPlay 2: control/data/audio/event streams, 117 protobuf definitions |
@basmilius/apple-companion-link |
packages/companion-link |
Companion Link: HID, apps, accounts, power, OPack framing |
@basmilius/apple-raop |
packages/raop |
RAOP audio streaming via RTSP |
@basmilius/apple-sdk |
packages/sdk |
High-level SDK: AppleTV, HomePod, controllers, discovery, pairing |
@basmilius/apple-proxy |
packages/proxy |
Pairing proxy for inspecting and relaying protocol traffic |
@basmilius/apple-diagnostics |
packages/diagnostics |
Electron app for protocol testing and debugging |
@basmilius/apple-sdk
├── @basmilius/apple-airplay
│ ├── @basmilius/apple-rtsp
│ │ └── @basmilius/apple-common
│ ├── @basmilius/apple-common
│ │ ├── @basmilius/apple-encoding
│ │ └── @basmilius/apple-encryption
│ ├── @basmilius/apple-encoding
│ └── @basmilius/apple-encryption
├── @basmilius/apple-companion-link
│ ├── @basmilius/apple-common
│ ├── @basmilius/apple-encoding
│ └── @basmilius/apple-encryption
├── @basmilius/apple-raop
│ ├── @basmilius/apple-rtsp
│ ├── @basmilius/apple-common
│ ├── @basmilius/apple-encoding
│ └── @basmilius/apple-encryption
├── @basmilius/apple-audio-source
│ └── @basmilius/apple-common
├── @basmilius/apple-common
└── @basmilius/apple-encoding
The @basmilius/apple-sdk package provides the high-level API. The examples below assume all packages are built.
import { Discovery } from '@basmilius/apple-common';
const discovery = Discovery.airplay();
const devices = await discovery.find();
const result = await discovery.findUntil('Living-Room.local');
// Scan AirPlay, Companion Link and RAOP together.
const all = await Discovery.discoverAll();Pairing is required once per Apple TV. HomePods use transient pairing and don't need stored credentials.
import * as AirPlay from '@basmilius/apple-airplay';
const protocol = new AirPlay.Protocol(discoveryResult);
await protocol.connect();
await protocol.pairing.start();
// A PIN is shown on the Apple TV screen.
const credentials = await protocol.pairing.pin(async () => {
return '1234'; // prompt the user for the PIN
});
// Store credentials for future connections.
protocol.disconnect();HomePods connect with transient pairing.
import { HomePod } from '@basmilius/apple-sdk';
const device = new HomePod({ airplay: discoveryResult });
await device.connect();Apple TV requires credentials from a previous pairing.
import { AppleTV } from '@basmilius/apple-sdk';
const device = new AppleTV({ airplay: airplayResult, companionLink: companionLinkResult });
await device.connect(credentials);import { Proto } from '@basmilius/apple-airplay';
// HID-based navigation
await device.remote.up();
await device.remote.down();
await device.remote.select();
await device.remote.menu();
await device.remote.home();
// Playback commands
await device.playback.play();
await device.playback.pause();
await device.playback.next();
await device.playback.previous();
// Volume
await device.volume.set(0.5);
await device.volume.up();
await device.volume.down();
// Seek and shuffle
await device.playback.skipForward(15);
await device.playback.seekTo(60);
await device.playback.setShuffleMode(Proto.ShuffleMode_Enum.Songs);device.state.on('nowPlayingChanged', (client, player) => {
if (!client) return;
console.log(client.bundleIdentifier); // 'com.apple.Music'
console.log(client.title);
console.log(client.artist);
console.log(client.isPlaying);
});
device.state.on('volumeChanged', (volume) => {
console.log(volume); // 0.0 to 1.0
});
const {title, artist, album, duration, elapsedTime, isPlaying} = device.state;import { Url } from '@basmilius/apple-audio-source';
// Client-side streaming: decode locally and send PCM via RTP.
const source = await Url.fromUrl('https://example.com/song.mp3');
await device.media.streamAudio(source);
// URL playback: device fetches and plays the URL itself.
await device.media.playUrl('https://example.com/video.mp4');
await device.media.playUrl('https://example.com/stream.m3u8', 30); // start at 30sconst apps = await device.apps.list();
await device.apps.launch('com.apple.TV');
const users = await device.accounts.list();
await device.accounts.switch(accountId);
await device.power.on();
await device.power.off();# Type-check + bundle
bun --cwd packages/airplay build
# Watch mode (bundle only, no type-check)
bun --cwd packages/airplay devThe proxy has a separate build: bun --cwd packages/proxy build.
bun --cwd packages/airplay gen:protoThis runs Buf over the 117 .proto files in packages/airplay/proto/ and outputs TypeScript to packages/airplay/src/proto/.
The diagnostics package is an Electron app for pairing, remote control, streaming, mDNS scans and inspecting protocol traffic.
bun --cwd packages/diagnostics devBuild the app with bun --cwd packages/diagnostics build. Create installers with bun --cwd packages/diagnostics pack; electron-builder writes them to packages/diagnostics/release/.
Automated tests are deferred until a dedicated testing effort. For device tests, start diagnostics with its agent bridge:
bun --cwd packages/diagnostics dev:agentThen use the CLI from another terminal:
bun run diag wait-ready
bun run diag devices --scan
bun run diag --helpThe user starts the app. See CLAUDE.md for the device-testing workflow and confirmation rules.
MIT