Embedded to machine learning

Setting up a Raspberry Pi sensor environment and teaching myself and the machine with it


While having our bathroom and sauna renovated, I wanted to be able to listen to music in sauna. Getting a speaker and its cable in place was straightforward, but after that the setup became more complex. Some amplifier with bluetooth audio or Spotify streaming would have been simple, but for bluetooth you’d still need the music somewhere, and I don’t want a Spotify subscription.

And of course you’d want the same music to play inside and outside the sauna. So, I had an excuse to build multi-room audio with inexpensive components for two rooms.


Raspberry Pis were an easy choice to build around, so everything has to work in Raspbian Linux.

Music Player Daemon (MPD) is used for playing music, it has a local playlist that can be changed with clients so that the device can keep playing music even if no controllers are connected to it. If you do have that Spotify subscription, Mopidy is a MPD compatible server with Spotify support through extensions.

Soundirok is a MPD client for iOS devices. It supports also loading cover images with HTTP, so I have nginx set up for that purpose also. On macOS, I use ncmpcpp.

Multi-room audio is achieved with Snapcast. Unfortunately it doesn’t have an iOS client software available, so controlling different rooms’ volumes separately is not easily possible at the moment.

ALSA is used for controlling the sound cards. It’s not that simple to configure, but then again, it’s quite flexible when you do get the configuration in place.

For sorting out the music library, I use beets, but explaining that pipeline would be a topic for another blog post. Here I assume that the music just appears to the correct place.


multiroom audio diagram

I have devices in two rooms: living room and sauna. rpi-olohuone in living room is the server device rpi-kph in sauna is a client device.

Living room server setup The server setup has Raspberry Pi model 1 B, but the actual model doesn’t really matter as everything could also be done with a Pi Zero also. Music is played with NuForce μDAC-2 USB sound card (aka DAC). Files are stored in a Seagate HDD.

μDAC and HDD are powered through USB, and at least the HDD requires more power than what Raspberry is able to give, so I have a powered TP-Link USB hub in the middle.

Sauna client setup Sauna client is a Raspberry Pi Zero with a RedBear IoT pHAT for WiFi connectivity and an external antenna for added range. The case was not optimal, but does its job.

Adding pHAT required soldering both on the Pi Zero and on the pHAT itself. There is also a model Pi Zero W with WiFi included, and with that you don’t need the pHAT at all.

Selecting the sound card required a bit more thought as I had to have a DAC and amplifier both. After some googling, I ended up with Topping VX1, an USB-DAC and amplifier in the same device, which should also work in Linux!


Configuration is done with Ansible and is available in my raspberry-ansible repo. Everything described here is included in two playbooks: bootstrap.yml and music.yml

Configuration: External HDD

bootstrap.yml playbook:

- include_role:
    name: external-hdd
  - exthdd is defined


exthdd: "/dev/sda1"
exthdd_fstype: "vfat"
exthdd_mountpoint: "/mnt/piikiekko"


- name: mount device
    src: "{{ exthdd }}"
    path: "{{ exthdd_mountpoint }}"
    fstype: "{{ exthdd_fstype }}"
    state: mounted
    opts: "umask=000" # allow writes from all users

Setting up the external HDD is quite simple with Ansible, you need only the mount module.

You can find out which device the external hdd is with lsblk and blkid (here /dev/sda1):

pi@rpi-olohuone:~ $ lsblk
sda           8:0    0 931.5G  0 disk
`-sda1        8:1    0 931.5G  0 part /mnt/piikiekko
mmcblk0     179:0    0  14.4G  0 disk
|-mmcblk0p1 179:1    0  41.5M  0 part /boot
`-mmcblk0p2 179:2    0  14.4G  0 part /

pi@rpi-olohuone:~ $ blkid
/dev/mmcblk0p1: LABEL="boot" UUID="CDD4-B453" TYPE="vfat" PARTUUID="dba1b453-01"
/dev/mmcblk0p2: LABEL="rootfs" UUID="72bfc10d-73ec-4d9e-a54a-1cc507ee7ed2" TYPE="ext4" PARTUUID="dba1b453-02"
/dev/sda1: LABEL="PIIKIEKKO" UUID="8001-1AF6" TYPE="vfat"

