How to integrate your Syncthing, and even make it talk

Tags: #<Tag:0x00007fc414f4eb80> #<Tag:0x00007fc414f4e9f0> #<Tag:0x00007fc414f4e4c8> #<Tag:0x00007fc414f4e130>

The situation

I’m a programmer and rely on Syncthing to sync ~1.3 million project files and smartphone pictures/videos across 11 devices. So why not have all important Syncthing data and its status in Home Assistant, and even let it talk to me/alert me when things happen?

I use TTS alerts a lot (using PicoTTS), have quite a well-defined structure for the sound files (in config/www/alerts/), and need (at least) bilingual spoken text. I also use the var custom component to store things like the default media player and the alerts base URI there. My HA already has inputs for TTS on/off and TTS language.

The plan

So I envisioned something like the following for “Syncthing at-a-glance” in HA:

  • A Lovelace glance card like this:
    ha-syncthing
  • A Syncthing info sensor that aggregates all info I need from several REST calls into the attributes of one single sensor:
  • Automations that could alert me when Syncthing goes down and/or is started again.
  • A script that could read out essential Syncthing info to me via TTS, in a comprehensible way. (I can trigger these using an RF remote when at home.)

Preparing Syncthing

Syncthing offers a REST API, so integration into HA should be relatively simple. Syncthing’s GUI and REST API are by default restricted to the machine it runs on (localhost), so we need to allow access from the network.

Allow remote access

Warning: If you do this, enable a username and strong password for Syncthing, otherwise this can be a security risk!

Open the Syncthing Web UI and enter Actions → Settings. In the GUI tab, overwrite the value 127.0.0.1:8384 with 0.0.0.0:8384:


This will enable access to Syncthings’s Web UI as well as access to its REST API from other machines (i.e., not only the one Syncthing runs on).

While here, also enter a username and password, if you haven’t already.

Get the API key

In the Web UI, under Actions → Settings, open the General tab:

If you already see an API key here, don’t change it. If you have no API key yet, click Generate to generate a new one.

Copy the API key and place it into your secrets.yaml file (fake key shown here):

syncthing_api_key: Ziegheizee7do7Je8uiMiepu8Abeevee

Try if it works

Using curl in a terminal window, you can easily check if the REST API works. Let’s assume Syncthing runs on a machine called studio1 in the local network. Type the following command, substituting server name and API key with your own:

curl -X GET -H "X-API-Key: Ziegheizee7do7Je8uiMiepu8Abeevee" http://studio1:8384/rest/system/version

You should get a JSON response like the following:

{
  "arch": "amd64",
  "codename": "Fermium Flea",
  "isBeta": false,
  "isCandidate": false,
  "isRelease": true,
  "longVersion": "syncthing v1.6.1 \"Fermium Flea\" (go1.14.4 linux-amd64) [email protected] 2020-06-02 09:49:22 UTC",
  "os": "linux",
  "version": "v1.6.1"
}

Great, it worked! Now let’s switch to Home Assistant.

Prepare Home Assistant

Note: I used Home Assistant Core version 0.112.0 for this. Your mileage may vary.

Sensors

Let’s add some sensors we can later use in a Lovelace glance card, and in our automations.

binary_sensor.syncthing

This sensor will show if Syncthing is alive.

Add the following binary sensor to your configuration, under the binary_sensor: section:

binary_sensor:

  # Syncthing (REST, requires GUI address '0.0.0.0:8384' and API Key)
  - platform: rest
    name: syncthing
    resource: http://studio1:8384/rest/system/ping
    headers:
      X-API-Key: !secret syncthing_api_key
    value_template: '{{ value_json.ping == "pong" }}'
    device_class: connectivity

sensor.syncthing_version

This sensor will get some version and OS info from Syncthing.

Add it to your configuration, this time under the sensor: section:

sensor:

  # Syncthing (REST, requires GUI address '0.0.0.0:8384' and API Key)
  # Version information
  - platform: rest
    name: syncthing_version
    resource: http://studio1:8384/rest/system/version
    headers:
      X-API-Key: !secret syncthing_api_key
    json_attributes:
      - version
      - longVersion
      - os
      - arch
    value_template: '{{ value_json.version }}'
    scan_interval: 300

