Chromecastデバイスにメディアファイルを再生させる

Posted on | 1240 words | ~6 mins

には、PyChromecastなどのライブラリをつかえばかんたんにできるが、 中身が気になったので、castデバイスとソケットを張ってやりたいこと(=今回はとあるmp3を再生させるだけ)ができるまでに必要となるプロトコルなどいろいろあったのでそのメモ(現時点でこのメモが未来に役立つイメージはゼロ。)

Chromecast API v2プロトコル

  • senderとreceiver
    • sender
      • castデバイスを操作するクライアント
    • receiver
      • castデバイス
  • 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などがつかわれる)
      • destination_id
        • receiverの識別子(receiver-0や、後述するreceiverアプリケーションのtransportIdに相当する)
  • 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を指定するなど

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
        • 今回再生したい音声ファイル

ref