GPS Tracker with balenaOS

At SC Robotics we’ve recently started using the Balena suite in some of our projects. Balena provides many tools to manage fleets of IoT Linux-based devices. Many of them are Open Source, the Community is fairly active and the documentation is not bad (I guess that’s the best thing you can say about docs). We’ve found their tools really useful so we decided to write a blog entry to explain why and how we use them.

In this post we are going to demonstrate how to create a GPS tracker using balenaOS and Balena CLI. Bear in mind we will not use balenaCloud for this demo, which is probably the main tool of their ecosystem because it would be overkill for just one device.

balenaOS is a minimal Linux-based OS which mainly packages balenaEngine, an engine designed to run Docker-like containers. The OS will launch the supervisor (the container in charge of interacting with Balena tools) and manage each of the containers deployed by the user. One of the things we like most about balenaOS is that it has been designed to make updates and SD Card operations safe thanks to the partitions’ design.

Hardware setup

For this project we have used a Raspberry Pi 3 Model B and a Radiolink GPS which sends NMEA sentences through UART. We have also connected an LED to demonstrate how to access and control GPIOs from within the container. This setup intends to be an introduction to the usage of containers on an embedded Linux device while interacting with the hardware.

The first step is to download balenaOS image. There are two different flavours for each target board: development and production. We are going to use the development version, which enables passwordless login and a special local development mode that allows us to push new changes and test them live (you will understand what I mean later on).

Next we are going to explain how to flash the image to the SD Card using Linux command line, but if you are not familiar with Linux or if you are not comfortable using the command line you can use balenaEtcher, a well-known GUI tool for flashing images onto SD cards. Let’s obtain the image:

$ wget https://files.resin.io/resinos/raspberrypi3/2.46.1%2Brev1.dev/image/balena.img.zip
$ unzip balena.img.zip

To find out the partition of your SD Card run lsblk and look for mmcblkX (where X is a number):

$ lsblk
NAME                            MAJ:MIN RM   SIZE RO TYPE MOUNTPOINT
sda                               8:0    0 238,5G  0 disk 
├─sda1                            8:1    0   600M  0 part /boot/efi
├─sda2                            8:2    0     1G  0 part /boot
└─sda3                            8:3    0 236,9G  0 part 
  ├─fedora_localhost--live-root 253:0    0    70G  0 lvm  /
  ├─fedora_localhost--live-swap 253:1    0   7,8G  0 lvm  [SWAP]
  └─fedora_localhost--live-home 253:2    0 159,1G  0 lvm  /home
mmcblk0                         179:0    0   7,4G  0 disk 
└─mmcblk0p1                     179:1    0    40M  0 part /run/media/flashdrive

And finally let’s use dd:

$ sudo dd if=balena.img of=/dev/mmcblk0 status=progress

The SD Card is now ready to boot.

Running the code

We will be using Balena’s CLI tool to connect, push and manage the container. Make sure you follow the installation instructions before continuing.

The balenaOS image comes pre-configured as a zeroconf mDNS endpoint. What this means is that if you are connected to the same local network as the Raspberry PI, you can identify it by it’s assigned IP or by it’s id balena.local. For the rest of this tutorial, we connected it to a local ethernet switch with auto assigned DHCP, therefore we will use the id balena.local.

One of the features of the balenaOS development image is that it attaches a Getty console to tty1 and serial (to access the shell and see the logs). However, since we want to use the UART to communicate with our GPS module we have to disable this feature:

$ balena ssh balena.local
# mount -o remount,rw / # Disable read-only rootfs
# systemctl mask serial-getty@serial0.service # Mask serial Getty service
# reboot

Let’s now clone the sources of the project:

$ git clone https://gitlab.com/scrobotics/balena-gps-tracker.git
$ cd balena-gps-tracker

Next we have to push the project to the Raspberry Pi and we’ll be using balena push for that. Another cool feature of balenaOS development image is that while this command is running it will monitor for changes in the host source files and push them to the Pi without having to start over. For instance, imagine you want to change the LED pin. To do it we can simply change the corresponding line in the .py, save the changes and wait for the supervisor to detect them and push them.