See also External storage configuration on official Raspberry Pi documentation.

Configuration: RedBear IoT pHAT

bootstrap.yml playbook:

- include_role:
    name: wifi
    - enable_wifi is defined and enable_wifi == True


enable_wifi: True

group_vars/pi/vault.yml (file with secret variables, this can be edited with ansible-vault edit and the file in my github repo cannot be used, you’ll have to overwrite it with your own):

wifi_network: <network name>
wifi_password: <password>


- name: Add wifi configuration
    dest: "/etc/wpa_supplicant/wpa_supplicant.conf"
    block: |
      	ssid="{{ wifi_network }}"
      	psk="{{ wifi_password }}"
  when: ansible_wlan0
  notify: restart machine

Setting up the RedBear IoT pHAT is also really simple, Linux kernel notices the device itself and you just have to setup the correct WiFi network parameters.

See also RedBear’s installation instructions.

Configuration: Sound cards / ALSA

ALSA configuration was probably the hardest part to get working, especially with the Topping VX1 sound card.

music.yml playbook, relevant parts:

- hosts: music-server
    - alsa

- hosts: music-client
    - alsa


asound_conf: |
  pcm.!default {
    type hw
    card 1

  ctl.!default {
    type hw
    card 1

Living room ALSA configuration is fairly simple, set the card number 1 (NuForce μDAC) as the default. Card numbers can be printed with aplay -l:

pi@rpi-olohuone:~ $ sudo aplay -l
**** List of PLAYBACK Hardware Devices ****
card 0: ALSA [bcm2835 ALSA], device 0: bcm2835 ALSA [bcm2835 ALSA]
  Subdevices: 8/8
  Subdevice #0: subdevice #0
  Subdevice #1: subdevice #1
  Subdevice #2: subdevice #2
  Subdevice #3: subdevice #3
  Subdevice #4: subdevice #4
  Subdevice #5: subdevice #5
  Subdevice #6: subdevice #6
  Subdevice #7: subdevice #7
card 0: ALSA [bcm2835 ALSA], device 1: bcm2835 ALSA [bcm2835 IEC958/HDMI]
  Subdevices: 1/1
  Subdevice #0: subdevice #0
card 1: N2 [NuForce µDAC 2], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0
card 1: N2 [NuForce µDAC 2], device 1: USB Audio [USB Audio #1]
  Subdevices: 1/1
  Subdevice #0: subdevice #0


asound_conf: |
  pcm.!default makemono

  pcm.makemono {
    type route
    slave.pcm "sysdefault:VX1"
    ttable {
      0.0 1    # in-channel 0, out-channel 0, 100% volume
      1.0 1    # in-channel 1, out-channel 0, 100% volume

There is only one speaker in sauna, so I’d want both the channels to be mixed into mono. Luckily there was a snippet for this. sysdefault:VX1 comes from aplay -L output, chosen with trial and error:

pi@rpi-kph:~ $ sudo aplay -L
    Discard all samples (playback) or generate zero samples (capture)
    bcm2835 ALSA, bcm2835 ALSA
    Default Audio Device


    VX1, USB Audio
    Default Audio Device



- name: install alsa-utils
    name: "{{ item }}"
    - "alsa-utils"

- name: add snd_bcm2835 kernel module
    name: "snd_bcm2835"

- name: add snd_bcm2835 kernel module to be loaded on boot
    path: "/etc/modules"
    line: "snd_bcm2835"

- name: setup alsa default sound card
    content: "{{ asound_conf }}" # device-dependent
    dest: "/etc/asound.conf"
    - reboot
    - wait for reboot

The task configures also the Raspberry Pi onboard sound card (snd_bcm2835) into use, even though neither of these current devices use it. The installed package alsa-utils can be used to test the configuration:

pi@rpi-kph:~ $ sudo speaker-test -c 2

speaker-test 1.1.3

Playback device is default
Stream parameters are 48000Hz, S16_LE, 2 channels
Using 16 octaves of pink noise
Rate set to 48000Hz (requested 48000Hz)
Buffer size range from 1024 to 8192
Period size range from 511 to 513
Using max buffer size 8192
Periods = 4
was set period_size = 512
was set buffer_size = 8192
 0 - Front Left
 1 - Front Left

should output noise on both channels in turns (and from the same speaker in sauna).

See also Alsa Opensrc Org: .asoundrc for troubleshooting help.

Configuration: Snapcast

Snapcast has to be configured so that music player, MPD sends audio to Snapcast server, which will forward the audio to all connected clients. In order to get the audio synchronized, also the device with MPD has to play music through Snapcast client.

music.yml playbook, relevant parts:

- hosts: music-server
    - snapcast-server
    - snapcast-client

- hosts: music-client
    - snapcast-client


- name: install snapserver
    deb: "https://github.com/badaix/snapcast/releases/download/v0.12.0/snapserver_0.12.0_armhf.deb"

- name: open ports in firewall
    rule: "allow"
    port: "{{ item }}"
    - 1704
    - 1705

Snapcast server is not available from Debian or Raspbian Aptitude repos, but luckily an Aptitude package is available in Github. The built package is different for different processor families, _armhf is the correct for Raspberry Pi, others are listed in the releases page.

Snapcast server requires ports 1704 and 1705 to be open, but otherwise the default configuration doesn’t need any adjustments.


- name: install snapclient
    deb: "https://github.com/badaix/snapcast/releases/download/v0.12.0/snapclient_0.12.0_armhf.deb"

- name: configure snapcast server address
    path: "/etc/default/snapclient"
    regexp: "^SNAPCLIENT_OPTS"
    line: "SNAPCLIENT_OPTS=\"{{ snapclient_opts }}\""
  notify: restart snapcast client


snapcast_server: ""
snapclient_opts: "--host {{ snapcast_server }}"


snapcast_server: ""
snapclient_opts: "--host {{ snapcast_server }} --soundcard makemono"

Snapcast client is installed from the same place as server. It doesn’t require that much configuration either, only the server IP address on both clients, and additionally the custom sound card makemono on rpi-kph (it’s supposed to be the default, don’t know why it didn’t work with snapclient without specifying it like this).

Troubleshooting Snapcast server can be started with systemctl, working output is something like this:

pi@rpi-olohuone:~ $ sudo systemctl -l status snapserver.service
● snapserver.service - Snapcast server
   Loaded: loaded (/lib/systemd/system/snapserver.service; enabled; vendor preset: enabled)
   Active: active (running) since Sun 2018-01-21 00:30:27 EET; 1 weeks 3 days ago
  Process: 464 ExecStart=/usr/bin/snapserver -d $USER_OPTS $SNAPSERVER_OPTS (code=exited, status=0/SUCCESS)
 Main PID: 472 (snapserver)
   CGroup: /system.slice/snapserver.service
           └─472 /usr/bin/snapserver -d --user snapserver:snapserver

Jan 21 00:30:26 rpi-olohuone systemd[1]: Starting Snapcast server...
Jan 21 00:30:27 rpi-olohuone snapserver[464]: Settings file: "/var/lib/snapserver/server.json"
Jan 21 00:30:27 rpi-olohuone snapserver[464]: 2018-01-21 00-30-27 [Notice] Settings file: "/var/lib/snapserver/server.json"
Jan 21 00:30:27 rpi-olohuone snapserver[472]: daemon started
Jan 21 00:30:27 rpi-olohuone systemd[1]: Started Snapcast server.
Jan 21 01:26:56 rpi-olohuone snapserver[472]: StreamServer::NewConnection: ::ffff:
Jan 21 01:27:34 rpi-olohuone snapserver[472]: StreamServer::NewConnection: ::ffff:

And Snapcast client the same way:

pi@rpi-kph:~ $ sudo systemctl status -l snapclient.service
● snapclient.service - Snapcast client
   Loaded: loaded (/lib/systemd/system/snapclient.service; enabled; vendor preset: enabled)
   Active: active (running) since Sun 2018-01-21 01:27:34 EET; 1 weeks 3 days ago
  Process: 1155 ExecStart=/usr/bin/snapclient -d $USER_OPTS $SNAPCLIENT_OPTS (code=exited, status=0/SUCCESS)
 Main PID: 1156 (snapclient)
   CGroup: /system.slice/snapclient.service
           └─1156 /usr/bin/snapclient -d --user snapclient:audio --host --soundcard makemono

Jan 21 01:27:34 rpi-kph systemd[1]: Starting Snapcast client...
Jan 21 01:27:34 rpi-kph snapclient[1156]: daemon started
Jan 21 01:27:34 rpi-kph systemd[1]: Started Snapcast client.
Jan 21 01:27:34 rpi-kph snapclient[1156]: Connected to

Configuration: MPD

music.yml playbook, relevant parts:

- hosts: music-server
    - mpd-server


- name: install mpd, nginx, mpc
    name: "{{ item }}"
    - "mpd"
    - "nginx"
    - "mpc"

- name: copy mpd configuration
    src: "mpd.conf.j2"
    dest: "/etc/mpd.conf"
  notify: restart mpd

- name: copy nginx cover art configuration
    src: "mpd-cover-art.conf.j2"
    dest: "/etc/nginx/sites-available/mpd-cover-art.conf"
  notify: restart nginx

- name: symlink nginx cover art configuration
    src: "/etc/nginx/sites-available/mpd-cover-art.conf"
    dest: "/etc/nginx/sites-enabled/mpd-cover-art.conf"
    state: "link"
  notify: restart nginx

- name: open ports in firewall
    rule: "allow"
    port: "{{ item }}"
    - "http"
    - 6600

MPD role installs and configures the MPD server (of course), and also nginx HTTP server for serving albums’ cover art images. MPD controls use port 6600.

mpc is a command-line client for MPD. It is not used in these Ansible scripts, but comes in very handy when you add music to the library and the MPD database has to be updated.


music_directory    "{{ music_dir }}"
playlist_directory "/var/lib/mpd/playlists"
db_file            "/var/lib/mpd/tag_cache"
log_file           "/var/log/mpd/mpd.log"
pid_file           "/run/mpd/pid"
state_file         "/var/lib/mpd/state"
sticker_file       "/var/lib/mpd/sticker.sql"

user               "mpd"
bind_to_address    ""
filesystem_charset "UTF-8"
id3v1_encoding     "UTF-8"

input {
  plugin "curl"

#audio_output {
#  type  "alsa"
#  name  "My ALSA Device"

audio_output {
  type       "fifo"
  name       "snapcast"
  path       "/tmp/snapfifo"
  format     "48000:16:2"
  mixer_type "software"

MPD configuration is fairly default. audio_output is the interesting part: MPD plays music to /tmp/snapfifo stream, from where the Snapcast server reads it.


mpd_server: True
music_dir: "{{ exthdd_mountpoint }}/musiikkia"

Variables are also straightforward, define this device as MPD server, and specify where the music is located.

MPD database can be updated with mpc:

pi@rpi-olohuone:~ $ mpc update
Updating DB (#1) ...
volume: 82%   repeat: off   random: off   single: off   consume: off

and queried, queued and played:

pi@rpi-olohuone:~ $ mpc search album highway
Bob Dylan/Highway 61 Revisited/01 Like a Rolling Stone.flac
Bob Dylan/Highway 61 Revisited/02 Tombstone Blues.flac
pi@rpi-olohuone:~ $ mpc add "Bob Dylan/Highway 61 Revisited/01 Like a Rolling Stone.flac"
pi@rpi-olohuone:~ $ mpc play
Bob Dylan - Like a Rolling Stone
[playing] #1/1   0:00/6:13 (0%)
volume: 82%   repeat: off   random: off   single: off   consume: off

Although you should definitely use some other client such as Soundirok for iOS or ncmpcpp for macOS/Linux.

Troubleshooting can be started with systemctl again:

pi@rpi-olohuone:~ $ sudo systemctl status -l mpd.service
● mpd.service - Music Player Daemon
   Loaded: loaded (/lib/systemd/system/mpd.service; enabled; vendor preset: enabled)
   Active: active (running) since Sun 2018-01-21 00:30:27 EET; 1 weeks 3 days ago
     Docs: man:mpd(1)
 Main PID: 469 (mpd)
   CGroup: /system.slice/mpd.service
           └─469 /usr/bin/mpd --no-daemon

Jan 21 00:30:27 rpi-olohuone systemd[1]: Started Music Player Daemon.

Configuration: Soundirok

For Soundirok, MPD server has to be added under SettingsDevices:

Soundirok configuration

If I remember correctly, Soundirok should start synchronizing the MPD music library to the client. It can be done manually by clicking the top bar device name (“Olohuone” in my case) and selecting Refresh Soundirok database.

Closing remarks

I was planning that the sauna devices could be turned on and off with the light switch, and there are power sockets to enable this. However, even though Topping VX1 features include Auto turn on / turn off synchronously with your PC (Only in USB mode), I haven’t figured out how to do this. Might be that this only works in Windows.

When the devices are powered on (or get electricity), I still have to press a button in VX1 to wake it up. It would be easier if this wasn’t necessary as the device isn’t in a very easily accessible place. As a workaround, I’ve had it turned on most of the time.

Other than that, the setup works really well.

You can leave questions and comments to Reddit.

More photos

client in the ceiling Sauna devices are in the bathroom ceiling.

speaker below sauna benches Speaker is under the sauna benches. The speaker is a weather-proof Bower & Wilkins AM-1 and it sounds really good.


RRDtool is a high performance data logging and graphing system for time series data. It doesn’t have a query language such as SQL, nor is it a NoSQL document store. Instead, you can store floating point numbers and accompanying timestamps there, and configure some aggregates.

RRD in the name comes from Round Robin Database, which means that the size of the database is decided when it is created, and once filled completely, the values are overwritten from the beginning. RRDtool has accompanying software for graphing the data called rrdgraph.

Required hardware and software

  1. Raspberry Pi
  2. Ansible and clone of raspberry-ansible repository configured with correct IP addresses and SSH keys
  3. One or more DS18B20 or compatible temperature sensors, see previous blog post

Installing temperature reading script

Running the Ansible playbook tempreader-rrdtool.yml installs a python script that reads all connected 1-Wire temperature sensors to RRDtool databases (one file for each sensor, files are created if they don’t exist). It also adds the temperature reading to be executed every five minutes as a cron job.

$ ansible-playbook -i hosts tempreader-rrdtool.yml

After which the Raspberry’s pi-user’s home directory should have files like these and a cron job:

pi@raspberry1:~ $ ls
0000075f24dc.rrd  0000075f5202.rrd  current.png  readtemp-rrd.py

pi@raspberry1:~ $ crontab -l
#Ansible: read all temperature sensors to rrd
*/5 * * * * /usr/bin/python /home/pi/readtemp-rrd.py

The first two files are RRDtool databases for temperature sensors with ids 0000075f24dc and 0000075f5202. current.png is a graph of last day’s temperatures from each sensor generated with RRDtool’s graphing function. And readtemp-rrd.py is the python script.

Crontab syntax means that the command /usr/bin/python /home/pi/readtemp-rrd.py is run every five minutes.

Setting up a RRDtool database for time series data

Diving into readtemp-rrd.py, first imports show that we are using external library rrdtool by Christian Kröger for using RRDtool from python.

The setting DATABASE_PATH defines where the database files are stored. Each sensor has its own database file.

DATABASE_PATH = '/home/pi/'

create_rrd_unless_exists creates an RRDtool database file unless one exists already using rrdcreate (or its python bindings). The data definition syntax is not simple, but first --step tells how frequently data is expected, 300 seconds being once every five minutes.

Then a data source (DS) called temp of type GAUGE is created. Gauges are for data that is just values which can increase or decrease over time, such as temperature. Next number is the heartbeat: how many seconds may pass without a new value before the data source is regarded as unknown, here 900 for 15 minutes. -100 is the minimum and 100 the maximum value for this data source.

RRA stands for round robin archive which is used for storing the read data. Data is run through a consolidation function (CF), here AVERAGE. Average is taken over 10 minutes (2 data points) and database has space for 10 years: 525600 of these 10 minute average values, if my math is correct. As the name suggests, when this time has passed, the database values will be overwritten from the beginning. This 10-year database for one sensor takes 4.1MB of space, so Raspberry wouldn’t choke even if there were more than two sensors.

    '--step', '300',

Current stable RRDtool version 1.6.0 supports giving these time arguments with easier syntax so that you don’t have to calculate for example how many 10-minute periods there are in 10 years. Unfortunately Raspbian Jessie has version 1.4.8 which doesn’t.

Reading temperature sensor data with Python

The temperature reading part uses Timo Furrer’s w1thermsensor python library, which makes the readings really straightforward.

Code loops through all the sensors, creates a RRDtool database for each sensor unless it exists, reads the temperature value and writes it to the database and prints it to standard output as well.

Writing to database uses rrdupdate, which has a relatively easy syntax: first parameter is a timestamp (N for now), following ones are values. %.2f is python syntax for formatting a decimal number as a string with two decimals.

for sensor in W1ThermSensor.get_available_sensors():
    filename = sensor.id + '.rrd'
    error = rrdtool.update(filename, 'N:%.2f' % (sensor.get_temperature()))
    print("Sensor %s has temperature %.2f" % (sensor.id, sensor.get_temperature()))

Graphing time-series data with RRDtool

RRDtool has functionality for generating graphs from databases included with command rrdgraph. Unfortunately its syntax is even more complex than database creation’s.

But first, the following static variables are used for configuring graph generation:

  • LAST_DAY_GRAPH_FILES is the path for current day’s temperature graph file
  • COLORS are used when graphing, first sensor’s line is drawn with the first color, second sensor with the second color, etc.
  • SENSOR_NAMES can be used to give the sensors meaningful names, which are used in the graph legend. Sensor ids are used if names are not given.
LAST_DAY_GRAPH_FILE = '/home/pi/current.png'
COLORS = ('#AA3939', '#226666', '#AA6C39', '#2D882D')
    '0000075f24dc': 'Living room'

Graph configuration has two main elements here: definitions (starting with DEF) and lines (starting with LINE1 here). Definitions specify the data that is used for graphing. Here temp:AVERAGE from each sensor’s database file is redefined as the sensor’s id. Then a line is drawn for each of these sensors with different color, and the line is labeled with possible name for the sensor id.

defs.append('DEF:' + sensor.id + '=' +
            DATABASE_PATH + sensor.id + '.rrd:temp:AVERAGE')
lines.append('LINE1:' + sensor.id + color + ':' + sensor_name(sensor.id))

Last read value from each sensor is also printed to the graph. Here I simply couldn’t get the texts to be right-justified and stay under the graph area. COMMENT: \l is required so that the prints would start from a new line, last value is read from sensor.id data, and it’s printed after a sensor name.

current_temps = ['COMMENT: \l']
current_temps.append('GPRINT:' + sensor.id +
                     ':LAST:' + sensor_name(sensor.id) + '\: %4.2lf\l')

All this outputs a temperature graph for previous 24 hours:

RRDtool graph example

In conclusion

After all, RRDtool does the job is it designed to do. The database file is created before any data is inserted, and it stays the same size no matter how much data is inserted. This could be beneficial for systems that have limited disk sizes: for example if RRDtool is used as a local datastore in sensors with unreliable connectivity. That way measurements can be done even if there is no connection to the master node, and data can be transferred afterwards.

Transferring RRDtool data between nodes would require another blog post. rrdxport supports exporting XML or JSON with specified time intervals, so that could be used.

Generating graphs is good for simple use cases, but ideally one would want an interactive, zoomable graph with tunable parameters in browser. I spent a lot of time to get the chart above, and am not that satisfied with the result.