Raspberry Pi Chromecast Remote
In a multi-person household the one downside of the Chromecast is that if I start the movie, I end up being the one to pause the movie. It's possible to do things like open up Plex or the appropriate app on another device, but it's extra effort (especially when most pause events are triggered by a toddler potty break) and guessing the app incorrectly often stops the movie instead of adding another controller to the Chromecast. Enter the Raspberry Pi Chromecast Remote.
The Chromecast now supports HDMI-CEC! You can see some details here, but the RazCast Remote went from a fun project to realizing it could never work for NetFlix or most apps, to being replaced by a Nexus Player, and now I'm back to a Chromecast everywhere despite a lack of NetFlix controls.
Of course, you'll need a Raspberry Pi connected to your network. This can be via a hardwired cable on a "B" model or through a WiFi dongle. You'll also want to make sure your Pi is up and running properly, I'm using the Raspian image from September 2014 for this downloaded from here.
I'm assuming a basic knowledge of Raspberry Pi installation and configuration as well as some Debian/Ubuntu knowledge. If you need any help, feel free to contact me and I'll try to update things to make them more clear.
Note, this whole project could be avoided if everyone would just star this issue in hopes that Google adds some better HDMI-CEC support to the Chromecast.
You can see how to get a basic Pi config up and running here: Raspberry_Pi_Initial_Setup
We'll need git to get started, luckily it's pre-installed so we really just need to install pychromecast using:
sudo apt-get update && sudo apt-get dist-upgrade sudo apt-get install python-pip vim git clone https://github.com/balloob/pychromecast cd pychromecast sudo pip install -r requirements.txt
Now we should have the Pi all set up. We've got ssh running, we've got IPv6 support, we've got vim installed along with python and pychromecast. Now we just need a python script to control the Chromecast when you push a button on the Pi and, you know, a button.
You'll want to make sure to pick out a case that has a bit of room for a button, I found a hole just above the SD card works well for the buttons I selected. The largest drill bit I had on hand (a 3/8" bit, hey, I live in an apartment and donated most of my tools!) wasn't quite big enough, but I was able to easily use a knife to scrape the hole slightly larger. By being such a tight fit I didn't need the retaining nut for the button, the threads hold it in place just fine. You may want to solder the wires on before you fit it in place like this - voice of experience talking here but it's not too bad. Below is a shot of the prematurely mounted button and the cable I bought with two wires stripped off.
Some needlenose pliers helped me to solder the wires onto the button. A little dab of non-conductive glue will help insulate things afterwards.
After that, we just connect the wires to pin 24 and the neighboring ground. You can see this is the 4th and 5th pin from the video out near the edge of the board.
After a quick test to make sure things were working here's the final assembled remote.
Note that my early build of the Pi B doesn't seem to have the mounting holes the case was designed for. I had to snap off the mounting post near the LEDs for my particular case to fit and it's a bit loose since I can't screw it together, but a touch of glue can fix that when I'm ready to make this permanent.
Note that a left-angle micro USB connector makes this a lot easier to manage, I wish the case had enough room for me to mount the button on the side. Perhaps a smaller button would have been a better choice, or even a larger button with a base so the Pi can be hidden.
Below are images from my alpha proof of concepts. On the left is Alpha 0 which made the user touch the two wires together manually to pause the Chromecast, kind of like hotwiring a car. Alpha 1 was born when I managed to dig up a pushbutton from an Arduino kit. Alpha 1 end user testing caused 68% less fear of electrocution in the test audience. Alpha 2 has a bottom case, some heatsinks that came with said case, and a more reliable button in addition to ditching the breadboard. However, I'm waiting on wires to go directly to the header instead of needing the ribbon cable (and the case won't close with the cable in place).
Bill of Materials
All prices are as of 2014-11-09):
- Raspberry Pi ($37.80): I use a model B (original generation), there are also kits including cases that may save you money. If you intend to go the WiFi route, a model A may be a better choice.
- Wires ($4.02): You only need two of these, and can likely scavenge. I ordered a slightly different one, but it ships slow-boat from China!
- Buttons ($5.76): I'm using a single-button design, so the same button will play and pause. Realistically the script may be easier with a play/pause button and you can even play with volume control if you want, but I'm looking for dead simple.
- Case ($7.99): You'll probably want something to mount the button to, I'm hoping this case will be large enough to support the button. Note that this case is for the Original B, not the second generation linked above.
Total price: $55.57 (assumes pre-existing Ethernet cable and MicroUSB charger)
As you can see, it's more expensive than the Chromecast itself. But the Pi can be used for other purposes at the same time and much of the rest could very well be lying around, salvageable out of other equipment, or will have plenty of leftovers for the next go around (you'll have enough wires for 20 of these and enough buttons for 10). Also note that a $20 A+ model plus an $8.50 WiFi dongle will save you nearly $10 over a B+.
You may also want to look into a USB WiFi device. Since I've got a model B and I'll be putting it close to a wired connection there is no need for me. I'll also be close to my router which has a USB port capable of powering the Pi.
Now for the rough part. If you're looking at $50 for the remote and $35 for the Chromecast that's getting pretty close to the $100 for a Nexus Player which includes a remote, has Chromecast support, and you don't need to worry about per-app compatibility.
First, let me warn all you devs out there. I'm not a developer. I'm not overly familiar with Python. I cobble together proofs of concept, but please forgive how messy and hacky the code below is - treat it like pseudocode that (mostly) actually runs! :)
I called this script pychromecast/chromecast_daemon.py and I added the following lines to root's crontab (be sure to replace "LivingroomChromecast" with your Chromecast's name):
@reboot python ~pi/pychromecast/chromecast_daemon.py LivingroomChromecast */5 * * * * python ~pi/pychromecast/chromecast_daemon.py LivingroomChromecast * */3 * * * shutdown -r now
Note that I now reboot every three hours, I kind of gave up trying to detect whether the Chromecast object was still valid (it had a tendency to lock up). It's a cheat, but it will hopefully work...
The script will detect whether or not it's running and close out if it is, this will cover the case where something crashes the script since it will run every five minutes. Note that GPIO access needs to be as root, too bad since running a persistent python script as root seems like a bad idea. I may play a bit later to pull out some functions into a non-privileged user's account. This script is still being actively edited, stop by frequently for updates. Eventually I'll put it under change control. Note that it's Apache licensed, that means you can do with it as you please - if you do make improvements or changes please let me know what cool things you're up to!
#!/usr/bin/python ''' Copyright 2014 Jeff Bower Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ''' from __future__ import print_function import atexit import time import sys import socket import logging import RPi.GPIO as GPIO import pychromecast cast="" chromecast_name="" # After running into some issues I need proper logging # I'll use an arbitrary 5 levels of logging: # critical, major, minor, info, debug log_level=5 logfile=open('/tmp/chromecast.log','a',1) def log(message, sev): global log_level if int(log_level) < int(sev): return(0) global logfile if sev == 1: sevtext = "CRITICAL" elif sev == 2: sevtext = "MAJOR" elif sev == 3: sevtext = "MINOR" elif sev == 4: sevtext = "INFO" elif sev == 5: sevtext = "DEBUG" logline=time.strftime("%Y-%m-%d_%H:%M:%S_%Z")+" "+sevtext+": "+message # If we're a real TTY, print to the terminal. Otherwise just to the file. if sys.stdout.isatty(): print(logline) print(logline, file=logfile) return(0) def initialize_chromecast(message): global cast global chromecast_name log("Initializing Chromecast: "+message,4) if log_level == 5: dump_status("Before "+message) del cast cast = pychromecast.get_chromecast(friendly_name=chromecast_name) if log_level == 5: dump_status("After "+message) # Sometimes we need to dump the Chromecast Status, this lets us do it quickly def dump_status(message): if int(log_level) == 5: # We need to wait for the Chromecast to react time.sleep(0.1) log("Cast status: "+message,5) try: print(cast, file=logfile) except: print("Unexpected error printing cast: "+str(sys.exc_info()), file=logfile) try: print(cast.status, file=logfile) except: print("Unexpected error printing cast.status: "+str(sys.exc_info()), file=logfile) try: print(cast.media_controller.status, file=logfile) except: print("Unexpected error printing media_controller.status: "+str(sys.exc_info()), file=logfile) # This grabs a socket to check to see if the program is actually running, a # tricky alternative to needing to worry about lockfiles and crashes. def get_lock(process_name): global lock_socket lock_socket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) try: lock_socket.bind('\0' + process_name) log("I got the lock for "+process_name, 4) except socket.error: log("Lock exists, exiting", 5) sys.exit(2) get_lock(sys.argv) # Now the lock is set up and we know we're the only instance running. # Make sure there's a parameter present and assume it's the Chromecast name if len(sys.argv) == 2: chromecast_name = sys.argv else: log("Usage: "+sys.argv+" Chromecast_Name", 1) sys.exit(1) initialize_chromecast("Script startup") # There's got to be a cleaner way than this, if accessing is_idle throws an # exception assume it was a bad Chromecast name. try: status=str(cast.is_idle) except AttributeError: log("Bad Chromecast name "+chromecast_name,1) sys.exit(3) # I've had issues with the reliability of determining the Chromecast state, # in a pinch we'll just toggle play/pause when we hit the button. curr_mode = "play" # When the button is pushed, we want to run this code. def pause_play_chromecast(channel): log("Button push detected",4) # Try to get the Chromecast status. If we fail, maybe the object was broken? # This is an attempt to fix a problem I ran into after extended idles. dump_status("Button push start") try: status=str(cast.is_idle) except AttributeError: log("Unable to get Chromecast status on button push, reinitializing "+chromecast_name,3) initialize_chromecast("Can't get status on button push") global curr_mode # Check to see if the Chromecast is running if cast.is_idle: log("Chromecast is idle, that means a button push isn't expected",4) initialize_chromecast("Idle on button push") return else: try: if cast.media_controller.status.player_state == "PAUSED": # If it's paused, we want to set curr_mode to paused dump_status("Chromecast Paused") log("Current state: "+cast.media_controller.status.player_state+" - playing",5) curr_mode = "pause" else: # If it's playing or buffering set the curr_mode appropriately dump_status("Chromecast Not Paused") log("Current state: "+cast.media_controller.status.player_state+" - pausing",5) curr_mode = "play" except: # If an exception was thrown just toggle between modes. log("Exception thrown, using previous curr_mode value of "+curr_mode,3) dump_status("Chromecast state exception") # In theory we should have detected the real mode for the stream, but if # we get exceptions we'll just toggle between them if curr_mode == "play": log("Pausing",3) cast.media_controller.pause() dump_status("Post pause") curr_mode = "pause" log("Tried to pause, current status "+cast.media_controller.status,3) if cast.media_controller.status != "PAUSED": log("Tried to pause failed, current status "+cast.media_controller.status,3) initialize_chromecast("Pause failed") elif curr_mode == "pause": log("Playing",3) cast.media_controller.play() dump_status("Post play") curr_mode = "play" log("Tried to play, current status "+cast.media_controller.status,3) if cast.media_controller.status != "PLAYING": log("Tried to play failed, current status "+cast.media_controller.status,3) initialize_chromecast("Play failed") # Now we're touching the GPIO and want an exit handler def cleanup(): log("Cleaning up GPIO",4) GPIO.cleanup() log("Closing logfile, goodbye!",4) global logfile logfile.close() atexit.register(cleanup) # Finally, set up the GPIO library and throw up a listener for pin 24. GPIO.setmode(GPIO.BCM) GPIO.setup(24, GPIO.IN, pull_up_down = GPIO.PUD_UP) # Bouncetime should probably be played with once I get real hardware. We don't # want to have multiple asserts because of a noisy button, but now we can't # register pushes less than two seconds apart. GPIO.add_event_detect(24, GPIO.FALLING, callback=pause_play_chromecast, bouncetime=2000) last_time = 0 curr_time = 0 while True: # Just sleep most of the time, again there may be a better way to daemonize # a Python script but I'm a neophyte. time.sleep(120) # Everything below here is trying to catch corner cases where I've found that # the cast object has frozen. Some of this will be resolved should the devs # implement a planned timestamp to the polling. try: status=str(cast.is_idle) except AttributeError: log("Unable to get Chromecast status on periodic poll, reinitializing "+chromecast_name,3) initialize_chromecast("Attribute exception checking periodic idle status") if not cast.is_idle and cast.media_controller.status.player_state == "IDLE": log("I'm idle, but not reporting as is_idle",2) initialize_chromecast("Idle mismatch") try: curr_time = cast.media_controller.status.current_time if not cast.media_controller.status.player_state == "PAUSED" and curr_time == last_time: log("I'm not paused, but current_time is stuck at "+str(curr_time),1) initialize_chromecast("Idle mismatch") except: log("Can't get current time",5) curr_time = last_time + 1 log("Current time is "+str(curr_time)+" last time is "+str(last_time),5) last_time = curr_time GPIO.cleanup()
The following apps seem to work fine with pychromecast:
- Google Play Movies
- Google Play Music
The following apps don't have a pychromecast handler but it makes sense to implement one:
- Netflix. When I tried to capture the JSON messages I ended up with just heartbeat messages the majority of the time. I'm not sure why but I'm guessing that whatever wrapper Netflix uses on Linux is hiding the socket controls from Chrome. Netflix is also pretty tight when it comes to 3rd party apps, if there is no standard Chromecast play/pause API there's a decent chance even basic controls are signed and we'd need a key to perform basic functionality.
- Pandora. This may be even tougher, there is no Cast icon on the web UI so capturing the data from Chrome may be a non-starter. Packet-level captures indicate that there is SSL traffic going back and forth, so without in-app captures we may need to decrypt the data first. It seems to be TLS 1.0 with (I'm guessing) repetitive data so BEAST attacks against it may yield results, but asking the Pandora guys nicely may also work...
As of now I could use a hand gathering information for the pychromecast project, as described here. If I can stabilize things and get some free time I'll try to at least gather data if not work on a handler. For those more competent than I, the pychromecast project has an example file called plex.py which may unlock some mysteries for you.
- I'm assuming you'll have each remote tied to a particular Chromecast. I don't have any support here for allowing the same Pi to control two or more Chromecasts, but in theory we could have a switch across other GPIO pins to select which one. Even better could be a method of cycling through on an LCD screen or using something like an NFC reader to switch as you move it between rooms, but this isn't a very portable device.
- I've had some issues around checking the current Chromecast state throwing exceptions. I think this is startup issues when you've done something like connected to the Chromecast but you haven't started playing anything, but worst case the fallback behavior is to toggle between play/pause.
- With additional buttons additional features could be unlocked. For example, you could have dedicated play and pause buttons which obviate the need for current status logic. You may be able to control the volume. Or you could tack on an IR blaster to turn off the TV or provide other controls. This last one is the most interesting, you could open up a whole webservices API on the Pi to control all of your AV equipment.
- After extended periods I get some odd behavior where I need a reboot. I'm fairly certain python is just garbage collecting the wrong variable, but I'm trying to troubleshoot. The problem is it takes a while to run into this problem so I just need to log everything on every button push to see what doesn't look right and then try to recreate the cast object when it fails... An alternative is to recreate the cast object periodically and after every button push, but that seems unnecessary - especially when it takes a few seconds to connect to the Chromecast.