Skip to content

2. The Wire Protocol & Sessions

Doc 1 ended with an open, encrypted TCP socket between the phone and the TV. This doc is about what actually flows over that socket — the language the two devices speak, called CASTV2.

CASTV2 is Google’s own binary protocol for Chromecast (and it’s genuinely Google-specific — this is not a generic industry standard the way mDNS is). The core idea is simple even though the name sounds intimidating:

Every message is a small protobuf envelope, wrapping a JSON payload, sent as one binary frame with a length prefix.

Breaking that down:

  • Protobuf (Protocol Buffers) is a compact binary format for structured data — think “JSON, but smaller and with a fixed schema,” used here for the envelope (routing information), not the actual message content.
  • JSON is the actual message content — the “payload” — a plain string of JSON text sitting inside that protobuf envelope. This is a deliberate two-layer design: the outer envelope is about routing (“who is this for”), the inner JSON is about meaning (“what does it say”).
  • A length prefix is necessary because TCP is a stream of bytes with no built-in concept of “messages” — the sender and receiver both need to agree on where one message ends and the next begins. CASTV2 does this the standard way: 4 bytes stating how many bytes follow, then exactly that many bytes of protobuf data.
one CASTV2 frame on the wire:
┌─────────────┬───────────────────────────────────────────┐
│ 4 bytes │ N bytes │
│ length = N │ protobuf-encoded CastMessage │
│ (big-endian) │ ┌───────────────────────────────────┐ │
│ │ │ namespace: "urn:x-cast:...media" │ │
│ │ │ sourceId: "sender-0" │ │
│ │ │ destinationId: "receiver-0" │ │
│ │ │ payloadUtf8: '{"type":"LOAD",...}' │◄───┼── the actual
│ │ └───────────────────────────────────┘ │ JSON message
└─────────────┴───────────────────────────────────────────┘

Namespaces: multiple conversations, one socket

Section titled “Namespaces: multiple conversations, one socket”

Rather than opening a separate connection for “connection management,” “volume control,” and “what’s currently playing,” CASTV2 multiplexes several independent conversations over the same socket, tagging each message with a namespace — a URN string acting like a channel name. The receiver looks at the namespace to know which “department” should handle a given message. The three that matter most for a video-casting app:

Namespace What it’s for
urn:x-cast:com.google.cast.tp.connection Opening/closing a logical connection, heartbeats
urn:x-cast:com.google.cast.receiver Launching apps on the TV, volume, receiver status
urn:x-cast:com.google.cast.media The actual “load this video” / “pause” / “seek” traffic

sourceId / destinationId: addressing within one socket

Section titled “sourceId / destinationId: addressing within one socket”

Since multiple logical connections can share one socket (your phone might, in principle, be one of several “senders” talking to the TV), every message carries a sourceId and destinationId — string identifiers, not IP addresses — so the receiver knows which logical session a message belongs to, separate from which topic (namespace) it’s about.

The rule: the namespace decides the destination

Section titled “The rule: the namespace decides the destination”

This is the part that is easy to get wrong, and this app got it wrong for the whole life of the feature. The TV is two addressable endpoints on one socket, and which one a message belongs to is decided entirely by its namespace:

┌───────────────────────────────────┐
│ the TV │
phone ──socket──▶│ │
│ receiver-0 ◄── LAUNCH │
│ (the platform GET_STATUS │ ...cast.receiver
│ receiver) SET_VOLUME │
│ │
│ web-5 ◄── LOAD, PLAY, │ ...cast.media
│ (the app it PAUSE, SEEK │
│ launched) │
└───────────────────────────────────┘

receiver-0 is a fixed, well-known address — it is there before anything launches. The app’s address (web-5 above) is the transportId, which you only learn from the RECEIVER_STATUS that confirms the launch.

Receiver-namespace messages keep going to receiver-0 for the whole life of the session, long after a transportId exists. Volume is on that namespace — see the table above — so SET_VOLUME is addressed to receiver-0, never to the media app.