Adjust resource: to reflect your server name.
You can also adjust the scan_interval:, I have set it to every 5 minutes (300 seconds) here.

sensor.syncthing_connections

This sensor gets the connection data for all your known devices, plus some totals. It can generate quite a lot of data, so don’t update it too often!

This sensor also belongs under sensors:, just put it after the previous one:

  # Connection information
  - platform: rest
    name: syncthing_connections
    resource: http://studio1:8384/rest/system/connections
    headers:
      X-API-Key: !secret syncthing_api_key
    json_attributes:
      - connections
      - total
    # Don't read sensor value (it's the OLD last one!) but update directly from JSON
    #value_template: "{{ states.sensor.syncthing_connections.attributes.connections.values() | selectattr('connected') | list | length }}"
    value_template: "{{ value_json.connections.values() | selectattr('connected') | list | length }}"
    scan_interval: 300

Again, adjust resource: and scan_interval: as needed.

sensor.syncthing_status

This sensor gets some status information from Syncthing. It also generates lots of data, so don’t update it too often!

You can also put this sensor just below the previous one:

  # Status information
  - platform: rest
    name: syncthing_status
    resource: http://studio1:8384/rest/system/status
    headers:
      X-API-Key: !secret syncthing_api_key
    json_attributes:
      - myID
      - startTime
      - uptime
      - alloc
    # Don't read sensor value (it's the OLD last one!) but update directly from JSON
    #value_template: "{{ (states.sensor.syncthing_status.attributes.uptime / 86400) | round(0) | int }}"
    value_template: "{{ (value_json.uptime / 86400) | round(0) | int }}"
    unit_of_measurement: 'd'
    scan_interval: 300

Again, adjust resource: and scan_interval: as needed.

sensor.syncthing_info

I wanted an extra “dummy” sensor that aggregates all information from the other sensors as attributes in a nice way. So this sensor uses the template platform to do that.

This sensor belongs under sensors:, just place it below the above ones:

  # Info "sensor" that aggregates info from above REST sensors
  - platform: template
    sensors:
      syncthing_info:
        # force update if any of these change
        entity_id:
          - binary_sensor.syncthing
          - sensor.syncthing_version
          - sensor.syncthing_status
          - sensor.syncthing_connections
        value_template: "{{ states('binary_sensor.syncthing') }}"
        # aggregate some useful info from the other sensors
        attribute_templates:
          my_ID: "{{ state_attr('sensor.syncthing_status', 'myID') }}"
          version: "{{ state_attr('sensor.syncthing_version', 'version') }}"
          version_long: "{{ state_attr('sensor.syncthing_version', 'longVersion') }}"
          os: "{{ state_attr('sensor.syncthing_version', 'os') }}"
          arch: "{{ state_attr('sensor.syncthing_version', 'arch') }}"
          # must subtract 1 because own machine is also in connections, albeit always disconnected
          connections_total: "{{ state_attr('sensor.syncthing_connections', 'connections') | length - 1 }}"
          # selectattr('connected') is the same as selectattr('connected', 'equalto', True)
          connections_active: "{{ states.sensor.syncthing_connections.attributes.connections.values()|selectattr('connected')|list|length }}"
          total_bytes_in: "{{ state_attr('sensor.syncthing_connections', 'total')['inBytesTotal'] }}"
          total_bytes_out: "{{ state_attr('sensor.syncthing_connections', 'total')['outBytesTotal'] }}"
          start_time: "{{ as_timestamp(state_attr('sensor.syncthing_status', 'startTime')) | timestamp_custom() }}"
          uptime: "{{ states('sensor.syncthing_status') }}"
          memory_usage: "{{ state_attr('sensor.syncthing_status', 'alloc') }}"

As you see, this sensor does some calculations: It calculates the number of connections total (minus one, because your own machine is also always in this list, and I want to see the connections going to the other Syncthing devices), the number of devices currently connected, and makes a nice datetime result to show when your Syncthing was last started.


The needed sensors are now configured. You might want to test them out before we move on. Remember you’ll need to restart HA every time when sensors are added, deleted, or modified!

Hint: If you want to make more sensors yourself, here is the documentation on Syncthing’s REST API.

Lovelace Glance Card

Let’s add a Syncthing Glance Card to our system. Add the sensors we defined to your new glance card:

You also might want to call up the code editor and give the entities nice names:

If all goes well, your new card should look somehow like this one:
ha-syncthing

Try playing around with it, click the sensors, and so forth!

Automation example

Now let’s add simple automations for alerts when Syncthing goes down/is started again. You can devise much more elaborated automations yourself: Now since we have the data, we could stop Syncthing if it goes beyond a monthly data allowance, have us notified if Syncthing stops unexpectedly, and so on … The sky’s the limit.

Here is an example I use (under automation:, or in automations.yaml):

  # Syncthing on
  - alias: "Syncthing on"
    trigger:
      platform: state
      entity_id: binary_sensor.syncthing
      to: 'on'
    action:
      - service: script.audio_alert
        data:
          audiofile: "syncthing-on.mp3"

  # Syncthing off
  - alias: "Syncthing off"
    trigger:
      platform: state
      entity_id: binary_sensor.syncthing
      to: 'off'
    action:
      - service: script.audio_alert
        data:
          audiofile: "syncthing-off.mp3"

The script will simply play an audio alert to a set of defined media players, in the language selected in my HA. You could use a notify instead, for example.

A script to read out Syncthing info

I can trigger this via a RF remote when at home. It’s not as nerdy as it seems, because scripts like these can actually help visibly impaired persons.

I use a lot of building blocks that rely on each other, so you might have to adapt the script to your needs and requirements.

The idea is to get an output like this:
German TTS, British TTS

Prerequisites

var custom component

I use the var custom component to store some “global” variables like the default media player, the default language, the path to the audio files, etc like this:

# Custom variables component (in "custom_components/var/")
var:
  audio_alerts_media_player:
    friendly_name: "Default Media Player for Audio Alerts"
    initial_value: media_player.signalpi1

  audio_alerts_base_url:
    friendly_name: "Base URL for audio alert messages"
    initial_value: "https://has1.example.com/local/alerts"

  audio_alerts_base_path:
    friendly_name: "Base (internal) path for audio alerts"
    initial_value: "/home/homeassistant/.homeassistant/www/alerts"

alerts folder structure

My alerts folder is in the config/www folder, as follows:

www/
  alerts/
    de/
      picotts-beep.wav
      syncthing-off.mp3
      syncthing-on.mp3
    en/
      picotts-beep.wav
      syncthing-off.mp3
      syncthing-on.mp3

You see the files have the same name, but are recorded in different languages. I make the same distinction by language for TTS commands.

input_boolean.audio_alerts

This is a boolean input to switch on/off any TTS alerts (like at night, or when I’m not at home).

input_boolean:

  audio_alerts:
    name: Audio Alerts
    initial: on
    icon: mdi:voice

input_select.audio_language

This serves as a language selector on my main HA page:

input_select:

  audio_language:
    name: 'Audio Language'
    # first 2 chars will be used as subfolder name for pre-recorded audio alerts
    # full name will be used as language input to the TTS engine
    options:
      - 'en-GB'
      - 'de-DE'

PicoTTS

I use PicoTTS as my TTS engine, because it can run locally and is absolutely “cloud-free” and doesn’t need a working internet connection. Also, it allows volume and pitch changes as well as inclusion of a sound bit in a WAV file to be spoken. (The WAV file to include has to be RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 16000 Hz. This can easily be generated using tools like Audacity.)

Under Linux (Debian variants), it can be easily installed using something like:

sudo apt-get install libttspico-utils

After that, install the PicoTTS integration in HA and you’re done. My configuration is like this:

# Text to speech
tts:
  - platform: picotts
    language: 'en-GB'
    base_url: https://has1.example.com
    cache: false

Note: I haven’t found a way to make this work with hass.io, but it runs just great if using Home Assistant Core.

Finally, the script!

If you have above prerequisites, here is my script.say_syncthing, to go into scripts.yaml:

say_syncthing:
  alias: 'Say Syncthing info'
  description: 'Speak Syncthing info'
  fields:
    entity_id:
      description: 'Entity Id of the media player to use'
      example: 'media_player.signalpi1'
    audiofile:
      description: 'Name of introductory audio file to play. MUST be WAV, pcm-s16le, mono, 16000 Hz.'
      example: 'picotts-beep.wav'
    message:
      description: 'The text message to speak (can be a template)'
      example: "Syncthing is at version {version}."
    language:
      description: 'The language to speak in (defaults to tts: setting)'
      example: 'en-GB'
  sequence:
    # only alert if "Audio Alerts" is on
    - condition: state
      entity_id: 'input_boolean.audio_alerts'
      state: 'on'
    # play text message
    - service: tts.picotts_say
      data_template:
        entity_id: "{{ entity_id|default(states('var.audio_alerts_media_player'),true) }}"
        language: "{{ language | default(states('input_select.audio_language'), true) }}"
        message: >-
          {% set language = language | default(states('input_select.audio_language'), true) %}
          {% set lang = language[0:2] %}
          {% if audiofile == '' %}
          {% elif audiofile %}
            <play file="{{ states('var.audio_alerts_base_path') }}/{{ lang }}/{{ audiofile }}"/>
          {% else %}
            <play file="{{ states('var.audio_alerts_base_path') }}/{{ lang }}/picotts-beep.wav"/>
          {% endif %}
          {% set state = states('sensor.syncthing_info') %}
          {% if state == 'on' %}
            {# Make "speakable" version number #}
            {% set version = state_attr('sensor.syncthing_info', 'version')[1:] | replace('.', ' ') %}
            {% set conn_total = state_attr('sensor.syncthing_info', 'connections_total') %}
            {% set conn_active = state_attr('sensor.syncthing_info', 'connections_active') %}
            {% set st = strptime(state_attr('sensor.syncthing_info','start_time'), '%Y-%m-%d %H:%M:%S') %}
            {% set uptime = state_attr('sensor.syncthing_info', 'uptime') %}
            {% set in_val  = (state_attr('sensor.syncthing_info','total_bytes_in') | filesizeformat(binary=false)).split(' ')[0] %}
            {% set in_unit = (state_attr('sensor.syncthing_info','total_bytes_in') | filesizeformat(binary=false)).split(' ')[1] %}
            {% set out_val  = (state_attr('sensor.syncthing_info','total_bytes_out') | filesizeformat(binary=false)).split(' ')[0] %}
            {% set out_unit = (state_attr('sensor.syncthing_info','total_bytes_out') | filesizeformat(binary=false)).split(' ')[1] %}
            {% set total_val  = ((state_attr('sensor.syncthing_info','total_bytes_in')|int + state_attr('sensor.syncthing_info','total_bytes_out')|int) | filesizeformat(binary=false)).split(' ')[0] %}
            {% set total_unit = ((state_attr('sensor.syncthing_info','total_bytes_in')|int + state_attr('sensor.syncthing_info','total_bytes_out')|int) | filesizeformat(binary=false)).split(' ')[1] %}
            {% set mem_val  = (state_attr('sensor.syncthing_info','memory_usage') | filesizeformat(binary=false)).split(' ')[0] %}
            {% set mem_unit = (state_attr('sensor.syncthing_info','memory_usage') | filesizeformat(binary=false)).split(' ')[1] %}
            {% if lang == 'de' %}
              {% set in_val = in_val | replace('.',',') %}
              {% set out_val = out_val | replace('.',',') %}
              {% set total_val = total_val | replace('.',',') %}
              {% set mem_val = mem_val | replace('.',',') %}
              {# Make "speakable" German date #}
              {% set start = "%s" % ( st.strftime('%d.%m., %H:%M') ) %}
              {% set message = "Syncthing Version {version} aktiv, {conn_active} von {conn_total} Geräten verbunden. " %}
              {% set message = message ~ "Speichernutzung {mem_val} {mem_unit}, " %}
              {% set message = message ~ "eingehend {in_val} {in_unit}, ausgehend {out_val} {out_unit}, gesamt {total_val} {total_unit} " %}
              {% set message = message ~ "seit {start} ({uptime} Tage)." %}
            {% else %}
              {# Make "speakable" British date #}
              {% set start = "%s, at %s" % ( st.strftime('%d %B %Y'), st.strftime('%H:%M') | regex_replace(find='^\s*0+:00', replace='midnight', ignorecase=False) | regex_replace(find='^\s*0+:0?', replace='zero ', ignorecase=False) ) %}
              {% set message = "Syncthing version {version} active, {conn_active} of {conn_total} devices connected. " %}
              {% set message = message ~ "Memory usage {mem_val} {mem_unit}, " %}
              {% set message = message ~ "{in_val} {in_unit} incoming, {out_val} {out_unit} outgoing, {total_val} {total_unit} total traffic " %}
              {% set message = message ~ "since {start} ({uptime} days)." %}
            {% endif %}
          {% else %}
            {% if lang == 'de' %}
              {% set message = "Syncthing ist nicht in Betrieb." %}
            {% else %}
              {% set message = "Syncthing is not running." %}
            {% endif %}
          {% endif %}
          {% set message = message.format(version=version, conn_total=conn_total, conn_active=conn_active, start=start, uptime=uptime, in_val=in_val, in_unit=in_unit, out_val=out_val, out_unit=out_unit, total_val=total_val, total_unit=total_unit, mem_val=mem_val, mem_unit=mem_unit) %}
          <volume level="60">{{ message }}</volume>

The script uses some little tricks to make the output better understandable, for both English and German. Also, I reduce the output volume to 60%, because PicoTTS otherwise tends to produce WAVs that clip easily. The picotts-beep.wav, which is included as a default start sound, is already appropriately replaygained to fit with the 60% volume TTS output.

Bonus script!

Did you really follow me until here? Wow! You deserve a bonus!

Since you already set up all these components, inputs and sensors, you now also get the script.audio_alert for pre-recorded MP3 messages. This script uses the same var components, the language selector and the audio alerts on/off switch!

Here we go, put it into your scripts.yaml and enjoy!

# Audio Alert
# Plays audio file given by "audiofile" dummy variable to media player.
# If "audiofile" is empty/undefined, plays "cantdothat.mp3".
# If no "entity_id" is given, plays to "media_player.signalpi1"
audio_alert:
  alias: 'Audio Alert'
  description: 'Send an audio alert to media player'
  fields:
    entity_id:
      description: 'Entity Id of the media player to use'
      example: 'media_player.signalpi1'
    audiofile:
      description: 'Name of the audio file to play, including extension'
      example: 'warning.mp3'
    language:
      description: 'Language for output (subfolder, can be either like "de" or "de-DE", only first 2 chars used)'
      example: 'de-DE'
  sequence:
    # only alert if "Audio Alerts" is on (or it's the "audio off" message)
    - condition: or
      conditions:
        - condition: state
          entity_id: 'input_boolean.audio_alerts'
          state: 'on'
        - condition: template
          value_template: "{{ audiofile == 'audio-alerts-off.mp3' }}"
    # we might be playing another message already, so wait until finished (or max. 30s)
#    - wait_template: "{{ states(entity_id|default('media_player.signalpi1',true)) in ['idle','paused','off'] }}"
#    - wait_template: "{{ not is_state(entity_id|default('media_player.signalpi1',true), 'playing') }}"
#      timeout: '00:00:30'
    # play message to player (or "signalpi1") (which is synchronised with others in LMS)
    - service_template: media_player.play_media
      data_template:
        entity_id: "{{ entity_id|default(states('var.audio_alerts_media_player'),true) }}"
        media_content_type: music
        media_content_id: "{{ states('var.audio_alerts_base_url') }}/{{ (language|default(states('input_select.audio_language'),true))[0:2] }}/{{ audiofile|default(['cantdothat.mp3','cantdothat-2.mp3']|random, true) }}"

Have fun!

Have lots of fun (and good results) experimenting with this and adapting it, and let me know what you did with it!

8 Likes

Fantastically well-written guide, was able to get the sensors working within five minutes :smiley:

FWIW I didn’t need to change the GUI Listen Address for Syncthing, which is running in a Docker container.