Turning a Raspberry Pi 2 Zero into a HomeKit-capable video doorbell

In order to fit a video doorbell into an existing doorbell plate of my parents’ old house with no power supply except the bell transformer (12 V AC) that also powers the electric door opener, I decided to custom build a video doorbell using a small Raspberry Pi 2 Zero W. The requirements were as follows:

  • Video/audio stream of the door in sufficient quality, resolution and framerate
  • Audio backchannel to be able to speak with the door
  • Smart phone notification plus a sufficiently loud auditory signal when someone presses the doorbell
  • Easily accessible way to remotely activate the door opener
  • Intuitive usability

Since my parents are both Mac and iOS users, I opted for a HomeKit compatible solution. This is the hardware I used:

After soldering the GPIO pins to the Raspberry and flashing the SD card using the Raspberry Pi Imager (despite Raspberry Pi OS Bookworm being released, it still recommends the 32 bit Bullseye version for the Pi 2 Zero, so I went with that; also I made sure to preconfigure the wifi and enable SSH), connecting the camera to the CSI connector and hooking up USB microphone and speaker, we boot up the Raspberry and logged in using SSH. We first run

sudo apt-get update
sudo apt-get dist-upgrade

to make sure we get the latest software and security updates. We then use sudo raspi-config to disable the graphical user interface and enable auto-login on the console instead.

After this, we can install NodeJS and NPM using

sudo apt-get update && sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=20
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update && sudo apt-get install nodejs gcc g++ make python net-tools -y
sudo npm install -g npm

Then we install Homebridge using

sudo npm install -g --unsafe-perm homebridge homebridge-config-ui-x

and install the Homebridge service:

sudo hb-service install --user homebridge

If everything worked, on the command line there should be a link to the Homebridge web interface. Within the Homebridge interface, we then install two plugins: Homebridge Camera FFmpeg (for the camera, return audio and notification functionality) and Homebridge Cmdtriggerswitch2 (for a HomeKit switch that triggers the door opener relais).

The way HB Camera FFmpeg works is it uses ffmpeg to transform any video/audio input into a HomeKit compatible video stream. Since the Pi’s CPU is quite limited but it has a H264 hardware encoder, it is wise to use it. To create a UDP audio video stream using the Pi’s hardware encoder, we can use libcamera-vid (rpicam-vid in newer versions of Raspberry Pi OS).

For audio, the Camera FFmpeg plugin relies on ffmpeg being compiled with libfdk-aac support which the ffmpeg in the Raspberry Pi OS repository is not. There is a Github project called ffmpeg-for-homebridge that provides a precompiled version of ffmpeg with libfdk-aac, but that version lacks support for PulseAudio or ALSA which we need in order to output the return audio on the Pi’s USB speaker. So unfortunately, there is no way around compiling our own version of ffmpeg. First, we need to get the dependencies for the compilation process:

sudo apt-get install pkg-config autoconf automake libtool yasm libx264-dev libmp3lame-dev libpulse-dev

Then we need to download the source codes for libfdk-aac and ffmpeg:

git clone git://source.ffmpeg.org/ffmpeg.git ffmpeg
git clone https://github.com/mstorsjo/fdk-aac.git ffmpeg/fdk-aac

To compile aac, we use

cd /home/pi/ffmpeg/fdk-aac
./autogen.sh
./configure --enable-shared --enable-static
sudo make
sudo make install
sudo ldconfig

To compile ffmpeg, we use:

cd /home/pi/ffmpeg
./configure --enable-libx264 --enable-gpl --enable-libmp3lame --enable-libfdk-aac --enable-nonfree --enable-libpulse
sudo make
sudo make install

My first compilation attempt failed after several hours because I was using make -j4 to speed up the compilation using all four CPU cores. But since the Pi 2 Zero only has 512 MB of memory, the compilation threw me an error in the very final linking process. Recompiling everything with a simple make took a bit longer (around 2-3 hours) but worked in the end.

Now, we can create an executable script to start the video stream in the home directory of the pi user called camstream.sh with the following content:

#!/bin/bash
while :
do
	libcamera-vid -t 1200000 --framerate 20 --bitrate 262144 --codec libav --libav-format mpegts --libav-audio --audio-codec libopus --audio-bitrate 32768 -o udp://127.0.0.1:8554
done

This infinite loop starts a UDP video livestream of the CSI camera and the USB microphone that can be accessed on localhost:8554. It lasts for 20 minutes (-t 1200000 stands for 1 200 000 milliseconds = 20 minutes) and then restarts it right after it stopped. Why not use -t 0 for an infinite stream? Because in my tests, the longer the stream ran, the further the audio started lagging behind the video – after six hours of continuous streaming there was a delay of almost ten seconds which is unacceptable for two-way communication. After only 20 minutes, the delay is still neglectable. Also, the restart only takes around two seconds which, should you just happen to watch the livestream at the very moment of the restart, results in an almost unnoticable two-second pausing of the video which then gets resumed instantly.

The video is encoded using the Pi’s hardware H264 encoder at a bitrate of 256 kbit/s, for the audio I used Opus with 32 kbit/s. I found this to be a good compromise between performance, quality and streaming resources.

Now we need to connect the relay to the Pi’s GPIOs: It needs a 5V (+), a GND (-) and a GPIO port for the signal (S), in my case 17. Let’s make sure GPIO 17 is configured as an output:

raspi-gpio set 17 op

Now we should be able to control the relay on the command line by replacing op with dl and dh in the above command respectively. To make sure the GPIO gets set to output at boot, we add the above command into the pi user’s .bashrc. Also, we add

./camstream.sh &

To .bashrc to make sure the camera stream is started at boot.

Using the Homebridge UI, we can now configure our plugins. Here is my full configuration file:

{
    "bridge": {
        "name": "Homebridge XXXX",
        "username": "XX:XX:XX:XX:XX:XX",
        "port": xxxxx,
        "pin": "xxx-xx-xxx",
        "advertiser": "avahi"
    },
    "accessories": [
        {
            "name": "Door Opener",
            "stateful": false,
            "logCmd": true,
            "onCmd": "raspi-gpio set 17 dh",
            "offCmd": "raspi-gpio set 17 dl",
            "delay": 5000,
            "accessory": "CmdTriggerSwitch2"
        }
    ],
    "platforms": [
        {
            "name": "Config",
            "port": 8581,
            "platform": "config"
        },
        {
            "name": "Camera FFmpeg",
            "porthttp": "8080",
            "videoProcessor": "/usr/local/bin/ffmpeg",
            "cameras": [
                {
                    "name": "DoorCamera",
                    "doorbell": true,
                    "motionDoorbell": false,
                    "unbridge": true,
                    "videoConfig": {
                        "source": "-i udp://127.0.0.1:8554 -c:a copy -c:v copy",
                        "returnAudioTarget": "-f alsa plughw:CARD=UACDemoV10,DEV=0",
                        "maxWidth": 0,
                        "maxHeight": 0,
                        "maxFPS": 0,
                        "audio": true,
                        "debug": true,
                        "debugReturn": true
                    }
                }
            ],
            "platform": "Camera-ffmpeg"
        }
    ]
}

The door opener is pretty straightforward. It uses a stateless switch (meaning it returns to its off position after a delay time of 5 seconds). Switched on, it gives GPIO 17 a high voltage, switched back off, it returns the voltage to low.

For the camera, we have to ensure that the video processor is set to /usr/local/bin/ffmpeg so it uses the self-compiled version. Using porthttp, we also set up a light web server that is later used to trigger the doorbell notification. Unbridging the device might seem inconvenient at first, but it apparently improves performance significantly. For the video source, we use our UDP stream and tell ffmpeg to not reencode but just copy it (-c:a copy -c:v copy). The returnAudioTarget, although still experimental, works quite well. We just have to tell ffmpeg to output the return audio stream directly on the USB speaker using ALSA. To find out the CARD and DEVICE names, we can use

aplay -L

on the command line. Using plughw ensures the necessary audio conversion is being handled at the driver level.

The only thing missing now is to monitor the GPIO pin (in my case 4) that the doorbell switch is connected to and to trigger the alarm. For this, we use Python and the RPi.GPIO library. Let’s create a script called monitor.py:

#!/usr/bin/env python3

import time
import RPi.GPIO as GPIO
import requests

BUTTON_GPIO = 4

if __name__ == '__main__':
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(BUTTON_GPIO, GPIO.IN, pull_up_down=GPIO.PUD_UP)

    pressed = False

    while True:
        if not GPIO.input(BUTTON_GPIO):
            if not pressed:
                print("Klingel!")
                x = requests.get('http://localhost:8080/doorbell?DoorCamera')
                print(x.text)
                pressed = True
                time.sleep(5.0)
        else:
            pressed = False
        time.sleep(0.1)

This interrupt executes the indented code as soon as the switch is pressed: It passes a HTTP request to the ffmpeg web server that triggers a HomeKit push notification. It then sleeps for five seconds to prevent excessive ringing before it resumes its monitoring activity.

Let’s check if the Python libraries are installed correctly:

sudo apt-get install python3-rpi.gpio 

We can now add

python3 monitor.py &

to .bashrc. After rebooting, the Doorbell Pi should do its job. The only thing left to do is to add it to Homekit. For this, we can follow the instructions in the Homebridge UI. Since the camera is not bridged, we might have to add it manually by entering the xxx-xx-xxx code that is being displayed in the Homebridge log at start under “add device/ further options…” in the Home app.

Hint: Use watchdog service