Getting this wrong fails silently. Sending a receiver-namespace message to the app’s transport is not a protocol error and draws no rejection: it simply arrives at something with no handler for that namespace and is dropped. That is precisely how this app shipped a volume slider that never moved a TV — see local_lib/flutter_cast/LOCAL_MODIFICATIONS.md and doc 8.

The handshake: from “socket is open” to “ready to cast”

Section titled “The handshake: from “socket is open” to “ready to cast””

Having a TCP+TLS socket open doesn’t mean you can send LOAD yet. There’s a short, mostly-fixed sequence of messages every cast session goes through:

Phone (sender) TV (Chromecast)
│ │
│ CONNECT (connection namespace) │
├────────────────────────────────────────────────────►│
│ │
│ LAUNCH (receiver namespace — "start the │
│ Default Media Receiver app") │
├────────────────────────────────────────────────────►│
│ │
│ RECEIVER_STATUS │
│◄────────────────────────────────────────────────────┤
│ (confirms the app launched, and includes a │
│ transportId — the address to send media │
│ commands to from here on) │
│ │
│ ── session is now "ready" ── │
│ │
│ PING ────────────────────────────────────────────►│
│◄────────────────────────────────────────────── PONG │ (repeats
│ │ periodically;
│ LOAD (media namespace — "play this URL") │ keeps the
├────────────────────────────────────────────────────►│ connection
│ │ alive and
│ MEDIA_STATUS │ lets either
│◄────────────────────────────────────────────────────┤ side detect
│ (playerState, currentTime, mediaSessionId, ...) │ a dead link)
│ │

A few things worth internalizing from this diagram:

  • LAUNCH targets an app, not a video. The “Default Media Receiver” is a Google-hosted web app (running Shaka Player, a JS video player) that the TV boots up. Only once that’s running does the TV know how to interpret a LOAD command at all.
  • The heartbeat (PING/PONG) isn’t optional decoration. TCP connections can silently die (Wi-Fi hiccup, the TV sleeps, someone unplugs it) without either side getting a clean “disconnected” event. A missed PONG is how the app detects that and can tell the user “lost connection to the TV” instead of hanging forever.
  • MEDIA_STATUS is the feedback loop. After LOAD, the phone doesn’t just fire-and-forget — the TV reports back its actual playback state (playerState: PLAYING, currentTime, etc.) so the phone’s UI (the scrubber, the play/pause icon) can mirror what’s genuinely happening on the TV, not just what the phone hopes is happening.
  • local_lib/flutter_cast/lib/cast_channel/cast_channel.pb.dart — generated protobuf code for the CastMessage envelope (auto-generated from Google’s .proto schema; you’d never hand-write this).
  • local_lib/flutter_cast/lib/socket.dart — CastSocket is the layer that turns a raw byte stream into discrete frames: it buffers incoming bytes, reads the 4-byte length prefix, and only treats a message as “arrived” once all N bytes it promised have actually shown up (a message can legitimately arrive split across multiple TCP reads, or several small messages can arrive coalesced into one — the framing layer has to handle both).
  • local_lib/flutter_cast/lib/session.dart — CastSession runs the CONNECT → LAUNCH → RECEIVER_STATUS handshake and the PING/PONG heartbeat, and exposes a simple sendMessage(namespace, payload) for everything above it to use.
  • lib/data/services/cast/cast_messages.dart — builds the actual JSON payloads (LOAD, PLAY, PAUSE, SEEK, EDIT_TRACKS_INFO, …) sent on the media namespace.
  • lib/presentation/widgets/player/cast_controller.dart — the app-level orchestrator: opens the session, dispatches LOAD, and parses every incoming MEDIA_STATUS to drive the on-screen remote.

CASTV2 is “JSON messages, tagged by topic, framed with a length prefix, sent over an encrypted socket.” Once you see it that way, the protocol isn’t mysterious — it’s a small, deliberate design for one job: let a phone remote-control a TV reliably, over a network where connections can drop and messages can arrive in whatever order and whatever chunks TCP happens to deliver them in.