Posted by virantha on Thu 09 January 2014

AirFrame: Hacking together a WiFi photo frame with a Toshiba FlashAir SD card and Flickr

I'm going to describe AirFrame, a Python script I wrote that's available on GitHub under ASL2 open-source, that can automatically pull photos matching a given set of tags from an authorized Flickr account, and wirelessly push them to a WiFi enabled SD card sitting inside an inexpensive digital photo frame. Pair this $30 SD card with an inexpensive 8-inch digital photo frame (like these 1, 2, 3), and you can have a wireless photo frame for well under $100. And unlike with just using a tablet, you can get a 15-inch display and still keep the cost under $150.

Please see the following documentation on how to use this script:

1   Background

1.1   Kodak's vanishing act

I watched Kodak's WiFi-enabled photoframes become useless paperweights with Kodak and its related FrameChannel service demise several years ago. Until then, this amazing product pulled images automatically from one's Flickr feed in realtime; you could also email photos to an associated address, and they would show up on the frame within a few minutes. Fast-forward a few years, and sadly, there is no reasonably priced photo-frame that I could find as of Jan 2014 that can pull in pictures wirelessly. As anyone with a regular digital photo frame can attest to, having to pull out a SD card, download pictures manually, then walk the SD card back to the frame, usually results in photos just not being updated on the frame.

1.2   SD cards with WiFi built-in for cameras

What has changed in the last few years is the introduction of a few remarkable devices that combine a SD card with a wifi radio built-in. The EyeFi WiFi SD card has really changed the way we take pictures in our house now; we use these cards in all our digital cameras, and it automatically uploads our photos from inside the camera to Flickr (with an auto-created set per day) and my home server over any recognized WiFi network. The iOS app, although slightly quirky, also does the same for any photos taken with our iPhones.

1.3   SD cards with upload capability

Unfortunately, the Eye-Fi only allows getting files/photos off the card onto a computer or cloud service (download). If you could go in the opposite direction (upload) that would be game-changing, and allow you to emulate a wireless photo frame with a regular inexpensive digital photo frame with an SD card slot. Although there is no card primarily designed for that, there are, in fact, several that support writing the SD card via the WiFi link:

Of these, only the first two (FlashAir) appear to work without any egregious hacks or custom firmwares, and involve just adding a few lines of text to a file on the card to enable wireless uploads. In the next section, I'll discuss how to set this up.

2   Toshiba FlashAir Uploads

I first learned about the upload capabilities of this card through a discussion post on the MakerBot 3D printer, where people would use this to print their designs wirelessly from their computer. You can basically enable this card to hop on to any WiFi network as a regular client (I currently have it on my 802.11n network with WPA2-AES encryption), where it presents a cursory REST and CGI interface to access the card.

2.1   Technical information

The following list is my collection of information that I found useful when writing my script for this card:

2.2   Enabling the FlashAir

