Apple TV Dashboard

So you have all these screens around and you want to have something awesome like on those one that Panic made. You may also already have Apple TVs on these TVs because AirPlay is an effective way to project screens in an office full of Macs. This is position we were in at TaskRabbit and we’d tried a few things.

The most recent iteration was to have one Mac Mini in a closet and lots of very long wires into the TVs. It displayed a web page that cycled through the content. This wasn’t bad, but had a few issues. First, the screens all had to show the same thing. I wanted to be able to have different content on each. For example, in addition to key metrics, the conference rooms would have who had the room booked or the one by customer support would have more data on the call volume. But it just isn’t worth it to have 10 Mac Minis for this purpose. The other issue is that people still wanted to use Airplay, so they would switch the input and then not switch it back to the ambient content.

The solution seemed clear. I needed to get the dashboards to be the screensaver of an Apple TV.

TLDR

Here is a sample project to ties all of this up into one package.

It uses the new gems, dashing-screenshots and icloud-photo, to take screenshots and upload them to iCloud. These then show up on an Apple TV

Sample dashboard on the wall

(not actual dashboard)

To make this happen, check out the project and follow the instructions there.

Dashboard

Looking around at various dashboard apps and having made a few custom solutions, I settled on Dashing this time. It looked nice by default, had a great model for adding custom content, and is written in the language we tend to use around here (Ruby).

The Dashing docs are quite good so I won’t go into detail of how we pulled in our data. The samples are actually the best as they show a simple use case of the pattern of how to add jobs. I made some helpers to be able to pull in data from Looker. By putting the actual data in Looker, I’m hoping it will allow others to maintain the metrics definitions. This will hopefully keep the dashboard up to date and relevant. An issue we’ve had in the past is that the SQL became somewhat stale.

Screenshots

I’d like a fully dynamic dashboard as much as the next guy, but the screensaver of an Apple TV consists only of images. Therefore, the next step was to get a screenshot of all of the dashboards that Dashing has to offer. I have worked on a similar project within our test suite and had great success with Selenium. I added selenium-webdriver to the Gemfile and this file to lib.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
require 'fileutils'

