Risto Hietala

Cool stuff about computers

header

As RRDTool wasn’t a perfect tool for storing sensor data, I tried a newer datastore: InfluxDB. It is a database designed specifically for time-series data, such as my tempereture readings here.

For visualizing that data, I used Grafana, The open platform for beautiful analytics and monitoring.

The ansible scripts for this post can be found at Github, namely the playbook tempreader-influxdb.yml.

InfluxDB

Setting up InfluxDB is done in the ansible role influxdb. It will install the server from Influxdata repository, copy its configuration to place, start the server, add a database user with admin rights, create a database and open required ports in ufw firewall.

tempreader-influxdb.yml playbook, relevant parts:

roles:
  - { role: "influxdb", when: "'server' in group_names" }

roles/influxdb/tasks/main.yml:

- name: "add influxdb package repository key"
  apt_key:
    url: "https://repos.influxdata.com/influxdb.key"
    state: "present"

- name: "add influxdb package repository"
  apt_repository:
    repo: "deb https://repos.influxdata.com/debian jessie stable"
    state: "present"

- name: "install influxdb"
  apt:
    name: "influxdb"
    update_cache: "yes"
  notify: "start influxdb"

- name: "copy influxdb.conf"
  template:
    src: "influxdb.conf.j2"
    dest: "/etc/influxdb/influxdb.conf"
  notify: "restart influxdb"

- name: "ensure that influxdb is running before adding admin user"
  meta: "flush_handlers"

- name: "wait for influxdb to start"
  pause:
    seconds: 10

- name: "add influxdb admin user"
  uri:
    url: "http://localhost:{{ influxdb_port }}/query"
    method: "POST"
    body: "q=CREATE USER {{ influxdb_user }} WITH PASSWORD '{{ influxdb_pass }}' WITH ALL PRIVILEGES"
    user: "{{ influxdb_user }}"
    password: "{{ influxdb_pass }}"

- name: "create database"
  uri:
    url: "http://localhost:{{ influxdb_port }}/query"
    method: "POST"
    body: "q=CREATE DATABASE {{ influxdb_dbname }}"
    user: "{{ influxdb_user }}"
    password: "{{ influxdb_pass }}"

- name: "open firewall ports for influxdb"
  ufw:
    rule: "allow"
    port: "{{ item }}"
  with_items:
    - "{{ influxdb_admin_port }}"
    - "{{ influxdb_port }}"

Variables for that role are given in tempreader-influxdb.yml playbook, and eventually in an ansible vault file:

vars:
  influxdb_admin_port: "8083"
  influxdb_port: "8086"
  influxdb_host: "{{ secret.influxdb_host }}"
  influxdb_user: "{{ secret.influxdb_user }}"
  influxdb_pass: "{{ secret.influxdb_pass }}"
  influxdb_dbname: "{{ secret.influxdb_dbname }}"

I have it running on an external server, but you should be able to install it also on a Pi device. The only thing with Grafana is that if you use the cloud-version of Grafana, InfluxDB should be available for connections from the internet.

Readings to InfluxDB

Sending the temperature readings from Pi to database is done with a simple python script that is executed periodically with cron.

tempreader-influxdb.yml playbook:

roles:
  - { role: "onewire", when: "'tempreader' in group_names" }
  - { role: "tempsensor-config", when: "'tempreader' in group_names" }
  - { role: "tempreader-influxdb", when: "'tempreader' in group_names" }

Onewire role is discussed already in post Raspberry Pi and 1-Wire temperature sensor. It will setup the Raspberry Pi’s 1-Wire bus for DS18B20 sensors.

roles/tempsensor-config/tasks/main.yml will setup a JSON file that describes all the available temperature reading sensors so that their 1-Wire ids will have also more meaningful tags in the readings:

- name: "copy temperature sensors config file"
  become_user: "{{ ansible_user }}"
  template:
    src: "sensors.json.j2"
    dest: "{{ script_install_path }}/sensors.json"

roles/tempsensor-config/tasks/main.yml has Jinja2 magic to create a pretty JSON file from ansible data:

{
{% for sensor in sensors %}
  "{{ sensor.id }}": {
    "shortname": "{{ sensor.shortname }}",
    "name": "{{ sensor.name }}"
  }{% if not loop.last %},{% endif %}

{% endfor %}
}

Sensor configuration is defined in group_vars/all/vars.yml:

sensors:
  - id: "05168020fdff"
    shortname: "oloh-tvtaso"
    name: "Olohuone TV-taso"
  - id: "05168043d3ff"
    shortname: "mh-ulko"
    name: "Makuuhuone ulko"
  - id: "05168068aeff"
    shortname: "mh-sisa"
    name: "Makuuhuone sisä"

roles/tempreader-influxdb/tasks/main.yml sets up the temperature reading script: it installs python and required libraries, copies the script, sets up the cron and runs it for the first time.

- name: "install required software from apt"
  apt:
    name: "{{ item }}"
  with_items:
    - "python"
    - "python-pip"

- name: "install python libraries"
  pip:
    name: "{{ item }}"
  with_items:
    - "w1thermsensor"
    - "influxdb"

- name: "install temperature reading script"
  become_user: "{{ ansible_user }}"
  become: true
  template:
    src: "readtemp-influxdb.py.j2"
    dest: "{{ script_install_path }}readtemp-influxdb.py"

- name: "add temperature reading to cron"
  cron:
    name: "read all temperature sensors to influxdb"
    job: "/usr/bin/python {{ script_install_path }}readtemp-influxdb.py"
    minute: "*/1"
    user: "{{ ansible_user }}"

- name: "run temperature reading for the first time"
  become_user: "{{ ansible_user }}"
  become: true
  shell: "/usr/bin/python {{ script_install_path }}readtemp-influxdb.py"

Finally, the interesting part roles/tempreader-influxdb/templates/readtemp-influxdb.py.j2 isn’t after all that special. It will read temperature values with W1ThermSensor and send data to InfluxDB with InfluxDBClient.

Tags for each reading are used from the JSON configuration file sensors.json.

DS18B20 temperature reading range is -55–+125°C, but it will send +85°C on error. Thus, all values above +80°C are filtered out as they wouldn’t be sane anyways in this setup.

from w1thermsensor import W1ThermSensor
from influxdb import InfluxDBClient
import datetime
import platform
import json

dbclient = InfluxDBClient(
    host="{{ influxdb_host }}",
    username="{{ influxdb_user }}",
    password="{{ influxdb_pass }}",
    port={{ influxdb_port }},
    database="{{ influxdb_dbname }}"
)

with open('sensors.json') as f:
    sensor_tags = json.load(f)

measurements = []

for sensor in W1ThermSensor.get_available_sensors():
    temp = sensor.get_temperature()
    if temp < 80: # 85 is an errorcode
        tags = sensor_tags.get(sensor.id, {})
        tags["sensor_id"] = sensor.id
        tags["host"] = platform.node()
        measurements.append({
            "measurement": "temperature",
            "time": datetime.datetime.utcnow().isoformat() + 'Z',
            "tags": tags,
            "fields": {
                "value": temp
            }
        })

print json.dumps(measurements, indent=4, sort_keys=True)
if measurements: dbclient.write_points(measurements)

InfluxDB can be queried manually with an SQL-like syntax:

rh@sokrates:~$ influx -database 'temperature' -username '...' -password '...'
Connected to http://localhost:8086 version 1.4.2
InfluxDB shell version: 1.4.2

> select * from temperature order by time desc limit 10;
name: temperature
time                host           name             sensor_id    shortname   value
----                ----           ----             ---------    ---------   -----
1532181008754363136 rpi-makuuhuone Makuuhuone sisä  05168068aeff mh-sisa     26.062
1532181007874356992 rpi-makuuhuone Makuuhuone ulko  05168043d3ff mh-ulko     23.875
1532181004805038080 rpi-olohuone   Olohuone TV-taso 05168020fdff oloh-tvtaso 28.437
1532180948674241024 rpi-makuuhuone Makuuhuone sisä  05168068aeff mh-sisa     25.812
1532180947794324992 rpi-makuuhuone Makuuhuone ulko  05168043d3ff mh-ulko     23.687
1532180944884818944 rpi-olohuone   Olohuone TV-taso 05168020fdff oloh-tvtaso 28.5
1532180888674245120 rpi-makuuhuone Makuuhuone sisä  05168068aeff mh-sisa     25.75
1532180887794349824 rpi-makuuhuone Makuuhuone ulko  05168043d3ff mh-ulko     23.625
1532180885044857088 rpi-olohuone   Olohuone TV-taso 05168020fdff oloh-tvtaso 28.562
1532180829634234880 rpi-makuuhuone Makuuhuone sisä  05168068aeff mh-sisa     25.562