You can follow the steps given at Extrd3D:

    1. Get your card from Amazon

    2. Statically assign an IP and/or hostname on your home network to this SD card's MAC address (this step depends on your wireless router, but should be pretty straightforward)

    3. Insert the SD card into a reader on your PC

      • Navigate to SD_WLAN and add/modify the following lines in the file called CONFIG

        UPLOAD=1
        APPMODE=5
        APPAUTOTIME=0
        APPNAME=AirFrame
        APPSSID=your_wifi_network
        APPNETWORKKEY=your_wifi_password
        COMMAND=wlan 11n 1
        
    4. Eject and re-insert the SD card, and see if you can ping it at your reserved IP/hostname. It should boot and be up and running in under 15 seconds

    5. Navigate to http://yourip and see if your card displays its web file browser

    3   AirFrame code discussion

    The code is raw right now, with little error-checking, but gets the job done. I'll first discuss the Flickr download code, and then how I do the FlashAir upload. First, get the code from GitHub and go to airframe/airframe.

    3.1   Flickr download

    For this, I rely on an excellent Flickr api library (formerly known as Beej's Flickr API).

    3.1.1   Authorization

    You need to first get yourself an authorization key for your Flickr account to get at your private photos. Go here to "apply" for one, which will be instantaneous, and note down the key and secret.

    Then, create a file in the src directory (or wherever you plan to run the file from) called flickr_api.yaml and put the following in it:

    key: "YOUR_API_KEY"
    secret: "YOUR_API_SECRET"
    

    And here's how we use that key and secret to authenticate using the flickrapi library; first we read the yaml file, set our instance variables, and authenticate via Flickr (the first time through, a browser window will pop-up and ask you for your Flickr login, and after that it will cache the authentication token).

    class Flickr(object):
    
        def __init__(self):
            self.set_keys(*self.read_keys())
            self.get_auth2()
    
        def read_keys(self):
            """
                Read the flickr API key and secret from a local file
            """
            with open("flickr_api.yaml") as f:
                api = yaml.load(f)
            return (api["key"], api["secret"])
    
        def set_keys(self, key, secret):
            self.api_key = key
            self.api_secret = secret
    
        def get_auth2(self):
            print("Authenticating to Flickr")
            self.flickr = flickrapi.FlickrAPI(self.api_key, self.api_secret)
            (token,frob) = self.flickr.get_token_part_one(perms='read')
            if not token: raw_input("Press ENTER after you authorized this program")
            self.flickr.get_token_part_two((token,frob))
            print("Authentication succeeded")
    

    3.1.2   Retrieving photos

    The flickrapi library provides direct access to the Flickr API, so for example, the API function flickr.photos.search would be called by using Flickr.photos_search, as shown in the method below that gets photos matching a list of tags:

    def get_tagged(self, tags, count, download_dir="photos"):
        """ Get photos with the given list of tags
        """
        print ("connecting to flickr, and getting %d photos with tags %s" % (count, tags))
        x = self.flickr.photos_search(api_key = self.api_key, user_id="me", tags=','.join(tags), per_page=count)
        photos = self._extract_photos_from_xml(x)
        photo_filenames = self._sync_photos(photos, download_dir)
        print("Found %d photos" % len(photos))
        return photo_filenames
    
    def _extract_photos_from_xml(self, xml):
        photos = []
        for i in xml.iter():
            if i.tag == 'rsp':
                # the response header.  stat member should be 'ok'
                if i.get('stat') == 'ok':
                    continue
                else:
                    # error, so just break
                    break
            if i.tag == 'photo':
                photos.append(Photo(i))
        return photos
    

    The flickrapi then returns a REST response with encapsulated XML matching this spec, a sample of which is shown below:

    <photos page="2" pages="89" perpage="10" total="881">
        <photo id="2636" owner="47058503995@N01"
            secret="a123456" server="2" title="test_04"
            ispublic="1" isfriend="0" isfamily="0" />
        <photo id="2635" owner="47058503995@N01"
            secret="b123456" server="2" title="test_03"
            ispublic="0" isfriend="1" isfamily="1" />
        <photo id="2633" owner="47058503995@N01"
            secret="c123456" server="2" title="test_01"
            ispublic="1" isfriend="0" isfamily="0" />
        <photo id="2610" owner="12037949754@N01"
            secret="d123456" server="2" title="00_tall"
            ispublic="1" isfriend="0" isfamily="0" />
    </photos>
    

    We then iterate through the XML photo tags, and construct a list of Photo objects, defined below, with some of these attributes being used to construct the final URL used to download the actual jpeg from Flickr's server farm:

    class Photo(object):
        def __init__(self, photo_element):
            """Construct a photo object out of the XML response from Flickr"""
            attrs = { 'farm': 'farmid', 'server':'serverid','id':'photoid','secret':'secret'}
            for flickr_attr, py_attr in attrs.items():
                setattr(self, py_attr, photo_element.get(flickr_attr))
    
        def _construct_flickr_url(self):
            url = "http://farm%s.staticflickr.com/%s/%s_%s_b.jpg" % (self.farmid,self.serverid, self.photoid, self.secret)
            return url
    
        def download_photo(self, dirname, cache=False, tgt_filename=None):
            if not os.path.exists(dirname):
                os.makedirs(dirname)
            tgt = os.path.join(dirname, "%s.jpg" % self.photoid)
            if cache:
                if os.path.isfile(tgt):
                    return tgt
            urllib.urlretrieve(self._construct_flickr_url(), tgt)
            return tgt
    

    There are then some other functions that do the mundane job of syncing the local cache of photos and calling the download_photo method of each Photo object.

    3.2   FlashAir code discussion

    With the images downloaded from Flickr, we now need to upload to the FlashAir card, using the following flow that calls various REST methods:

    1. Set the card to write-protect mode (to prevent whoever is powering the card from writing while we upload the photos)
    2. Get a list of files already present on the card
    3. Do some hashing of the filenames (explained below)
    4. Construct the FAT32 timestamp
    5. Upload whatever files are not already present

    Here is the simple init method of our Python class, and we will add the other methods for each item above, in the sections that follow.

    class FlashAir(object):
        """
            Interface to the REST API of the Toshiba FlashAir card.
            See the documentation to their API at:
                https://flashair-developers.com/en/
        """
        def __init__(self, hostname):
            """
                :param hostname: IP address or hostname of the FlashAir card
                :type hostname: string
            """
            self.hostname = hostname
            self.card_path = "/DCIM/100__TSB"
    

    3.2.1   Setting the write-protect

    This is a simple GET http://ip/upload.cgi?WRITEPROTECT=ON that execute using the Python Requests library.

    def _set_write_protect(self):
        # Change the upload directory on the card
        url = "http://%s/upload.cgi" % self.hostname
    
        payload = {'WRITEPROTECT': "ON"}
        r = requests.get(url, params=payload)
        r.raise_for_status()
        if not "SUCCESS" in r.content:
            print("Could not put card into host-write-protect mode")
    

    3.2.2   Getting the file list

    The file listing is also a http request, and the SD card returns a list that looks like the following:

    WLANSD_FILELIST
    /DCIM/100__TSB,FA000001.JPG,128751,33,16602,18432
    /DCIM/100__TSB,FLASH3.JPG,370952,32,0,0
    /DCIM/100__TSB,FLASH5.JPG,287034,32,0,0
    /DCIM/100__TSB,FLASH6.JPG,387163,32,17295,31508
    /DCIM/100__TSB,FLASH7.JPG,533998,32,17295,31520
    

    We then parse this using the following code:

    def get_file_list(self):
         payload = {"op":100, "DIR":self.card_path}
         r = requests.get("http://%s/command.cgi" % self.hostname, params=payload)
         r.raise_for_status()                                                           print r.text
         # Divide the returned text by newline, and ignore the first line "WLANSD_FILELIST"
         lines = r.text.split('\n')
         assert lines[0].strip()=='WLANSD_FILELIST'
    
         filenames = []
         for line in lines[1:]:
             # Split each line by ',' and take the second column for the filename
             values = line.split(',')
             if len(values) > 2:
                 filename = values[1].strip()
                 filenames.append(filename)
    
         print filenames
         return filenames
    

    3.2.3   Hash the filenames

    The first-generation FlashAir card only allows REST/CGI uploading of files that follow the 8.3 DOS naming convention (I've read that the 2nd gen does away with this requirement). So, we need to convert the jpg filenames to this convention. I decided to use a hash (SHA-1) to minimize the chances of a filename conflict, using the hashlib libary:

    def _get_renamed_filename(self, filename):
            # First, separate out the path from the filename
            path = os.path.dirname(filename)
            fn = os.path.basename(filename)
            h = hashlib.sha1()
            h.update(fn)
            hash_filename = "%s.JPG" % (h.hexdigest()[:8].upper())
    
            hash_full_filename = os.path.join(path, hash_filename)
            logging.debug("Hashed file: %s" % (hash_full_filename))
            return hash_full_filename
    

    3.2.4   Construct the timestamp

    The FlashAir also requires that you set the timestamp before uploading any file, in the FAT32 format (a 32-bit integer). After some googling, I found a solution to convert a time object into this format:

    def set_timestamp(self, t):
            """
                :param t: The time stamp
                :type t: time.struct_time
            """
            fat32_time = ((t.tm_year-1980)<<25) | (t.tm_mon << 21) | (t.tm_mday << 16) | (t.tm_hour << 11) | (t.tm_min << 5) | (t.tm_sec >>1)
    
            url = "http://%s/upload.cgi" % self.hostname
            payload = {'FTIME': "0x%0.8X" % fat32_time}
            r = requests.get(url, params=payload)
            r.raise_for_status()
    

    3.2.5   Upload files to the FlashAir

    Finally, the actual upload is via a HTML form that we access using the Requests library. First, we set the timestamp, then set the upload directory, and then submit the upload via a POST.

    def upload_file(self, filename):
         # First, set the time of the photo to right now
         self.set_timestamp(time.localtime())
         # Change the upload directory on the card
         url = "http://%s/upload.cgi" % self.hostname
    
         payload = {'UPDIR':self.card_path}
         r = requests.get(url, params=payload)
         r.raise_for_status()
    
         # Now, upload the file
         hash_full_filename = self._get_renamed_filename(filename)
         hash_filename = os.path.basename(hash_full_filename)
    
         files = {'file':(hash_filename, open(filename,'rb'))}
         r = requests.post(url, files=files)
         r.raise_for_status()
    

    4   Summary

    In this post, I've gone over how I added wireless photo sync from Flickr to any digital photo frame using the FlashAir Wifi SD card. It was quite easy to get working, and it wouldn't be too hard to extend this to other photo services, and perhaps even other hacked Wifi SD card types. I may get around to converting the script architecture to allow plugins for these different endpoints, depending on need or interest. Hope you find this useful! Many thanks to all the Makerbot users that first figured out the upload capabilities of this card!

    © Virantha Ekanayake. Built using Pelican. Modified svbhack theme, based on theme by Carey Metcalfe