$ balena push balena.local
[Info]    Starting build on device balena.local
[Info]    Creating default composition with source: .
[Build]   [main] Step 1/8 : FROM balenalib/raspberrypi3-alpine-python:3-build
[Build]   [main]  ---> 3b4f32c791c1
[Build]   [main] Step 2/8 : WORKDIR /usr/src/app
[Build]   [main]  ---> Running in cab29b9b83b0
[Build]   [main] Removing intermediate container cab29b9b83b0
[Build]   [main]  ---> 2cedf2c80c43
[Build]   [main] Step 3/8 : COPY requirements.txt requirements.txt
[Build]   [main]  ---> c5d66e4d2057
[Build]   [main] Step 4/8 : RUN apk add --no-cache wiringpi &&     pip3 install -r requirements.txt
[Build]   [main]  ---> Running in 620176ac01a3
[Build]   [main] fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/main/armv7/APKINDEX.tar.gz
[Build]   [main] fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/community/armv7/APKINDEX.tar.gz
[Build]   [main] (1/1) Installing wiringpi (2.46-r2)
[Build]   [main] Executing busybox-1.30.1-r2.trigger
[Build]   [main] OK: 458 MiB in 187 packages
[Build]   [main] Collecting pyserial
[Build]   [main]   Downloading https://files.pythonhosted.org/packages/0d/e4/2a744dd9e3be04a0c0907414e2a01a7c88bb3915cbe3c8cc06e209f59c30/pyserial-3.4-py2.py3-none-any.whl (193kB)
[Build]   [main] Collecting wiringpi
[Build]   [main]   Downloading https://files.pythonhosted.org/packages/95/8a/2bb23fe154a5ca4df5f8f0758614c2aa84dac2d9dd4151dea3ea3a368f35/wiringpi-2.60.0-cp35-cp35m-linux_armv7l.whl (265kB)
[Build]   [main] Collecting pynmea2
[Build]   [main]   Downloading https://files.pythonhosted.org/packages/88/5f/a3d09471582e710b4871e41b0b7792be836d6396a2630dee4c6ef44830e5/pynmea2-1.15.0-py3-none-any.whl
[Build]   [main] Collecting geojson
[Build]   [main]   Downloading https://files.pythonhosted.org/packages/e4/8d/9e28e9af95739e6d2d2f8d4bef0b3432da40b7c3588fbad4298c1be09e48/geojson-2.5.0-py2.py3-none-any.whl
[Build]   [main] Installing collected packages: pyserial, wiringpi, pynmea2, geojson
[Build]   [main] Successfully installed geojson-2.5.0 pynmea2-1.15.0 pyserial-3.4 wiringpi-2.60.0
[Build]   [main] Removing intermediate container 620176ac01a3
[Build]   [main]  ---> 5a43c0181a0a
[Build]   [main] Step 5/8 : COPY . .
[Build]   [main]  ---> 45216d9103b4
[Build]   [main] Step 6/8 : CMD ["python3", "cli.py", "gps", "-s", "/dev/ttyAMA0", "-vv"]
[Build]   [main]  ---> Running in c01856c7287b
[Build]   [main] Removing intermediate container c01856c7287b
[Build]   [main]  ---> 781e4119619d
[Build]   [main] Step 7/8 : LABEL io.resin.local.image=1
[Build]   [main]  ---> Running in 74a7183f77d4
[Build]   [main] Removing intermediate container 74a7183f77d4
[Build]   [main]  ---> 9fdece1be5d0
[Build]   [main] Step 8/8 : LABEL io.resin.local.service=main
[Build]   [main]  ---> Running in ee397dcbc60e
[Build]   [main] Removing intermediate container ee397dcbc60e
[Build]   [main]  ---> bb390609fa21
[Build]   [main] Successfully built bb390609fa21
[Build]   [main] Successfully tagged local_image_main:latest
[Live]    Waiting for device state to settle...
[Info]    Streaming device logs...
[Live]    Watching for file changes...
[Logs]    [2020-1-17 12:21:00] Creating volume 'resin-data'
[Logs]    [2020-1-17 12:21:01] Creating network 'default'
[Logs]    [2020-1-17 12:21:01] Installing service 'main sha256:bb390609fa219ee64221f8e1b83330cb31aecb6894956483dd04c7a27c2b2caf'
[Logs]    [2020-1-17 12:21:10] Installed service 'main sha256:bb390609fa219ee64221f8e1b83330cb31aecb6894956483dd04c7a27c2b2caf'
[Logs]    [2020-1-17 12:21:10] Starting service 'main sha256:bb390609fa219ee64221f8e1b83330cb31aecb6894956483dd04c7a27c2b2caf'
[Logs]    [2020-1-17 12:21:14] Started service 'main sha256:bb390609fa219ee64221f8e1b83330cb31aecb6894956483dd04c7a27c2b2caf'
[Logs]    [2020-1-17 12:21:16] [main] INFO:__main__:Connecting serial port '/dev/ttyAMA0'
[Logs]    [2020-1-17 12:21:16] [main] INFO:__main__:GPS tracker

How it works

Data persistence

The received GPS values are stored in a file using the GeoJSON format. Since we want to save this file even if the container crashes or if the power goes suddenly down, we need to understand how persistent storage works. Balena (and in general Docker) uses volumes to achieve this, which means that one directory of the container (/data) is linked to a directory in the main file system (/var/lib/docker/volumes/_resin-data/_data).

Docker image

FROM balenalib/raspberrypi3-alpine-python:3-build
WORKDIR /usr/src/app
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
COPY . .
CMD ["python3", "cli.py", "gps", "-s", "/dev/ttyS0", "-vv"]

In the first line we select an Alpine Linux image from Docker Hub. The image includes Python 3 and many build tools like make and gcc. These tools increase the size of the container but they are necessary to build RPi.GPIO library used to control the LED. If we needed to optimize the size we could do it using Docker multistage builds.

Balena also provides thousands of base images freely available. You can check their Docker Hub profile or import them using the following format:

FROM balenalib/--:--(build|run)-

The rest of the lines are regular Docker instructions.

Results

Once we’ve pushed the image, the supervisor will make sure it’s running, even after a crash or after system start up. Let’s test the code.

The output is a JSON file located at /var/lib/docker/volumes/1_resin-data/_data/, or if you mount the SD card into the host computer you will have to go to the resin-data partition and check resin-data/docker/volumes/1_resin-data/_data/. Below you can see and interact with an output example. If you click on any point you will find more information attached, in this case a timestamp and the temperature of the CPU.

Conclusions

Balena brings the benefits of using containers to the world of embedded devices. Containers are great to ensure every device (and every developer) is running exactly the same software (dependency management). Thanks to containers we can also increase the portability of our projects.

Although it was not the goal of this post, fleet management through balenaCloud is a very interesting resource. If you’ve ever tried to deploy a few internet connected devices, you surely understand the importance and difficulty of keeping them up to date. We were a bit reluctant to use a closed-source solution for this because we try to avoid lock-in solutions for key parts of the project, however Balena has recently announced an open source version(openBalena) which is, so to speak, the core of balenaCloud.

Share this: