Platypush

Get started with Platypush to automate your smart home and beyond

Author photo Platypush
.

In the last few years we have experienced a terrific spike of products and solutions targeting home automation and automation in general. After a couple of years of hype around IoT, assistants and home automation the market is slowly cooling down, and a few solutions are emerging out of the primordial oh-look-at-this-shiny-new-toy chaos.

There are however a couple of issues I’ve still got with most of the available solutions that led me to invest more time in building Platypush.

The rationale behind a self-hosted, open-source and modular automation service

First, one class of solutions for home automation is provided by large for-profit companies. Such solutions, like the Google Home platform and Alexa, are intuitive to set up and use, but they’re quite rigid in the possibility of customization — meaning that you’ve only got the integrations that Google or Amazon provide you with, based on the partnerships they decide to build, you’ve only got to use the user interfaces they provide you with on the devices they decide to support, you can’t install the platform wherever you like or interact with it in any way outside of what’s provided by the company, and such decisions also subject to change over time. Additionally, a truly connected house will generate a lot of data about you (much more than what you generate by browsing the web), and I feel a bit uncomfortable sharing that data with people who don’t seem to bother to share it with anyone else without my consent. Plus, the integrations provided by such platforms often break (see this, this and this, and they’re only a few examples) . It can be quite annoying if all of a sudden you can no longer control your expensive lights or cameras from your expensive assistant or smart screen, and the code for controlling them runs on somebody else’s cloud, and that somebody else just informs you that “they’re working on that”.

Another issue with home automation solutions so far is fragmentation. You’ll easily have to buy 3–4 different bridges, devices or adapters if you want to control your smart lights, smart buttons, switches or a living room media centre. Compatibility with existing remotes is also most of the times neglected. Not to mention the number of apps you’ll have to download — each provided by a different author, each eating up space on your phone, and don’t expect that many options to make them communicate with each other. Such fragmentation has been indeed one of the core issues I’ve tried to tackle when building platypush, as I aimed to have only one central entry point, one control panel, one dashboard, one protocol to control everything, while still providing all the needed flexibility in terms of supported communication backends and APIs. The need for multiple bridges for home automation also goes away once you provide modules to manage Bluetooth, Zigbee and Z-Wave, and all you need to interact with your devices is a physical adapter connected to any kind of computer. The core idea is that, if there is a Python library or API to do what you want to do (or at least something that can be wrapped in a simple Python logic), then there should also be a plugin to effortlessly integrate what you want to do into the ecosystem you already have. It’s similar to the solution that Google has later tried to provide with the device custom actions, even though the latter is limited to assistant interactions so far, and it’s currently subject to change because of the deprecation of the assistant library.

Another class of solutions for such problems come from open source products like Home Assistant. While Platypush and the Home Assistant share quite a few things — they both started being developed around the same time, both started out as a bunch of scripts that we developers used to control our own things, and we eventually ended up gluing together in a larger platform for general use, both are open source and developed in Python, and both aim to bring the logic for controlling your house inside your house instead of running it on somebody else’s cloud — there are a couple of reasons why I eventually decided to keep working on my own platform instead of converging my efforts into Home Assistant.

First, Home Assistant has a strong Raspberry Pi-first approach. The suggested way to get started is to flash the Hass.io image to an SD card and boot up your RPi. While my solution is heavily tested within the Raspberry Pi ecosystem as well, it aims to run on any device that comes with a CPU and a Python interpreter. You can easily install it and run it on any x86/x86_64 architecture too if you want to handle your automation logic on an old laptop, a desktop or any Intel-based micro-computer. You can easily run it on other single-board computers, such as the Asus Tinkerboard, any BananaPi or Odroid device. You can even run it on Android if you have an implementation of the Python interpreter installed. You can even run a stripped-down version on a microcontroller that runs MicroPython. While Home Assistant has tackled its growth and increasing complexity by narrowing down the devices it supports, and providing pre-compiled OS and Docker images to reduce the complex process of getting it to run on bare-metal, I've tried to keep Platypush as modular, lightweight and easy to setup and run as possible. You can run it in a Python virtual environment, in a Docker container, in a virtual machine or KVM — if you can name it, you can probably do it already. I have even managed to run it on an old Nokia N900, both on the original Maemo and Arch Linux, and on several Android smartphones and tablets. And, most of all, it has a very small memory and CPU footprint. Running hotword detection, assistant, web panel, camera, lights, music control and a few sensors on a small Raspberry Zero is guaranteed to take not more than 5-10% of CPU load and just a few MBs of RAM.

The flexibility of Platypush comes however a slightly steeper learning curve, but it rewards the user with much more room for customization. You are expected to install it via pip or the git repo, install the dependencies based on the plugins you want (although managing per-plugin dependencies is quite easy via pip), and manually create or edit a configuration file. But it provides much, much more flexibility. It can listen for messages on MQTT, HTTP (but you don’t have to run the webserver if you don’t want to), websocket, TCP socket, Redis, Kafka, Pushbullet — you name it, it has probably got it already. It allows you to create powerful procedures and event hooks written either in an intuitive YAML-based language or as drop-in Python scripts. Its original mission is to simplify home automation, but it doesn't stop there: you can use it to send text messages, read notifications from Android devices, control robots, turn your old box into a fully capable media center (with support for torrents, YouTube, Kodi, Plex, Chromecast, vlc, subtitles and many other players and formats) or a music center (with support for local collections, Spotify, SoundCloud, radios etc.), stream audio from your house, play raw or sampled sounds from a MIDI interface, monitor access to the file system on a server, run custom actions when some NFC tag is detected by your reader, read and extract content from RSS feeds and send it to your Kindle, read and write data to a remote database, send metrics to Grafana, create a custom voice assistant, run and train machine learning models, and so on — basically, you can do anything that comes with one of the hundreds of supported plugin.

Another issue I’ve tried to tackle is the developer and power user experience. I wanted to make it easy to use the automation platform as a library or a general-purpose API, so you can easily invoke a custom logic to turn on the lights or control the music in any of your custom scripts through something as simple as a get_plugin('light.hue').on() call, or by sending a simple JSON request over whichever API, queue or socket communication you have set up. I also wanted to make it easy to create complex custom actions (something like “when I get home, turn on the lights if it’s already dark, turn on the fan if it’s hot, the thermostat if it’s cold, the dehumidifier if it’s too humid, and play the music you were listening on your phone”) through native pre-configured action — similar to what is offered by Node-Red but with more flexibility and ability to access the local context, and less agnostic when it comes to plugins, similar to what is offered by IFTTT and Microsoft Flow but running in your own network instead of somebody else’s cloud, similar to the flexibility offered by apps like Tasker and AutoApps, but not limited to your Android device. My goal was also to build a platform that aims to be completely agnostic about how the messages are exchanged and which specific logic is contained in the plugins. As long as you’ve got plugins and backends that implement a certain small set of elements, then you can plug them in.

Another issue I’ve tried to tackle is the developer and power user experience. I wanted to make it easy to use the automation platform as a library or a general-purpose API, so you can easily invoke a custom logic to turn on the lights or control the music in any of your custom scripts through something as simple as a get_plugin('light.hue').on() call, or by sending a simple JSON request over whichever API, queue or socket communication you have set up. I also wanted to make it easy to create complex custom actions (something like “when I get home, turn on the lights if it’s already dark, turn on the fan if it’s hot, the thermostat if it’s cold, the dehumidifier if it’s too humid, and play the music I was listening on my phone”) through native pre-configured action — similar to what is offered by Node-Red but with more flexibility and ability to access the local context, and without delegating too much of the integrations logic to other blocks; similar to what is offered by IFTTT and Microsoft Flow, but running in your own network instead of somebody else’s cloud; similar to the flexibility offered by apps like Tasker and AutoApps, but not limited to your Android device.

Finally, extensibility was also a key factor I had in mind. I know how fundamental the contribution of other developers is if you want your platform to support as many things as possible out there. One of my goals has been to provide developers with the possibility of building a simple plugin in around 15 lines of Python code, and a UI integration in around 40 lines of HTML+Javascript code thanks to a consistent API that takes care of all the boilerplate.

Let’s briefly analyze how platypush is designed to better grasp how it can provide more features and flexibility than most of the platforms I’ve seen so far.

The building blocks

There are a couple of connected elements at the foundations of platypush that allow users to build whichever solution they like:

  • Plugins: they are arguably the most important component of the platform. A plugin is a Python class that handles a type of device or service (like lights, music, calendar etc.), and it exposes a set of methods that enable you to programmatically invoke actions over those devices and services (like turn on, play, get upcoming events etc.). All plugins implement an abstract Plugin class and their configuration is completely transparent to the constructor arguments of the plugin itself - i.e. you can look at the constructor itself in the source code to understand which arguments a plugin takes, and you can fill those variables directly in your config.yaml. Example:
light.hue:
  bridge: 192.168.1.100
  groups:
    - Living Room
    - Bathroom
  • Backends: they are threads that run in the background and listen for something to happen (an HTTP request, a websocket or message queue message, a voice assistant interaction, a new played song or movie…). When it happens, they will trigger events, and other parts of the platform can asynchronously react to those events.

  • Messages: a message in platypush is just a simple JSON string that comes with a type and a set of arguments. You have three main types of messages on the platform:

  • Requests: they are messages used to require a certain plugin action to be executed. The format of the action name is quite simple (plugin_name.method_name), and you can, of course, pass extra arguments to the action. Actions are mapped one-to-one to methods in the associated plugin class through the @action annotation. It means that a request object is transparent to the organization of the plugin, and such a paradigm enables the user to build flexible JSON-RPC-like APIs. For example, the on action of the light.hue plugin accepts lights and groups as optional parameters. It means that you can easily build a request like this and deliver it to platypush through whichever backend you prefer (note that args are optional in this case):

{
    "type":"request",
    "action":"light.hue.on",
    "args": {
        "groups": [
            "Living Room",
            "Bathroom"
        ]
    }
}

If you have the HTTP backend running, for example, you can easily dispatch such a request to it through the available JSON-RPC execute endpoint.

First create a user through the web panel at http://localhost:8008, then generate a token for the user to authenticate the API calls - you can easily generate a token from the web panel itself, Settings -> Generate token.

Store the token under an environment variable (e.g. $PP_TOKEN) and use it in your calls over the Authorization: Bearer header:

curl -XPOST -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $PP_TOKEN" \
    -d '{"type":"request", "action":"light.hue.on", "args": {"groups": ["Living Room", "Bedroom"]}}' \
    http://localhost:8008/execute

# HTTPie example
echo '{
  "type":"request",
  "action":"light.hue.on",
  "args": {
    "groups": ["Living Room", "Bedroom"]
  }
}' | http http://localhost:8008/execute "Authorization: Bearer $PP_TOKEN"

And you can also easily send requests programmatically through your own Python scripts, basically using Platypush as a library in other scripts or projects:

from platypush.context import get_plugin

response = get_plugin('light.hue').on(groups=['Living Room', 'Bathroom'])
print(response)
  • Responses: they are messages that contain the output and errors resulting from the execution of a request. If you send this request for example:
{
    "type":"request",
    "action":"light.hue.get_lights"
}

You'll get back something like this:

{
  "id": "6e5383cee53e8330afc5dfb9bde12a25",
  "type": "response",
  "target": "http",
  "origin": "your_server_name",
  "_timestamp": 1564154465.715452,
  "response": {
    "output": {
      "1": {
        "state": {
          "on": false,
          "bri": 254,
          "hue": 14916,
          "sat": 142,
          "effect": "none",
          "xy": [
            0.4584,
            0.41
          ],
          "ct": 366,
          "alert": "lselect",
          "colormode": "xy",
          "mode": "homeautomation",
          "reachable": true
        },
        "type": "Extended color light",
        "name": "Living Room Ceiling Right",
        "manufacturername": "Philips",
        "productname": "Hue color lamp"
      },
      "errors": [
      ]
    }
  }
}

If you send a request over a synchronous backend (e.g. the HTTP or TCP backend) then you can expect the response to be delivered back on the same channel. If you send it over an asynchronous backend (e.g. a message queue or a websocket) then the response will be sent asynchronously, creating a dedicated queue or channel if required.

  • Events: they are messages that can be triggered by backends (and also some plugins) when a certain condition is verified. They can be delivered to connected web clients via websocket, or you can build your own custom logic on them through pre-configured event hooks. Event hooks are similar to applets on IFTTT or profiles in Tasker — they execute a certain action (or set of actions) when a certain event occurs. For example, if you enable the Google Assistant backend and some speech is detected then a SpeechRecognizedEvent will be fired. You can create an event hook like this in your configuration file to execute custom actions when a certain phrase is detected (note that regular expressions and extractions of parts from the phrase, at least to some extent, are also supported):
# Play a specific radio on the mpd (or mopidy) plugin
event.hook.PlayRadioParadiseAssistantCommand:
    if:
        type: platypush.message.event.assistant.SpeechRecognizedEvent
        phrase: "play (the)? radio paradise"
    then:
        action: music.mpd.play
        args:
            resource: tunein:station:s13606

# Search and play a song by an artist. Note the use of ${} to identify
# parts of the target attribute that should be preprocessed by the hook
event.hook.SearchSongVoiceCommand:
    if:
        type: platypush.message.event.assistant.SpeechRecognizedEvent
        phrase: "play ${title} by ${artist}"
    then:
      - action: music.mpd.clear   # Clear the current playlist
      - action: music.mpd.search
        args:
            artist: ${artist}  # Note the special map variable "context" to access data from the current context
            title: ${title}

      # music.mpd.search will return a list of results under "output". context['output'] will
      # therefore always contain the output of the last request. We can then get the first
      # result and play it.
      - action: music.mpd.play
        args:
          resource: ${context['output'][0]['file']}

You may have noticed that you can wrap Python expression by ${} . You can also access context data through the special context variable. As we saw in the example above, that allows you to easily access the output and errors of the latest executed command (but also the event that triggered the hook, through context.get('event')). If the previous command returned a key-value map, or if we extracted named-value pairs from one of the event arguments, then you can also omit the context and access those directly by name — in the example above you can access the artist either through context.get('artist') or simply artist, for example.

And you can also define event hooks directly in Python by just creating a script under ~/.config/platypush/scripts:

from platypush.context import get_plugin
from platypush.event.hook import hook
from platypush.message.event.assistant import SpeechRecognizedEvent

@hook(SpeechRecognizedEvent, phrase='play (the)? radio paradise')
def play_radio_hook(event, **context):
    mpd = get_plugin('music.mpd')
    mpd.play(resource='tunein:station:s13606')
  • Procedures: the last fundamental element of platypush are procedures. They are groups of actions that can embed more complex logic, like conditions and loops. They can also embed small snippets of Python logic to access the context variables or evaluate expressions, as we have seen in the event hook example. For example, this procedure can execute some custom code when you get home that queries a luminosity and a temperature sensor connected over USB interface (e.g. Arduino), and turns on your Hue lights if it’s below a certain threshold, says a welcome message over the text-to-speech plugin, switches on a fan connected over a TPLink smart plug if the temperature is above a certain threshold, and plays your favourite Spotify playlist through mopidy:
# Note: procedures can be synchronous (`procedure.sync` prefix) or asynchronous
# (`procedure.async` prefix). In a synchronous procedure the logic will wait for
# each action to be completed before proceeding with the next - useful if you
# want to link actions together, letting each action access the response of the
# previous one(s). An asynchronous procedure will execute instead all the actions
# in parallel. Useful if you want to execute a set of actions independent from
# each other, but be careful not to stack too many of them - each action will be
# executed in a new thread.
procedure.sync.at_home:
    - action: serial.get_measurement
      # Your device should return a JSON over the serial interface structured like:
      # {"luminosity":45, "temperature":25}

      # Note that you can either access the full output of the previous command through
      # the `output` context variable, as we saw in the event hook example, or, if the
      # output is a JSON-like object, you can access individual attributes of it
      # directly through the context. It is indeed usually more handy to access individual
      # attributes like this: the `output` context variable will be overwritten by the
      # next response, while the individual attributes of a response will remain until
      # another response overwrites them (they're similar to local variables)
    - if ${luminosity < 30}:
        - action: light.hue.on

    - if ${temperature > 25}:
        - action: switch.tplink.on
          args:
            device: Fan

    - action: tts.google.say
      args:
        text: Welcome home

    - action: music.mpd.play
      args:
        resource: spotify:user:1166720951:playlist:0WGSjpN497Ht2wYl0YTjvz

Again, you can also define procedures purely in Python by dropping a script under ~/.config/platypush/scripts - just make sure that those procedures are also imported in ~/.config/platypush/scripts/__init__.py so they are visible to the main application:

# ~/.config/platypush/scripts/at_home.py

from platypush.procedure import procedure
from platypush.utils import run

@procedure
def at_home(**context):
    sensors = run('serial.get_measurement')

    if sensors['luminosity'] < 30:
        run('light.hue.on')

    if sensors['temperature'] > 25:
        run('switch.tplink.on', device='Fan')

    run('tts.google.say', text='Welcome home')
    run('music.mpd.play', resource='spotify:user:1166720951:playlist:0WGSjpN497Ht2wYl0YTjvz')

# ~/.config/platypush/scripts/__init__.py
from scripts.at_home import at_home

In both cases, you can call the procedure either from an event hook or directly through API:

# cURL example
curl -XPOST -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $PP_TOKEN" \
    -d '{"type":"request", "action":"procedure.at_home"}' \
    http://localhost:8008/execute

You can also create a Tasker profile with a WiFi-connected or an AutoLocation trigger that fires when you enter your home area, and that profile can send the JSON request to your platypush device over e.g. HTTP /execute endpoint or MQTT — and you’ve got your custom welcome when you arrive home!

Now that you’ve got a basic idea of what’s possible with Platypush and which are its main components, it’s time to get the hands dirty, getting it installed and configure your own plugins and rules.

Installation - Quickstart

First of all, Platypush relies on Redis as in-memory storage and internal message queue for delivering messages, so that's the only important dependency to install:

# Installation on Debian and derived distros
[sudo] apt install redis-server

# Installation on Arch and derived distros
[sudo] pacman -S redis

# Enable and start the service
[sudo] systemctl enable redis.service
[sudo] systemctl start redis.service

You can then install Platypush through pip:

[sudo] pip install platypush

Or through the Gitlab repo:

git clone https://git.platypush.tech/platypush/platypush
cd platypush
python setup.py build
[sudo] python setup.py install

In both the cases, however, this will install only the dependencies for the core platform - that, by design, is kept very small and relies on backends and plugins to actually do things.

The dependencies for each integration are reported in the documentation of that integration itself ( see official documentation), and there are mainly four ways to install them:

  • Through pip extras: this is probably the most immediate way, although (for now) it requires you to take a look at the extras_require section of the setup.py to see what's the name of the extra required by your plugin/backend. For example, a common use case usually includes enabling the HTTP backend (for the /execute endpoint and for the UI), and perhaps you may want to enable a plugin for managing your lights (e.g. light.hue), your music server (e.g. music.mpd) and your Chromecasts (e.g. media.chromecast). If that's the case, then you can just get the name of the extras required by these integrations from setup.py and install them via pip:
# If you are installing Platypush directly from pip
[sudo] pip install 'platypush[http,hue,mpd,chromecast]'

# If you are installing Platypush from sources
cd /your/path/to/platypush
[sudo] pip install '.[http,hue,mpd,chromecast]'
  • From requirements.txt. The file reports the core required dependencies as uncommented lines, and optional dependencies as commented lines. Uncomment the dependencies you need for your integrations and then from the Platypush source directory type:
cd /your/path/to/platypush
[sudo] pip install -r requirements.txt
  • Manually. The official documentation reports the dependencies required by each integration and the commands to install them, so an option would be to simply paste those commands. Another way to check the dependencies is by inspecting the __doc__ item of a plugin or backend through the Python interpreter itself:
>>> from platypush.context import get_plugin
>>> plugin = get_plugin('light.hue')
>>> print(plugin.__doc__)

    Philips Hue lights plugin.

    Requires:

        * **phue** (``pip install phue``)
  • Through your OS package manager. This may actually be the best option if you want to install Platypush globally and not in a virtual environment or in your user dir, as it helps keeping your system Python modules libraries clean without too much pollution from pip modules and it would let your package manager take care of installing updates when they are available or when you upgrade your version of Python. However, you may have to map the dependencies provided by each integration to the corresponding package name on Debian/Ubuntu/Arch/CentOS etc.

Configuration