Grafana

For data visualization, Grafana was easy to set up. I used the hosted grafana as it was free for single user. Grafana can also be installed to your own server.

So there are no ansible scripts for this part. After creating a hosted grafana instance, you can follow the good instructions Using InfluxDB in Grafana. From the main points listed in Grafana’s Home Dashboard, important ones are adding a datasource and creating a dashboard.

Datasource configuration is straightforward, here are the configuration options and respective variables in tempreader-influxdb.yml:

  • Type: InfluxDB
  • HTTP URL: http://<influxdb_host>:<influxdb_port>
  • HTTP Access: Server (Default)
  • Auth: nothing selected (database requires authenticated user, but HTTP connection does not)
  • InfluxDB Details - Database: <influxdb_dbname>
  • InfluxDB Details - User: <influxdb_user>
  • InfluxDB Details - Password: <influxdb_password>

For me, the dashboard configuration wasn’t intuitive at first. After clicking new dashboard and then “Graph”, you’ll end up with a blank panel and no hints on how to go forward. The trick is to click “Panel title” and then “Edit”. Then configure the query as follows:

grafana - new dashboard

For the query under “Metrics” tab, set up like this:

grafana - query

Here the GROUP BY clause will separate the data into one graph per sensor. tag(name) is the best tag for grouping as it will show up in the legend also. fill(previous) will connect the data points in the graph, and selecting previous will make “current” to show the latest data point in the legend.

In “Legend” tab, select:

grafana - query

And you’ll have a beautiful graph of your temperature readings!

grafana - graph

You can leave questions and comments to Reddit.

header

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.

Software

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.

Hardware

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

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
  when:
  - exthdd is defined

host_vars/rpi-olohuone.yml:

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

roles/external-hdd/tasks/main.yml:

- name: mount device
  mount:
    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
NAME        MAJ:MIN RM   SIZE RO TYPE MOUNTPOINT
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
  when:
    - enable_wifi is defined and enable_wifi == True

host_vars/rpi-kph.yml:

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>

roles/external-hdd/tasks/main.yml:

- name: Add wifi configuration
  blockinfile:
    dest: "/etc/wpa_supplicant/wpa_supplicant.conf"
    block: |
      network={
      	ssid="{{ wifi_network }}"
      	psk="{{ wifi_password }}"
      	key_mgmt=WPA-PSK
      }
  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
  roles:
    - alsa

- hosts: music-client
  roles:
    - alsa

host_vars/rpi-olohuone.yml:

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

host_vars/rpi-olohuone.yml:

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
null
    Discard all samples (playback) or generate zero samples (capture)
makemono
sysdefault:CARD=ALSA
    bcm2835 ALSA, bcm2835 ALSA
    Default Audio Device

...

sysdefault:CARD=VX1
    VX1, USB Audio
    Default Audio Device

...

roles/alsa/tasks/main.yml:

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

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

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

- name: setup alsa default sound card
  copy:
    content: "{{ asound_conf }}" # device-dependent
    dest: "/etc/asound.conf"
  notify:
    - 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
  roles:
    - snapcast-server
    - snapcast-client

- hosts: music-client
  roles:
    - snapcast-client

roles/snapcast-server/tasks/main.yml

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

- name: open ports in firewall
  ufw:
    rule: "allow"
    port: "{{ item }}"
  with_items:
    - 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.

roles/snapcast-client/tasks/main.yml

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

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

host_vars/rpi-olohuone.yml

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

host_vars/rpi-kph.yml

snapcast_server: "192.168.1.249"
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:192.168.1.249
Jan 21 01:27:34 rpi-olohuone snapserver[472]: StreamServer::NewConnection: ::ffff:192.168.1.3

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 192.168.1.249 --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 192.168.1.249

Configuration: MPD

music.yml playbook, relevant parts:

- hosts: music-server
  roles:
    - mpd-server

roles/mpd-master/tasks/main.yml:

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

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

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

- name: symlink nginx cover art configuration
  file:
    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
  ufw:
    rule: "allow"
    port: "{{ item }}"
  with_items:
    - "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.

roles/mpd-master/templates/mpd.conf.j2:

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    "0.0.0.0"
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.

host_vars/rpi-olohuone.yml

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)
           man:mpd.conf(5)
           file:///usr/share/doc/mpd/user-manual.html
 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.