Daniel Pullan

Trying is the first step towards failure.

Note from future Dan: this is from when I was an Apprentice. I learned a lot on this project and would love to revisit it one day.

Introduction

Just to preface this entire post: It's a long post, there's a lot of words. I might not make sense at time. I wrote this because I felt like it.

At work, I'm currently working on Thorium. Thorium is a digital signage... thing... that has a few cool features.

For example, it's designed to run on Raspberry Pi's. This has been done a few times before, but we're using the original Pi's since that is what we have currently. We're also using 4gb SD Cards, so space is at a premium.

It also has some pretty cool python scripts (one of which I'll walk you through in this post), for things like updating, upgrading, restarting and setting up a device for use.

I've learned a lot along the way since I've started this project. At one point, I was trying to work out a way of inserting data into an XML file and pulling it out again as a way of having a super weight CMS.

After a few horrible headaches later, I dropped this idea for a MySQL alternative. I've since dropped THAT idea for using API's instead (Google Calendar API and Twitter API).

Follow Along

Anyways, in this post I'll be talking about Thorium's Install Script.

To follow along, you can find the current version of the thorium install script here. It may still get updated with bug fixes or new features, so if what I write now doesn't make sense, I forgot to update this post.

About Thorium Install Script

Thorium Install Script is written in plain Python 3. I used PyCharm Professional as my editor, tested the code using Pycharm's tools, as well as Bash for Windows (the linux subsystem on Windows 10) and on Raspberry Pi's running Raspbian Lite.

The first part of setting up a Pi for Thorium actually involves using PiBakery, a tool written by David Ferguson. Pi Bakery lets us write a Raspbian Lite image to our SD card, whilst using recipes to do things like setting up a password from the beginning and setting a few packages to install (Python3). It also downloads Thorium Install Script.

With Python3 installed, we can run Thorium Install Script. I opted for Python3 over Python2 as I feel like it makes things more future proof, at the cost of a little bit of work now.

Imports

So first things first, we import some packages. I opted to import:

  • subprocess to run linux commands to actually do stuff.
  • Path (from pathlib) for the bit of the script that detects whether our config file exists and if it does, what to do.
  • socket for the bit of the script that changes our device's hostname.

I feel like this is a reasonable amount of imports, I don't think I would change anything here. If I wanted to add more features later, I'd feel happy import a couple more packages if they required it, without feeling like the whole thing was starting to get a bit bloated.

filestep()

filestep() is the reason we require Path. It basically detects whether a file (called config.file, original I know) exists, then if it does, read the contents of it and assigns it to a variable so we know what step of the install process we're on.

If the file doesn't exist, then the script hasn't been run yet. It then creates config.file and writes 0 to it, which is our beginning step. I chose to start with 0 rather than 1 because that's how most programmers or scripters would expect it to be.

Here's filestep() in it's current glory. I've left the comments in so that you can follow along. There is probably a significantly shorter way of doing what I did, but this works, is human readable and I wrote it myself. I'm personally quite happy with the result.

# Define a function for our config file detection / creation
def filestep():
    # define the file we will use as our config file
    my_file = Path('config.file')
    # if the file exists, we're not on the first boot
    if my_file.is_file():
        # open the file in read mode, write would wipe it
        configfile = open('config.file', 'r')
        # read the value, assign it's content to a value
        configvalue = configfile.read()
        # close the file like a good citizen
        configfile.close()
        # use rstrip to get rid of /n
        theresult = configvalue.rstrip()
        # return the variable that doesn't have /n
        return theresult
    # if the file doesn't exist, this is the first install
    else:
        # open our already defined config file in write mode
        configfile = open('config.file', 'w')
        # write 0, our starting variable
        configfile.write('0')
        # close the file like a good citizen
        configfile.close()
        # open it up again in read mode
        configfile = open('config.file', 'r')
        # set it's content to a variable
        configvalue = configfile.read()
        # close the file yet again, we're such good citizens
        configfile.close()
        # strip out /n, use that as our final variable for the step
        theresult = configvalue.rstrip()
        # return it so we can use it
        return theresult

nameDevice()

nameDevice() is a pretty simple function, with a simple name to suit. It is also the reason we need socket.

What this function does is sets a variable called hostname to the current hostname of the device, using socket. We then detect if "raspberry" is in the hostname. "raspberry" being in the hostname means the device hasn't been named yet.

If "raspberry" exists in the hostname, we read from boot/hostname.txt and make the contents of that file the new hostname. The reason why `hostname.txt exists in the boot partition, is because we can write to the boot partition from Windows after the Pi has been written to with PiBakery.

If "raspberry" doesn't exist, then the device has already been named. We then print the name of the device so that we know what it's called and that's the end of that function. Pretty easy right?

Here's nameDevice() in it's current glory, I expect I'll need to make some changes later as I recall there being an issue with the name not being pretty or something. I may have already fixed this.

# Define a function so that we can name our device
def namedevice():
    # get the current hostname, set to a variable
    hostname = socket.gethostname()
    # if raspberry is in hostname, we haven't set the hostname yet
    if "raspberry" in hostname:
        # open our hostname config in read mode, I don't remember doing it this way, it's smarter
        hostnameconfig = open('/boot/hostname.txt', 'r').read()
        # strip the /n, use that as our hostname
        newhostname = hostnameconfig.rstrip()
        # set the /n-free variable as our new hostname
        subprocess.call(["sudo", "hostnamectl", "set-hostname", newhostname])
    # else, raspberry doesn't exist in the name and this pi has already been named
    else:
        # print the name of our device, mainly for logging purposes
        print('this device has been named', hostname)

The Bit After The Functions

I know that heading sounds really stupid, but it's the most accurate name I could think of. Basically, it consists of this:

# I don't think this needs to exist, but it doesn't hurt
filestep()

# Set our bootvalue (the step that the script is in with the install) to the result of our file name function
bootvalue = filestep()

# Print the result, this was for debugging
print("the value of bootvalue is", bootvalue)

The only really useful bit of this is bootvalue = filestep(), which sets bootvalue to the result of our filestep() function. I could happily delete everything else, the other stuff is only there for debugging really. I was having a lot of issues with filestep(), but after rewriting it, the issue is gone. I think I had been too sloppy with tabs and spacing (I switched from Atom to PyCharm midproject. I didn't have them set to the same settings when it comes to tabs and spaces.

The Meat & Gravy

I like this heading better, it sounds like it would be a pub that does pretty good food. Like you could imagine saying to your Missus "Oh, it's a nice day, let's go to the Meat & Gravy". I should trademark that one. I don't have the first clue when it comes to running pubs, I don't even drink.

Maybe I should stick to code instead.

Anyways, with that sidetracking out of the way, let's talk about the main part of this entire script. The general idea is that bootvalue is the current part of the install script. 0 would be the first things to do, 1 would be the next step, so on, so forth. The only time the bootvalue would go up is when a step is complete. We only need as many steps as we do restarts.

The Pi gets set to run the install script on startup, so when it needs to restart, it runs the script again, knows which point it left on from, restarts again and again until the install is complete. When it gets to the final step, the script actually gets deleted, so that when the Pi restarts again, it won't be able to run the script anymore.

I won't go into too much with this part, since it's literally a if bootvalue == whatever, do this stuff related to this step. Then write the new bootvalue to the config.file and restart.

One bit I would like to explain, is subprocess. An example line using subprocess looks like this:

subprocess.call(["sudo", "apt-get", "install", "apache2", "-y"])

It's a function that uses an array as it's parameters, it runs the command and then anything that gets printed in the commandline gets printed, so you can see what is happening with the command it called. Each item in the array is each part of the command, with a new item in the array being made where there is a space in the command.

I was originally using os.system (you need to import os for that one) but I changed after reading online that subprocess was the correct way of doing things.

Final Words

I'm quite happy with the script I made, there's probably some bits I could make cleaner, or an entirely different approach I could take, but this way works and will be the script that we will use in production. It's always a nice day when you get to write production code.

If anyone has any advice or tips that I might find useful, I try to be very responsive with my emails (dan@3264.uk).

My apologies for the long post, until next time, cheers.