には、PyChromecastなどのライブラリをつかえばかんたんにできるが、 中身が気になったので、castデバイスとソケットを張ってやりたいこと(=今回はとあるmp3を再生させるだけ)ができるまでに必要となるプロトコルなどいろいろあったのでそのメモ(現時点でこのメモが未来に役立つイメージはゼロ。)
Chromecast API v2プロトコル
- senderとreceiver
- sender
- castデバイスを操作するクライアント
- receiver
- castデバイス
- sender
- castクライアントは、TCP(w/TLS) 8009ポートでcastデバイスに接続する
- TCPコネクション確立後、senderとreceiverは固定長のバイナリメッセージを送り合う
- パケット構造は単純で、
Packet length | Payload(message body)
- の2部構成
- Packet length
- uint32(ビッグエンディアン)
- Payload
- protobufでシリアライズされたバイナリ
- protoファイルは以下のようなもの
message CastMessage { // Always pass a version of the protocol for future compatibility // requirements. enum ProtocolVersion { CASTV2_1_0 = 0; } required ProtocolVersion protocol_version = 1; // source and destination ids identify the origin and destination of the // message. They are used to route messages between endpoints that share a // device-to-device channel. // // For messages between applications: // - The sender application id is a unique identifier generated on behalf of // the sender application. // - The receiver id is always the the session id for the application. // // For messages to or from the sender or receiver platform, the special ids // 'sender-0' and 'receiver-0' can be used. // // For messages intended for all endpoints using a given channel, the // wildcard destination_id '*' can be used. required string source_id = 2; required string destination_id = 3; // This is the core multiplexing key. All messages are sent on a namespace // and endpoints sharing a channel listen on one or more namespaces. The // namespace defines the protocol and semantics of the message. required string namespace = 4; // Encoding and payload info follows. // What type of data do we have in this message. enum PayloadType { STRING = 0; BINARY = 1; } required PayloadType payload_type = 5; // Depending on payload_type, exactly one of the following optional fields // will always be set. optional string payload_utf8 = 6; optional bytes payload_binary = 7; }
- このCastMessageを介してsender<->receiver間のデータの送受信が行われる
source_id
- senderの識別子(
sender-0
などがつかわれる)
- senderの識別子(
destination_id
- receiverの識別子(
receiver-0
や、後述するreceiverアプリケーションのtransportId
に相当する)
- receiverの識別子(
- Namespace
- sender<->receiver間のメッセージは、Namespaceというチャネルを明示された上で行われる
- それぞれのNamespace毎にセマンティクスがあり、Payloadメッセージの内容が変わる
- たとえば以下のようなNamespaceが定義されている
urn:x-cast:com.google.cast.tp.connection # コネクションに関するもの urn:x-cast:com.google.cast.receiver # receiver情報に関するもの urn:x-cast:com.google.cast.media # メディア再生に関するもの etc...
接続からreceiverでメディアファイル再生させるまでのシーケンス
- 1.) senderは、TCP(w/TLS)8009ポートでreceiverとコネクションを張る
- 2.) sender<->receiver間のメッセージを開始するには、まず
urn:x-cast:com.google.cast.tp.connection
Namespaceで仮想的なコネクションを確立するためのメッセージを送る必要がある。payload type: CONNECTのメッセージをsenderから送る。(この手順を省くと、senderから特定Namespaceセマンティクスのメッセージをreceiverにそもそも送れない(すぐにソケットが閉じられてしまう) - 3, 4.)
urn:x-cast:com.google.cast.receiver
NamespaceでGET_STATUSメッセージを送り、receiverのアプリケーション情報を取得する(RECEIVER_STATUSメッセージを受け取る)- RECEIVER_STATUSメッセージは以下のようなもの
{ "requestId": 8476438, "status": { "applications": [ { "appId": "CC1AD845", "displayName": "Default Media Receiver", "namespaces": [ "urn:x-cast:com.google.cast.player.message", "urn:x-cast:com.google.cast.media" ], "sessionId": "7E2FF513-CDF6-9A91-2B28-3E3DE7BAC174", "statusText": "Ready To Cast", "transportId": "web-5" } ], "isActiveInput": true, "volume": { "level": 1, "muted": false } }, "type": "RECEIVER_STATUS" }
- 5, 6.) 4で取得したstatus情報に、起動中アプリケーションが存在しなければ、
urn:x-cast:com.google.cast.receiver
namespaceでLAUNCHメッセージを送る- LAUNCHメッセージに付与したrequestIdに対応するRECEIVER_STATUSメッセージを受け取る
- 7.) RECEIVER_STATUSメッセージに含まれる、transPortIdをdestinationIdとして指定した上で、
urn:x-cast:com.google.cast.tp.connection
namespaceでCONNECTメッセージを送り、以降のsender(cast client)<->receiver(cast device)間の仮想的なコネクションを張る - 8.)
urn:x-cast:com.google.cast.media
NamespaceでLOADメッセージを送り、receiverにメディアファイル再生させる- このときpayloadに再生するメディア情報を付与する
- 以下のようなデータフォーマット
type LoadMediaCommand struct { PayloadHeader Media MediaItem `json:"media"` Autoplay bool `json:"autoplay"` CustomData interface{} `json:"customData"` } type MediaItem struct { ContentID string `json:"contentId"` ContentType string `json:"contentType"` StreamType string `json:"streamType"` Metadata interface{} `json:"metadata"` }
- contentIdでメディアファイルのURLを指定
- contentTypeでMIMEを指定するなど
- 以下のようなデータフォーマット
- このときpayloadに再生するメディア情報を付与する
castクライアント実装
以上のシーケンスを実装したものが以下(今回はGoで書いた。protocが対応している言語なら何でも書けるとおもう)
main.go
package main
import (
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"time"
"github.com/gogo/protobuf/proto"
jsoniter "github.com/json-iterator/go"
"github.com/pkg/errors"
pb "github.com/hrfmmr/chromecast-client-test-go/protobuf"
)
const (
googlehomeIP = "192.168.11.4"
defaultChromecastPort = 8009
defaultSender = "sender-0"
defaultReceiver = "receiver-0"
namespaceConnection = "urn:x-cast:com.google.cast.tp.connection"
namespaceRecv = "urn:x-cast:com.google.cast.receiver"
namespaceMedia = "urn:x-cast:com.google.cast.media"
defaultMediaReceiverApp = "CC1AD845"
audioURL = "http://192.168.11.2:8001/hello"
streamTypeBuffered = "BUFFERED"
)
var (
json = jsoniter.ConfigCompatibleWithStandardLibrary
requestID = RequestID(0)
ConnectHeader = PayloadHeader{Type: "CONNECT"}
GetStatusHeader = PayloadHeader{Type: "GET_STATUS"}
LaunchHeader = PayloadHeader{Type: "LAUNCH"}
LoadHeader = PayloadHeader{Type: "LOAD"}
)
type RequestID int
type Payload interface {
SetRequestID(id RequestID)
}
type PayloadHeader struct {
Type string `json:"type"`
RequestId int `json:"requestId,omitempty"`
}
func (p *PayloadHeader) SetRequestID(id RequestID) {
p.RequestId = int(id)
}
type LaunchRequest struct {
PayloadHeader
AppID string `json:"appId"`
}
type LoadMediaCommand struct {
PayloadHeader
Media MediaItem `json:"media"`
Autoplay bool `json:"autoplay"`
CustomData interface{} `json:"customData"`
}
type MediaItem struct {
ContentID string `json:"contentId"`
ContentType string `json:"contentType"`
StreamType string `json:"streamType"`
Metadata interface{} `json:"metadata"`
}
type ReceiverStatusResponse struct {
RequestID int `json:"requestId"`
Status ReceiverStatus `json:"status"`
Type string `json:"type"`
}
type ReceiverStatus struct {
Applications []CastApplication `json:"applications"`
}
type CastApplication struct {
AppID string `json:"appId"`
SessionID string `json:"sessionId"`
TransportID string `json:"transportId"`
}
func connect(addr string, port int) (*tls.Conn, error) {
dialer := &net.Dialer{
Timeout: time.Second * 15,
}
conn, err := tls.DialWithDialer(dialer, "tcp", fmt.Sprintf("%s:%d", addr, port), &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
return nil, err
}
return conn, nil
}
func recvLoop(conn *tls.Conn, recvMsgChan chan<- *pb.CastMessage) {
for {
var length uint32
if err := binary.Read(conn, binary.BigEndian, &length); err != nil {
log.Fatal(err)
}
if length == 0 {
log.Fatal("empty payload received")
}
payload := make([]byte, length)
n, err := io.ReadFull(conn, payload)
if err != nil {
log.Fatal(err)
}
if n != int(length) {
log.Fatalf("invalid payload, wanted:%d but read:%d", length, n)
}
msg := &pb.CastMessage{}
if err := proto.Unmarshal(payload, msg); err != nil {
log.Printf("failed to unmarshal proto cast message '%s': %v", payload, err)
continue
}
recvMsgChan <- msg
}
}
func recvMsg(recvMsgChan <-chan *pb.CastMessage, resultChanMap map[RequestID]chan *pb.CastMessage) {
for msg := range recvMsgChan {
reqID := json.Get([]byte(*msg.PayloadUtf8), "requestId")
if err := reqID.LastError(); err != nil {
continue
}
id := reqID.ToInt()
if ch, ok := resultChanMap[RequestID(id)]; ok {
ch <- msg
}
}
}
func sendAndWait(conn *tls.Conn, payload Payload, sourceID, destinationID, namespace string, resultChanMap map[RequestID]chan *pb.CastMessage) (*pb.CastMessage, error) {
requestID, err := sendMessage(conn, payload, sourceID, destinationID, namespace)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resultChan := make(chan *pb.CastMessage, 1)
resultChanMap[requestID] = resultChan
defer delete(resultChanMap, requestID)
select {
case <-ctx.Done():
return nil, ctx.Err()
case msg := <-resultChan:
return msg, nil
}
}
func sendMessage(conn *tls.Conn, payload Payload, sourceID, destinationID, namespace string) (RequestID, error) {
requestID += 1
payload.SetRequestID(requestID)
payloadJson, err := json.Marshal(payload)
if err != nil {
return requestID, err
}
payloadUtf8 := string(payloadJson)
msg := &pb.CastMessage{
ProtocolVersion: pb.CastMessage_CASTV2_1_0.Enum(),
SourceId: &sourceID,
DestinationId: &destinationID,
Namespace: &namespace,
PayloadType: pb.CastMessage_STRING.Enum(),
PayloadUtf8: &payloadUtf8,
}
proto.SetDefaults(msg)
data, err := proto.Marshal(msg)
if err != nil {
return requestID, err
}
if err := binary.Write(conn, binary.BigEndian, uint32(len(data))); err != nil {
return requestID, err
}
if _, err := conn.Write(data); err != nil {
return requestID, err
}
return requestID, nil
}
func playAudio(conn *tls.Conn, destinationID, audioURL string, resultChanMap map[RequestID]chan *pb.CastMessage) {
media := MediaItem{
ContentID: audioURL,
ContentType: "audio/mp3",
StreamType: streamTypeBuffered,
}
cmd := LoadMediaCommand{
PayloadHeader: LoadHeader,
Media: media,
Autoplay: true,
}
msg, err := sendAndWait(conn, &cmd, defaultSender, destinationID, namespaceMedia, resultChanMap)
if err != nil {
log.Fatal(errors.Wrap(err, "failed to play media"))
}
}
func main() {
conn, err := connect(googlehomeIP, defaultChromecastPort)
if err != nil {
log.Fatal(err)
}
recvMsgChan := make(chan *pb.CastMessage)
resultChanMap := map[RequestID]chan *pb.CastMessage{}
go recvLoop(conn, recvMsgChan)
go recvMsg(recvMsgChan, resultChanMap)
if _, err := sendMessage(conn, &ConnectHeader, defaultSender, defaultReceiver, namespaceConnection); err != nil {
log.Fatal(errors.Wrap(err, fmt.Sprintf("failed to connect to:%s", defaultReceiver)))
}
msg, err := sendAndWait(conn, &GetStatusHeader, defaultSender, defaultReceiver, namespaceRecv, resultChanMap)
if err != nil {
log.Fatal(err)
}
var receiverStatusResp ReceiverStatusResponse
var defaultApp CastApplication
if err := json.Get([]byte(*msg.PayloadUtf8), "status", "applications").LastError(); err != nil {
payload := &LaunchRequest{
PayloadHeader: LaunchHeader,
AppID: defaultMediaReceiverApp,
}
msg, err := sendAndWait(conn, payload, defaultSender, defaultReceiver, namespaceRecv, resultChanMap)
if err != nil {
log.Fatal(errors.Wrap(err, "failed to launch app"))
}
if err := json.Unmarshal([]byte(*msg.PayloadUtf8), &receiverStatusResp); err != nil {
log.Fatal(errors.Wrap(err, fmt.Sprintf("failed to unmarshal json:%s", *msg.PayloadUtf8)))
}
defaultApp = receiverStatusResp.Status.Applications[0]
} else {
if err := json.Unmarshal([]byte(*msg.PayloadUtf8), &receiverStatusResp); err != nil {
log.Fatal(errors.Wrap(err, fmt.Sprintf("failed to unmarshal json:%s", *msg.PayloadUtf8)))
}
defaultApp = receiverStatusResp.Status.Applications[0]
}
destinationID := defaultApp.TransportID
if _, err := sendMessage(conn, &ConnectHeader, defaultSender, destinationID, namespaceConnection); err != nil {
log.Fatal(errors.Wrap(err, fmt.Sprintf("failed to connect to:%s", destinationID)))
}
playAudio(conn, destinationID, audioURL, resultChanMap)
}
- 内部の
protobuf
packageには、cast_channel.protoファイル(上述のCastMessageなどが定義されているもの)からprotoc(w/ protoc-gen-go)コンパイラで生成されたgoソースが含まれている audioURL = "http://192.168.11.2:8001/hello"
でcastデバイスからアクセスする先の音声ファイルの在り処を示しているが、これはこれで別途サーバを立てて配信する必要がある- 雑にcastクライントと同一マシンで配信するnginxコンテナを動かす
- docker-compose.yml
version: '3' services: nginx: build: .docker/nginx ports: - 8001:8001 volumes: - .docker/nginx/static:/static - .docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- .docker/nginx/Dockerfile
FROM nginx:latest ADD default.conf /etc/nginx/conf.d
- .docker/nginx/default.conf
server { listen 8001; location =/hello { alias /static/media/hello.mp3; } location ~ \.mp3 { root /static/media; if ($request_uri ~* "([^/]*$)" ) { set $last_path_component $1; } try_files $last_path_component =404; } }
- .docker/nginx/static/hello.mp3
- 今回再生したい音声ファイル
- docker-compose.yml
- 雑にcastクライントと同一マシンで配信するnginxコンテナを動かす