Once we have our dependencies installed, it’s time to configure the plugin. For example, if you want to manage your Hue lights and your music server, create a ~/.config/platypush/config.yaml configuration file (it's always advised to run Platypush as a non-privileged user) with a configuration that looks like this:

# Enable the web service and UI
backend.http:
  enabled: True

light.hue:
    bridge: 192.168.1.10  # IP address of your Hue bridge
    groups:               # Default groups that you want to control
        - Living Room

# Enable also the light.hue backend to get events when the status
# of the lights changes
backend.light.hue:
  poll_seconds: 20  # Check every 20 seconds

music.mpd:
  host: localhost

# Enable either backend.music.mpd, or backend.music.mopidy if you
# use Mopidy instead of MPD, to receive events when the playback state
# or the played track change
backend.music.mpd:
  poll_seconds: 10  # Check every 10 seconds

A few notes:

  • The list of events triggered by each backend is also available in the documentation of those backends, and you can write your own hooks (either in YAML inside of config.yaml or as Python drop-in scripts) to capture them and execute custom logic - for instance, backend.light.hue can trigger platypush.message.event.light.LightStatusChangeEvent.

  • By convention, plugins are identified by the lowercase name of their class without the Plugin suffix (e.g. light.hue) while backends are identified by the lowercase name of their class without the Backend suffix.

  • The configuration of a plugin or backend expects exactly the parameters of the constructor of its class, so it's very easy to look either at the source code or the documentation and get the parameters required by the configuration and their default values.

  • By default, the HTTP backend will run the web service directly through a Flask wrapper. If you are planning to run the service on a machine with more traffic or in production mode, then it's advised to use a uwsgi+nginx wrapper - the official documentation explains how to do that.

  • If the plugin or the backend doesn't require parameters, or if you want to keep the default values for the parameters, then its configuration can simply contain enabled: True.

You can now add your hooks and procedures either directly inside the config.yaml (if they are in YAML format) or in ~/.config/platypush/scripts (if they are in Python format). Also, the config.yaml can easily get messy, especially if you add many integrations, hooks and procedures, so you can split it on multiple files and use the include directive to include those external files (if relative paths are used then their reference base directory will be ~/.config/platypush):

include:
  - integrations/lights.yaml
  - integrations/music.yaml
  - integrations/media.yaml
  - hooks/home.yaml
  - hooks/media.yaml
  # - ...

Finally, launch the service from the command line:

$ platypush   # If the executable is installed in your PATH
$ python -m platypush  # If you want to run it from the sources folder

You can also create a systemd service for it and have it to automatically start. Copy something like this to ~/.config/systemd/user/platypush.service:

[Unit]
Description=Universal command executor and automation platform
After=network.target redis.service

[Service]
ExecStart=/usr/bin/platypush
Restart=always
RestartSec=5

[Install]
WantedBy=default.target

Then:

systemctl --user start platypush  # Will start the service
systemctl --user enable platypush # Will spawn it at startup

If everything went smooth and you have e.g. enabled the Hue plugin, you will see a message like this on your logs/stdout:

Bridge registration error: The link button has not been pressed in the last 30 seconds.

As you may have guessed, you’ll need to press the link button on your Hue bridge to complete the pairing. After that, you’re ready to go, and you should see traces with information about your bridge popping up in your log file.

If you enabled the HTTP backend then you may want to point your browser to http://localhost:8008 to create a new user. Then you can test the HTTP backend by sending e.g. a get_lights command:

curl -XPOST \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $PP_TOKEN" \
  -d '{"type":"request", "action":"light.hue.get_lights"}' \
  http://localhost:8008/execute

You may also notice that a panel is accessible on http://localhost:8008 upon login that contains the UI for each integration that provides it. Example for the light.hue plugin:

Example image of a Platypush integration UI

Now you’ve got all the basic notions to start playing with Platypush on your own! Remember that all you need to know to initialize a specific plugin, interact with it and which dependencies it requires is in the official documentation. Stay tuned for the next articles that will show you how to do more with platypush — configuring your voice assistant, control your media, manage your music collection, read data from sensors or NFC tags, control motors, enable multi-room music control, implement a security system for your house with real-time video and audio stream, control your switches and smart devices, turn your old TV remote into a universal remote to control anything attached to platypush, and much more.

Reactions

📣 4 🔗 4

I have been a quite strong advocate of Webmentions for a long time.

The idea is simple and powerful, and very consistent with the decentralized POSSE approach to content syndication.

Suppose that Alice finds an interesting article on Bob's website, at https://bob.com/article.

She writes a comment about it on her own website, at https://alice.com/comment.

If both Alice's and Bob's websites support Webmentions, then their websites will both advertise an e.g. POST /webmentions endpoint.

When Alice publishes her comment, her website will send a Webmention to Bob's website, with the source URL (https://alice.com/comment) and the target URL (https://bob.com/article).

Bob's website will receive the Webmention, verify that the source URL actually mentions the target URL, and then display the comment on the article page.

No 3rd-party commenting system. No intermediate services. No social media login buttons. No ad-hoc comment storage and moderation solutions. Just a simple, decentralized, peer-to-peer mechanism based on existing Web standards.

This is an alternative (and complementary) approach to federation mechanisms like ActivityPub, which are very powerful but also quite complex to implement, as implementations must deal with concepts such as actors, relays, followers, inboxes, outboxes, and so on.

It is purely peer-to-peer, based on existing Web infrastructure, and with no intermediate actors or services.

Moreover, thanks to Microformats, Webmentions can be used to share any kind of content, not just comments: likes, reactions, RSVPs, media, locations, events, and so on.

However, while the concept is simple, implementing Webmentions support from scratch can be a bit cumbersome, especially if you want to do it right and support all the semantic elements.

I have thus proceeded to implement a simple Python library (but more bindings are on the backlog) that can be easily integrated into any website, and that takes care of all the details of the Webmentions protocol implementation. You only have to worry about writing good semantic HTML, and rendering Webmention objects in your pages.

Quick start

If you use FastAPI or Flask, serve your website as static files and you're ok to use an SQLAlchemy engine to store Webmentions, you can get started in a few lines of code.

pip install "webmentions[db,file,fastapi]"
# For Flask bindings
pip install "webmentions[db,file,flask]"

Base implementation:

import os

from webmentions import WebmentionsHandler
from webmentions.storage.adapters.db import init_db_storage
from webmentions.server.adapters.fastapi import bind_webmentions
from webmentions.storage.adapters.file import FileSystemMonitor

# This should match the public URL of your website
base_url = "https://example.com"

# The directory that serves your static articles/posts.
# HTML, Markdown and plain text are supported
static_dir = "/srv/html/articles"

# A function that takes a path to a created/modified/deleted text/* file
# and maps it to a URL on the Web server to be used as the Webmention source
def path_to_url(path: str) -> str:
    # Convert path (absolute) to a path relative to static_dir
    # and drop the extension.
    # For example, /srv/http/articles/2022/01/01/article.md
    # becomes /2022/01/01/article
    path = os.path.relpath(path, static_dir).rsplit(".", 1)[0].lstrip("/")
    # Convert the path to a URL on the Web server
    # For example, /2022/01/01/article
    # becomes https://example.com/articles/2022/01/01/article
    return f"{base_url.rstrip('/')}/articles/{path}"

##### For FastAPI

from fastapi import FastAPI
from webmentions.server.adapters.fastapi import bind_webmentions

app = FastAPI()

##### For Flask

from flask import Flask
from webmentions.server.adapters.flask import bind_webmentions

app = Flask(__name__)

# ...Initialize your Web app as usual...

# Create a Webmention handler

handler = WebmentionsHandler(
    storage=init_db_storage(engine="sqlite:////tmp/webmentions.db"),
    base_url=base_url,
)

# Bind Webmentions to your app
bind_webmentions(app, handler)

# Create and start the filesystem monitor before running your app
with FileSystemMonitor(
    root_dir=static_dir,
    handler=handler,
    file_to_url_mapper=path_to_url,
) as monitor:
    app.run(...)

This will:

  • Register a POST /webmentions endpoint to receive Webmentions
  • Advertise the Webmentions endpoint in every text/* response provided by the server
  • Expose a GET /webmentions endpoint to list Webmentions (takes resource URL and direction (in or out) query parameters)
  • Store Webmentions in a database (using SQLAlchemy)
  • Monitor static_dir for changes to HTML or text files, automatically parse them to extract Webmention targets and sources, and send Webmentions when new targets are found

Generic Web framework setup

If you don't use FastAPI or Flask, or you want a higher degree of customization, you can still use the library by implementing and advertising your own Webmentions endpoint, which in turn will simply call WebmentionsHandler.process_incoming_webmention.

You will also have advertise the Webmentions endpoint in your responses, either through:

  • A Link header (with a value in the format <https://example.com/webmentions>; rel="webmention")
  • A <link> or <a> element in the HTML head or body (in the format <link rel="webmention" href="https://example.com/webmentions">)

An example is provided in the documentation.

Generic storage setup

If you don't want to use SQLAlchemy, you can implement your own storage by implementing the WebmentionsStorage interface (namely the store_webmention, retrieve_webmentions, and delete_webmention methods), then pass that to the WebmentionsHandler constructor.

An example is provided in the documentation.

Manual handling of outgoing Webmentions

The FileSystemMonitor approach is quite convenient if you serve your website (or a least the mentionable parts of it) as static files.

However, if you have a more dynamic website (with posts and comments stored on e.g. a database), or you want to have more control over when Webmentions are sent, you can also call the WebmentionsHandler.process_outgoing_webmentions method whenever a post or comment is published, updated or deleted, to trigger the sending of Webmentions to the referenced targets.

An example is provided in the documentation.

Subscribe to mention events

You may want to add your custom callbacks when a Webmention is sent or received - for example to send notifications to your users when some of their content is mentioned, or to keep track of the number of mentions sent by your pages, or to perform any automated moderation or filtering when mentions are processed etc.

This can be easily achieved by providing custom callback functions (on_mention_processed and on_mention_deleted) to the WebmentionsHandler constructor, and both take a single Webmention object as a parameter.

An example is provided in the documentation.

Filtering and moderation

This library is intentionally agnostic about filtering and moderation, but it provides you with the means to implement your own filtering and moderation logic through the on_mention_processed and on_mention_deleted callbacks.

By default all received Webmentions are stored with WebmentionStatus.CONFIRMED status.

This can be changed by setting the initial_mention_status parameter of the WebmentionsHandler constructor to WebmentionStatus.PENDING, which will cause all received Webmentions to be stored but not visible on the website until they are manually confirmed by an administrator.

You can then use the on_mention_processed callback to implement your own logic to either notify the administrator of new pending mentions, or to automatically confirm them based on some criteria.

A minimal example is provided in the documentation.

Make your pages mentionable

Without good semantic HTML, Webmentions will be quite minimal. They will still work, but they will probably be rendered simply as a source URL and a creation timestamp.

The Webmention specification is intentionally simple, in that the POST endpoint only expects a source URL and a target URL. The rest of the information about the mention (the author, the content, the type of mention, any attachments, and so on) is all derived from the source URL, by parsing the HTML of the source page and extracting the relevant Microformats.

While the Microformats2 specification is quite flexible and a work-in-progress, there are a few basic elements whose usage is recommended to make the most out of Webmentions.

A complete example with a semantic-aware HTML article is provided in the documentation.

Rendering mentions on your pages

Finally, the last step is to render the received Webmentions on your pages.

A WebmentionsHandler.render_webmentions helper is provided to automatically generate a safe pre-rendered and reasonably styled (but customizable through CSS variables) Markup object, which you can then render in your templates. Example:

from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from webmentions import WebmentionsHandler
from webmentions.server.adapters.fastapi import bind_webmentions

base_url = "https://example.com"
app = FastAPI()
handler = WebmentionsHandler(...)
bind_webmentions(app, handler)

# ...

@app.get("/articles/{article_id}")
def article(request, article_id: int):
    templates = Jinja2Templates(directory="templates")
    mentions = handler.retrieve_webmentions(
      f"{base_url}/articles/{article_id}",
      WebmentionDirection.IN,
    )

    rendered_mentions = handler.render_webmentions(mentions)
    return templates.TemplateResponse(
      "article.html",
      {
        "request": request,
        "article_id": article_id,
        "mentions": rendered_mentions,
      },
    )

Where article.html is a Jinja template that looks like this:

<!doctype html>
<html>
  <head>
    <title>Example article</title>
  </head>
  <body>
    <main>
      <article class="h-entry">
        <h1 class="p-name">Example article</h1>
        <time class="dt-published" datetime="2026-02-07T21:03:00+01:00">
          Feb 7, 2026
        </time>
        <div class="e-content">
          <p>Your article content goes here.</p>
        </div>
      </article>

      {{ mentions }}
    </div>
  </body>
</html>

More details are provided in the documentation.

For more customizing rendering, a reference Jinja template is also provided in the documentation.

Current implementations

So far the library is used in madblog, a minimal zero-database Markdown-based blogging engine I maintain, which powers both my personal blog and the Platypush blog.

You can see some Webmentions in action on some of my blog posts.

And, if you include a link to any article of mine in your website, and your website supports Webmentions (for example there is a Wordpress plugin), you should see the mention appear in the comments of the article page.

Fabio Manganiello
A smart home can generate and collect data. Lots of data. And there are currently a few outstanding issues with home-generated data: Fragmentation. You probably have your home weather station, your own motion detectors, security cameras, gas and smoke detectors, body sensors, GPS and fit trackers and smart plugs around. It’s quite likely that most of these devices generates data, and that such data will in most of the cases be only accessible through a proprietary app or web service, and that any integration with other services, or any room for tinkering and automation purposes, will mostly depend on the benevolence of the developer or business in building third-party interfaces for such data. In this article, we’ll explore how, thanks to open source solutions like platypush, Grafana and Mosquitto, it’s possible to overcome the fragmentation issue and “glue” together data sources that wouldn’t otherwise be able to communicate nor share data. Ability to query. Most of the hardware and data geeks out there won’t settle with the ability to access their data through a gauge in an app or a timeline graph. Many of us want the ability to explore our own generated data in a structured way, preferably through SQL, or any query language, and we demand tailor-made dashboards to explore our data, not dumb mobile apps. The ability to generate custom monthly reports of our fit activities, query the countries where we’ve been in a certain range of time, or how much time we spent indoor in the past three months, or how many times the smoke detectors in our guest room went above threshold in the past week, is, for many of us, priceless, and often neglected by hardware and software makers. In this article we’ll explore how to leverage an open-source relational database (PostgreSQL in this example) and some elementary data pipelines to dispatch and store your data on your private computers, ready to be queried or visualized however you like. Privacy. Many of the solutions or services I’ve mentioned in the previous examples come with their cloud-based infrastructure to store user data. While storing your data on somebody else’s computers saves you the time and disk space required to invest in your local solution, it also comes with all the concerns related to — ehm — storing your data on somebody else’s computer. That somebody else can decide if and how you can access your data, can decide to sell your data for profit, or can be hacked in a way or another. This can be especially worrisome if we’re talking data about your own body, location or house environment. A house-hosted data infrastructure bypasses the issue with third-party ownership of your data. This article will analyze the building blocks to set up your data infrastructure and build automation on it. We’ll see how to set up data collection and monitoring for a few use cases (temperature, humidity, gas, phone location and fit data) and how to build automation triggers based on such data. Dependencies setup First, you’ll need a RaspberryPi (or any similar clone) with Platypush. I assume that you’ve already got Platypush installed and configured. If not, please head to my my previous article on getting started with Platypush. You’ll also need a relational database installed on your device. The example in this article will rely on PostgreSQL, but any relational database will do its job. To install and configure PostgreSQL on Raspbian and create a database named sensors: [sudo] apt-get install postgresql libpq-dev postgresql-client postgresql-client-common -y [sudo] systemctl restart postgresql.service [sudo] su postgres createuser pi -P --interactive psql -U pi > create database sensors; We’ll use the database to store the following information: System metrics Sensors data Smartphone and location data Fit data You’ll also need a message queue broker running on your RaspberryPi to dispatch messages with new data reads — check this Instructables tutorial on how to get Mosquitto up and running on your RaspberryPi. For some of the data measurements, we’ll also need an MQTT client to test messages over the configured queue — for example, to send measurements from a shell script. I like to use mqttcli for these purposes — it’s fast, lightweight and written in Go: go get github.com/shirou/mqttcli Finally, install Grafana as a web-based gateway to visualize your data: [sudo] apt-get install grafana [sudo] systemctl restart grafana After starting the service head to http://your-pi:3000 and make sure that you see the Grafana splash screen — create a new admin user and you’re good to go for now. Now that you’ve got all the fundamental pieces in place it’s time to set up your data collection pipeline and dashboard. Let’s start from setting up the tables and data storage logic on your database. Database configuration If you followed the instructions above then you’ll have a PostgreSQL instance running on your RaspberryPi, accessible through the user pi, and a sensors database created for the purpose. In this section, I’ll explain how to create the basic tables and the triggers to normalize the data. Keep in mind that your measurement tables might become quite large, depending on how much data you process and how often you process it. It’s relatively important, to keep database size under control and to make queries efficient, to provide normalized tables structures enforced by triggers. I’ve prepared the following provisioning script for my purposes: -- Temporary sensors table where we store the raw -- measurements as received on the message queue drop table if exists tmp_sensors cascade; create table tmp_sensors( id serial not null, host varchar(64) not null, metric varchar(255) not null, data double precision, created_at timestamp with time zone default CURRENT_TIMESTAMP, primary key(id) ); -- Table to store the hosts associated to the data points drop table if exists sensor_host cascade; create table sensor_host( id serial not null, host varchar(64) unique not null, primary key(id) ); -- Table to store the metrics drop table if exists sensor_metric cascade; create table sensor_metric( id serial not null, metric varchar(255) unique not null, primary key(id) ); -- Table to store the normalized data points drop table if exists sensor_data cascade; create table sensor_data( id serial not null, host_id integer not null, metric_id integer not null, data double precision, created_at timestamp with time zone default CURRENT_TIMESTAMP, primary key(id), foreign key(host_id) references sensor_host(id), foreign key(metric_id) references sensor_metric(id) ); -- Define a stored procedure that normalizes new rows on tmp_sensors -- by either creating or returning the associated host_id and metric_id, -- creating a normalized representation of the row on sensor_data and -- delete the original raw entry on tmp_sensors. create or replace function sync_sensors_data() returns trigger as $$ begin insert into sensor_host(host) values(new.host) on conflict do nothing; insert into sensor_metric(metric) values(new.metric) on conflict do nothing; insert into sensor_data(host_id, metric_id, data) values( (select id from sensor_host where host = new.host), (select id from sensor_metric where metric = new.metric), new.data ); delete from tmp_sensors where id = new.id; return new; end; $$ language 'plpgsql'; -- Create a trigger that invokes the store procedure defined above -- after a row is inserted on tmp_sensors drop trigger if exists on_sensor_data_insert on tmp_sensors; create trigger on_sensor_data_insert after insert on tmp_sensors for each row execute procedure sync_sensors_data(); create view public.vsensors AS select d.id AS data_id, h.host, m.metric, d.data, d.created_at from ((public.sensor_data d join public.sensor_host h ON ((d.host_id = h.id))) join public.sensor_metric m ON ((d.metric_id = m.id))); The script above will keep the data on your database normalized and query-friendly even if the messages pushed on the message queue don’t care about which is the right numeric host_id or metric_id. Run it against your PostgreSQL instance: psql -U pi < database_provisioning.sql Now that you’ve got the tables ready it’s time to fill them with data. We’ll see a few examples of metrics collection, starting with system metrics. System metrics You may want to monitor the CPU, RAM or disk usage of your own RaspberryPi or any other host or virtual server you’ve got around, do things like setting up a dashboard to easily monitor your metrics or set up alerts in case something goes out of control. First, create a script that checks the memory available on your system and sends the percentage of used memory on a message queue channel — we’ll store this script under ~/bin/send_mem_stats.sh for the purposes of this tutorial: #!/bin/bash total=$(free -m | head -2 | tail -1 | awk '{print $2}') avail=$(free -m | head -2 | tail -1 | awk '{print $7}') let used=$total-$avail export MQTT_HOST=your-mqtt-server export MQTT_PORT=1883 $HOME/go/bin/mqttcli pub -t "sensors/$(hostname)/memory" -m $used And schedule it to run every e.g. 5 minutes in your crontab: */5 * * * * /bin/bash /home/pi/bin/send_mem_stats.sh Similar scripts can be made also for other system stats, for example to monitor the root disk usage: #!/bin/bash usage=$(df -h | egrep '/$' | awk '{print $5}' | tr -d % | awk '{printf "%.2f", ($0/100)}') export MQTT_HOST=your-mqtt-server export MQTT_PORT=1883 $HOME/go/bin/mqttcli pub -t "sensors/$(hostname)/disk_root" -m $usage Alternatively, you can also write the logic for sending system data points directly in Platypush - e.g. using the system plugin - and that will be executed while the service is running, so you won't need the mqttcli dependency: from datetime import datetime from platypush.config import Config from platypush.cron import cron from platypush.utils import run @cron('*/5 * * * *') def send_memory_stats(**context): mem = run('system.mem_virtual') run('mqtt.publish', host='your-mqtt-server', port=1883, topic=f'sensors/{Config.get("device_id")}/memory', msg=mem['percent']) You can extend this pattern to any sensor data you want to send over the queue. Once scheduled these jobs will start pushing data to your message queue, on the configured topic (in the examples above respectively to sensors//memory and sensors//disk_root) at regular intervals. It’s now time to set up Platypush to listen on those channels and whenever a new message comes in store it in the database you have provisioned. Add the following configuration to your ~/.config/platypush/config.yaml file: # Enable the MQTT backend backend.mqtt: host: your-mqtt-server port: 1883 # Configure platypush to listen for new messages on these topics listeners: - host: your-mqtt-server topics: - sensors/host1/disk_root - sensors/host2/disk_root - sensors/host1/memory - sensors/host2/memory And create an event hook (e.g. under ~/.config/platypush/scripts/mqtt.py) that stores the messages received on some specified channels to your database: from platypush.event.hook import hook from platypush.utils import run from platypush.message.event.mqtt import MQTTMessageEvent db_engine = 'postgresql+pg8000://pi:your-password@localhost/sensors' @hook(MQTTMessageEvent) def on_mqtt_message(event, **context): if not event.topic.startswith('sensors/'): return (prefix, host, metric) = event.topic.split('/') run('db.insert', engine=db_engine, table='tmp_sensors', records=[{ 'host': host, 'metric': metric, 'data': event.msg, }] ) By inserting the data into tmp_sensors we make sure that the triggers that we previously declared on the database will be executed and data will be normalized. Start Platypush, and if everything went smooth you’ll soon see your sensor_data table getting populated with memory and disk usage stats. Sensors data Commercial weather stations, air quality solutions and presence detectors can be relatively expensive, and relatively limited when it comes to opening up their data, but by using the ingredients we’ve talked about so far it’s relatively easy to set up your network of sensors around the house and get them to collect data on your existing data infrastructure. Let’s consider for the purposes of this post an example that collects temperature and humidity measurements from some sensors around the house. You’ve got mainly two options when it comes to set up analog sensors on a RaspberryPi: Option 1: Use an analog microprocessor (like Arduino or ESP8266) connected to your RaspberryPi over USB and configure platypush to read analogue measurements over serial port. The RaspberryPi is an amazing piece of technology but it doesn’t come with a native ADC converter. That means that many simple analog sensors available on the market that map different environment values to different voltage values won’t work on a RaspberryPi unless you use a device in between that can actually read the analog measurements and push them to the RaspberryPi over serial interface. For my purposes I often use Arduino Nano clones, as they’re usually quite cheap, but any device that can communicate over USB/serial port should do its job. You can find cheap but accurate temperature and humidity sensors on the internet, like the TMP36, DHT11 and AM2320, that can easily be set up to communicate with your Arduino/ESP device. All you need is to make sure that your Arduino/ESP device spits a valid JSON message back on the serial port whenever it performs a new measurement (e.g. {"temperature": 21.0, "humidity": 45.0}), so Platypush can easily understand when there is a change in value for a certain measurement. Option 2: Devices like the ESP8266 already come with a Wi-Fi module and can directly send message over MQTT through small MicroPython libraries like umqttsimple (check out this tutorial for ESP8266+MQTT setup). In this case you won’t need a serial connection, and you can directly send data from your sensor to your MQTT server from the device. Option 3: Use a breakout sensor (like the BMP280, SHT31 or HTU21D-F) that communicates over I2C/SPI that you can plug directly on the RaspberryPi. If you go for this solution then you won’t need another microprocessor to deal with the ADC conversion, but you’ll also have to make sure that these devices come with a Python library and they’re supported in Platypush (feel free to open an issue or send a pull request if that’s not the case). Let’s briefly analyze an example of the option 1 implementation. Let’s suppose that you have an Arduino with a connected DHT11 temperature and humidity sensor on the PIN 7. You can prepare a sketch that looks like this to send new measurements over USB to the RaspberryPi in JSON format: #include #include #define DHT11_PIN 7 dht DHT; void setup() { Serial.begin(9600); } void loop() { int ret = DHT.read11(DHT11_PIN); if (ret < -1) { delay(1000); return; } Serial.print("{\"temperature\":"); Serial.print(DHT.temperature); Serial.print(", \"humidity\":"); Serial.print(DHT.humidity); Serial.println("}"); delay(1000); } Install the Platypush serial plugin dependencies: [sudo] pip install 'platypush[serial]' Then you can add the following lines into the ~/.config/platypush/config.yaml file of the RaspberryPi that has the sensors connected to forward new measurements to the message queue, and store them on your local database. The example also shows how to tweak polling period, tolerance and thresholds: # Enable the serial plugin and specify # the path to your Arduino/Esp* device serial: device: /dev/ttyUSB0 # Enable the serial sensor backend to # listen for changes in the metrics backend.sensor.serial: # How often we should poll for new data poll_seconds: 5.0 # Which sensors should be enabled. These are # the keys in the JSON you'll be sending over serial enabled_sensors: - temperature - humidity # Specify the tolerance for the metrics. A new # measurement event will be triggered only if # the absolute value difference between the value in # the latest event and the value in the current # measurement is higher than these thresholds. # If no tolerance value is set for a specific metric # then new events will be triggered whenever we've # got new values, as long as they're different from # the previous, no matter the difference. tolerance: temperature: 0.25 humidity: 0.5 # Specify optional thresholds for the metrics. A new # sensor above/below threshold event will be triggered # when the value of that metric goes above/below the # configured threshold. thresholds: humidity: 70.0 # You can also specify multiple thresholds values for a metric temperature: - 20.0 - 25.0 - 30.0 backend.sensor.serial (and, in general, any sensor backend) will trigger a SensorDataChangeEvent when new sensor data is available, and SensorDataBelowThresholdEvent / SensorDataAboveThresholdEvent respectively when the new sensor data is respectively below or above one of the configured threshold. We can now configure an event hook to send new sensor data to MQTT to be stored on the database by dropping another script into ~/.config/platypush/scripts: from platypush.config import Config from platypush.event.hook import hook from platypush.utils import run from platypush.message.event.sensor import SensorDataChangeEvent @hook(SensorDataChangeEvent) def on_sensor_data(event, **context): hostname = Config.get('device_id') for metric in ['temperature', 'humidity']: if 'temperature' in event.data: run('mqtt.publish', topic=f'sensors/{hostname}/{metric}', host='your-mqtt-server', port=1883, msg=event.data[metric]) Just remember to add sensors/your-rpi/temperature, sensors/your-rpi/humidity and any other MQTT topic that you want to monitor to the list of topics watched by backend.mqtt on the MQTT/database host. You can also trigger actions when some sensor data goes above or below a configured threshold - for instance, turn on/off the lights if the luminosity sensor goes below/above threshold, or turn on/off the fan if the temperature sensor goes above/below a certain threshold: from platypush.event.hook import hook from platypush.utils import run from platypush.message.event.sensor import \ SensorDataAboveThresholdEvent, SensorDataBelowThresholdEvent @hook(SensorDataAboveThresholdEvent) def on_sensor_data(event, **context): if 'luminosity' in event.data: run('light.hue.off') if 'temperature' in event.data: run('switch.tplink.on', device='Fan') @hook(SensorDataBelowThresholdEvent) def on_sensor_data(event, **context): if 'luminosity' in event.data: run('light.hue.on') if 'temperature' in event.data: run('switch.tplink.off', device='Fan') This logic isn't limited to sensor events sent over a serial interface like an Arduino or ESP8266. If you have sensors that communicate over e.g. Zigbee, Z-Wave or Bluetooth, you can also configure them in Platypush through the respective backends and react to their events. Smartphone and location data Our smartphones also generate a lot of data that would be nice to track on our new data infrastructure and automate to make our lives easier. We’ll show in this example how to leverage Tasker, Pushbullet and AutoLocation on your Android device to regularly check your location, store it on your local database (so you can turn off the creepy Google’s location history — sorry, Google) and implement smart rules such as turning on lighting and heating and saying a welcome message when you arrive home. Let’s first see how to store your phone location data to your local database. Install the Pushbullet, Tasker and AutoLocation apps on your phone. Head to your Pushbullet account settings page and create a new access token. Enable the Pushbullet backend on the platypush installation on your database host. Lines to add to your config.yaml:backend.pushbullet: token: your-token device: platypush Add a table to your database to store location data:CREATE TABLE location_history ( id serial NOT NULL, latitude double precision, longitude double precision, altitude double precision, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, primary key(id) ); Create an event hook that listens for specifically formatted Pushbullet notes (e.g. LATLNG#7.8712,57.3123) that contains lat/long information and stores them on your PostgreSQL database:from platypush.event.hook import hook from platypush.utils import run from platypush.message.event.pushbullet import PushbulletEvent db_engine = 'postgresql+pg8000://pi:your-password@localhost/sensors' @hook(PushbulletEvent) def on_push_data(event, **context): if event.body.startswith('LATLNG'): run('db.insert', engine=db_engine, table='location_history', records=[{ 'latitude': event.body[len('LATLNG#'):].split(',')[0], 'longitude': event.body[len('LATLNG#'):].split(',')[1], }] ) You can also configure Tasker to use a 3rd-party app to send messages directly to MQTT (like Join or MQTT Client) so you don't have to stuff data into Pushbullet notes, but for the purpose of this example we'll analyze the Pushbullet way because it's easier to set up. Create a Tasker task that runs every 10 minutes (or 5, or 20, or however often you like) to update your location by sending a Pushbullet note to your Platypush virtual device: Tasker create new task Tasker create new task Tasker create new task After saving the Tasker profile your smartphone will start sending its location data at regular intervals to Pushbullet, your RaspberryPi will intercept those notifications and store the data on your local database. Time to ditch third-party location trackers for good! How about running custom actions when you enter or exit your home area? Let’s create a Tasker profile that, based on AutoLocation lat/long data, detects when you enter or exit a certain area. Tasker create new task The task will simply send a Pushbullet note to your Platypush virtual device that contains HOME#1 (you entered your home area) or HOME#0 (you exited your home area). Tasker create new task Tasker create new task Add an event hook to intercept the notification and run your custom logic: from platypush.context import get_plugin from platypush.event.hook import hook from platypush.message.event.pushbullet import PushbulletEvent @hook(PushbulletEvent) def on_home_push_data(event, **context): if not event.body.startswith('HOME#'): return # Initialize the plugins # Note that get_plugin('plugin').method() and # run('plugin.method') can be used interexchangably variable = get_plugin('variable') lights = get_plugin('light.hue') music = get_plugin('music.mpd') tts = get_plugin('tts') # Get the AT_HOME status at_home = int(event.body.split('#')[1]) # Get the previous AT_HOME status prev_at_home = int(variable.get('AT_HOME').get('AT_HOME', 0)) if at_home and not prev_at_home: # Example: turn on the lights, start playing the music and # say a welcome message lights.on() tts.say(text='Welcome home') music.play() elif not at_home and prev_at_home: # Turn off the lights and stop the music lights.off() music.stop() # Store the new AT_HOME status variable.set(AT_HOME=at_home) With the simple ingredients shown so far, it is relatively straightforward to connect events on your phone to your smart home infrastructure, as long as you’ve got a Tasker plugin on your smartphone for achieving what you want to do. Fit data The explosion of smartwatches, fit trackers, body sensors and smart fit algorithms running on our phones in the last years has opened the gates to an authentic revolution for health and fit technologies. However, such a revolution is still failing to reach its full potential because of the fragmentation of the market and the limited possibilities when it comes to visualize and query data. Most of the health and fit solutions come with their walled garden app: you can only access the data using the app provided by the developer, and you can only use that app to access the data generated by your specific sensors. The lack of integration between solutions has turned what could be a revolution in the way we measure the data generated by our bodies in cool pieces of tech that we like to show to friends without much practical utility. In the last years, some steps forward have been done by Google Fit; more and more products nowadays can synchronize their data to Google Fit (and my advice is to steer clear of those who don’t: they’re nothing more but shiny toys with no practical utility). However, although Google Fit allows you to have a single view on your body data even if the data points are collected by different sensors, it’s still very limited when it comes to providing you with a powerful way to query, compare and visualize your data. The web service has been killed a while ago, and that means that the only way to access your data is through the (frankly very limited) mobile app. And you’ve got no way to perform more advanced queries, such as comparisons of data between different periods, finding the day during the month when you’ve walked or slept the most, or even just visualizing the data on a computer unless you make your own program leveraging the Fit API. Luckily, Platypush comes with a handy Google Fit backend and plugin, and you can leverage them to easily build your visualization, automation and queriable fit database. Prepare the fit tables on your database. Again, we’ll leverage a trigger to take care of the normalization:-- -- tmp_fit_data table setup -- drop sequence if exists tmp_fit_data_seq cascade; create sequence tmp_fit_data_seq; drop table if exists tmp_fit_data cascade; create table tmp_fit_data( id integer not null default nextval('tmp_fit_data_seq'), username varchar(255) not null default 'me', data_source varchar(1024) not null, orig_data_source varchar(1024), data_type varchar(255) not null, value float, json_value jsonb, start_time timestamp with time zone not null, end_time timestamp with time zone not null, primary key(id) ); alter sequence tmp_fit_data_seq owned by tmp_fit_data.id; -- -- fit_user table setup -- drop sequence if exists fit_user_seq cascade; create sequence fit_user_seq; drop table if exists fit_user cascade; create table fit_user( id integer not null default nextval('fit_user_seq'), name varchar(255) unique not null, primary key(id) ); alter sequence fit_user_seq owned by fit_user.id; -- -- fit_data_source table setup -- drop sequence if exists fit_data_source_seq cascade; create sequence fit_data_source_seq; drop table if exists fit_data_source cascade; create table fit_data_source( id integer not null default nextval('fit_data_source_seq'), name varchar(255) unique not null, primary key(id) ); alter sequence fit_data_source_seq owned by fit_data_source.id; -- -- fit_data_type table setup -- drop sequence if exists fit_data_type_seq cascade; create sequence fit_data_type_seq; drop table if exists fit_data_type cascade; create table fit_data_type( id integer not null default nextval('fit_data_type_seq'), name varchar(255) unique not null, primary key(id) ); alter sequence fit_data_type_seq owned by fit_data_type.id; -- -- fit_data table setup -- drop sequence if exists fit_data_seq cascade; create sequence fit_data_seq; drop table if exists fit_data cascade; create table fit_data( id integer not null default nextval('fit_data_seq'), user_id integer not null, data_source_id integer not null, orig_data_source_id integer, data_type_id integer not null, value float, json_value jsonb, start_time timestamp with time zone not null, end_time timestamp with time zone not null, primary key(id), foreign key(user_id) references fit_user(id), foreign key(data_source_id) references fit_data_source(id), foreign key(orig_data_source_id) references fit_data_source(id), foreign key(data_type_id) references fit_data_type(id) ); alter sequence fit_data_seq owned by fit_data.id; -- -- Sync fit_data table trigger setup -- create or replace function sync_fit_data() returns trigger as $$ begin insert into fit_user(name) values(new.username) on conflict do nothing; insert into fit_data_source(name) values(new.data_source) on conflict do nothing; insert into fit_data_source(name) values(new.orig_data_source) on conflict do nothing; insert into fit_data_type(name) values(new.data_type) on conflict do nothing; insert into fit_data(user_id, data_source_id, orig_data_source_id, data_type_id, value, json_value, start_time, end_time) values( (select id from fit_user u where u.name = new.username), (select id from fit_data_source ds where ds.name = new.data_source), (select id from fit_data_source ds where ds.name = new.orig_data_source), (select id from fit_data_type dt where dt.name = new.data_type), new.value, new.json_value, new.start_time, new.end_time ); delete from tmp_fit_data where id = new.id; return new; end; $$ language 'plpgsql'; drop trigger if exists on_tmp_fit_data_insert on tmp_fit_data; create trigger on_tmp_fit_data_insert after insert on tmp_fit_data for each row execute procedure sync_fit_data(); -- -- vfit view definition drop view if exists vfit; create view vfit as select d.id , u.name as username , ds.name as data_source , ods.name as orig_data_source , dt.name as data_type , value , json_value , start_time , end_time from fit_data d join fit_user u on d.user_id = u.id join fit_data_source ds on d.data_source_id = ds.id left join fit_data_source ods on d.orig_data_source_id = ods.id join fit_data_type dt on d.data_type_id = dt.id; Head to the Google developers console and get your credentials JSON file: Google Fit developers console Run the following command to authorize platypush to access your Fit data:python -m platypush.plugins.google.credentials \ "https://www.googleapis.com/auth/fitness.activity.read https://www.googleapis.com/auth/fitness.body.read https://www.googleapis.com/auth/fitness.body_temperature.read https://www.googleapis.com/auth/fitness.location.read" \ /path/to/your/credentials.json \ --noauth_local_webserver With Platypush running, check the data sources that are available on your account:curl -XPOST \ -H "Authorization: Bearer $PP_TOKEN" \ -H 'Content-Type: application/json' -d ' { "type":"request", "action":"google.fit.get_data_sources" }' http://your-pi:8008/execute Take note of the dataStreamId attributes of the metrics that you want to monitor and add them to the configuration of the Google Fit backend:backend.google.fit: poll_seconds: 1800 data_sources: - derived:com.google.weight:com.google.android.gms:merge_weight - derived:com.google.calories.bmr:com.google.android.gms:merged - derived:com.google.distance.delta:com.google.android.gms:platform_distance_delta - derived:com.google.speed:com.google.android.gms:merge_speed - derived:com.google.step_count.delta:com.google.android.gms:merge_step_deltas - derived:com.google.heart_rate.bpm:com.google.android.gms:merge_heart_rate_bpm - derived:com.google.calories.expended:com.google.android.gms:from_activities - derived:com.google.calories.expended:com.google.android.gms:from_bmr - derived:com.google.calories.expended:com.google.android.gms:platform_calories_expended - derived:com.google.activity.segment:com.google.android.gms:platform_activity_segments - derived:com.google.activity.segment:com.google.android.gms:merge_activity_segments - derived:com.google.activity.segment:com.google.android.gms:session_activity_segment - derived:com.google.active_minutes:com.google.android.gms:merge_active_minutes Finally, create an event hook that inserts new data into your newly created tables:import datetime import json from platypush.event.hook import hook from platypush.utils import run from platypush.message.event.google.fit import GoogleFitEvent db_engine = 'postgresql+pg8000://pi:your-password@localhost/sensors' @hook(GoogleFitEvent) def on_home_push_data(event, **context): run('db.insert', engine=db_engine, table='tmp_fit_data', records=[{ 'username': event.user_id, 'data_source': event.data_source_id, 'orig_data_source': event.origin_data_source_id, 'data_type': event.data_type, 'value': event.values[0], 'json_value': json.dumps(event.values), 'start_time': datetime.datetime.fromtimestamp(event.start_time), 'end_time': datetime.datetime.fromtimestamp(event.end_time), }] ) Restart Platypush. You should soon start to see your fit data populating your tables. Data visualization and automatic alerting So now you’ve built your data pipeline to deliver system, sensor, mobile and fit data points to your local database and build automation on those events. But we all know that data collection is only half fun if we can’t visualize that data. Time to head to the Grafana dashboard we’ve installed and create some graphs! You can install Grafana on Debian/Ubuntu/Raspbian/RaspberryPi OS by adding the Grafana repository to your apt sources: wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add - echo "deb https://packages.grafana.com/oss/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list [sudo] apt-get update [sudo] apt-get install -y grafana # Enable and start the service [sudo] systemctl enable grafana-server [sudo] systemctl start grafana-server On Arch Linux the grafana package is instead provided in the default repository. Open http://your-pi-address:3000/ in your browser, create an admin user and add your database to the configuration as a PostgreSQL source. Creating dashboards and panels in Grafana is really straightforward. All you need is to specify the visualization type and the query that you want to run against your database. A simple panel that displays the steps walked per day and the active time would look like this: Grafana view Grafana also allows you to create alerts when some metrics go below/above a certain threshold or when there are no data points for a certain period of time. You can also connect such alerts back to platypush events by leveraging Platypush’s web hooks. Let’s see for example how to configure Grafana to send a notification to a Platypush custom web hook that sends a Pushbullet notification to your mobile device when the measurements from one of your gas sensors go above a certain threshold: Add a web hook script:from platypush.event.hook import hook from platypush.utils import run from platypush.message.event.http.hook import WebhookEvent @hook(WebhookEvent, hook='gas_alert') def on_gas_alert(event, **context): if event.state != 'ok': run('pushbullet.send_note', title=event.title, body='High concentration of gas detected!') This configuration will create a dynamic web hook that can be accessed through http://your-pi:8008/hook/gas_alert. Go to your Grafana dashboard, click on “Alerting” (bell icon on the right) -> Notifications channels and add your web hook: Grafana view Edit the panel that contains your gas sensor measurements, click on the bell icon and add an automatic alert whenever the value goes above a certain threshold: Grafana view You’ll receive a Pushbullet notification on your mobile device whenever there is an alert associated with your metric. If you’ve read the article so far you should have all the ingredients in place to do anything you want with your own data. This article tries its best to show useful examples but isn’t intended to be an exhaustive guide to everything that you can do by connecting a database, a data pipeline and an event and automation engine. I hope that I have provided you with enough inputs to stimulate your creativity and build something new :)
Fabio Manganiello
Triggering events based on the presence of people has been the dream of many geeks and DIY automation junkies for a while. Having your house to turn the lights on or off when you enter or exit your living room is an interesting application, for instance. Most of the solutions out there to solve these kinds of problems, even more high-end solutions like the Philips Hue sensors, detect motion, not actual people presence — which means that the lights will switch off once you lay on your couch like a sloth. The ability to turn off music and/or tv when you exit the room and head to your bedroom, without the hassle of switching all the buttons off, is also an interesting corollary. Detecting the presence of people in your room while you’re not at home is another interesting application. Thermal cameras coupled with deep neural networks are a much more robust strategy to actually detect the presence of people. Unlike motion sensors, they will detect the presence of people even when they aren’t moving. And unlike optical cameras, they detect bodies by measuring the heat that they emit in the form of infrared radiation, and are therefore much more robust — their sensitivity doesn’t depend on lighting conditions, on the position of the target, or the colour. Before exploring the thermal camera solution, I tried for a while to build a model that instead relied on optical images from a traditional webcam. The differences are staggering: I trained the optical model on more than ten thousands 640x480 images taken all through a week in different lighting conditions, while I trained the thermal camera model on a dataset of 900 24x32 images taken during a single day. Even with more complex network architectures, the optical model wouldn’t score above a 91% accuracy in detecting the presence of people, while the thermal model would achieve around 99% accuracy within a single training phase of a simpler neural network. Despite the high potential, there’s not much out there in the market — there’s been some research work on the topic (if you google “people detection thermal camera” you’ll mostly find research papers) and a few high-end and expensive products for professional surveillance. In lack of ready-to-go solutions for my house, I decided to take on my duty and build my own solution — making sure that it can easily be replicated by anyone. Prepare the hardware For this example we'll use the following hardware: A RaspberryPi (cost: around $35). In theory any model should work, but it’s probably not a good idea to use a single-core RaspberryPi Zero for machine learning tasks — the task itself is not very expensive (we’ll only use the Raspberry for doing predictions on a trained model, not to train the model), but it may still suffer some latency on a Zero. Plus, it may be really painful to install some of the required libraries (like Tensorflow or OpenCV) on the arm6 architecture used by the RaspberryPi Zero. Any better performing model (from RPi3 onwards) should definitely do the job. A thermal camera. For this project, I’ve used the MLX90640 Pimoroni breakout camera (cost: $55), as it’s relatively cheap, easy to install, and it provides good results. This camera comes in standard (55°) and wide-angle (110°) versions. I’ve used the wide-angle model as the camera monitors a large living room, but take into account that both have the same resolution (32x24 pixels), so the wider angle comes with the cost of a lower spatial resolution. If you want to use a different thermal camera there’s not much you’ll need to change, as long as it comes with a software interface for RaspberryPi and it’s compatible with Platypush. Setting up the MLX90640 on your RaspberryPi if you have a Breakout Garden it’s easy as a pie. Fit the Breakout Garden on top of your RaspberryPi. Fit the camera breakout into an I2C slot. Boot the RaspberryPi. Done. Otherwise, you can also connect the device directly to the RaspberryPi I2C interface, either using the right hardware PINs or the software emulation layer. Prepare the software I tested my code on Raspbian, but with a few minor modifications it should be easily adaptable to any distribution installed on the RaspberryPi. The software support for the thermal camera requires a bit of work. The MLX90640 doesn’t come (yet) with a Python ready-to-use interface, but a C++ open-source driver is provided - and that's the driver that is wrapped by the Platypush integration. Instructions to install it: # Install the dependencies [sudo] apt-get install libi2c-dev # Enable the I2C interface echo dtparam=i2c_arm=on | sudo tee -a /boot/config.txt # It's advised to configure the SPI bus baud rate to # 400kHz to support the higher throughput of the sensor echo dtparam=i2c1_baudrate=400000 | sudo tee -a /boot/config.txt # A reboot is required here if you didn't have the # options above enabled in your /boot/config.txt [sudo] reboot # Clone the driver's codebase git clone https://github.com/pimoroni/mlx90640-library cd mlx90640-library # Compile the rawrgb example make clean make bcm2835 make I2C_MODE=LINUX examples/rawrgb If it all went well you should see an executable named rawrgb under the examples directory. If you run it you should see a bunch of binary data — that’s the raw binary representation of the frames captured by the camera. Remember where it is located or move it to a custom bin folder, as it’s the executable that platypush will use to interact with the camera module. This post assumes that you have already installed and configured Platypush on your system. If not, head to my post on getting started with Platypush, the readthedocs page, the repository page or the wiki. Install also the Python dependencies for the HTTP server, the MLX90640 plugin and Tensorflow: [sudo] pip install 'platypush[http,tensorflow,mlx90640]' Tensorflow may also require some additional dependencies installable via apt-get: [sudo] apt-get install python3-numpy \ libatlas-base-dev \ libblas-dev \ liblapack-dev \ python3-dev \ gfortran \ python3-setuptools \ python3-scipy \ python3-h5py Heading to your computer (we'll be using it for building the model that will be used on the RaspberryPi), install OpenCV, Tensorflow and Jupyter and my utilities for handling images: # For image manipulation [sudo] pip install opencv # Install Jupyter notebook to run the training code [sudo] pip install jupyterlab # Then follow the instructions at https://jupyter.org/install # Tensorflow framework for machine learning and utilities [sudo] pip install tensorflow numpy matplotlib # Clone my repository with the image and training utilities # and the Jupyter notebooks that we'll use for training. git clone https://github.com/BlackLight/imgdetect-utils ~/projects/imgdetect-utils Capture phase Now that you’ve got all the hardware and software in place, it’s time to start capturing frames with your camera and use them to train your model. First, configure the MLX90640 plugin in your Platypush configuration file (by default, ~/.config/platypush/config.yaml): # Enable the webserver backend.http: enabled: True camera.ir.mlx90640: fps: 16 # Frames per second rotate: 270 # Can be 0, 90, 180, 270 rawrgb_path: /path/to/your/rawrgb Restart the service, and if you haven't already create a user from the web interface at http://your-rpi:8008. You should now be able to take pictures through the API: curl -XPOST \ -H "Authorization: Bearer $PP_TOKEN" \ -H 'Content-Type: application/json' -d ' { "type":"request", "action":"camera.ir.mlx90640.capture", "args": { "output_file":"~/snap.png", "scale_factor":20 } }' http://localhost:8008/execute If everything went well, the thermal picture should be stored under ~/snap.png. In my case it looks like this while I’m in standing front of the sensor: Thermal camera snapshot Notice the glow at the bottom-right corner — that’s actually the heat from my RaspberryPi 4 CPU. It’s there in all the images I take, and you may probably see similar results if you mounted your camera on top of the Raspberry itself, but it shouldn’t be an issue for your model training purposes. If you open the web panel (http://your-host:8008) you’ll also notice a new tab, represented by the sun icon, that you can use to monitor your camera from a web interface. Thermal camera web panel screenshot You can also monitor the camera directly outside of the webpanel by pointing your browser to http://your-host:8008/camera/ir/mlx90640/stream?rotate=270&scale_factor=20. Now add a cronjob to your config.yaml to take snapshots every minute: cron.ThermalCameraSnapshotCron: cron_expression: '* * * * *' actions: - action: camera.ir.mlx90640.capture args: output_file: "${__import__(’datetime’).datetime.now().strftime(’/home/pi/datasets/people_detect/images/%Y-%m-%d_%H-%M-%S.jpg’)}" grayscale: true Or directly as a Python script under e.g. ~/.config/platypush/thermal.py (make sure that ~/.config/platypush/__init__.py also exists so the folder is recognized as a Python module): from datetime import datetime from platypush.cron import cron from platypush.utils import run @cron('* * * * *') def take_thermal_picture(**context): run('camera.ir.mlx90640.capture', grayscale=True, output_file=datetime.now().strftime('/home/pi/datasets/people_detect/images/%Y-%m-%d_%H-%m-%S.jpg')) The images will be stored under /home/pi/datasets/people_detect/images (make sure that the directory exists before starting the service) in the format YYYY-mm-dd_HH-MM-SS.jpg. No scale factor is applied — even if the images will be tiny we’ll only need them to train our model. Also, we’ll convert the images to grayscale — the neural network will be lighter and actually more accurate, as it will only have to rely on one variable per pixel without being tricked by RGB combinations. Restart Platypush and verify that every minute a new picture is created under your images directory. Let it run for a few hours or days until you’re happy with the number of samples. Try to balance the numbers of pictures with no people in the room and those with people in the room, trying to cover as many cases as possible — e.g. sitting, standing in different points of the room etc. As I mentioned earlier, in my case I only needed less than 1000 pictures with enough variety to achieve accuracy levels above 99%. Labelling phase Once you’re happy with the number of samples you’ve taken, copy the images over to the machine you’ll be using to train your model (they should be all small JPEG files weighing under 500 bytes each). Copy them to your local machine: BASEDIR=~/datasets/people_detect mkdir -p "$BASEDIR" # Copy the images scp -r pi@raspberry:/home/pi/datasets/people_detect ~ IMGDIR="$BASEDIR/images" # This directory will contain the raw numpy training # data parsed from the images (useful if you want to # re-train the model without having to reprocess all # the images) DATADIR="$BASEDIR/data" mkdir -p "$IMGDIR" mkdir -p "$DATADIR" # Create the labels for the images. Each label is a # directory under $IMGDIR mkdir "$IMGDIR/negative" mkdir "$IMGDIR/positive" Once the images have been copied, and the directories for the labels created, run the label.py script provided in the repository to interactively label the images: UTILS_DIR=~/projects/imgdetect-utils cd "$UTILS_DIR" python utils/label.py -d "$IMGDIR" --scale-factor 10 Each image will open in a new window and you can label it by typing either 1 (negative) or 2 (positive) - the label names are gathered from the names of the directories you created at the previous step: Thermal camera pictures labelling At the end of the procedure the negative and positive directories under the images directory should have been populated. Training phase Once we’ve got all the labelled images it’s time to train our model. A train.ipynb Jupyter notebook is provided under notebooks/ir and it should be relatively self-explanatory: import os import numpy as np import matplotlib.pyplot as plt import tensorflow as tf from tensorflow import keras from tensorflow.keras.preprocessing.image import ImageDataGenerator # Define the dataset directory - replace it with the path on your local # machine where you have stored the previously labelled dataset. dataset_dir = os.path.join(os.path.expanduser('~'), 'datasets', 'people_detect') # Define the size of the input images. In the case of an # MLX90640 it will be (24, 32) for horizontal images and # (32, 24) for vertical images image_size = (32, 24) # Image generator batch size batch_size = 64 # Number of training epochs epochs = 5 # Instantiate a generator that puts 30% of the images into the validation set # and normalizes their pixel values between 0 and 1 generator = ImageDataGenerator(rescale=1./255, validation_split=0.3) train_data = generator.flow_from_directory(dataset_dir, target_size=image_size, batch_size=batch_size, subset='training', class_mode='categorical', color_mode='grayscale') test_data = generator.flow_from_directory(dataset_dir, target_size=image_size, batch_size=batch_size, subset='validation', class_mode='categorical', color_mode='grayscale') After initializing the generators, let's take a look at a sample of 25 images from the training set together with their labels: index_to_label = { index: label for label, index in train_data.class_indices.items() } plt.figure(figsize=(10, 10)) batch = train_data.next() for i in range(min(25, len(batch[0]))): img = batch[0][i] label = index_to_label[np.argmax(batch[1][i])] plt.subplot(5, 5, i+1) plt.xticks([]) plt.yticks([]) plt.grid(False) # Note the np.squeeze call - matplotlib can't # process grayscale images unless the extra # 1-sized dimension is removed. plt.imshow(np.squeeze(img)) plt.xlabel(label) plt.show() You should see an image like this: Thermal camera pictures labelling Let's now declare a model and train it on the given training set: model = keras.Sequential([ # Layer 1: flatten the input images keras.layers.Flatten(input_shape=image_size), # Layer 2: fully-connected layer with 80% the neurons as the input images # and RELU activation function keras.layers.Dense(round(0.8 * image_size[0] * image_size[1]), activation=tf.nn.relu), # Layer 2: fully-connected layer with 30% the neurons as the input images # and RELU activation function keras.layers.Dense(round(0.3 * image_size[0] * image_size[1]), activation=tf.nn.relu), # Layer 3: fully-connected layer with as many units as the output labels # and Softmax activation function keras.layers.Dense(len(train_data.class_indices), activation=tf.nn.softmax) ]) # Compile the model for classification, use the Adam optimizer and pick # accuracy as optimization metric model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy']) # Train the model in batches history = model.fit( train_data, steps_per_epoch=train_data.samples/batch_size, validation_data=test_data, validation_steps=test_data.samples/batch_size, epochs=epochs ) # Example output: # Epoch 1/5 loss: 0.2529 - accuracy: 0.9196 - val_loss: 0.0543 - val_accuracy: 0.9834 # Epoch 2/5 loss: 0.0572 - accuracy: 0.9801 - val_loss: 0.0213 - val_accuracy: 0.9967 # Epoch 3/5 loss: 0.0254 - accuracy: 0.9915 - val_loss: 0.0080 - val_accuracy: 1.0000 # Epoch 4/5 loss: 0.0117 - accuracy: 0.9979 - val_loss: 0.0053 - val_accuracy: 0.9967 # Epoch 5/5 loss: 0.0058 - accuracy: 1.0000 - val_loss: 0.0046 - val_accuracy: 0.9983 We can now see how the accuracy of the model progressed over the iteration: epochs = history.epoch accuracy = history.history['accuracy'] fig = plt.figure() plot = fig.add_subplot() plot.set_xlabel('epoch') plot.set_ylabel('accuracy') plot.plot(epochs, accuracy) The output should look like this: Thermal camera pictures labelling By constraining the problem properly (i.e. translating "detect people in an image" to "infer the presence of people by telling if there are more white halos than usual in a small grayscale image") we have indeed managed to achieve high levels of accuracy both on the training and validation set despite using a relatively small dataset. Deploying the model Once you are happy with the model, it's time to save it so it can be deployed to your RaspberryPi for real-time predictions: def model_save(model, target, labels=None, overwrite=True): import json import pathlib # Check if we should save it like a .h5/.pb file or as a directory model_dir = pathlib.Path(target) if str(target).endswith('.h5') or \ str(target).endswith('.pb'): model_dir = model_dir.parent # Create the model directory if it doesn't exist pathlib.Path(model_dir).mkdir(parents=True, exist_ok=True) # Save the Tensorflow model using the .save method model.save(target, overwrite=overwrite) # Save the label names of your model in a separate JSON file if labels: labels_file = os.path.join(model_dir, 'labels.json') with open(labels_file, 'w') as f: f.write(json.dumps(list(labels))) model_dir = os.path.expanduser('~/models/people_detect') model_save(model, model_dir, labels=train_data.class_indices.keys(), overwrite=True) If you managed to execute the whole notebook then you’ll have your model saved under ~/models/people_detect. You can now copy it over to the RaspberryPi and use it to do predictions (first create ~/models on the RaspberryPi if it's not available already): scp -r ~/models/people_detect pi@raspberry:/home/pi/models Detect people in the room Once the Tensorflow model has been deployed to the RaspberryPi you can quickly test how it performs against some pictures taken on the device using the tensorflow.predict method: curl -XPOST \ -H "Authorization: Bearer $PP_TOKEN" \ -H 'Content-Type: application/json' -d ' { "type":"request", "action":"tensorflow.predict", "args": { "inputs": "~/datasets/people_detect/positive/some_image.jpg", "model": "~/models/people_detect" } }' http://your-raspberry-pi:8008/execute Expected output: { "id": "", "type": "response", "target": "http", "origin": "raspberrypi", "response": { "output": { "model": "~/models/people_detect", "outputs": [ { "negative": 0, "positive": 1 } ], "predictions": [ "positive" ] }, "errors": [] } } Once the structure of the response is clear, we can replace the previous cronjob that stores pictures at regular intervals with a new one that captures pictures and feeds them to the previously trained model to make predictions (I'll use a Python script stored under ~/.config/platypush/scripts in this case, but it will also work with a cron defined in YAML in config.yaml) and, for example, turns on the lights when presence is detected and turns them off when presence is no longer detected (I'll use the light.hue plugin in this example): import os from platypush.context import get_plugin from platypush.cron import cron @cron('* * * * * */30') def check_presence(**context): # Get plugins by name camera = get_plugin('camera.ir.mlx90640') tensorflow = get_plugin('tensorflow') lights = get_plugin('light.hue') image_file = '/tmp/frame.jpg' model_file = os.path.expanduser('~/models/people_detect/saved_model.h5') camera.capture_image( image_file=image_file, grayscale=True) prediction = tensorflow.predict( inputs=image_file, model=model_file)['predictions'][0] if prediction == 'positive': lights.on() else: lights.off() Restart the service and let it run. Every 30 seconds the cron will run, take a picture, check if people are detected in that picture and turn the lights on/off accordingly. What's next? That’s your call! Feel free to experiment with more elaborate rules, for example to change the status of the music/video playing in the room when someone enters, using Platypush media plugins. Or say a custom good morning text when you first enter the room in the morning. Or build your own surveillance system to track the presence of people when you’re not at home. Or enhance the model to detect also the number of people in the room, not only the presence. Or you can combine it with an optical flow sensor, distance sensor, laser range sensor or optical camera (platypush provides plugins for many of them) to build an even more robust system that also detects and tracks movements or proximity to the sensor, and so on.
Fabio Manganiello
My dream of a piece of software that you could simply talk to and get things done started more than 10 years ago, when I was still a young M.Sc student who imagined getting common tasks done on my computer through the same kind of natural interaction you see between Dave and HAL 9000 in 2001: A Space Odyssey. Together with a friend I developed Voxifera way back in 2008. Although the software worked well enough for basic tasks, as long as it was always me to provide the voice commands and as long as the list of custom voice commands was below 10 items, Google and Amazon in the latest years have gone way beyond what an M.Sc student alone could do with fast-Fourier transforms and Markov models. When years later I started building Platypush, I still dreamed of the same voice interface, leveraging the new technologies, while not being caged by the interactions natively provided by those commercial assistants. My goal was still to talk to my assistant and get it to do whatever I wanted to, regardless of the skills/integrations supported by the product, regardless of whichever answer its AI was intended to provide for that phrase. And, most of all, my goal was to have all the business logic of the actions to run on my own device(s), not on someone else’s cloud. I feel like by now that goal has been mostly accomplished (assistant technology with 100% flexibility when it comes to phrase patterns and custom actions), and today I’d like to show you how to set up your own Google Assistant on steroids as well with a Raspberry Pi, microphone and Platypush. I’ll also show how to run your custom hotword detection models through the Snowboy integration, for those who wish greater flexibility when it comes to how to summon your digital butler besides the boring “Ok Google” formula, or those who aren’t that happy with the idea of having Google to constantly listen to everything that is said in the room. For those who are unfamiliar with Platypush, I suggest reading my previous article on what it is, what it can do, why I built it and how to get started with it. Context and expectations First, a bit of context around the current state of the assistant integration (and the state of the available assistant APIs/SDKs in general). My initial goal was to have a voice assistant that could: Continuously listen through an audio device for a specific audio pattern or phrase and process the subsequent voice requests. Support multiple models for the hotword, so that multiple phrases could be used to trigger a request process, and optionally one could even associate a different assistant language to each hotword. Support conversation start/end actions even without hotword detection — something like “start listening when I press a button or when I get close to a distance sensor”. Provide the possibility to configure a list of custom phrases or patterns (ideally through regular expressions) that, when matched, would run a custom pre-configured task or list of tasks on the executing device, or on any device connected through it. If a phrase doesn’t match any of those pre-configured patterns, then the assistant would go on and process the request in the default way (e.g. rely on Google’s “how’s the weather?” or “what’s on my calendar?” standard response). Basically, I needed an assistant SDK or API that could be easily wrapped into a library or tiny module, a module that could listen for hotwords, start/stop conversations programmatically, and return the detected phrase directly back to my business logic if any speech was recognized. I eventually decided to develop the integration with the Google Assistant and ignore Alexa because: Alexa’s original sample app for developers was a relatively heavy piece of software that relied on a Java backend and a Node.js web service. In the meantime Amazon has pulled the plug off that original project. The sample app has been replaced by the Amazon AVS (Alexa Voice Service), which is a C++ service mostly aimed to commercial applications and doesn’t provide a decent quickstart for custom Python integrations. There are few Python examples for the Alexa SDK, but they focus on how to develop a skill. I’m not interested in building a skill that runs on Amazon’s servers — I’m interested in detecting hotwords and raw speech on any device, and the SDK should let me do whatever I want with that. I eventually opted for the Google Assistant library, but that has recently been deprecated with short notice, and there’s an ongoing discussion of which will be the future alternatives. However, the voice integration with Platypush still works, and whichever new SDK/API Google will release in the near future I’ll make sure that it’ll still be supported. The two options currently provided are: If you’re running Platypush on an x86/x86_64 machine or on a Raspberry Pi earlier than the model 4 (except for the Raspberry Pi Zero, since it’s based on ARM6 and the Assistant library wasn’t compiled it for it), you can still use the assistant library — even though it’s not guaranteed to work against future builds of the libc, given the deprecated status of the library. Otherwise, you can use the Snowboy integration for hotword detection together with Platypush’ s wrapper around the Google push-to-talk sample for conversation support. In this article we’ll see how to get started with both the configurations. Installation and configuration First things first: in order to get your assistant working you’ll need: An x86/x86_64/ARM device/OS compatible with Platypush and either the Google Assistant library or Snowboy (tested on most of the Raspberry Pi models, Banana Pis and Odroid, and on ASUS Tinkerboard). A microphone. Literally any Linux-compatible microphone would work. I’ll also assume that you have already installed Platypush on your device — the instructions are provided on the Github page, on the wiki and in my previous article. Follow these steps to get the assistant running: Install the required dependencies:# To run the Google Assistant hotword service + speech detection # (it won't work on RaspberryPi Zero and arm6 architecture) [sudo] pip install 'platypush[google-assistant-legacy]' # To run the just the Google Assistant speech detection and use # Snowboy for hotword detection [sudo] pip install 'platypush[google-assistant]' Follow these steps to create and configure a new project in the Google Console and download the required credentials files. Generate your user’s credentials file for the assistant to connect it to your account: export CREDENTIALS_FILE=~/.config/google-oauthlib-tool/credentials.json google-oauthlib-tool --scope https://www.googleapis.com/auth/assistant-sdk-prototype \ --scope https://www.googleapis.com/auth/gcm \ --save --headless --client-secrets $CREDENTIALS_FILE Open the prompted URL in your browser, log in with your Google account if needed and then enter the prompted authorization code in the terminal. The above steps are common both for the Assistant library and the Snowboy+push-to-talk configurations. Let’s now tackle how to get things working with the Assistant library, provided that it still works on your device. Google Assistant library Enable the Google Assistant backend (to listen to the hotword) and plugin (to programmatically start/stop conversations in your custom actions) in your Platypush configuration file (by default ~/.config/platypush/config.yaml):backend.assistant.google: enabled: True assistant.google: enabled: True Refer to the official documentation to check the additional initialization parameters and actions provided by the assistant backend and plugin. Restart Platypush and keep an eye on the output to check that everything is alright. Oh, and also double check that your microphone is not muted. Just say “OK Google” or “Hey Google”. The basic assistant should work out of the box. Snowboy + Google Assistant library Follow the steps in the next section if the Assistant library doesn’t work on your device (in most of the cases you’ll see a segmentation fault if you try to import it caused by a mismatching libc version), or if you want more options when it comes to supported hotwords, and/or you don’t like the idea of having Google to constantly listen all of your conversation to detect when you say the hotword. # Install the Snowboy dependencies [sudo] pip install 'platypush[hotword]' Go to the Snowboy home page, register/login and then select the hotword model(s) you like. You’ll notice that before downloading a model you’ll be asked to provide three voice sample of yours saying the hotword — a good idea to keep voice models free while getting everyone to improve them. Configure the Snowboy backend and the Google push-to-talk plugin in your Platypush configuration. Example: backend.assistant.snowboy: audio_gain: 1.0 models: computer: voice_model_file: ~/path/models/computer.umdl assistant_plugin: assistant.google.pushtotalk assistant_language: it-IT detect_sound: ~/path/sounds/sound1.wav sensitivity: 0.45 ok_google: voice_model_file: ~/path/models/OK Google.pmdl assistant_plugin: assistant.google.pushtotalk assistant_language: en-US detect_sound: ~/path/sounds/sound2.wav sensitivity: 0.42 assistant.google.pushtotalk: language: en-US A few words about the configuration tweaks: Tweak audio_gain to adjust the gain of your microphone (1.0 for a 100% gain). model will contain a key-value list of the voice models that you want to use. For each model you’ll have to specify its voice_model_file (downloaded from the Snowboy website), which assistant_plugin will be used (assistant.google.pushtotalk in this case), the assistant_language code, i.e. the selected language for the assistant conversation when that hotword is detected (default: en-US), an optional detect_sound, a WAV file that will be played when a conversation starts, and the sensitivity of that model, between 0 and 1 — with 0 meaning no sensitivity and 1 very high sensitivity (tweak it to your own needs, but be aware that a value higher than 0.5 might trigger more false positives). The assistant.google.pushtotalk plugin configuration only requires the default assistant language to be used. Refer to the official documentation for extra initialization parameters and methods provided by the Snowboy backend and the push-to-talk plugin. Restart Platypush and check the logs for any errors, then say your hotword. If everything went well, an assistant conversation will be started when the hotword is detected. Create custom events on speech detected So now that you’ve got the basic features of the assistant up and running, it’s time to customize the configuration and leverage the versatility of Platypush to get your assistant to run whatever you like through when you say whichever phrase you like. You can create event hooks for any of the events triggered by the assistant — among those, SpeechRecognizedEvent, ConversationStartEvent, HotwordDetectedEvent, TimerEndEvent etc., and those hooks can run anything that has a Platypush plugin. Let’s see an example to turn on your Philips Hue lights when you say “turn on the lights”: event.hook.AssistantTurnLightsOn: if: type: platypush.message.event.assistant.SpeechRecognizedEvent phrase: "turn on (the)? lights?" then: - action: light.hue.on You’ll also notice that the answer of the assistant is suppressed if the detected phrase matches an existing rule, but if you still want the assistant to speak a custom phrase you can use the tts or tts.google plugins: event.hook.AssistantTurnOnLightsAnimation: if: type: platypush.message.event.assistant.SpeechRecognizedEvent phrase: "turn on (the)? animation" then: - action: light.hue.animate args: animation: color_transition transition_seconds: 0.25 - action: tts.say args: text: Enjoy the light show You can also programmatically start a conversation without using the hotword to trigger the assistant. For example, this is a rule that triggers the assistant whenever you press a Flic button: event.hook.FlicButtonStartConversation: if: type: platypush.message.event.button.flic.FlicButtonEvent btn_addr: 00:11:22:33:44:55 sequence: - ShortPressEvent then: - action: assistant.google.start_conversation # or: # - action: assistant.google.pushtotalk.start_conversation Additional win: if you have configured the HTTP backend and you have access to the web panel or the dashboard then you’ll notice that the status of the conversation will also appear on the web page as a modal dialog, where you’ll see when a hotword has been detected, the recognized speech and the transcript of the assistant response. That’s all you need to know to customize your assistant — now you can for instance write rules that would blink your lights when an assistant timer ends, or programmatically play your favourite playlist on mpd/mopidy when you say a particular phrase, or handle a home made multi-room music setup with Snapcast+platypush through voice commands. As long as there’s a platypush plugin to do what you want to do, you can do it already. Live demo A TL;DR video with a practical example: In this video: Using Google Assistant basic features ("how's the weather?") with the "OK Google" hotword (in English) Triggering a conversation in Italian when I say the "computer" hotword instead Support for custom responses through the Text-to-Speech plugin Control the music through custom hooks that leverage mopidy as a backend (and synchronize music with devices in other rooms through the Snapcast plugin) Trigger a conversation without hotword - in this case I defined a hook that starts a conversation when something approaches a distance sensor on my Raspberry Take pictures from a camera on another Raspberry and preview them on the screen through platypush' camera plugins, and send them to mobile devices through the Pushbullet or AutoRemote plugins All the conversations and responses are visually shown on the platypush web dashboard