Chromecast protocol is a wierd beast. You need to open connection via tls, and send json embeded inside a proto messages while prepending proto message with its length. Google’s documentation is to say it softly… lacking. It is as if they don’t want engineers developing for default reciever app and using chromecast. However due to Open Source nature of chromium various people have managed to crack chromecast protocol and cast their own stuff.
As we can see from schema we need about 5 actions to play video with subtitles. It doesn’t look that bad. However the devil is in the details.
First thing, first. From where do we get our android TV IP, and what is its port. From un-documented chromium code we know chromecast always listens on port :8009. With port in our hand we just need to run some mdns lib to get lan ip of our chromecast device. Lucky for us there is one from hashicorp we can use. Keep in mind most home network have a DHCP lease which means device ip usually rotates every 24h or so.
I honestly don’t understand the benefits of using tls for lan communications, in my mind it is much simpler to just use http and trust lan. However chromecast uses it, so… here is a code to open unsecure tls with golang:
import "crypto/tls"
....
deviceIP := "192.168.0.15"
devicePort := ":8009"
conn, err := tls.Dial("tcp", deviceIP + devicePort , &tls.Config{
InsecureSkipVerify: true,
})
It ain’t that bad right? Well yeah finding this param was the easiest step.
There is this protobuf I’ve managed to dig up. However figuring out what goes where is a bit painful. Especially when you need to chain 4 messages in a row without any helpful errors as a response. Just connection terminations as a response 😂
Anyhow sending a valid message comes down to using proto to marshal the CastMessage from protobuf defintion, and prepending it with bigEndian uin32 value of bytes it occupies. Here is the code to do it (it took some time to figure it out)
jsonMsgBytes, _ := json.Marshal(&msg.JsonData)
jsonMsgBytesS := string(jsonMsgBytes)
msg.Proto.PayloadUtf8 = &jsonMsgBytesS
castMsgBytes, _ := proto.Marshal(msg.Proto)
lenBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lenBytes, uint32(len(castMsgBytes)))
fullMsg := append(lenBytes, castMsgBytes...)
conn.Write(fullMsg)
The funny thing is… that proto definition file is a bit lacking. A castMessage also has a field called payload_utf8 which is used for sending marshalled jsons, which is in turn used for communicating with the reciever app. On top of that you need to keep an increasing counter and increase it by 1 for each message (including pings).
At this point I was seriosuly wondering why just not use either JSON or grpc with well defined proto. I was expecting more from google guys.
Each one of the messages is sent on it’s own namespace, so for namespaces this constants are used:
const NamespaceAuth = "urn:x-cast:com.google.cast.tp.deviceauth"
const NamespaceConnection = "urn:x-cast:com.google.cast.tp.connection"
const NamespaceReceiver = "urn:x-cast:com.google.cast.receiver"
const NamespaceMedia = "urn:x-cast:com.google.cast.media"
const NamespacePing = "urn:x-cast:com.google.cast.tp.heartbeat"
You also need to know the id of default reciever app (luckly it is the same on each android tv) You must identify android tv which is “receiver-0” as well as identify yourself (can be any short string)
const ReceiverID = "receiver-0" //hardcoded needs to be this value
const DefaultMediaAppID = "CC1AD845" // hardcoded default media reciever app ID
const SenderID = "sender-vjerci"
Pulling all of these out of chromium source code is to say it softly… a lot of fun.
Once protocol is figured out, the way to send messages figured out, as well as constants. The communication ain’t that bad its just a matter of sending and receiving data over tls while spaming keep alive messages.
Here is a dump of messages:
Sent message -> Ping:
protocol_version:CASTV2_1_0 source_id:"sender-vjerci" destination_id:"receiver-0" namespace:"urn:x-cast:com.google.cast.tp.heartbeat" payload_type:STRING payload_utf8:"{\"type\":\"PING\",\"requestId\":3}"
Recieved message -> pong:
protocol_version:CASTV2_1_0 source_id:"receiver-0" destination_id:"sender-vjerci" namespace:"urn:x-cast:com.google.cast.tp.heartbeat" payload_type:STRING payload_utf8:"{\"type\":\"PONG\"}"
Sent message -> Connect to device:
protocol_version:CASTV2_1_0 source_id:"sender-vjerci" destination_id:"receiver-0" namespace:"urn:x-cast:com.google.cast.tp.connection" payload_type:STRING payload_utf8:"{\"type\":\"CONNECT\",\"origin\":{},\"userAgent\":\"GoChromecast\",\"senderInfo\":{\"sdkType\":2,\"version\":\"15.605.1.3\",\"browserVersion\":\"44.0.2403.30\",\"platform\":4,\"systemVersion\":\"Macintosh; Intel Mac OS X10_10_3\",\"connectionType\":1}}"
Sent message -> Launch default media reciever app:
protocol_version:CASTV2_1_0 source_id:"sender-vjerci" destination_id:"receiver-0" namespace:"urn:x-cast:com.google.cast.receiver" payload_type:STRING payload_utf8:"{\"type\":\"LAUNCH\",\"appId\":\"CC1AD845\",\"requestId\":2}"
Recieved message -> Receiver status:
protocol_version:CASTV2_1_0 source_id:"receiver-0" destination_id:"sender-vjerci" namespace:"urn:x-cast:com.google.cast.receiver" payload_type:STRING payload_utf8:"{\"requestId\":2,\"status\":{\"applications\":[{\"appId\":\"CC1AD845\",\"appType\":\"WEB\",\"displayName\":\"Default Media Receiver\",\"iconUrl\":\"\",\"isIdleScreen\":false,\"launchedFromCloud\":false,\"namespaces\":[{\"name\":\"urn:x-cast:com.google.cast.debugoverlay\"},{\"name\":\"urn:x-cast:com.google.cast.cac\"},{\"name\":\"urn:x-cast:com.google.cast.media\"}],\"senderConnected\":true,\"sessionId\":\"5321e93c-4176-4fd6-bb9d-0feb3077daf6\",\"statusText\":\"Default Media Receiver\",\"transportId\":\"5321e93c-4176-4fd6-bb9d-0feb3077daf6\",\"universalAppId\":\"CC1AD845\"}],\"userEq\":{},\"volume\":{\"controlType\":\"master\",\"level\":0.20000000298023224,\"muted\":false,\"stepInterval\":0.01666666753590107}},\"type\":\"RECEIVER_STATUS\"}"
Sent message -> Connect to media reciever app:
protocol_version:CASTV2_1_0 source_id:"sender-vjerci" destination_id:"5321e93c-4176-4fd6-bb9d-0feb3077daf6" namespace:"urn:x-cast:com.google.cast.tp.connection" payload_type:STRING payload_utf8:"{\"type\":\"CONNECT\",\"origin\":{},\"userAgent\":\"GoChromecast\",\"senderInfo\":{\"sdkType\":2,\"version\":\"15.605.1.3\",\"browserVersion\":\"44.0.2403.30\",\"platform\":4,\"systemVersion\":\"Macintosh; Intel Mac OS X10_10_3\",\"connectionType\":1}}
Sent message -> Play media:
protocol_version:CASTV2_1_0 source_id:"sender-vjerci" destination_id:"5321e93c-4176-4fd6-bb9d-0feb3077daf6" namespace:"urn:x-cast:com.google.cast.media" payload_type:STRING payload_utf8:"{\"type\":\"LOAD\",\"media\":{\"contentId\":\"http://192.168.8.115:8889/files/playlist.m3u8\",\"streamType\":\"\",\"contentType\":\"application/x-mpegurl\",\"tracks\":[{\"trackId\":3,\"trackContentId\":\"http://192.168.8.115:8889/files/subtitles.vtt\",\"trackContentType\":\"text/vtt\",\"type\":\"TEXT\",\"subtype\":\"SUBTITLES\",\"language\":\"en\",\"name\":\"en subtitles\"}]},\"currentTime\":0,\"activeTrackIds\":[3],\"requestId\":4}"
We’ve learned a bit how chromecast protocol works. We’ve dived deep into encoding and decoding of messages, as well as seen some message dumps. We’ve got .proto file from which we can backtrace how current things work. Next we will dive into server and what is needed to serve our media over lan.