module Dashing
  class Screenshot
    def initialize(directory, options=nil)
      @directory = directory
      @options = options || {}
    end

    def capture!
      require 'selenium-webdriver'
      ::FileUtils.mkdir_p(@directory)

      @driver = Selenium::WebDriver.for :firefox
      Dir["dashboards/**/*.erb"].each do |file|
        file.gsub!(/^dashboards\//, "")
        file.gsub!(/\.erb$/, "")
        next if ["layout"].include?(file)
        snap(file)
      end
      @driver.quit
    end

    private

    def snap(path)
      puts "Fetching #{path}" if @options[:log]
      @driver.navigate.to "http://localhost:3030/#{path}"
      filename = File.join(@directory, "#{path.gsub("/", "__")}.png")
      puts "  saving: #{filename}" if @options[:log]
      sleep 2
      @driver.save_screenshot(filename)
    rescue Exception => e
      puts "        SCREENSHOT ERROR: #{e.message}"
    end
  end
end

This is called by something like this (which I added to the Rakefile):

1
2
3
4
5
6
7
namespace :dashing do
  desc "takes screenshots"
  task :screenshots do
    require_relative './lib/screenshot' # now available with require 'dashing-screenshots'
    Dashing::Screenshot.new("screenshots").capture!
  end
end

So running bundle exec rake dashing:screenshots will take pictures of all the dashboards that your Dashing project knows about. I picked the folder “screenshots,” but that could also take any path in or outside of the project.

There are few gotchas during the process:

  • I also tried to use PhantomJS and Poltergeist, but these systems both have an issue with server-sent events which Dashing uses to update the data.
  • For similar updating reasons, I found that the sleep 2 or something along those lines was necessary.
  • Dashing initially loads with data from history.yml so that is most likely what you’ll get from this tool. Therefore, it’s necessary to have Dashing running in the background and be updating to always get “current” data.

Apple TV

I figured I was in the home stretch, but Apple had other plans for me. Other the next day or so, I struggled with ways to get these screenshots updating in (near) real-time onto the TVs. There are several ways to populate the screensaver and I still can’t believe how hard it turned out to be.

The best chance seemed to the the Flickr option. I knew how to upload photos to Flickr. Apple TV knew how to show photos from Flickr. Done. I made a private photo album for each TV and went about uploading the right screenshots to them. But it turns out that Apple TV doesn’t poll Flickr to get the updated sets. After several tests of changing the contents of the Flickr albums and waiting for hours, they never updated. Even restarting the device didn’t seem to help. I wanted these to update on the order of every 10 minutes or so. It just didn’t work.

I next tried the iTunes feature. iTunes has something called “Home Sharing” and the Apple TV knows how to get it’s photos from there. Home sharing lets you point it at a folder on the system, so I thought that would work. I just had it pointed to the screenshots folder of my Dashing project. Unfortunately, the same issue occurs here where either Apple TV or iTunes does not look for changed content. The same is true of using iPhoto for Home Sharing instead of a simple folder.

The final feasible option for populating the screen saver was iCloud. The Apple TV will read from your Photo Stream or an album in iCloud. I ran a manual test and literally leapt with joy (it had been hours trying all the configurations) when the screensaver updated in real time as I added things to iCloud via iPhoto. It seems that, for whatever reason, the Apple TV is set to poll for changed content in this one case. So that’s how it had to be then.

iCloud

Feeling some momentum, the only task left to complete was to upload the screenshots automatically to the new iCloud account I created. Following the trend, this turned out to be at least 10 times harder than expected.

As far as I can tell, there is no server API for iCloud Photos. I found a few projects that seem to have tried to reverse engineer various protocols, but nothing definitively for photos. There may be an iOS SDK, but that was not going to help me in this case.

Thinking they had figured it out, I spent a long time trying to get the right set up to make this IFTTT recipe work. I set it up to sync from the computer’s Dropbox to an always running iOS device also running Dropbox. At this point I was prepared to sacrifice the iPod touch to make it work. But it seems that the recipe is simply misnamed. It puts the photos in your iOS library and not into iCloud.

Applescript

Resigned to the fact that the only this that would work is doing it through iPhoto, I set out to learn Applescript to automate that process. Luckily, our new VPE Paul Devine had used it heavily in a past life. We wrote this script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
tell application "iPhoto"
  quit
  delay 10
  
  activate
  delay 1
  tell album "Photos"
      set thePhotos to get every photo
      repeat with aPhoto in thePhotos
          try
              -- log aPhoto's name as text
              remove aPhoto
          on error errMsg
              log "ERROR: " & errMsg
          end try
      end repeat
  end tell
  
  set iFolder to "/Users/dashboard/screenshots"
  import photo from iFolder
  
  delay 2
  
  tell me to refreshCloud("dashboard", {"sampletv"})
  
  empty trash
  delay 10
  quit
end tell

on refreshCloud(cloudName, thePhotos)
  tell application "iPhoto"
      set currentPhotos to {}
      set currentSize to 0
      set hasHold to false
      tell album cloudName
          set cloudPhotos to get every photo
          set currentSize to the count of cloudPhotos
          repeat with aPhoto in cloudPhotos
              if name of aPhoto is "hold" then
                  set hasHold to true
              else
                  set the end of currentPhotos to aPhoto
              end if
          end repeat
      end tell
      
      repeat with aName in thePhotos
          activate
          delay 1
          
          tell album "Last Import" to select photo aName
          delay 1
          
          tell application "System Events"
              tell process "iPhoto"
                  click menu item "iCloud…" of menu "Share" of menu bar 1
                  delay 1
                  
                  keystroke cloudName
                  delay 1
                  
                  keystroke (ASCII character 13) -- return
                  delay 1
                  
                  keystroke (ASCII character 13) -- return
                  delay 3
              end tell
          end tell
      end repeat
      
      if not hasHold then
          -- need to let iCloud catch up
          delay 100
      end if
      
      delay 20 -- make sure all uploaded and stuff
      select album cloudName
      
      set updatedSize to the count of every photo in album cloudName
      
      if updatedSize > currentSize then
          -- for some reason, it doesn't always actually upload
          if (count of currentPhotos) > 0 then
              repeat with aPhoto in currentPhotos
                  activate
                  delay 1
                  select aPhoto
                  tell application "System Events"
                      delay 1
                      keystroke (ASCII character 127) -- delete
                      delay 1
                      keystroke "d" using command down -- yes, really
                      delay 3
                  end tell
              end repeat
          end if
      end if
      
  end tell
end refreshCloud

It does the following:

  • closes (if necessary) iPhoto
  • launches iPhoto
  • imports the “screenshots” folder into iCloud
  • switches to the “dashboard” iCloud album
  • notes all the photos currently in there
  • switches to the “Last Import” album
  • finds the picture named “sampletv”
  • click Share… iCloud and picks the “dashboard” and adds it
  • waits for a minute
  • goes through all the old ones in “dashboard” and removes them

It waits for a minute because I found that the Apple TV screensaver is not able to recover from an empty set. It will switch permanently to National Geographic photos. I’m not sure how this is possible other than some sort of lag in iCloud. Another approach that I ended up going with is to use the “Shifting Tiles” screensaver and always made sure to always have 1 image that never got removed in the album. I set up the Applescript so that if you make “hold” the image’s name, it will leave it alone. So try that if you keep seeing beautiful whale pictures instead of your dashboard.

Note that is also assumes that the iCloud albums already exist. I made those manually.

It’s obviously exhausting that this is how it had to be. However, I’m sure very little resources are being given to Applescript these days and I find it amazing that it’s still able to automate everything in OS X.

Gems

The final step was to automate this in some way. I decided to keep it in Ruby.

To do that and reduce the maintenance of the Applescript, I made it so that the script was more configurable. Maybe it’s a terrible idea, but it turned out pretty well.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# encoding: utf-8

require 'fileutils'
require 'tempfile'

module ICloudPhoto
  class Sync

    SCRIPT = File.read(File.expand_path("../../applescript/sync.applescript", __FILE__), encoding: "UTF-8")
    def initialize(directory)
      @directory = directory
      @targets = []
    end

    def add(icloud_name, image_names)
      image_names = [image_names].flatten
      @targets << [icloud_name, image_names]
    end

    def upload!
      tmpfile = nil
      uploader = nil
      script = SCRIPT.dup
      script.gsub!("/Users/dashboard/screenshots", File.expand_path(@directory))

      marker = 'tell me to refreshCloud("dashboard", {"sampletv"})'
      index = script.index(marker) + 1
      script.gsub!(marker, '')

      @targets.each do |tuple|
        icloud_name, image_names = tuple
        files = image_names.collect{ |name| "\"#{name}\"" }
        val = "\ttell me to refreshCloud(\"#{icloud_name}\", {#{files.join(", ")}})\n"
        script.insert(index, val)
      end

      tmpfile = Tempfile.new('applescript')
      tmpfile.write(script)
      tmpfile.close

      uploader = Tempfile.new('uploader')
      uploader.close

      puts "osacompile -o #{uploader.path} #{tmpfile.path}"
      puts `osacompile -o #{uploader.path} #{tmpfile.path}`

      puts "osascript #{uploader.path}"
      puts `osascript #{uploader.path}`
    ensure
      tmpfile.unlink  if tmpfile
      uploader.unlink if uploader
    end
  end
end

So the Applescript was checked into the project. This code does the following:

  • reads the script from the file.
  • replaces my directory with the given “real” one
  • removes this “dashboard” and “sampletv” business and keeps a marker
  • for every iCloud to photo(s) mapping, it adds a line to the script in memory to call the refreshCloud method
  • saves that all to a temp file
  • compiles the temp file to another temp file
  • runs the compiled version

It is called like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace :dashing do
  desc "takes screenshots"
  task :screenshots do
    require 'dashing-screenshots'
    Dashing::Screenshot.new("screenshots").capture!
  end

  desc "uploads screenshots to iCloud"
  task :upload do
      require 'icloud-photo'
      cloud = ICloudPhoto::Sync.new("screenshots")
      # put the sampletv image in the dashboard album
      cloud.add("dashboard", ["sampletv"])
      cloud.upload!
  end

  desc "record and upload dashboards"
  task cron: [:screenshots, :upload]
end

Then it can be automated using the whenever gem and running whenever -w

1
2
3
every 10.minutes do
  rake "dashing:cron"
end

It’s important to be sure to be running dashing start as well. I also found it helpful to use Insomnia X to not go to sleep when the laptop lid is shut.

Apple TV setup

Just a few notes on the settings I used when configuring the Apple TV

Restore to newest software

General

  • Set up keyboard
  • Set name
  • Sleep after: never
  • Software updates - Update automatically: on

Screensaver

  • Start after: 2 minutes
  • Photos - iCloud photos - Login - Pick “dashboard” album
  • Classic - Fade through black - 20 seconds

AirPlay

  • Conference room display - off
  • Play iTunes from the cloud - off

The “Classic” screensaver will show one image at a time. This works pretty well if you are taking a picture of the whole dashboard. I’ve also had success making a “dashboard” out of each widget and taking several screenshots and uploading all of them. In this case, I would use the “Shifting Tiles” noted above to solve the iCloud empty issue. This ends up looking like the original dashboard but it moves around a bit.

It works

We now have an old laptop with the Dashing project and iPhoto. Every ten minutes, the screen flashes and firefox pops up and takes some screenshots. Then iPhoto pops up and things start moving around. Within a few minutes of that, the screensaver on all the Apple TVs automatically refresh to the new content.

It’s not pretty, but it does work. One of my fears (and hopes) is that somebody emails me a better way to make this happen. I spent about three days figuring this out but would gladly throw it away for a better solution. Ideally, there would be one that did not involved this running laptop and especially not this Applescript business.

To be clear, though, I don’t see it as time wasted. I believe it’s exceptionally important to increase visibility about the health of the business. By doing so in the most pervasive way I could think of, it may help set the context for people as they make decisions throughout the day. I’d say that’s worth one old laptop and some janky Applescript.

Copyright © 2017 Brian Leonard