├── test ├── test_helper.exs └── chromecast_test.exs ├── .gitignore ├── mix.lock ├── LICENSE.TXT ├── config └── config.exs ├── mix.exs ├── proto └── cast_channel.proto ├── lib └── chromecast.ex └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/chromecast_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ChromecastTest do 2 | use ExUnit.Case 3 | doctest Chromecast 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.0.2", "a0b0904d74ecc14da8bd2e6e0248e1a409a2bc91aade75fcf428125603de3853", [:mix], []}, 2 | "ex_doc": {:hex, :ex_doc, "0.14.3", "e61cec6cf9731d7d23d254266ab06ac1decbb7651c3d1568402ec535d387b6f7", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 3 | "exprotobuf": {:hex, :exprotobuf, "1.1.0", "0d91a0b527abac118e03361d12eac7262299cdda3a0316fc60763557c227c890", [:mix], [{:gpb, "~> 3.24", [hex: :gpb, optional: false]}]}, 4 | "gpb": {:hex, :gpb, "3.24.3", "1a73dc1e023e92c5849c590c9600c98d7c3657038d41c8a29889a580c9be60b3", [:make, :rebar], []}, 5 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}} 6 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2016] [National Association of REALTORS] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :chromecast, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:chromecast, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Chromecast.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :chromecast, 6 | version: "0.1.5", 7 | elixir: "~> 1.3", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | description: description(), 11 | package: package(), 12 | deps: deps()] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type "mix help compile.app" for more information 18 | def application do 19 | [applications: [:logger, :ssl, :exprotobuf, :poison]] 20 | end 21 | 22 | def description do 23 | """ 24 | A library for controlling and monitoring a Chromecast 25 | """ 26 | end 27 | 28 | def package do 29 | [ 30 | name: :chromecast, 31 | files: ["lib", "proto", "mix.exs", "README*", "LICENSE*"], 32 | maintainers: ["Christopher Steven Coté"], 33 | licenses: ["MIT License"], 34 | links: %{"GitHub" => "https://github.com/NationalAssociationOfRealtors/chromecast", 35 | "Docs" => "https://github.com/NationalAssociationOfRealtors/chromecast"} 36 | ] 37 | end 38 | 39 | defp deps do 40 | [ 41 | {:exprotobuf, "~> 1.1.0"}, 42 | {:poison, "~> 2.2.0"}, 43 | {:ex_doc, ">= 0.0.0", only: :dev} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /proto/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 | message CastMessage { 10 | // Always pass a version of the protocol for future compatibility 11 | // requirements. 12 | enum ProtocolVersion { 13 | CASTV2_1_0 = 0; 14 | } 15 | required ProtocolVersion protocol_version = 1; 16 | 17 | // source and destination ids identify the origin and destination of the 18 | // message. They are used to route messages between endpoints that share a 19 | // device-to-device channel. 20 | // 21 | // For messages between applications: 22 | // - The sender application id is a unique identifier generated on behalf of 23 | // the sender application. 24 | // - The receiver id is always the the session id for the application. 25 | // 26 | // For messages to or from the sender or receiver platform, the special ids 27 | // 'sender-0' and 'receiver-0' can be used. 28 | // 29 | // For messages intended for all endpoints using a given channel, the 30 | // wildcard destination_id '*' can be used. 31 | required string source_id = 2; 32 | required string destination_id = 3; 33 | 34 | // This is the core multiplexing key. All messages are sent on a namespace 35 | // and endpoints sharing a channel listen on one or more namespaces. The 36 | // namespace defines the protocol and semantics of the message. 37 | required string namespace = 4; 38 | 39 | // Encoding and payload info follows. 40 | 41 | // What type of data do we have in this message. 42 | enum PayloadType { 43 | STRING = 0; 44 | BINARY = 1; 45 | } 46 | required PayloadType payload_type = 5; 47 | 48 | // Depending on payload_type, exactly one of the following optional fields 49 | // will always be set. 50 | optional string payload_utf8 = 6; 51 | optional bytes payload_binary = 7; 52 | } 53 | 54 | // Messages for authentication protocol between a sender and a receiver. 55 | message AuthChallenge { 56 | } 57 | 58 | message AuthResponse { 59 | required bytes signature = 1; 60 | required bytes client_auth_certificate = 2; 61 | } 62 | 63 | message AuthError { 64 | enum ErrorType { 65 | INTERNAL_ERROR = 0; 66 | NO_TLS = 1; // The underlying connection is not TLS 67 | } 68 | required ErrorType error_type = 1; 69 | } 70 | 71 | message DeviceAuthMessage { 72 | // Request fields 73 | optional AuthChallenge challenge = 1; 74 | // Response fields 75 | optional AuthResponse response = 2; 76 | optional AuthError error = 3; 77 | } 78 | -------------------------------------------------------------------------------- /lib/chromecast.ex: -------------------------------------------------------------------------------- 1 | defmodule Chromecast do 2 | require Logger 3 | use GenServer 4 | use Protobuf, from: Path.expand("../proto/cast_channel.proto", __DIR__) 5 | 6 | @ping "PING" 7 | @pong "PONG" 8 | @receiver_status "RECEIVER_STATUS" 9 | @media_status "MEDIA_STATUS" 10 | 11 | 12 | defmodule State do 13 | defstruct media_session: nil, 14 | session: nil, 15 | destination_id: "receiver-0", 16 | ssl: nil, 17 | ip: nil, 18 | request_id: 0, 19 | receiver_status: %{}, 20 | media_status: %{} 21 | end 22 | 23 | @namespace %{ 24 | :con => "urn:x-cast:com.google.cast.tp.connection", 25 | :receiver => "urn:x-cast:com.google.cast.receiver", 26 | :cast => "urn:x-cast:com.google.cast.media", 27 | :heartbeat => "urn:x-cast:com.google.cast.tp.heartbeat", 28 | :message => "urn:x-cast:com.google.cast.player.message", 29 | :media => "urn:x-cast:com.google.cast.media", 30 | :youtube => "urn:x-cast:com.google.youtube.mdx", 31 | } 32 | 33 | def start_link(ip \\ {192,168,1,15}) do 34 | GenServer.start_link(__MODULE__, ip) 35 | end 36 | 37 | def play(device) do 38 | GenServer.call(device, :play) 39 | end 40 | 41 | def pause(device) do 42 | GenServer.call(device, :pause) 43 | end 44 | 45 | def set_volume(device, level) do 46 | GenServer.call(device, {:set_volume, level}) 47 | end 48 | 49 | def state(device) do 50 | GenServer.call(device, :state) 51 | end 52 | 53 | def create_message(namespace, payload, destination) when payload |> is_map do 54 | Chromecast.CastMessage.new( 55 | protocol_version: :CASTV2_1_0, 56 | source_id: "sender-0", 57 | destination_id: destination, 58 | payload_type: :STRING, 59 | namespace: @namespace[namespace], 60 | payload_utf8: Poison.encode!(payload) 61 | ) 62 | end 63 | 64 | def connect_channel(namespace, state) do 65 | con = create_message(:con, %{:type => "CONNECT", :origin => %{}, :requestId => state.request_id}, state.destination_id) 66 | state = send_msg(state.ssl, con, state) 67 | status = create_message(namespace, %{:type => "GET_STATUS", :requestId => state.request_id}, state.destination_id) 68 | state = send_msg(state.ssl, status, state) 69 | end 70 | 71 | def send_msg(ssl, msg, state) do 72 | case :ssl.send(ssl, encode(msg)) do 73 | :ok -> 74 | cond do 75 | state.request_id > 2000 -> %State{state | :request_id => 0} 76 | true -> %State{state | :request_id => state.request_id + 1} 77 | end 78 | {:error, reason} -> 79 | Logger.error "SSL Send Error: #{inspect reason}" 80 | state 81 | end 82 | end 83 | 84 | def connect(ip) do 85 | {:ok, ssl} = :ssl.connect(ip, 8009, [:binary, {:reuseaddr, true}]) 86 | end 87 | 88 | def init(ip) do 89 | {:ok, ssl} = connect(ip) 90 | state = %State{:ssl => ssl, :ip => ip} 91 | state = connect_channel(:receiver, state) 92 | {:ok, state} 93 | end 94 | 95 | def handle_call(:state, _from, state) do 96 | {:reply, state, state} 97 | end 98 | 99 | def handle_call(:play, _from, state) do 100 | msg = create_message(:media, %{ 101 | :mediaSessionId => state.media_session, 102 | :requestId => state.request_id, 103 | :type => "PLAY" 104 | }, state.destination_id) 105 | {:reply, :ok, send_msg(state.ssl, msg, state)} 106 | end 107 | 108 | def handle_call(:pause, _from, state) do 109 | msg = create_message(:media, %{ 110 | :mediaSessionId => state.media_session, 111 | :requestId => state.request_id, 112 | :type => "PAUSE" 113 | }, state.destination_id) 114 | {:reply, :ok, send_msg(state.ssl, msg, state)} 115 | end 116 | 117 | def handle_call({:set_volume, level}, _from, state) do 118 | msg = create_message(:media, %{ 119 | :mediaSessionId => state.media_session, 120 | :requestId => state.request_id, 121 | :type => "VOLUME", 122 | :Volume => %{:level => level, :muted => 0} 123 | }, state.destination_id) 124 | {:reply, :ok, send_msg(state.ssl, msg, state)} 125 | end 126 | 127 | def handle_info({:ssl_closed, _}, state) do 128 | Logger.debug("SSL Connection Closed. Re-opening...") 129 | {:ok, ssl} = connect(state.ip) 130 | state = %State{state | :ssl => ssl} 131 | state = connect_channel(:receiver, state) 132 | {:noreply, state} 133 | end 134 | 135 | def handle_info({:ssl_closed, _}, {:sslsocket, _, state}) do 136 | Logger.debug("SSL Connection Closed. Re-opening...") 137 | {:ok, ssl} = connect(state.ip) 138 | state = %State{state | :ssl => ssl} 139 | state = connect_channel(:receiver, state) 140 | {:noreply, state} 141 | end 142 | 143 | def handle_info({:ssl, {sslsocket, new_ssl, _}, data}, state) do 144 | state = 145 | case data |> decode do 146 | {:error, _} -> state 147 | %{payload_utf8: nil} -> state 148 | %{payload_utf8: payload} -> 149 | Logger.debug("Chromecast Data: #{payload}") 150 | payload |> Poison.Parser.parse! |> handle_payload(state) 151 | end 152 | {:noreply, state} 153 | end 154 | 155 | def handle_payload(%{"type" => @ping} = payload, state) do 156 | msg = create_message(:heartbeat, %{:type => @pong}, "receiver-0") 157 | send_msg(state.ssl, msg, state) 158 | end 159 | 160 | def handle_payload(%{"type" => @receiver_status} = payload, state) do 161 | case payload["status"]["applications"] do 162 | nil -> state 163 | other -> 164 | app = Enum.at(other, 0) 165 | cond do 166 | app["transportId"] != state.destination_id -> 167 | state = %State{state | :destination_id => app["transportId"], :session => app["sessionId"]} 168 | state = connect_channel(:media, state) 169 | %State{state | :receiver_status => payload} 170 | true -> state 171 | end 172 | end 173 | end 174 | 175 | def handle_payload(%{"type" => @media_status} = payload, state) do 176 | status = 177 | case Enum.at(payload["status"], 0) do 178 | nil -> state.media_status 179 | %{} = stat -> Map.merge(state.media_status, stat) 180 | end 181 | %State{state | :media_status => status, :media_session => status["mediaSessionId"]} 182 | end 183 | 184 | def handle_payload(%{"backendData" => status} = payload, state) do 185 | %State{state | :media_status => payload} 186 | end 187 | 188 | def handle_payload(%{} = payload, state) do 189 | Logger.debug "Unknown Payload: #{inspect payload}" 190 | state 191 | end 192 | 193 | def encode(msg) do 194 | m = Chromecast.CastMessage.encode(msg) 195 | << byte_size(m)::big-unsigned-integer-size(32) >> <> m 196 | end 197 | 198 | def decode(<< length::big-unsigned-integer-size(32), rest::binary >> = msg) 199 | when length < 102400 do 200 | try do 201 | Chromecast.CastMessage.decode(rest) 202 | rescue 203 | _ -> 204 | Logger.error "ProtoBuf Parse Error: #{inspect msg}" 205 | {:error, :parse_error} 206 | end 207 | end 208 | 209 | def decode(<< length::big-unsigned-integer-size(32), rest::binary >> = msg) do 210 | try do 211 | Chromecast.CastMessage.decode(msg) 212 | rescue 213 | _ -> 214 | Logger.error "ProtoBuf Parse Error: #{inspect msg}" 215 | {:error, :parse_error} 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chromecast 2 | 3 | A library for controlling and monitoring a [Chromecast](https://www.google.com/intl/en_us/chromecast/). 4 | It currently only supports pause/play/volume, but also keeps track of the state of the device, such as media type, playback position, background images, etc. There is no support for queuing or playing new media files. 5 | 6 | ## Installation 7 | 8 | 1. git clone https://github.com/NationalAssociationOfRealtors/chromecast.git 9 | 2. mix do deps.get, deps.compile 10 | 3. iex -S mix 11 | 12 | ## Usage 13 | 14 | iex(4)> {:ok, device} = Chromecast.start_link {192,168,1,138} 15 | {:ok, #PID<0.225.0>} 16 | 17 | ##### Idle Screen with background images 18 | 19 | iex(7)> Chromecast.state(device) 20 | %Chromecast.State{destination_id: "web-5", ip: {192, 168, 1, 138}, 21 | media_session: nil, 22 | media_status: %{"appDeviceId" => "BBC9E06BCA89EA246A21D650BE44BA52", 23 | "backendData" => "[\"https://lh3.googleusercontent.com/mij2Eglc324jD_kxhu43aSnX8w9Xfr7XQdEwLpWpiVoFWZSh8Ljj=s1920-w1920-h1080-p-k-no-nd-mv\",\"Justin Brown\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/101005060236931055507/albums/5720519753508285169/6122212643311873906\",null,null,\"Photo by Justin Brown\",null,[[\"New York City, NY\",\"https://www.google.com/search?q=New+York+City%2C+New+York\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipPSuE14ATWH-FwoO3DGJyiyewcTORhjDEuFTF__\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],1293145096798261,[9700053]]", 24 | "numLinkedUsers" => 0, "requestId" => "0", 25 | "topicHistory" => ["[\"https://lh3.googleusercontent.com/proxy/GUfWvBxLY7h8Li2oVoqt5QMs8h6heAX-TTnlKUKND1JCbg2MKPD9yi1QzUXhp3oEWbpfkRp8MWRepmgYBMOm5vNn4O2F1XCCoWG37QCbweiYEJ8mTj_aknB0306wcWfqcSuo7gc6ZOdO2mOod9lnaX453YPntwNsNws4Ux_g=s1920-w1920-h1080-n-k-no-nd-mv\",null,null,null,120,11,null,null,null,\"https://500px.com/photo/105754217/gravity-chamber-by-alex-noriega?utm_source=google&utm_medium=chromecast&utm_campaign=september_launch\",null,null,\"Photo by Alex Noriega\",null,[],null,0,\"FEATURED_500PX_TITLE\",\"0312daec19f4b4cb0ccf374d83a605e3\",null,null,0,null,null,null,47,[[47,\"FEATURED_500PX_TITLE\",\"500px\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],-7860625334833097,[9700053]]", 26 | "[\"https://lh3.googleusercontent.com/NcFlzZdTZazuc1FH1vuyExOFYMdz6rKtMrtdtghDJ_ScngVdnmWmgwxffJbyWRWfq-tvVFT5zZvtHWpeXvw=s1920-w1920-h1080-p-k-no-nd-mv\",\"Robin Griggs Wood\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/103698889037599783920/albums/5893830837236231873/6286435320971968402\",null,null,\"Photo by Robin Griggs Wood\",null,[[\"Palace of Fine Arts, San Francisco, California\",\"https://www.google.com/search?q=Palace+of+Fine+Arts%2C+San+Francisco\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipMKwdLuP0T3JWxRBCBiDGJxXNmSxTOIaAHM7Gxe\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],5097477668030082,[9700053]]", 27 | "[\"https://lh5.googleusercontent.com/proxy/vkAEc_MrmRauQq8jR40hx_yh-ExzYSupzCaA3uKbLv6MKhRFd4tit_f1CTneIYHT7vYkgbPANzXQ0vuYzXm1WLpUyTFHv0Vcj13gH9Ibr_z9AJR0SrTQ2hmfXz7isqP_EhXtk9-ldjRQOhhc5n1waAu8IXtiid5Pt5ZYbNE2oooJysxGh8-2vprNq3yArVBNC-DWdvzEjaJoR_wfkdSGWG2houxsQzAG6ZgInJDJ1cz0U1_a8tdTStJ56CoTMAs=s1920-w1920-h1080-p-k-no-nd-mv\",null,null,null,120,11,null,null,null,\"http://www.gettyimages.com/detail/photo/suspension-bridge-royalty-free-image/107709060?esource=chromecast\",null,null,\"Photo by Daniel Muller\",null,[],null,0,\"FEATURED_GETTYIMAGES_TITLE\",\"89ae3a3b697da2318e0a9fbd8951b7d5\",null,null,0,null,null,null,48,[[48,\"FEATURED_GETTYIMAGES_TITLE\",\"Getty Images\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],3037105898765724,[9700053]]", 28 | "[\"https://lh3.googleusercontent.com/SxgLlfCrzM66njIgpKlq2nkRrCdq2_sONwQDxl0AAImggIso_VkVzg=s1920-w1920-h1080-p-k-no-nd-mv\",\"Saurabh Paranjape\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/105737724482908033948/albums/6213733340481709841/6213733341343554690\",null,null,\"Photo by Saurabh Paranjape\",null,[[\"Westminster Bridge, London, England\",\"https://www.google.com/search?q=Westminster+Bridge\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipN2LkA4gtWKmP4xk7RWQFR9IKB9EnW2m4s3qVsp\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],-6380615397988733,[9700053]]", 29 | "[\"https://lh3.googleusercontent.com/mij2Eglc324jD_kxhu43aSnX8w9Xfr7XQdEwLpWpiVoFWZSh8Ljj=s1920-w1920-h1080-p-k-no-nd-mv\",\"Justin Brown\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/101005060236931055507/albums/5720519753508285169/6122212643311873906\",null,null,\"Photo by Justin Brown\",null,[[\"New York City, NY\",\"https://www.google.com/search?q=New+York+City%2C+New+York\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipPSuE14ATWH-FwoO3DGJyiyewcTORhjDEuFTF__\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],1293145096798261,[9700053]]"]}, 30 | receiver_status: %{"requestId" => 1, 31 | "status" => %{"applications" => [%{"appId" => "E8C28D3C", 32 | "displayName" => "Backdrop", "isIdleScreen" => true, 33 | "namespaces" => [%{"name" => "urn:x-cast:com.google.cast.sse"}, 34 | %{"name" => "urn:x-cast:com.google.cast.inject"}], 35 | "sessionId" => "C6591D59-64B2-4357-98DE-A982A75FAA6F", 36 | "statusText" => "", "transportId" => "web-5"}], 37 | "volume" => %{"controlType" => "attenuation", "level" => 1.0, 38 | "muted" => false, "stepInterval" => 0.05000000074505806}}, 39 | "type" => "RECEIVER_STATUS"}, request_id: 4, 40 | session: "C6591D59-64B2-4357-98DE-A982A75FAA6F", 41 | ssl: {:sslsocket, {:gen_tcp, #Port<0.7995>, :tls_connection, :undefined}, 42 | #PID<0.226.0>}} 43 | 44 | ##### Youtube Video playing 45 | 46 | iex(126)> Chromecast.state(device) 47 | %Chromecast.State{destination_id: "web-13", ip: {192, 168, 1, 138}, 48 | media_session: 1936679159, 49 | media_status: %{"appDeviceId" => "BBC9E06BCA89EA246A21D650BE44BA52", 50 | "backendData" => "[\"https://lh4.googleusercontent.com/proxy/c5GLPnubdevNNbhBlOekeEAA64Us7uNSMJkhgjWZlCQIo1eqqwXve4RZlIcBQwahoEI32MXkXXOhZmFWayEpF-UJafokwVKemB2EWX42dJDUi6xCLmPYLRDEmZ5YPCDefGJikV2XgR8e9pE5b4XS215Bygf6t-oNSTK-Ae_uKtKq3gOMvFt7PmmFPj9uvqjCu9N1ehUQ6CXiH7Ke7z6nqpO5dW6kfDVvdAV1oEtl6bD572C1QHWaD5d9HKnRzx-l0j4CryxMq0xIODl2ziPasNGGe4zHCYpGXIzq7qrF=s1920-w1920-h1080-fcrop64=1,0000170affffef93-k-no-nd-mv\",null,null,null,120,11,null,null,null,\"http://www.gettyimages.com/detail/photo/grand-prismatic-spring-yellowstone-national-high-res-stock-photography/462556881?esource=chromecast\",null,null,\"Photo by Peter Adams\",null,[],null,0,\"FEATURED_GETTYIMAGES_TITLE\",\"363fad4f1769ed622205dc4e1b75281a\",null,null,0,null,null,null,48,[[48,\"FEATURED_GETTYIMAGES_TITLE\",\"Getty Images\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],4718130360558134,[9700053]]", 51 | "currentTime" => 0.453, "customData" => %{"playerState" => 1}, 52 | "media" => %{"contentId" => "RuWRnIAwPIU", 53 | "contentType" => "x-youtube/video", 54 | "customData" => %{"currentIndex" => 2, 55 | "listId" => "RQAGdO5p9nVvzfKwZGP9XUYZDXbqcljCV01fxpzRs0Qpu0d_ZPowkJ508lgJyOldy_hFam51_fF_NieXwcngOoZlXYeR89R8CXAMGvyLvP82nkOuUUAVbL1hQ"}, 56 | "duration" => 157.314693877551, 57 | "metadata" => %{"images" => [%{"url" => "https://i.ytimg.com/vi/RuWRnIAwPIU/hqdefault.jpg"}], 58 | "metadataType" => 0, 59 | "title" => "Metropolis 80 Freeskate Vs Barcelona - Powerslide"}, 60 | "streamType" => "BUFFERED"}, "mediaSessionId" => 1936679159, 61 | "numLinkedUsers" => 0, "playbackRate" => 1, "playerState" => "PLAYING", 62 | "requestId" => "0", "supportedMediaCommands" => 3, 63 | "topicHistory" => ["[\"https://lh5.googleusercontent.com/proxy/vkAEc_MrmRauQq8jR40hx_yh-ExzYSupzCaA3uKbLv6MKhRFd4tit_f1CTneIYHT7vYkgbPANzXQ0vuYzXm1WLpUyTFHv0Vcj13gH9Ibr_z9AJR0SrTQ2hmfXz7isqP_EhXtk9-ldjRQOhhc5n1waAu8IXtiid5Pt5ZYbNE2oooJysxGh8-2vprNq3yArVBNC-DWdvzEjaJoR_wfkdSGWG2houxsQzAG6ZgInJDJ1cz0U1_a8tdTStJ56CoTMAs=s1920-w1920-h1080-p-k-no-nd-mv\",null,null,null,120,11,null,null,null,\"http://www.gettyimages.com/detail/photo/suspension-bridge-royalty-free-image/107709060?esource=chromecast\",null,null,\"Photo by Daniel Muller\",null,[],null,0,\"FEATURED_GETTYIMAGES_TITLE\",\"89ae3a3b697da2318e0a9fbd8951b7d5\",null,null,0,null,null,null,48,[[48,\"FEATURED_GETTYIMAGES_TITLE\",\"Getty Images\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],3037105898765724,[9700053]]", 64 | "[\"https://lh3.googleusercontent.com/SxgLlfCrzM66njIgpKlq2nkRrCdq2_sONwQDxl0AAImggIso_VkVzg=s1920-w1920-h1080-p-k-no-nd-mv\",\"Saurabh Paranjape\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/105737724482908033948/albums/6213733340481709841/6213733341343554690\",null,null,\"Photo by Saurabh Paranjape\",null,[[\"Westminster Bridge, London, England\",\"https://www.google.com/search?q=Westminster+Bridge\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipN2LkA4gtWKmP4xk7RWQFR9IKB9EnW2m4s3qVsp\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],-6380615397988733,[9700053]]", 65 | "[\"https://lh3.googleusercontent.com/mij2Eglc324jD_kxhu43aSnX8w9Xfr7XQdEwLpWpiVoFWZSh8Ljj=s1920-w1920-h1080-p-k-no-nd-mv\",\"Justin Brown\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/101005060236931055507/albums/5720519753508285169/6122212643311873906\",null,null,\"Photo by Justin Brown\",null,[[\"New York City, NY\",\"https://www.google.com/search?q=New+York+City%2C+New+York\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipPSuE14ATWH-FwoO3DGJyiyewcTORhjDEuFTF__\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],1293145096798261,[9700053]]", 66 | "[\"https://lh3.googleusercontent.com/LEwrbgnOQd-jBz6s5FuTqtKlUaa_UExFsBLBlzOi0rtsopryVScqkQ=s1920-w1920-h1080-p-k-no-nd-mv\",\"Aaron Choi\",null,null,120,7,null,null,null,\"https://plus.google.com/photos/111628818598106803270/albums/6117592132440816289/6140512115181587250\",null,null,\"Photo by Aaron Choi\",null,[[\"Manarola, Cinque Terre, Italy\",\"https://www.google.com/search?q=Manarola+in+Cinque+Terre%2C+Italy\"]],null,0,\"FEATURED_GPLUS_TITLE\",\"AF1QipNQEjzYMlvgXzfQkE6l9Yrsip6SGFnuQim2xlcY\",null,null,0,null,null,null,46,[[46,\"FEATURED_GPLUS_TITLE\",\"Google+\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],7159867778728899,[9700053]]", 67 | "[\"https://lh4.googleusercontent.com/proxy/c5GLPnubdevNNbhBlOekeEAA64Us7uNSMJkhgjWZlCQIo1eqqwXve4RZlIcBQwahoEI32MXkXXOhZmFWayEpF-UJafokwVKemB2EWX42dJDUi6xCLmPYLRDEmZ5YPCDefGJikV2XgR8e9pE5b4XS215Bygf6t-oNSTK-Ae_uKtKq3gOMvFt7PmmFPj9uvqjCu9N1ehUQ6CXiH7Ke7z6nqpO5dW6kfDVvdAV1oEtl6bD572C1QHWaD5d9HKnRzx-l0j4CryxMq0xIODl2ziPasNGGe4zHCYpGXIzq7qrF=s1920-w1920-h1080-fcrop64=1,0000170affffef93-k-no-nd-mv\",null,null,null,120,11,null,null,null,\"http://www.gettyimages.com/detail/photo/grand-prismatic-spring-yellowstone-national-high-res-stock-photography/462556881?esource=chromecast\",null,null,\"Photo by Peter Adams\",null,[],null,0,\"FEATURED_GETTYIMAGES_TITLE\",\"363fad4f1769ed622205dc4e1b75281a\",null,null,0,null,null,null,48,[[48,\"FEATURED_GETTYIMAGES_TITLE\",\"Getty Images\"],[3,\"PHOTO_COMMUNITIES_TITLE\",\"Featured photos\"]],4718130360558134,[9700053]]"], 68 | "volume" => %{"level" => 1, "muted" => false}}, 69 | receiver_status: %{"requestId" => 12, 70 | "status" => %{"applications" => [%{"appId" => "233637DE", 71 | "displayName" => "YouTube", "isIdleScreen" => false, 72 | "namespaces" => [%{"name" => "urn:x-cast:com.google.youtube.mdx"}, 73 | %{"name" => "urn:x-cast:com.google.cast.media"}, 74 | %{"name" => "urn:x-cast:com.google.cast.cac"}, 75 | %{"name" => "urn:x-cast:com.google.cast.inject"}], 76 | "sessionId" => "9F2318D0-8581-446E-9BFB-AA8FDABD74F4", 77 | "statusText" => "YouTube", "transportId" => "web-13"}], 78 | "volume" => %{"controlType" => "attenuation", "level" => 1.0, 79 | "muted" => false, "stepInterval" => 0.05000000074505806}}, 80 | "type" => "RECEIVER_STATUS"}, request_id: 39, 81 | session: "9F2318D0-8581-446E-9BFB-AA8FDABD74F4", 82 | ssl: {:sslsocket, {:gen_tcp, #Port<0.7995>, :tls_connection, :undefined}, 83 | #PID<0.226.0>}} 84 | 85 | ## Explanation 86 | 87 | `Chromecast.start_link(ip \\ {192, 168, 1, 15})` starts a `GenServer` that opens a binary SSL connection to the Chromecast. The protocol is based on Protobufs, and uses the `exprotobuf` library. Every few seconds the Chromecast will send out a `ping` request and expects a `pong` within a few seconds, otherwise it closes the session. Periodically Chromecast sends a request with it's current state, this is captured by the Chromecast process and it's state is updated. To pause media call `Chromecast.pause(:device)` where device is the `PID` returned when calling `Chromecast.start_link`. Play is similar `Chromecast.play(:device)`. You can easily connect to multiple Chromecasts by calling `Chromecast.start_link(ip)` with unique IP addresses. You can discover your Chromecast(s) using [mDNS](https://github.com/NationalAssociationOfRealtors/mdns) and/or [SSDP](https://github.com/NationalAssociationOfRealtors/ssdp). 88 | --------------------------------------------------------------------------------