To ensure a continuous service of the doorbell and automatic rebooting in case of freezing, I would recommend to install the Raspberry watchdog service on the Doorbell Pi. A good tutorial can be found here.

Bonus feature: Louder doorbell chime

The HomeKit doorbell chime is quite short and quiet. Of course we could use HomeKit Automations to use the door notification to trigger some other HomeKit device (for instance, let a HomePod mini play a certain audio file from the Music Library).

But I still had an unused Pimoroni Pirate Radio that I now repurposed to play a configurable doorbell chime. You can also use any other spare Pi together with a USB speaker, even really old ones since the task of playing a chime does not use a lot of resources. But the Pimoroni already came with a speaker built-in, so that was handy.

I installed a lighttpd server with PHP on the Pimoroni and because I was lazy, let ChatGPT build me the following frontend:

<?php

$chimesDirectory = '/var/www/html/chimes';
$chimeConfigFile = '/var/www/html/chimeconfig.txt';

function readConfig() {
    global $chimeConfigFile;
    $config = @file_get_contents($chimeConfigFile);
    if ($config) {
        return json_decode($config, true);
    }
    return ['chime' => '', 'volume' => 50]; // Default configuration
}

function writeConfig($config) {
    global $chimeConfigFile;
    file_put_contents($chimeConfigFile, json_encode($config));
}

function playChime($config) {
/*    shell_exec('amixer set PCM ' . escapeshellarg($config['volume'] . '%'));
    if ($config['chime']) {
        $command = 'play ' . escapeshellarg($GLOBALS['chimesDirectory'] . '/' . $config['chime']) . ' -t alsa 2>&1';
        shell_exec($command);
    } */

    $volumeSetCommand = 'amixer set PCM ' . escapeshellarg($config['volume'] . '%');
    $playCommand = 'play ' . escapeshellarg($GLOBALS['chimesDirectory'] . '/' . $config['chime']) . ' -t alsa 2>&1';

    echo "<p>Setting volume: " . shell_exec($volumeSetCommand) . "</p>";
    echo "<p>Playing chime: " . shell_exec($playCommand) . "</p>";

}

if (isset($_GET['ring']) || isset($_POST['test'])) {
    playChime(readConfig());
    return;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && !isset($_POST['test'])) {
    // Handle file upload
    if (isset($_FILES['newChime']) && $_FILES['newChime']['error'] == UPLOAD_ERR_OK) {
        $uploadFilename = $_FILES['newChime']['name'];
        move_uploaded_file($_FILES['newChime']['tmp_name'], $chimesDirectory . '/' . $uploadFilename);
    }

    // Update configuration
    $config = readConfig();
    $config['chime'] = $_POST['selectedChime'] ?? $config['chime'];
    $config['volume'] = $_POST['volume'] ?? $config['volume'];
    writeConfig($config);
}

?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chime Configuration</title>
    <style>
        body { font-family: Tahoma, sans-serif; margin: 0; padding: 0; background-color: #f4f4f4; }
        .container { width: 80%; margin: 0 auto; padding: 20px; background-color: #fff; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); }
        h1 { color: #333; }
        form { margin-top: 20px; }
        select, input[type="range"], input[type="file"], .button { margin-bottom: 10px; width: 100%; padding: 10px; }
        .button { background-color: #4CAF50; color: white; cursor: pointer; }
        .button:hover { background-color: #45a049; }
    </style>
</head>
<body>
<div class="container">
    <h1>Chime Configuration</h1>
    <form action="" method="post" enctype="multipart/form-data">
        <label>Select a Chime:</label>
        <select name="selectedChime">
            <?php
            $config = readConfig();
            $chimeFiles = array_filter(scandir($chimesDirectory), function ($file) {
                return in_array(pathinfo($file, PATHINFO_EXTENSION), ['mp3', 'm4a']);
            });

            foreach ($chimeFiles as $file) {
                $selected = ($file === $config['chime']) ? 'selected' : '';
                echo '<option value="' . htmlspecialchars($file) . '" ' . $selected . '>' . htmlspecialchars($file) . '</option>';
            }
            ?>
        </select>

        <label>Upload New Chime:</label>
        <input type="file" name="newChime">

        <label>Set Volume (1-100):</label>
        <input type="range" name="volume" min="1" max="100" value="<?php echo htmlspecialchars($config['volume']); ?>">

        <input type="submit" value="Save" class="button">
        <button type="button" onclick="window.open('?ring', '_blank')" class="button">Test configuration (save first!)</button>
    </form>
</div>
</body>
</html>

Now, calling http://ringelingeling in my browser, I can upload my own doorbell chimes and control their volume. A HTTP request to http://ringelingeling?ring causes the configured doorbell chime to be rung. I could simply add this request to the Python script above and get a proper, configurable doorbell chime.

Leave a Reply

Your email address will not be published. Required fields are marked *