├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── api ├── cast_channel.pb.go ├── cast_channel.proto └── generate-proto.sh ├── apps.go ├── client.go ├── cmd └── cast │ └── main.go ├── controllers ├── connection.go ├── controller.go ├── controller_test.go ├── heartbeat.go ├── media.go ├── receiver.go └── url.go ├── discovery ├── service.go └── service_test.go ├── events ├── appstarted.go ├── appstopped.go ├── connected.go ├── disconnected.go ├── event.go └── status_updated.go ├── go.mod ├── go.sum ├── log └── log.go ├── net ├── channel.go ├── connection.go └── payload.go └── version.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | 5 | jobs: 6 | release: 7 | name: Release 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Set up Go 1.15 11 | uses: actions/setup-go@v2 12 | with: 13 | go-version: 1.15 14 | 15 | - name: Check out code 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v2 22 | with: 23 | version: latest 24 | args: release --rm-dist 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | cast 26 | release/ 27 | /dist/ 28 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: cast 3 | main: ./cmd/cast 4 | ldflags: -s -w -X=github.com/barnybug/go-cast.Version={{.Version}} 5 | goos: 6 | - linux 7 | - darwin 8 | - windows 9 | goarch: 10 | - amd64 11 | - 386 12 | - arm 13 | ignore: 14 | - goos: darwin 15 | goarch: 386 16 | hooks: 17 | post: 18 | - upx "{{ .Path }}" 19 | 20 | archives: 21 | - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" 22 | format: binary 23 | replacements: 24 | darwin: mac 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ninja Blocks Inc. 4 | Copyright (c) 2014 Thibaut Séguy 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GO15VENDOREXPERIMENT=1 2 | 3 | exe = ./cmd/cast 4 | buildargs = -ldflags '-w -s -X github.com/barnybug/go-cast.Version=${TRAVIS_TAG}' 5 | 6 | .PHONY: all build install test coverage release upx 7 | 8 | all: install 9 | 10 | test: 11 | go test . ./api/... ./cmd/... ./controllers/... ./discovery/... ./events/... ./log/... ./net/... 12 | 13 | build: 14 | go build -i -v $(exe) 15 | 16 | install: 17 | go install $(exe) 18 | 19 | release: 20 | GOOS=linux GOARCH=amd64 go build $(buildargs) -o release/cast-linux-amd64 $(exe) 21 | GOOS=linux GOARCH=386 go build $(buildargs) -o release/cast-linux-386 $(exe) 22 | GOOS=linux GOARCH=arm go build $(buildargs) -o release/cast-linux-arm $(exe) 23 | GOOS=darwin GOARCH=amd64 go build $(buildargs) -o release/cast-mac-amd64 $(exe) 24 | GOOS=windows GOARCH=386 go build $(buildargs) -o release/cast-windows-386.exe $(exe) 25 | GOOS=windows GOARCH=amd64 go build $(buildargs) -o release/cast-windows-amd64.exe $(exe) 26 | goupx release/cast-linux-amd64 27 | upx release/cast-linux-386 release/cast-linux-arm release/cast-windows-386.exe 28 | 29 | upx: 30 | upx dist/go-cast-linux-386/cast-linux-386 dist/go-cast-linux-amd64/cast-linux-amd64 dist/go-cast-linux-arm/cast-linux-arm dist/go-cast-windows-386/cast-windows-386.exe 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status](https://secure.travis-ci.org/barnybug/go-cast.png?branch=master)](https://secure.travis-ci.org/barnybug/go-cast) 2 | 3 | # go-cast 4 | 5 | A command line tool to control Google Chromecast devices. 6 | 7 | ## Installation 8 | 9 | Download the latest binaries from: 10 | https://github.com/barnybug/go-cast/releases/latest 11 | 12 | $ sudo mv cast-my-platform /usr/local/bin/cast 13 | $ sudo chmod +x /usr/local/bin/cast 14 | 15 | ## Usage 16 | 17 | $ cast help 18 | 19 | Play a media file: 20 | 21 | $ cast --name Hifi media play http://url/file.mp3 22 | 23 | Stop playback: 24 | 25 | $ cast --name Hifi media stop 26 | 27 | Set volume: 28 | 29 | $ cast --name Hifi volume 0.5 30 | 31 | Close app on the Chromecast: 32 | 33 | $ cast --name Hifi quit 34 | 35 | ## Bug reports 36 | 37 | Please open a github issue including cast version number `cast --version`. 38 | 39 | ## Pull requests 40 | 41 | Pull requests are gratefully received! 42 | 43 | - please 'gofmt' the code. 44 | 45 | ## Credits 46 | 47 | Based on go library port by [ninjasphere](https://github.com/ninjasphere/node-cast) 48 | -------------------------------------------------------------------------------- /api/cast_channel.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-gogo. 2 | // source: api/cast_channel.proto 3 | // DO NOT EDIT! 4 | 5 | /* 6 | Package api is a generated protocol buffer package. 7 | 8 | It is generated from these files: 9 | api/cast_channel.proto 10 | 11 | It has these top-level messages: 12 | CastMessage 13 | AuthChallenge 14 | AuthResponse 15 | AuthError 16 | DeviceAuthMessage 17 | */ 18 | package api 19 | 20 | import proto "github.com/gogo/protobuf/proto" 21 | import json "encoding/json" 22 | import math "math" 23 | 24 | // Reference proto, json, and math imports to suppress error if they are not otherwise used. 25 | var _ = proto.Marshal 26 | var _ = &json.SyntaxError{} 27 | var _ = math.Inf 28 | 29 | // Always pass a version of the protocol for future compatibility 30 | // requirements. 31 | type CastMessage_ProtocolVersion int32 32 | 33 | const ( 34 | CastMessage_CASTV2_1_0 CastMessage_ProtocolVersion = 0 35 | ) 36 | 37 | var CastMessage_ProtocolVersion_name = map[int32]string{ 38 | 0: "CASTV2_1_0", 39 | } 40 | var CastMessage_ProtocolVersion_value = map[string]int32{ 41 | "CASTV2_1_0": 0, 42 | } 43 | 44 | func (x CastMessage_ProtocolVersion) Enum() *CastMessage_ProtocolVersion { 45 | p := new(CastMessage_ProtocolVersion) 46 | *p = x 47 | return p 48 | } 49 | func (x CastMessage_ProtocolVersion) String() string { 50 | return proto.EnumName(CastMessage_ProtocolVersion_name, int32(x)) 51 | } 52 | func (x *CastMessage_ProtocolVersion) UnmarshalJSON(data []byte) error { 53 | value, err := proto.UnmarshalJSONEnum(CastMessage_ProtocolVersion_value, data, "CastMessage_ProtocolVersion") 54 | if err != nil { 55 | return err 56 | } 57 | *x = CastMessage_ProtocolVersion(value) 58 | return nil 59 | } 60 | 61 | // What type of data do we have in this message. 62 | type CastMessage_PayloadType int32 63 | 64 | const ( 65 | CastMessage_STRING CastMessage_PayloadType = 0 66 | CastMessage_BINARY CastMessage_PayloadType = 1 67 | ) 68 | 69 | var CastMessage_PayloadType_name = map[int32]string{ 70 | 0: "STRING", 71 | 1: "BINARY", 72 | } 73 | var CastMessage_PayloadType_value = map[string]int32{ 74 | "STRING": 0, 75 | "BINARY": 1, 76 | } 77 | 78 | func (x CastMessage_PayloadType) Enum() *CastMessage_PayloadType { 79 | p := new(CastMessage_PayloadType) 80 | *p = x 81 | return p 82 | } 83 | func (x CastMessage_PayloadType) String() string { 84 | return proto.EnumName(CastMessage_PayloadType_name, int32(x)) 85 | } 86 | func (x *CastMessage_PayloadType) UnmarshalJSON(data []byte) error { 87 | value, err := proto.UnmarshalJSONEnum(CastMessage_PayloadType_value, data, "CastMessage_PayloadType") 88 | if err != nil { 89 | return err 90 | } 91 | *x = CastMessage_PayloadType(value) 92 | return nil 93 | } 94 | 95 | type AuthError_ErrorType int32 96 | 97 | const ( 98 | AuthError_INTERNAL_ERROR AuthError_ErrorType = 0 99 | AuthError_NO_TLS AuthError_ErrorType = 1 100 | ) 101 | 102 | var AuthError_ErrorType_name = map[int32]string{ 103 | 0: "INTERNAL_ERROR", 104 | 1: "NO_TLS", 105 | } 106 | var AuthError_ErrorType_value = map[string]int32{ 107 | "INTERNAL_ERROR": 0, 108 | "NO_TLS": 1, 109 | } 110 | 111 | func (x AuthError_ErrorType) Enum() *AuthError_ErrorType { 112 | p := new(AuthError_ErrorType) 113 | *p = x 114 | return p 115 | } 116 | func (x AuthError_ErrorType) String() string { 117 | return proto.EnumName(AuthError_ErrorType_name, int32(x)) 118 | } 119 | func (x *AuthError_ErrorType) UnmarshalJSON(data []byte) error { 120 | value, err := proto.UnmarshalJSONEnum(AuthError_ErrorType_value, data, "AuthError_ErrorType") 121 | if err != nil { 122 | return err 123 | } 124 | *x = AuthError_ErrorType(value) 125 | return nil 126 | } 127 | 128 | type CastMessage struct { 129 | ProtocolVersion *CastMessage_ProtocolVersion `protobuf:"varint,1,req,name=protocol_version,enum=api.CastMessage_ProtocolVersion" json:"protocol_version,omitempty"` 130 | // source and destination ids identify the origin and destination of the 131 | // message. They are used to route messages between endpoints that share a 132 | // device-to-device channel. 133 | // 134 | // For messages between applications: 135 | // - The sender application id is a unique identifier generated on behalf of 136 | // the sender application. 137 | // - The receiver id is always the the session id for the application. 138 | // 139 | // For messages to or from the sender or receiver platform, the special ids 140 | // 'sender-0' and 'receiver-0' can be used. 141 | // 142 | // For messages intended for all endpoints using a given channel, the 143 | // wildcard destination_id '*' can be used. 144 | SourceId *string `protobuf:"bytes,2,req,name=source_id" json:"source_id,omitempty"` 145 | DestinationId *string `protobuf:"bytes,3,req,name=destination_id" json:"destination_id,omitempty"` 146 | // This is the core multiplexing key. All messages are sent on a namespace 147 | // and endpoints sharing a channel listen on one or more namespaces. The 148 | // namespace defines the protocol and semantics of the message. 149 | Namespace *string `protobuf:"bytes,4,req,name=namespace" json:"namespace,omitempty"` 150 | PayloadType *CastMessage_PayloadType `protobuf:"varint,5,req,name=payload_type,enum=api.CastMessage_PayloadType" json:"payload_type,omitempty"` 151 | // Depending on payload_type, exactly one of the following optional fields 152 | // will always be set. 153 | PayloadUtf8 *string `protobuf:"bytes,6,opt,name=payload_utf8" json:"payload_utf8,omitempty"` 154 | PayloadBinary []byte `protobuf:"bytes,7,opt,name=payload_binary" json:"payload_binary,omitempty"` 155 | XXX_unrecognized []byte `json:"-"` 156 | } 157 | 158 | func (m *CastMessage) Reset() { *m = CastMessage{} } 159 | func (m *CastMessage) String() string { return proto.CompactTextString(m) } 160 | func (*CastMessage) ProtoMessage() {} 161 | 162 | func (m *CastMessage) GetProtocolVersion() CastMessage_ProtocolVersion { 163 | if m != nil && m.ProtocolVersion != nil { 164 | return *m.ProtocolVersion 165 | } 166 | return CastMessage_CASTV2_1_0 167 | } 168 | 169 | func (m *CastMessage) GetSourceId() string { 170 | if m != nil && m.SourceId != nil { 171 | return *m.SourceId 172 | } 173 | return "" 174 | } 175 | 176 | func (m *CastMessage) GetDestinationId() string { 177 | if m != nil && m.DestinationId != nil { 178 | return *m.DestinationId 179 | } 180 | return "" 181 | } 182 | 183 | func (m *CastMessage) GetNamespace() string { 184 | if m != nil && m.Namespace != nil { 185 | return *m.Namespace 186 | } 187 | return "" 188 | } 189 | 190 | func (m *CastMessage) GetPayloadType() CastMessage_PayloadType { 191 | if m != nil && m.PayloadType != nil { 192 | return *m.PayloadType 193 | } 194 | return CastMessage_STRING 195 | } 196 | 197 | func (m *CastMessage) GetPayloadUtf8() string { 198 | if m != nil && m.PayloadUtf8 != nil { 199 | return *m.PayloadUtf8 200 | } 201 | return "" 202 | } 203 | 204 | func (m *CastMessage) GetPayloadBinary() []byte { 205 | if m != nil { 206 | return m.PayloadBinary 207 | } 208 | return nil 209 | } 210 | 211 | // Messages for authentication protocol between a sender and a receiver. 212 | type AuthChallenge struct { 213 | XXX_unrecognized []byte `json:"-"` 214 | } 215 | 216 | func (m *AuthChallenge) Reset() { *m = AuthChallenge{} } 217 | func (m *AuthChallenge) String() string { return proto.CompactTextString(m) } 218 | func (*AuthChallenge) ProtoMessage() {} 219 | 220 | type AuthResponse struct { 221 | Signature []byte `protobuf:"bytes,1,req,name=signature" json:"signature,omitempty"` 222 | ClientAuthCertificate []byte `protobuf:"bytes,2,req,name=client_auth_certificate" json:"client_auth_certificate,omitempty"` 223 | XXX_unrecognized []byte `json:"-"` 224 | } 225 | 226 | func (m *AuthResponse) Reset() { *m = AuthResponse{} } 227 | func (m *AuthResponse) String() string { return proto.CompactTextString(m) } 228 | func (*AuthResponse) ProtoMessage() {} 229 | 230 | func (m *AuthResponse) GetSignature() []byte { 231 | if m != nil { 232 | return m.Signature 233 | } 234 | return nil 235 | } 236 | 237 | func (m *AuthResponse) GetClientAuthCertificate() []byte { 238 | if m != nil { 239 | return m.ClientAuthCertificate 240 | } 241 | return nil 242 | } 243 | 244 | type AuthError struct { 245 | ErrorType *AuthError_ErrorType `protobuf:"varint,1,req,name=error_type,enum=api.AuthError_ErrorType" json:"error_type,omitempty"` 246 | XXX_unrecognized []byte `json:"-"` 247 | } 248 | 249 | func (m *AuthError) Reset() { *m = AuthError{} } 250 | func (m *AuthError) String() string { return proto.CompactTextString(m) } 251 | func (*AuthError) ProtoMessage() {} 252 | 253 | func (m *AuthError) GetErrorType() AuthError_ErrorType { 254 | if m != nil && m.ErrorType != nil { 255 | return *m.ErrorType 256 | } 257 | return AuthError_INTERNAL_ERROR 258 | } 259 | 260 | type DeviceAuthMessage struct { 261 | // Request fields 262 | Challenge *AuthChallenge `protobuf:"bytes,1,opt,name=challenge" json:"challenge,omitempty"` 263 | // Response fields 264 | Response *AuthResponse `protobuf:"bytes,2,opt,name=response" json:"response,omitempty"` 265 | Error *AuthError `protobuf:"bytes,3,opt,name=error" json:"error,omitempty"` 266 | XXX_unrecognized []byte `json:"-"` 267 | } 268 | 269 | func (m *DeviceAuthMessage) Reset() { *m = DeviceAuthMessage{} } 270 | func (m *DeviceAuthMessage) String() string { return proto.CompactTextString(m) } 271 | func (*DeviceAuthMessage) ProtoMessage() {} 272 | 273 | func (m *DeviceAuthMessage) GetChallenge() *AuthChallenge { 274 | if m != nil { 275 | return m.Challenge 276 | } 277 | return nil 278 | } 279 | 280 | func (m *DeviceAuthMessage) GetResponse() *AuthResponse { 281 | if m != nil { 282 | return m.Response 283 | } 284 | return nil 285 | } 286 | 287 | func (m *DeviceAuthMessage) GetError() *AuthError { 288 | if m != nil { 289 | return m.Error 290 | } 291 | return nil 292 | } 293 | 294 | func init() { 295 | proto.RegisterEnum("api.CastMessage_ProtocolVersion", CastMessage_ProtocolVersion_name, CastMessage_ProtocolVersion_value) 296 | proto.RegisterEnum("api.CastMessage_PayloadType", CastMessage_PayloadType_name, CastMessage_PayloadType_value) 297 | proto.RegisterEnum("api.AuthError_ErrorType", AuthError_ErrorType_name, AuthError_ErrorType_value) 298 | } 299 | -------------------------------------------------------------------------------- /api/cast_channel.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Chromium Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | syntax = "proto2"; 6 | 7 | option optimize_for = LITE_RUNTIME; 8 | 9 | package api;// EDITED: Was extensions.api.cast_channel. ; 10 | 11 | message CastMessage { 12 | // Always pass a version of the protocol for future compatibility 13 | // requirements. 14 | enum ProtocolVersion { 15 | CASTV2_1_0 = 0; 16 | } 17 | required ProtocolVersion protocol_version = 1; 18 | 19 | // source and destination ids identify the origin and destination of the 20 | // message. They are used to route messages between endpoints that share a 21 | // device-to-device channel. 22 | // 23 | // For messages between applications: 24 | // - The sender application id is a unique identifier generated on behalf of 25 | // the sender application. 26 | // - The receiver id is always the the session id for the application. 27 | // 28 | // For messages to or from the sender or receiver platform, the special ids 29 | // 'sender-0' and 'receiver-0' can be used. 30 | // 31 | // For messages intended for all endpoints using a given channel, the 32 | // wildcard destination_id '*' can be used. 33 | required string source_id = 2; 34 | required string destination_id = 3; 35 | 36 | // This is the core multiplexing key. All messages are sent on a namespace 37 | // and endpoints sharing a channel listen on one or more namespaces. The 38 | // namespace defines the protocol and semantics of the message. 39 | required string namespace = 4; 40 | 41 | // Encoding and payload info follows. 42 | 43 | // What type of data do we have in this message. 44 | enum PayloadType { 45 | STRING = 0; 46 | BINARY = 1; 47 | } 48 | required PayloadType payload_type = 5; 49 | 50 | // Depending on payload_type, exactly one of the following optional fields 51 | // will always be set. 52 | optional string payload_utf8 = 6; 53 | optional bytes payload_binary = 7; 54 | } 55 | 56 | // Messages for authentication protocol between a sender and a receiver. 57 | message AuthChallenge { 58 | } 59 | 60 | message AuthResponse { 61 | required bytes signature = 1; 62 | required bytes client_auth_certificate = 2; 63 | } 64 | 65 | message AuthError { 66 | enum ErrorType { 67 | INTERNAL_ERROR = 0; 68 | NO_TLS = 1; // The underlying connection is not TLS 69 | } 70 | required ErrorType error_type = 1; 71 | } 72 | 73 | message DeviceAuthMessage { 74 | // Request fields 75 | optional AuthChallenge challenge = 1; 76 | // Response fields 77 | optional AuthResponse response = 2; 78 | optional AuthError error = 3; 79 | } 80 | -------------------------------------------------------------------------------- /api/generate-proto.sh: -------------------------------------------------------------------------------- 1 | protoc --gogo_out=. cast_channel.proto 2 | -------------------------------------------------------------------------------- /apps.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | const AppMedia = "CC1AD845" 4 | const AppURL = "5CB45E5A" 5 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | 8 | "golang.org/x/net/context" 9 | 10 | "github.com/barnybug/go-cast/controllers" 11 | "github.com/barnybug/go-cast/events" 12 | "github.com/barnybug/go-cast/log" 13 | castnet "github.com/barnybug/go-cast/net" 14 | ) 15 | 16 | type Client struct { 17 | name string 18 | info map[string]string 19 | host net.IP 20 | port int 21 | conn *castnet.Connection 22 | ctx context.Context 23 | cancel context.CancelFunc 24 | heartbeat *controllers.HeartbeatController 25 | connection *controllers.ConnectionController 26 | receiver *controllers.ReceiverController 27 | media *controllers.MediaController 28 | url *controllers.URLController 29 | 30 | Events chan events.Event 31 | } 32 | 33 | const DefaultSender = "sender-0" 34 | const DefaultReceiver = "receiver-0" 35 | const TransportSender = "Tr@n$p0rt-0" 36 | const TransportReceiver = "Tr@n$p0rt-0" 37 | 38 | func NewClient(host net.IP, port int) *Client { 39 | return &Client{ 40 | host: host, 41 | port: port, 42 | ctx: context.Background(), 43 | Events: make(chan events.Event, 16), 44 | } 45 | } 46 | 47 | func (c *Client) IP() net.IP { 48 | return c.host 49 | } 50 | 51 | func (c *Client) Port() int { 52 | return c.port 53 | } 54 | 55 | func (c *Client) SetName(name string) { 56 | c.name = name 57 | } 58 | 59 | func (c *Client) Name() string { 60 | return c.name 61 | } 62 | 63 | func (c *Client) SetInfo(info map[string]string) { 64 | c.info = info 65 | } 66 | 67 | func (c *Client) Uuid() string { 68 | return c.info["id"] 69 | } 70 | 71 | func (c *Client) Device() string { 72 | return c.info["md"] 73 | } 74 | 75 | func (c *Client) Status() string { 76 | return c.info["rs"] 77 | } 78 | 79 | func (c *Client) String() string { 80 | return fmt.Sprintf("%s - %s:%d", c.name, c.host, c.port) 81 | } 82 | 83 | func (c *Client) Connect(ctx context.Context) error { 84 | c.conn = castnet.NewConnection() 85 | err := c.conn.Connect(ctx, c.host, c.port) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | ctx, cancel := context.WithCancel(c.ctx) 91 | c.cancel = cancel 92 | 93 | // start connection 94 | c.connection = controllers.NewConnectionController(c.conn, c.Events, DefaultSender, DefaultReceiver) 95 | if err := c.connection.Start(ctx); err != nil { 96 | return err 97 | } 98 | 99 | // start heartbeat 100 | c.heartbeat = controllers.NewHeartbeatController(c.conn, c.Events, TransportSender, TransportReceiver) 101 | if err := c.heartbeat.Start(ctx); err != nil { 102 | return err 103 | } 104 | 105 | // start receiver 106 | c.receiver = controllers.NewReceiverController(c.conn, c.Events, DefaultSender, DefaultReceiver) 107 | if err := c.receiver.Start(ctx); err != nil { 108 | return err 109 | } 110 | 111 | c.Events <- events.Connected{} 112 | 113 | return nil 114 | } 115 | 116 | func (c *Client) NewChannel(sourceId, destinationId, namespace string) *castnet.Channel { 117 | return c.conn.NewChannel(sourceId, destinationId, namespace) 118 | } 119 | 120 | func (c *Client) Close() { 121 | c.cancel() 122 | if c.conn != nil { 123 | c.conn.Close() 124 | c.conn = nil 125 | } 126 | } 127 | 128 | func (c *Client) Receiver() *controllers.ReceiverController { 129 | return c.receiver 130 | } 131 | 132 | func (c *Client) launchApp(ctx context.Context, appId string) (string, error) { 133 | // get transport id 134 | status, err := c.receiver.GetStatus(ctx) 135 | if err != nil { 136 | return "", err 137 | } 138 | app := status.GetSessionByAppId(appId) 139 | if app == nil { 140 | // needs launching 141 | status, err = c.receiver.LaunchApp(ctx, appId) 142 | if err != nil { 143 | return "", err 144 | } 145 | app = status.GetSessionByAppId(appId) 146 | } 147 | 148 | if app == nil { 149 | return "", errors.New("Failed to get transport") 150 | } 151 | return *app.TransportId, nil 152 | } 153 | 154 | func (c *Client) launchMediaApp(ctx context.Context) (string, error) { 155 | return c.launchApp(ctx, AppMedia) 156 | } 157 | 158 | func (c *Client) launchURLApp(ctx context.Context) (string, error) { 159 | return c.launchApp(ctx, AppURL) 160 | } 161 | 162 | func (c *Client) IsPlaying(ctx context.Context) bool { 163 | status, err := c.receiver.GetStatus(ctx) 164 | if err != nil { 165 | log.Fatalln(err) 166 | return false 167 | } 168 | app := status.GetSessionByAppId(AppMedia) 169 | if app == nil { 170 | return false 171 | } 172 | if *app.StatusText == "Ready To Cast" { 173 | return false 174 | } 175 | return true 176 | } 177 | 178 | func (c *Client) Media(ctx context.Context) (*controllers.MediaController, error) { 179 | if c.media == nil { 180 | transportId, err := c.launchMediaApp(ctx) 181 | if err != nil { 182 | return nil, err 183 | } 184 | conn := controllers.NewConnectionController(c.conn, c.Events, DefaultSender, transportId) 185 | if err := conn.Start(ctx); err != nil { 186 | return nil, err 187 | } 188 | c.media = controllers.NewMediaController(c.conn, c.Events, DefaultSender, transportId) 189 | if err := c.media.Start(ctx); err != nil { 190 | return nil, err 191 | } 192 | } 193 | return c.media, nil 194 | } 195 | 196 | func (c *Client) URL(ctx context.Context) (*controllers.URLController, error) { 197 | if c.url == nil { 198 | transportId, err := c.launchURLApp(ctx) 199 | if err != nil { 200 | return nil, err 201 | } 202 | conn := controllers.NewConnectionController(c.conn, c.Events, DefaultSender, transportId) 203 | if err := conn.Start(ctx); err != nil { 204 | return nil, err 205 | } 206 | c.url = controllers.NewURLController(c.conn, c.Events, DefaultSender, transportId) 207 | } 208 | return c.url, nil 209 | } 210 | -------------------------------------------------------------------------------- /cmd/cast/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "golang.org/x/net/context" 13 | 14 | "github.com/barnybug/go-cast" 15 | "github.com/barnybug/go-cast/controllers" 16 | "github.com/barnybug/go-cast/discovery" 17 | "github.com/barnybug/go-cast/events" 18 | "github.com/barnybug/go-cast/log" 19 | "github.com/urfave/cli" 20 | ) 21 | 22 | func checkErr(err error) { 23 | if err != nil { 24 | if err == context.DeadlineExceeded { 25 | fmt.Println("Timeout exceeded") 26 | } else { 27 | fmt.Println(err) 28 | } 29 | os.Exit(1) 30 | } 31 | } 32 | 33 | func main() { 34 | commonFlags := []cli.Flag{ 35 | cli.BoolFlag{ 36 | Name: "debug, d", 37 | Usage: "enable debug logging", 38 | }, 39 | cli.StringFlag{ 40 | Name: "host", 41 | Usage: "chromecast hostname or IP (required)", 42 | }, 43 | cli.IntFlag{ 44 | Name: "port", 45 | Usage: "chromecast port", 46 | Value: 8009, 47 | }, 48 | cli.StringFlag{ 49 | Name: "name", 50 | Usage: "chromecast name (required)", 51 | }, 52 | cli.DurationFlag{ 53 | Name: "timeout", 54 | Value: 15 * time.Second, 55 | }, 56 | } 57 | app := cli.NewApp() 58 | app.Name = "cast" 59 | app.Usage = "Command line tool for the Chromecast" 60 | app.Version = cast.Version 61 | app.Flags = commonFlags 62 | app.Commands = []cli.Command{ 63 | { 64 | Name: "media", 65 | Usage: "media commands", 66 | Subcommands: []cli.Command{ 67 | { 68 | Name: "play", 69 | Usage: "play some media", 70 | ArgsUsage: "play url [content type]", 71 | Action: cliCommand, 72 | }, 73 | { 74 | Name: "stop", 75 | Usage: "stop playing media", 76 | Action: cliCommand, 77 | }, 78 | { 79 | Name: "pause", 80 | Usage: "pause playing media", 81 | Action: cliCommand, 82 | }, 83 | }, 84 | }, 85 | { 86 | Name: "url", 87 | Usage: "url commands (chromecast tv only)", 88 | Subcommands: []cli.Command{ 89 | { 90 | Name: "load", 91 | Usage: "load a url", 92 | ArgsUsage: "load url", 93 | Action: cliCommand, 94 | }, 95 | }, 96 | }, 97 | { 98 | Name: "volume", 99 | Usage: "set current volume", 100 | Action: cliCommand, 101 | }, 102 | { 103 | Name: "quit", 104 | Usage: "close current app on Chromecast", 105 | Action: cliCommand, 106 | }, 107 | { 108 | Name: "script", 109 | Usage: "Run the set of commands passed to stdin", 110 | Action: scriptCommand, 111 | }, 112 | { 113 | Name: "status", 114 | Usage: "Get status of the Chromecast", 115 | Action: statusCommand, 116 | }, 117 | { 118 | Name: "discover", 119 | Usage: "Discover Chromecast devices", 120 | Action: discoverCommand, 121 | }, 122 | { 123 | Name: "watch", 124 | Usage: "Discover and watch Chromecast devices for events", 125 | Action: watchCommand, 126 | }, 127 | } 128 | app.Run(os.Args) 129 | log.Println("Done") 130 | } 131 | 132 | func cliCommand(c *cli.Context) { 133 | log.Debug = c.GlobalBool("debug") 134 | ctx, cancel := context.WithTimeout(context.Background(), c.GlobalDuration("timeout")) 135 | defer cancel() 136 | if !checkCommand(c.Command.Name, c.Args()) { 137 | return 138 | } 139 | client := connect(ctx, c) 140 | runCommand(ctx, client, c.Command.Name, c.Args()) 141 | } 142 | 143 | func connect(ctx context.Context, c *cli.Context) *cast.Client { 144 | host := c.GlobalString("host") 145 | name := c.GlobalString("name") 146 | if host == "" && name == "" { 147 | fmt.Println("Either --host or --name is required") 148 | os.Exit(1) 149 | } 150 | 151 | var client *cast.Client 152 | if host != "" { 153 | log.Printf("Looking up %s...", host) 154 | ips, err := net.LookupIP(host) 155 | checkErr(err) 156 | 157 | client = cast.NewClient(ips[0], c.GlobalInt("port")) 158 | } else { 159 | // run discovery and stop once we have find this name 160 | service := discovery.NewService(ctx) 161 | go service.Run(ctx, 2*time.Second) 162 | 163 | LOOP: 164 | for { 165 | select { 166 | case c := <-service.Found(): 167 | if c.Name() == name { 168 | log.Printf("Found: %s at %s:%d", c.Name(), c.IP(), c.Port()) 169 | client = c 170 | break LOOP 171 | } 172 | case <-ctx.Done(): 173 | break LOOP 174 | } 175 | } 176 | 177 | // check for timeout 178 | checkErr(ctx.Err()) 179 | } 180 | 181 | fmt.Printf("Connecting to %s:%d...\n", client.IP(), client.Port()) 182 | err := client.Connect(ctx) 183 | checkErr(err) 184 | 185 | fmt.Println("Connected") 186 | return client 187 | } 188 | 189 | func scriptCommand(c *cli.Context) { 190 | log.Debug = c.GlobalBool("debug") 191 | ctx, cancel := context.WithTimeout(context.Background(), c.GlobalDuration("timeout")) 192 | defer cancel() 193 | scanner := bufio.NewScanner(os.Stdin) 194 | commands := [][]string{} 195 | 196 | for scanner.Scan() { 197 | args := strings.Split(scanner.Text(), " ") 198 | if len(args) == 0 { 199 | continue 200 | } 201 | if !checkCommand(args[0], args[1:]) { 202 | return 203 | } 204 | commands = append(commands, args) 205 | } 206 | 207 | client := connect(ctx, c) 208 | 209 | for _, args := range commands { 210 | runCommand(ctx, client, args[0], args[1:]) 211 | } 212 | } 213 | 214 | func statusCommand(c *cli.Context) { 215 | log.Debug = c.GlobalBool("debug") 216 | ctx, cancel := context.WithTimeout(context.Background(), c.GlobalDuration("timeout")) 217 | defer cancel() 218 | client := connect(ctx, c) 219 | 220 | status, err := client.Receiver().GetStatus(ctx) 221 | checkErr(err) 222 | 223 | if len(status.Applications) > 0 { 224 | for _, app := range status.Applications { 225 | fmt.Printf("[%s] %s\n", *app.DisplayName, *app.StatusText) 226 | } 227 | } else { 228 | fmt.Println("No applications running") 229 | } 230 | fmt.Printf("Volume: %.2f", *status.Volume.Level) 231 | if *status.Volume.Muted { 232 | fmt.Print("muted\n") 233 | } else { 234 | fmt.Print("\n") 235 | } 236 | } 237 | 238 | func discoverCommand(c *cli.Context) { 239 | log.Debug = c.GlobalBool("debug") 240 | timeout := c.GlobalDuration("timeout") 241 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 242 | defer cancel() 243 | discover := discovery.NewService(ctx) 244 | go func() { 245 | found := map[string]bool{} 246 | for client := range discover.Found() { 247 | if _, ok := found[client.Uuid()]; !ok { 248 | fmt.Printf("Found: %s:%d '%s' (%s) %s\n", client.IP(), client.Port(), client.Name(), client.Device(), client.Status()) 249 | found[client.Uuid()] = true 250 | } 251 | } 252 | }() 253 | fmt.Printf("Running discovery for %s...\n", timeout) 254 | err := discover.Run(ctx, 5*time.Second) 255 | if err == context.DeadlineExceeded { 256 | fmt.Println("Done") 257 | return 258 | } 259 | checkErr(err) 260 | } 261 | 262 | func watchCommand(c *cli.Context) { 263 | log.Debug = c.GlobalBool("debug") 264 | timeout := c.GlobalDuration("timeout") 265 | 266 | CONNECT: 267 | for { 268 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 269 | client := connect(ctx, c) 270 | client.Media(ctx) 271 | cancel() 272 | 273 | for event := range client.Events { 274 | switch t := event.(type) { 275 | case events.Connected: 276 | case events.AppStarted: 277 | fmt.Printf("App started: %s [%s]\n", t.DisplayName, t.AppID) 278 | case events.AppStopped: 279 | fmt.Printf("App stopped: %s [%s]\n", t.DisplayName, t.AppID) 280 | case events.StatusUpdated: 281 | fmt.Printf("Status updated: volume %.2f [%v]\n", t.Level, t.Muted) 282 | case events.Disconnected: 283 | fmt.Printf("Disconnected: %s\n", t.Reason) 284 | fmt.Println("Reconnecting...") 285 | client.Close() 286 | continue CONNECT 287 | case controllers.MediaStatus: 288 | fmt.Printf("Media Status: state: %s %.1fs\n", t.PlayerState, t.CurrentTime) 289 | default: 290 | fmt.Printf("Unknown event: %#v\n", t) 291 | } 292 | } 293 | } 294 | } 295 | 296 | var minArgs = map[string]int{ 297 | "play": 1, 298 | "pause": 0, 299 | "stop": 0, 300 | "quit": 0, 301 | "volume": 1, 302 | "load": 1, 303 | } 304 | 305 | var maxArgs = map[string]int{ 306 | "play": 2, 307 | "pause": 0, 308 | "stop": 0, 309 | "quit": 0, 310 | "volume": 1, 311 | "load": 1, 312 | } 313 | 314 | func checkCommand(cmd string, args []string) bool { 315 | if _, ok := minArgs[cmd]; !ok { 316 | fmt.Printf("Command '%s' not understood\n", cmd) 317 | return false 318 | } 319 | if len(args) < minArgs[cmd] { 320 | fmt.Printf("Command '%s' requires at least %d argument(s)\n", cmd, minArgs[cmd]) 321 | return false 322 | } 323 | if len(args) > maxArgs[cmd] { 324 | fmt.Printf("Command '%s' takes at most %d argument(s)\n", cmd, maxArgs[cmd]) 325 | return false 326 | } 327 | switch cmd { 328 | case "volume": 329 | if err := validateFloat(args[0], 0.0, 1.0); err != nil { 330 | fmt.Printf("Command '%s': %s\n", cmd, err) 331 | return false 332 | } 333 | 334 | } 335 | return true 336 | } 337 | 338 | func validateFloat(val string, min, max float64) error { 339 | fval, err := strconv.ParseFloat(val, 64) 340 | if err != nil { 341 | return fmt.Errorf("Expected a number between 0.0 and 1.0") 342 | } 343 | if fval < min { 344 | return fmt.Errorf("Value is below minimum: %.2f", min) 345 | } 346 | if fval > max { 347 | return fmt.Errorf("Value is below maximum: %.2f", max) 348 | } 349 | return nil 350 | } 351 | 352 | func runCommand(ctx context.Context, client *cast.Client, cmd string, args []string) { 353 | switch cmd { 354 | case "play": 355 | media, err := client.Media(ctx) 356 | checkErr(err) 357 | url := args[0] 358 | contentType := "audio/mpeg" 359 | if len(args) > 1 { 360 | contentType = args[1] 361 | } 362 | item := controllers.MediaItem{ 363 | ContentId: url, 364 | StreamType: "BUFFERED", 365 | ContentType: contentType, 366 | } 367 | _, err = media.LoadMedia(ctx, item, 0, true, map[string]interface{}{}) 368 | checkErr(err) 369 | 370 | case "pause": 371 | media, err := client.Media(ctx) 372 | checkErr(err) 373 | _, err = media.Pause(ctx) 374 | checkErr(err) 375 | 376 | case "stop": 377 | if !client.IsPlaying(ctx) { 378 | // if media isn't running, no media can be playing 379 | return 380 | } 381 | media, err := client.Media(ctx) 382 | checkErr(err) 383 | _, err = media.Stop(ctx) 384 | checkErr(err) 385 | 386 | case "volume": 387 | receiver := client.Receiver() 388 | level, _ := strconv.ParseFloat(args[0], 64) 389 | muted := false 390 | volume := controllers.Volume{Level: &level, Muted: &muted} 391 | _, err := receiver.SetVolume(ctx, &volume) 392 | checkErr(err) 393 | 394 | case "load": 395 | controller, err := client.URL(ctx) 396 | checkErr(err) 397 | url := args[0] 398 | _, err = controller.LoadURL(ctx, url) 399 | checkErr(err) 400 | 401 | case "quit": 402 | receiver := client.Receiver() 403 | _, err := receiver.QuitApp(ctx) 404 | checkErr(err) 405 | 406 | default: 407 | fmt.Printf("Command '%s' not understood - ignored\n", cmd) 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /controllers/connection.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/barnybug/go-cast/events" 5 | "github.com/barnybug/go-cast/net" 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | type ConnectionController struct { 10 | channel *net.Channel 11 | } 12 | 13 | var connect = net.PayloadHeaders{Type: "CONNECT"} 14 | var close = net.PayloadHeaders{Type: "CLOSE"} 15 | 16 | func NewConnectionController(conn *net.Connection, eventsCh chan events.Event, sourceId, destinationId string) *ConnectionController { 17 | controller := &ConnectionController{ 18 | channel: conn.NewChannel(sourceId, destinationId, "urn:x-cast:com.google.cast.tp.connection"), 19 | } 20 | 21 | return controller 22 | } 23 | 24 | func (c *ConnectionController) Start(ctx context.Context) error { 25 | return c.channel.Send(connect) 26 | } 27 | 28 | func (c *ConnectionController) Close() error { 29 | return c.channel.Send(close) 30 | } 31 | -------------------------------------------------------------------------------- /controllers/controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import "golang.org/x/net/context" 4 | 5 | type Controller interface { 6 | Start(ctx context.Context) error 7 | } 8 | -------------------------------------------------------------------------------- /controllers/controller_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import "testing" 4 | 5 | func TestInterfaces(t *testing.T) { 6 | // assert controllers implement interfaces 7 | var _ Controller = (*ConnectionController)(nil) 8 | var _ Controller = (*HeartbeatController)(nil) 9 | var _ Controller = (*ReceiverController)(nil) 10 | var _ Controller = (*MediaController)(nil) 11 | } 12 | -------------------------------------------------------------------------------- /controllers/heartbeat.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "errors" 5 | "sync/atomic" 6 | "time" 7 | 8 | "golang.org/x/net/context" 9 | 10 | "github.com/barnybug/go-cast/api" 11 | "github.com/barnybug/go-cast/events" 12 | "github.com/barnybug/go-cast/log" 13 | "github.com/barnybug/go-cast/net" 14 | ) 15 | 16 | const interval = time.Second * 5 17 | const maxBacklog = 3 18 | 19 | type HeartbeatController struct { 20 | pongs int64 21 | ticker *time.Ticker 22 | channel *net.Channel 23 | eventsCh chan events.Event 24 | } 25 | 26 | var ping = net.PayloadHeaders{Type: "PING"} 27 | var pong = net.PayloadHeaders{Type: "PONG"} 28 | 29 | func NewHeartbeatController(conn *net.Connection, eventsCh chan events.Event, sourceId, destinationId string) *HeartbeatController { 30 | controller := &HeartbeatController{ 31 | channel: conn.NewChannel(sourceId, destinationId, "urn:x-cast:com.google.cast.tp.heartbeat"), 32 | eventsCh: eventsCh, 33 | } 34 | 35 | controller.channel.OnMessage("PING", controller.onPing) 36 | controller.channel.OnMessage("PONG", controller.onPong) 37 | 38 | return controller 39 | } 40 | 41 | func (c *HeartbeatController) onPing(_ *api.CastMessage) { 42 | err := c.channel.Send(pong) 43 | if err != nil { 44 | log.Errorf("Error sending pong: %s", err) 45 | } 46 | } 47 | 48 | func (c *HeartbeatController) sendEvent(event events.Event) { 49 | select { 50 | case c.eventsCh <- event: 51 | default: 52 | log.Printf("Dropped event: %#v", event) 53 | } 54 | } 55 | 56 | func (c *HeartbeatController) onPong(_ *api.CastMessage) { 57 | atomic.StoreInt64(&c.pongs, 0) 58 | } 59 | 60 | func (c *HeartbeatController) Start(ctx context.Context) error { 61 | if c.ticker != nil { 62 | c.Stop() 63 | } 64 | 65 | c.ticker = time.NewTicker(interval) 66 | go func() { 67 | LOOP: 68 | for { 69 | select { 70 | case <-c.ticker.C: 71 | if atomic.LoadInt64(&c.pongs) >= maxBacklog { 72 | log.Errorf("Missed %d pongs", c.pongs) 73 | c.sendEvent(events.Disconnected{errors.New("Ping timeout")}) 74 | break LOOP 75 | } 76 | err := c.channel.Send(ping) 77 | atomic.AddInt64(&c.pongs, 1) 78 | if err != nil { 79 | log.Errorf("Error sending ping: %s", err) 80 | c.sendEvent(events.Disconnected{err}) 81 | break LOOP 82 | } 83 | case <-ctx.Done(): 84 | log.Println("Heartbeat stopped") 85 | break LOOP 86 | } 87 | } 88 | }() 89 | 90 | log.Println("Heartbeat started") 91 | return nil 92 | } 93 | 94 | func (c *HeartbeatController) Stop() { 95 | if c.ticker != nil { 96 | c.ticker.Stop() 97 | c.ticker = nil 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /controllers/media.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "golang.org/x/net/context" 10 | 11 | "github.com/barnybug/go-cast/api" 12 | "github.com/barnybug/go-cast/events" 13 | "github.com/barnybug/go-cast/log" 14 | "github.com/barnybug/go-cast/net" 15 | ) 16 | 17 | type MediaController struct { 18 | interval time.Duration 19 | channel *net.Channel 20 | eventsCh chan events.Event 21 | DestinationID string 22 | MediaSessionID int 23 | } 24 | 25 | const NamespaceMedia = "urn:x-cast:com.google.cast.media" 26 | 27 | var getMediaStatus = net.PayloadHeaders{Type: "GET_STATUS"} 28 | 29 | var commandMediaPlay = net.PayloadHeaders{Type: "PLAY"} 30 | var commandMediaPause = net.PayloadHeaders{Type: "PAUSE"} 31 | var commandMediaStop = net.PayloadHeaders{Type: "STOP"} 32 | var commandMediaLoad = net.PayloadHeaders{Type: "LOAD"} 33 | 34 | type MediaCommand struct { 35 | net.PayloadHeaders 36 | MediaSessionID int `json:"mediaSessionId"` 37 | } 38 | 39 | type LoadMediaCommand struct { 40 | net.PayloadHeaders 41 | Media MediaItem `json:"media"` 42 | CurrentTime int `json:"currentTime"` 43 | Autoplay bool `json:"autoplay"` 44 | CustomData interface{} `json:"customData"` 45 | } 46 | 47 | type MediaItem struct { 48 | ContentId string `json:"contentId"` 49 | StreamType string `json:"streamType"` 50 | ContentType string `json:"contentType"` 51 | } 52 | 53 | type MediaStatusMedia struct { 54 | ContentId string `json:"contentId"` 55 | StreamType string `json:"streamType"` 56 | ContentType string `json:"contentType"` 57 | Duration float64 `json:"duration"` 58 | } 59 | 60 | func NewMediaController(conn *net.Connection, eventsCh chan events.Event, sourceId, destinationID string) *MediaController { 61 | controller := &MediaController{ 62 | channel: conn.NewChannel(sourceId, destinationID, NamespaceMedia), 63 | eventsCh: eventsCh, 64 | DestinationID: destinationID, 65 | } 66 | 67 | controller.channel.OnMessage("MEDIA_STATUS", controller.onStatus) 68 | 69 | return controller 70 | } 71 | 72 | func (c *MediaController) SetDestinationID(id string) { 73 | c.channel.DestinationId = id 74 | c.DestinationID = id 75 | } 76 | 77 | func (c *MediaController) sendEvent(event events.Event) { 78 | select { 79 | case c.eventsCh <- event: 80 | default: 81 | log.Printf("Dropped event: %#v", event) 82 | } 83 | } 84 | 85 | func (c *MediaController) onStatus(message *api.CastMessage) { 86 | response, err := c.parseStatus(message) 87 | if err != nil { 88 | log.Errorf("Error parsing status: %s", err) 89 | } 90 | 91 | for _, status := range response.Status { 92 | c.sendEvent(*status) 93 | } 94 | } 95 | 96 | func (c *MediaController) parseStatus(message *api.CastMessage) (*MediaStatusResponse, error) { 97 | response := &MediaStatusResponse{} 98 | 99 | err := json.Unmarshal([]byte(*message.PayloadUtf8), response) 100 | 101 | if err != nil { 102 | return nil, fmt.Errorf("Failed to unmarshal status message:%s - %s", err, *message.PayloadUtf8) 103 | } 104 | 105 | for _, status := range response.Status { 106 | c.MediaSessionID = status.MediaSessionID 107 | } 108 | 109 | return response, nil 110 | } 111 | 112 | type MediaStatusResponse struct { 113 | net.PayloadHeaders 114 | Status []*MediaStatus `json:"status,omitempty"` 115 | } 116 | 117 | type MediaStatus struct { 118 | net.PayloadHeaders 119 | MediaSessionID int `json:"mediaSessionId"` 120 | PlaybackRate float64 `json:"playbackRate"` 121 | PlayerState string `json:"playerState"` 122 | CurrentTime float64 `json:"currentTime"` 123 | SupportedMediaCommands int `json:"supportedMediaCommands"` 124 | Volume *Volume `json:"volume,omitempty"` 125 | Media *MediaStatusMedia `json:"media"` 126 | CustomData map[string]interface{} `json:"customData"` 127 | RepeatMode string `json:"repeatMode"` 128 | IdleReason string `json:"idleReason"` 129 | } 130 | 131 | func (c *MediaController) Start(ctx context.Context) error { 132 | _, err := c.GetStatus(ctx) 133 | return err 134 | } 135 | 136 | func (c *MediaController) GetStatus(ctx context.Context) (*MediaStatusResponse, error) { 137 | message, err := c.channel.Request(ctx, &getMediaStatus) 138 | if err != nil { 139 | return nil, fmt.Errorf("Failed to get receiver status: %s", err) 140 | } 141 | 142 | return c.parseStatus(message) 143 | } 144 | 145 | func (c *MediaController) Play(ctx context.Context) (*api.CastMessage, error) { 146 | message, err := c.channel.Request(ctx, &MediaCommand{commandMediaPlay, c.MediaSessionID}) 147 | if err != nil { 148 | return nil, fmt.Errorf("Failed to send play command: %s", err) 149 | } 150 | return message, nil 151 | } 152 | 153 | func (c *MediaController) Pause(ctx context.Context) (*api.CastMessage, error) { 154 | message, err := c.channel.Request(ctx, &MediaCommand{commandMediaPause, c.MediaSessionID}) 155 | if err != nil { 156 | return nil, fmt.Errorf("Failed to send pause command: %s", err) 157 | } 158 | return message, nil 159 | } 160 | 161 | func (c *MediaController) Stop(ctx context.Context) (*api.CastMessage, error) { 162 | if c.MediaSessionID == 0 { 163 | // no current session to stop 164 | return nil, nil 165 | } 166 | message, err := c.channel.Request(ctx, &MediaCommand{commandMediaStop, c.MediaSessionID}) 167 | if err != nil { 168 | return nil, fmt.Errorf("Failed to send stop command: %s", err) 169 | } 170 | return message, nil 171 | } 172 | 173 | func (c *MediaController) LoadMedia(ctx context.Context, media MediaItem, currentTime int, autoplay bool, customData interface{}) (*api.CastMessage, error) { 174 | message, err := c.channel.Request(ctx, &LoadMediaCommand{ 175 | PayloadHeaders: commandMediaLoad, 176 | Media: media, 177 | CurrentTime: currentTime, 178 | Autoplay: autoplay, 179 | CustomData: customData, 180 | }) 181 | if err != nil { 182 | return nil, fmt.Errorf("Failed to send load command: %s", err) 183 | } 184 | 185 | response := &net.PayloadHeaders{} 186 | err = json.Unmarshal([]byte(*message.PayloadUtf8), response) 187 | if err != nil { 188 | return nil, err 189 | } 190 | if response.Type == "LOAD_FAILED" { 191 | return nil, errors.New("Load media failed") 192 | } 193 | 194 | return message, nil 195 | } 196 | -------------------------------------------------------------------------------- /controllers/receiver.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "golang.org/x/net/context" 9 | 10 | "github.com/barnybug/go-cast/api" 11 | "github.com/barnybug/go-cast/events" 12 | "github.com/barnybug/go-cast/log" 13 | "github.com/barnybug/go-cast/net" 14 | ) 15 | 16 | type ReceiverController struct { 17 | interval time.Duration 18 | channel *net.Channel 19 | eventsCh chan events.Event 20 | status *ReceiverStatus 21 | } 22 | 23 | var getStatus = net.PayloadHeaders{Type: "GET_STATUS"} 24 | var commandLaunch = net.PayloadHeaders{Type: "LAUNCH"} 25 | var commandStop = net.PayloadHeaders{Type: "STOP"} 26 | 27 | func NewReceiverController(conn *net.Connection, eventsCh chan events.Event, sourceId, destinationId string) *ReceiverController { 28 | controller := &ReceiverController{ 29 | channel: conn.NewChannel(sourceId, destinationId, "urn:x-cast:com.google.cast.receiver"), 30 | eventsCh: eventsCh, 31 | } 32 | 33 | controller.channel.OnMessage("RECEIVER_STATUS", controller.onStatus) 34 | 35 | return controller 36 | } 37 | 38 | func (c *ReceiverController) sendEvent(event events.Event) { 39 | select { 40 | case c.eventsCh <- event: 41 | default: 42 | log.Printf("Dropped event: %#v", event) 43 | } 44 | } 45 | 46 | func (c *ReceiverController) onStatus(message *api.CastMessage) { 47 | response := &StatusResponse{} 48 | err := json.Unmarshal([]byte(*message.PayloadUtf8), response) 49 | if err != nil { 50 | log.Errorf("Failed to unmarshal status message:%s - %s", err, *message.PayloadUtf8) 51 | return 52 | } 53 | 54 | previous := map[string]*ApplicationSession{} 55 | if c.status != nil { 56 | for _, app := range c.status.Applications { 57 | previous[*app.AppID] = app 58 | } 59 | } 60 | 61 | c.status = response.Status 62 | vol := response.Status.Volume 63 | c.sendEvent(events.StatusUpdated{Level: *vol.Level, Muted: *vol.Muted}) 64 | 65 | for _, app := range response.Status.Applications { 66 | if _, ok := previous[*app.AppID]; ok { 67 | // Already running 68 | delete(previous, *app.AppID) 69 | continue 70 | } 71 | event := events.AppStarted{ 72 | AppID: *app.AppID, 73 | DisplayName: *app.DisplayName, 74 | StatusText: *app.StatusText, 75 | } 76 | c.sendEvent(event) 77 | } 78 | 79 | // Stopped apps 80 | for _, app := range previous { 81 | event := events.AppStopped{ 82 | AppID: *app.AppID, 83 | DisplayName: *app.DisplayName, 84 | StatusText: *app.StatusText, 85 | } 86 | c.sendEvent(event) 87 | } 88 | } 89 | 90 | type StatusResponse struct { 91 | net.PayloadHeaders 92 | Status *ReceiverStatus `json:"status,omitempty"` 93 | } 94 | 95 | type ReceiverStatus struct { 96 | net.PayloadHeaders 97 | Applications []*ApplicationSession `json:"applications"` 98 | Volume *Volume `json:"volume,omitempty"` 99 | } 100 | 101 | type LaunchRequest struct { 102 | net.PayloadHeaders 103 | AppId string `json:"appId"` 104 | } 105 | 106 | func (s *ReceiverStatus) GetSessionByNamespace(namespace string) *ApplicationSession { 107 | for _, app := range s.Applications { 108 | for _, ns := range app.Namespaces { 109 | if ns.Name == namespace { 110 | return app 111 | } 112 | } 113 | } 114 | return nil 115 | } 116 | 117 | func (s *ReceiverStatus) GetSessionByAppId(appId string) *ApplicationSession { 118 | for _, app := range s.Applications { 119 | if *app.AppID == appId { 120 | return app 121 | } 122 | } 123 | return nil 124 | } 125 | 126 | type ApplicationSession struct { 127 | AppID *string `json:"appId,omitempty"` 128 | DisplayName *string `json:"displayName,omitempty"` 129 | Namespaces []*Namespace `json:"namespaces"` 130 | SessionID *string `json:"sessionId,omitempty"` 131 | StatusText *string `json:"statusText,omitempty"` 132 | TransportId *string `json:"transportId,omitempty"` 133 | } 134 | 135 | type Namespace struct { 136 | Name string `json:"name"` 137 | } 138 | 139 | type Volume struct { 140 | Level *float64 `json:"level,omitempty"` 141 | Muted *bool `json:"muted,omitempty"` 142 | } 143 | 144 | func (c *ReceiverController) Start(ctx context.Context) error { 145 | // noop 146 | return nil 147 | } 148 | 149 | func (c *ReceiverController) GetStatus(ctx context.Context) (*ReceiverStatus, error) { 150 | message, err := c.channel.Request(ctx, &getStatus) 151 | if err != nil { 152 | return nil, fmt.Errorf("Failed to get receiver status: %s", err) 153 | } 154 | 155 | response := &StatusResponse{} 156 | err = json.Unmarshal([]byte(*message.PayloadUtf8), response) 157 | if err != nil { 158 | return nil, fmt.Errorf("Failed to unmarshal status message: %s - %s", err, *message.PayloadUtf8) 159 | } 160 | 161 | return response.Status, nil 162 | } 163 | 164 | func (c *ReceiverController) SetVolume(ctx context.Context, volume *Volume) (*api.CastMessage, error) { 165 | return c.channel.Request(ctx, &ReceiverStatus{ 166 | PayloadHeaders: net.PayloadHeaders{Type: "SET_VOLUME"}, 167 | Volume: volume, 168 | }) 169 | } 170 | 171 | func (c *ReceiverController) GetVolume(ctx context.Context) (*Volume, error) { 172 | status, err := c.GetStatus(ctx) 173 | if err != nil { 174 | return nil, err 175 | } 176 | return status.Volume, err 177 | } 178 | 179 | func (c *ReceiverController) LaunchApp(ctx context.Context, appId string) (*ReceiverStatus, error) { 180 | message, err := c.channel.Request(ctx, &LaunchRequest{ 181 | PayloadHeaders: commandLaunch, 182 | AppId: appId, 183 | }) 184 | if err != nil { 185 | return nil, fmt.Errorf("Failed sending request: %s", err) 186 | } 187 | 188 | response := &StatusResponse{} 189 | err = json.Unmarshal([]byte(*message.PayloadUtf8), response) 190 | if err != nil { 191 | return nil, fmt.Errorf("Failed to unmarshal status message: %s - %s", err, *message.PayloadUtf8) 192 | } 193 | return response.Status, nil 194 | } 195 | 196 | func (c *ReceiverController) QuitApp(ctx context.Context) (*api.CastMessage, error) { 197 | return c.channel.Request(ctx, &commandStop) 198 | } 199 | -------------------------------------------------------------------------------- /controllers/url.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "golang.org/x/net/context" 10 | 11 | "github.com/barnybug/go-cast/api" 12 | "github.com/barnybug/go-cast/events" 13 | "github.com/barnybug/go-cast/log" 14 | "github.com/barnybug/go-cast/net" 15 | ) 16 | 17 | type URLController struct { 18 | interval time.Duration 19 | channel *net.Channel 20 | eventsCh chan events.Event 21 | DestinationID string 22 | URLSessionID int 23 | } 24 | 25 | const NamespaceURL = "urn:x-cast:com.url.cast" 26 | 27 | var getURLStatus = net.PayloadHeaders{Type: "GET_STATUS"} 28 | 29 | var commandURLLoad = net.PayloadHeaders{Type: "LOAD"} 30 | 31 | type LoadURLCommand struct { 32 | net.PayloadHeaders 33 | URL string `json:"url"` 34 | Type string `json:"type"` 35 | } 36 | 37 | type URLStatusURL struct { 38 | ContentId string `json:"contentId"` 39 | StreamType string `json:"streamType"` 40 | ContentType string `json:"contentType"` 41 | Duration float64 `json:"duration"` 42 | } 43 | 44 | func NewURLController(conn *net.Connection, eventsCh chan events.Event, sourceId, destinationID string) *URLController { 45 | controller := &URLController{ 46 | channel: conn.NewChannel(sourceId, destinationID, NamespaceURL), 47 | eventsCh: eventsCh, 48 | DestinationID: destinationID, 49 | } 50 | 51 | controller.channel.OnMessage("URL_STATUS", controller.onStatus) 52 | 53 | return controller 54 | } 55 | 56 | func (c *URLController) SetDestinationID(id string) { 57 | c.channel.DestinationId = id 58 | c.DestinationID = id 59 | } 60 | 61 | func (c *URLController) sendEvent(event events.Event) { 62 | select { 63 | case c.eventsCh <- event: 64 | default: 65 | log.Printf("Dropped event: %#v", event) 66 | } 67 | } 68 | 69 | func (c *URLController) onStatus(message *api.CastMessage) { 70 | response, err := c.parseStatus(message) 71 | if err != nil { 72 | log.Errorf("Error parsing status: %s", err) 73 | } 74 | 75 | for _, status := range response.Status { 76 | c.sendEvent(*status) 77 | } 78 | } 79 | 80 | func (c *URLController) parseStatus(message *api.CastMessage) (*URLStatusResponse, error) { 81 | response := &URLStatusResponse{} 82 | 83 | err := json.Unmarshal([]byte(*message.PayloadUtf8), response) 84 | 85 | if err != nil { 86 | return nil, fmt.Errorf("Failed to unmarshal status message:%s - %s", err, *message.PayloadUtf8) 87 | } 88 | 89 | for _, status := range response.Status { 90 | c.URLSessionID = status.URLSessionID 91 | } 92 | 93 | return response, nil 94 | } 95 | 96 | type URLStatusResponse struct { 97 | net.PayloadHeaders 98 | Status []*URLStatus `json:"status,omitempty"` 99 | } 100 | 101 | type URLStatus struct { 102 | net.PayloadHeaders 103 | URLSessionID int `json:"mediaSessionId"` 104 | PlaybackRate float64 `json:"playbackRate"` 105 | PlayerState string `json:"playerState"` 106 | CurrentTime float64 `json:"currentTime"` 107 | SupportedURLCommands int `json:"supportedURLCommands"` 108 | Volume *Volume `json:"volume,omitempty"` 109 | URL *URLStatusURL `json:"media"` 110 | CustomData map[string]interface{} `json:"customData"` 111 | RepeatMode string `json:"repeatMode"` 112 | IdleReason string `json:"idleReason"` 113 | } 114 | 115 | func (c *URLController) Start(ctx context.Context) error { 116 | _, err := c.GetStatus(ctx) 117 | return err 118 | } 119 | 120 | func (c *URLController) GetStatus(ctx context.Context) (*URLStatusResponse, error) { 121 | message, err := c.channel.Request(ctx, &getURLStatus) 122 | if err != nil { 123 | return nil, fmt.Errorf("Failed to get receiver status: %s", err) 124 | } 125 | 126 | return c.parseStatus(message) 127 | } 128 | 129 | func (c *URLController) LoadURL(ctx context.Context, url string) (*api.CastMessage, error) { 130 | message, err := c.channel.Request(ctx, &LoadURLCommand{ 131 | PayloadHeaders: commandURLLoad, 132 | URL: url, 133 | Type: "loc", 134 | }) 135 | if err != nil { 136 | return nil, fmt.Errorf("Failed to send load command: %s", err) 137 | } 138 | 139 | response := &net.PayloadHeaders{} 140 | err = json.Unmarshal([]byte(*message.PayloadUtf8), response) 141 | if err != nil { 142 | return nil, err 143 | } 144 | if response.Type == "LOAD_FAILED" { 145 | return nil, errors.New("Load URL failed") 146 | } 147 | 148 | return message, nil 149 | } 150 | -------------------------------------------------------------------------------- /discovery/service.go: -------------------------------------------------------------------------------- 1 | // Package discovery provides a discovery service for chromecast devices 2 | package discovery 3 | 4 | import ( 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "golang.org/x/net/context" 11 | 12 | "github.com/barnybug/go-cast" 13 | "github.com/barnybug/go-cast/log" 14 | "github.com/hashicorp/mdns" 15 | ) 16 | 17 | type Service struct { 18 | found chan *cast.Client 19 | entriesCh chan *mdns.ServiceEntry 20 | 21 | stopPeriodic chan struct{} 22 | } 23 | 24 | func NewService(ctx context.Context) *Service { 25 | s := &Service{ 26 | found: make(chan *cast.Client), 27 | entriesCh: make(chan *mdns.ServiceEntry, 10), 28 | } 29 | 30 | go s.listener(ctx) 31 | return s 32 | } 33 | 34 | func (d *Service) Run(ctx context.Context, interval time.Duration) error { 35 | err := mdns.Query(&mdns.QueryParam{ 36 | Service: "_googlecast._tcp", 37 | Domain: "local", 38 | Timeout: interval, 39 | Entries: d.entriesCh, 40 | }) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | ticker := time.NewTicker(interval) 46 | for { 47 | select { 48 | case <-ticker.C: 49 | err = mdns.Query(&mdns.QueryParam{ 50 | Service: "_googlecast._tcp", 51 | Domain: "local", 52 | Timeout: time.Second * 3, 53 | Entries: d.entriesCh, 54 | }) 55 | if err != nil { 56 | return err 57 | } 58 | case <-ctx.Done(): 59 | return ctx.Err() 60 | } 61 | } 62 | } 63 | 64 | func (d *Service) Stop() { 65 | if d.stopPeriodic != nil { 66 | close(d.stopPeriodic) 67 | d.stopPeriodic = nil 68 | } 69 | } 70 | 71 | func (d *Service) Found() chan *cast.Client { 72 | return d.found 73 | } 74 | 75 | func (d *Service) listener(ctx context.Context) { 76 | for entry := range d.entriesCh { 77 | name := strings.Split(entry.Name, "._googlecast") 78 | // Skip everything that doesn't have googlecast in the fdqn 79 | if len(name) < 2 { 80 | continue 81 | } 82 | 83 | log.Printf("New entry: %#v\n", entry) 84 | client := cast.NewClient(entry.AddrV4, entry.Port) 85 | info := decodeTxtRecord(entry.Info) 86 | client.SetName(info["fn"]) 87 | client.SetInfo(info) 88 | 89 | select { 90 | case d.found <- client: 91 | case <-time.After(time.Second): 92 | case <-ctx.Done(): 93 | break 94 | } 95 | } 96 | } 97 | 98 | func decodeDnsEntry(text string) string { 99 | text = strings.Replace(text, `\.`, ".", -1) 100 | text = strings.Replace(text, `\ `, " ", -1) 101 | 102 | re := regexp.MustCompile(`([\\][0-9][0-9][0-9])`) 103 | text = re.ReplaceAllStringFunc(text, func(source string) string { 104 | i, err := strconv.Atoi(source[1:]) 105 | if err != nil { 106 | return "" 107 | } 108 | 109 | return string([]byte{byte(i)}) 110 | }) 111 | 112 | return text 113 | } 114 | 115 | func decodeTxtRecord(txt string) map[string]string { 116 | m := make(map[string]string) 117 | 118 | s := strings.Split(txt, "|") 119 | for _, v := range s { 120 | s := strings.Split(v, "=") 121 | if len(s) == 2 { 122 | m[s[0]] = s[1] 123 | } 124 | } 125 | 126 | return m 127 | } 128 | -------------------------------------------------------------------------------- /discovery/service_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDecodeDnsEntry(t *testing.T) { 10 | 11 | source := `Stamp\.\.\ \195\132r\ En\ Liten\ Fisk` 12 | 13 | result := decodeDnsEntry(source) 14 | 15 | assert.Equal(t, result, "Stamp.. Är En Liten Fisk") 16 | 17 | } 18 | func TestDecodeTxtRecord(t *testing.T) { 19 | 20 | source := `id=87cf98a003f1f1dbd2efe6d19055a617|ve=04|md=Chromecast|ic=/setup/icon.png|fn=Chromecast PO|ca=5|st=0|bs=FA8FCA7EE8A9|rs=` 21 | 22 | result := decodeTxtRecord(source) 23 | 24 | assert.Equal(t, result["id"], "87cf98a003f1f1dbd2efe6d19055a617") 25 | 26 | } 27 | -------------------------------------------------------------------------------- /events/appstarted.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type AppStarted struct { 4 | AppID string 5 | DisplayName string 6 | StatusText string 7 | } 8 | -------------------------------------------------------------------------------- /events/appstopped.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type AppStopped struct { 4 | AppID string 5 | DisplayName string 6 | StatusText string 7 | } 8 | -------------------------------------------------------------------------------- /events/connected.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type Connected struct{} 4 | -------------------------------------------------------------------------------- /events/disconnected.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type Disconnected struct { 4 | Reason error 5 | } 6 | -------------------------------------------------------------------------------- /events/event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type Event interface { 4 | } 5 | -------------------------------------------------------------------------------- /events/status_updated.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type StatusUpdated struct { 4 | Level float64 5 | Muted bool 6 | } 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/barnybug/go-cast 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.0.1-0.20160907170601-6d212800a42e // indirect 7 | github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e 8 | github.com/hashicorp/mdns v1.0.5 9 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0 // indirect 10 | github.com/stretchr/testify v1.1.5-0.20160925220609-976c720a22c8 11 | github.com/urfave/cli v1.20.0 12 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.0.1-0.20160907170601-6d212800a42e h1:9EoM2C6YAkhnxTxG3LrAos2/KaALZdSNG5HTGPEEedE= 2 | github.com/davecgh/go-spew v1.0.1-0.20160907170601-6d212800a42e/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e h1:eeyMpoxANuWNQ9O2auv4wXxJsrXzLUhdHaOmNWEGkRY= 4 | github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 5 | github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE= 6 | github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= 7 | github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= 8 | github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= 9 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0 h1:GD+A8+e+wFkqje55/2fOVnZPkoDIu1VooBWfNrnY8Uo= 10 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/testify v1.1.5-0.20160925220609-976c720a22c8 h1:f4Xo/Dhbk4mbPFN+QqSzSsXt1bK2fMLqpMY+Jx6AR6A= 12 | github.com/stretchr/testify v1.1.5-0.20160925220609-976c720a22c8/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 13 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 14 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 15 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 16 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc= 17 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= 18 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 19 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c= 23 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 25 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 26 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 27 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 28 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "log" 4 | 5 | var Debug = false 6 | 7 | func init() { 8 | log.SetFlags(log.LstdFlags | log.Lmicroseconds) 9 | } 10 | 11 | func Println(v ...interface{}) { 12 | if Debug { 13 | log.Println(v...) 14 | } 15 | } 16 | 17 | func Printf(format string, v ...interface{}) { 18 | if Debug { 19 | log.Printf(format, v...) 20 | } 21 | } 22 | 23 | func Fatalln(v ...interface{}) { 24 | log.Fatalln(v...) 25 | } 26 | 27 | func Fatalf(format string, v ...interface{}) { 28 | log.Fatalf(format, v...) 29 | } 30 | 31 | func Errorln(v ...interface{}) { 32 | log.Println(v...) 33 | } 34 | 35 | func Errorf(format string, v ...interface{}) { 36 | log.Printf(format, v...) 37 | } 38 | -------------------------------------------------------------------------------- /net/channel.go: -------------------------------------------------------------------------------- 1 | package net 2 | 3 | import ( 4 | "sync/atomic" 5 | 6 | "golang.org/x/net/context" 7 | 8 | "github.com/barnybug/go-cast/api" 9 | "github.com/barnybug/go-cast/log" 10 | ) 11 | 12 | type Channel struct { 13 | conn *Connection 14 | sourceId string 15 | DestinationId string 16 | namespace string 17 | _ int32 18 | requestId int64 19 | inFlight map[int]chan *api.CastMessage 20 | listeners []channelListener 21 | } 22 | 23 | type channelListener struct { 24 | responseType string 25 | callback func(*api.CastMessage) 26 | } 27 | 28 | type Payload interface { 29 | setRequestId(id int) 30 | getRequestId() int 31 | } 32 | 33 | func NewChannel(conn *Connection, sourceId, destinationId, namespace string) *Channel { 34 | return &Channel{ 35 | conn: conn, 36 | sourceId: sourceId, 37 | DestinationId: destinationId, 38 | namespace: namespace, 39 | listeners: make([]channelListener, 0), 40 | inFlight: make(map[int]chan *api.CastMessage), 41 | } 42 | } 43 | 44 | func (c *Channel) Message(message *api.CastMessage, headers *PayloadHeaders) { 45 | if *message.DestinationId != "*" && (*message.SourceId != c.DestinationId || *message.DestinationId != c.sourceId || *message.Namespace != c.namespace) { 46 | return 47 | } 48 | 49 | if headers.Type == "" { 50 | log.Errorf("Warning: No message type. Don't know what to do. headers: %v message:%v", headers, message) 51 | return 52 | } 53 | 54 | if headers.RequestId != nil && *headers.RequestId != 0 { 55 | if listener, ok := c.inFlight[*headers.RequestId]; ok { 56 | listener <- message 57 | delete(c.inFlight, *headers.RequestId) 58 | } 59 | } 60 | 61 | for _, listener := range c.listeners { 62 | if listener.responseType == headers.Type { 63 | listener.callback(message) 64 | } 65 | } 66 | } 67 | 68 | func (c *Channel) OnMessage(responseType string, cb func(*api.CastMessage)) { 69 | c.listeners = append(c.listeners, channelListener{responseType, cb}) 70 | } 71 | 72 | func (c *Channel) Send(payload interface{}) error { 73 | return c.conn.Send(payload, c.sourceId, c.DestinationId, c.namespace) 74 | } 75 | 76 | func (c *Channel) Request(ctx context.Context, payload Payload) (*api.CastMessage, error) { 77 | requestId := int(atomic.AddInt64(&c.requestId, 1)) 78 | 79 | payload.setRequestId(requestId) 80 | response := make(chan *api.CastMessage) 81 | c.inFlight[requestId] = response 82 | 83 | err := c.Send(payload) 84 | if err != nil { 85 | delete(c.inFlight, requestId) 86 | return nil, err 87 | } 88 | 89 | select { 90 | case reply := <-response: 91 | return reply, nil 92 | case <-ctx.Done(): 93 | delete(c.inFlight, requestId) 94 | return nil, ctx.Err() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /net/connection.go: -------------------------------------------------------------------------------- 1 | package net 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/binary" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net" 10 | 11 | "golang.org/x/net/context" 12 | 13 | "github.com/barnybug/go-cast/api" 14 | "github.com/barnybug/go-cast/log" 15 | "github.com/gogo/protobuf/proto" 16 | ) 17 | 18 | type Connection struct { 19 | conn *tls.Conn 20 | channels []*Channel 21 | } 22 | 23 | func NewConnection() *Connection { 24 | return &Connection{ 25 | conn: nil, 26 | channels: make([]*Channel, 0), 27 | } 28 | } 29 | 30 | func (c *Connection) NewChannel(sourceId, destinationId, namespace string) *Channel { 31 | channel := NewChannel(c, sourceId, destinationId, namespace) 32 | c.channels = append(c.channels, channel) 33 | return channel 34 | } 35 | 36 | func (c *Connection) Connect(ctx context.Context, host net.IP, port int) error { 37 | var err error 38 | deadline, _ := ctx.Deadline() 39 | dialer := &net.Dialer{ 40 | Deadline: deadline, 41 | } 42 | c.conn, err = tls.DialWithDialer(dialer, "tcp", fmt.Sprintf("%s:%d", host, port), &tls.Config{ 43 | InsecureSkipVerify: true, 44 | }) 45 | if err != nil { 46 | return fmt.Errorf("Failed to connect to Chromecast: %s", err) 47 | } 48 | 49 | go c.ReceiveLoop() 50 | 51 | return nil 52 | } 53 | 54 | func (c *Connection) ReceiveLoop() { 55 | for { 56 | var length uint32 57 | err := binary.Read(c.conn, binary.BigEndian, &length) 58 | if err != nil { 59 | log.Printf("Failed to read packet length: %s", err) 60 | break 61 | } 62 | if length == 0 { 63 | log.Println("Empty packet received") 64 | continue 65 | } 66 | 67 | packet := make([]byte, length) 68 | i, err := io.ReadFull(c.conn, packet) 69 | if err != nil { 70 | log.Printf("Failed to read packet: %s", err) 71 | break 72 | } 73 | 74 | if i != int(length) { 75 | log.Printf("Invalid packet size. Wanted: %d Read: %d", length, i) 76 | break 77 | } 78 | 79 | message := &api.CastMessage{} 80 | err = proto.Unmarshal(packet, message) 81 | if err != nil { 82 | log.Printf("Failed to unmarshal CastMessage: %s", err) 83 | break 84 | } 85 | 86 | log.Printf("%s ⇐ %s [%s]: %+v", 87 | *message.DestinationId, *message.SourceId, *message.Namespace, *message.PayloadUtf8) 88 | 89 | var headers PayloadHeaders 90 | err = json.Unmarshal([]byte(*message.PayloadUtf8), &headers) 91 | 92 | if err != nil { 93 | log.Printf("Failed to unmarshal message: %s", err) 94 | break 95 | } 96 | 97 | for _, channel := range c.channels { 98 | channel.Message(message, &headers) 99 | } 100 | } 101 | } 102 | 103 | func (c *Connection) Send(payload interface{}, sourceId, destinationId, namespace string) error { 104 | payloadJson, err := json.Marshal(payload) 105 | if err != nil { 106 | return err 107 | } 108 | payloadString := string(payloadJson) 109 | message := &api.CastMessage{ 110 | ProtocolVersion: api.CastMessage_CASTV2_1_0.Enum(), 111 | SourceId: &sourceId, 112 | DestinationId: &destinationId, 113 | Namespace: &namespace, 114 | PayloadType: api.CastMessage_STRING.Enum(), 115 | PayloadUtf8: &payloadString, 116 | } 117 | 118 | proto.SetDefaults(message) 119 | 120 | data, err := proto.Marshal(message) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | log.Printf("%s ⇒ %s [%s]: %s", *message.SourceId, *message.DestinationId, *message.Namespace, *message.PayloadUtf8) 126 | 127 | err = binary.Write(c.conn, binary.BigEndian, uint32(len(data))) 128 | if err != nil { 129 | return err 130 | } 131 | _, err = c.conn.Write(data) 132 | return err 133 | } 134 | 135 | func (c *Connection) Close() error { 136 | // TODO: graceful shutdown 137 | return c.conn.Close() 138 | } 139 | -------------------------------------------------------------------------------- /net/payload.go: -------------------------------------------------------------------------------- 1 | package net 2 | 3 | type PayloadHeaders struct { 4 | Type string `json:"type"` 5 | RequestId *int `json:"requestId,omitempty"` 6 | } 7 | 8 | func (h *PayloadHeaders) setRequestId(id int) { 9 | h.RequestId = &id 10 | } 11 | 12 | func (h *PayloadHeaders) getRequestId() int { 13 | return *h.RequestId 14 | } 15 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | var Version string = "dev" /* passed in by travis build */ 4 | --------------------------------------------------------------------------------