I have a super-annoying doorbell in my flat, and my cats hate it. Even when it’s on the lowest volume, they panic and hide as soon as it goes off…

Initial Plans

Luckily, the doorbell is powered using a 9V battery and only two cables for the trigger are connected to the actual doorbell module on our wall, using screw terminal blocks.

Judging from this, it should be possible to remove those cables and put them onto custom hardware which then does some magic to notify me using my phone.

My first thought was to write an iOS app, which can receive push notifications from the hardware, and initially I planned to build this using an ESP32 (because I already have some of those) but it turns out this wasn’t a good plan for multiple reasons:

  • The ESP32 is way too underpowered to send Apple push notifications which uses modern encryption with HTTP/2
  • The iOS app I would build to receive notifications will either have to be published to the AppStore (publicly) or rebuilt every few days when the developer certificate expires
  • The notification won’t arrive if there are problems with the internet connection, since the notification goes through Apple Servers

So I decided to do more research, luckily I found HAP-NodeJS which is a HomeKit library for Node.js which is made by the Homebridge developers. This needs a Node.js environment though, so the ESP32 would not work.

Setting up the hardware

The next best piece of hardware (that I know of) is a RaspberryPi Zero W which is running Raspbian (Debian).

For a how-to on configuring (and securing) the operating system on the Pi, you can use the post installation steps of my Debian server setup post, as well as the official documentation provided by RaspberryPi.

It’s important to ensure that you install the pigpiod package by running: apt install pigpiod as root on the Pi.

Getting Node.js

There was another pitfall though, the Raspberry Pi Zero W is 32-bit (armv6l) only. The current Node.js LTS (v22) is not built for this anymore, I tried cross compiling it myself using a Debian aarch64 Docker image on my MacBook but that didn’t work because some tools during build would call armv6l executables which of course don’t run on aarch64. Next, I tried emulating a Raspberry Pi using qemu in UTM, but that would only let me use the actual specs of the Raspberry Pi (single CPU core, 512MB RAM), which would run slower than the actual hardware. So I tried compiling Node.js 22 directly on the Pi Zero, but the Pi lost WiFi connection after two days of uninterrupted compilation and I had to restart it because it wouldn’t reconnect, after which I would have to start the process all over again.

So in the end I settled with the latest available Node.js prebuilt for armv6l, which turns out to be Node.js v20.19.3.

Building the Service

Now that we have the Node.js environment, we can start developing the service which will run on the Pi.

Package.json

I created an initial package.json with the data I wanted and added two dependencies:

{
  "dependencies": {
    "@homebridge/hap-nodejs": "^2.0.0",
    "pigpio-client": "^1.5.2"
  }
}

Here’s a short explanation of the dependencies:

  • HAP-NodeJS is the HomeKit library which lets us communicate with HomeKit
  • onoff is a GPIO library which lets us control the GPIO pins of the Pi Zero

Main.js

Now it’s time to add the actual code (you have the luxury of being able t copy it, but remember to change the id and metadata values to fit your setup):

import {Accessory, Categories, Characteristic, Service, uuid} from "@homebridge/hap-nodejs";
import pkg from '../package.json' with { type: 'json' };
import PigpioClient from 'pigpio-client';

console.log("Initializing…");
const accessoryUuid = uuid.generate("com.example.doorbell"); // this should stay the same for the entire lifetime of the accessory
const accessory = new Accessory("Doorbell", accessoryUuid); // the name can be anything you want, can also be changed later


console.log("Registering doorbell service…");
const doorbellService = new Service.Doorbell("Doorbell"); // the service name can be anything you want 
const triggerCharacteristic = doorbellService
    .getCharacteristic(Characteristic.ProgrammableSwitchEvent)
    .setProps({
        validValues: [Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS]
    });
accessory.addService(doorbellService);


console.log("Registering info service…");
const infoService = accessory.getService(Service.AccessoryInformation)
infoService
    .setCharacteristic(Characteristic.Manufacturer, 'Your Name') // add your name here
    .setCharacteristic(Characteristic.Model, 'Doorbell') // The model can be whatever you want
    .setCharacteristic(Characteristic.SerialNumber, '42') // You can also decide the serial number
    .setCharacteristic(Characteristic.FirmwareRevision, 'bookworm') // I used the os version of the Pi zero here, feel free to use whatever you want
    .setCharacteristic(Characteristic.HardwareRevision, 'Mk1') // This can be also whatever you want
    .setCharacteristic(Characteristic.SoftwareRevision, pkg.version) // I used the "version" property of the package.json here for clarity


console.log("Registering indentify service…");
accessory.on('identify', (paired, callback) => {
    console.warn('Identify is not yet implemented in hardware!');
    // If you add an LED or something similar to help with identification you can toggle it in this handler.
    callback()
})


console.log("Initializing GPIO…");
const client = new PigpioClient.pigpio({ host: '::1', port: 8888 });
client.on('error', err => {
    console.error('GPIO Error:', err);
    process.exit(1);
});
client.on('connected', () => {
    console.log('Connected to pigpiod…');
    const button = client.gpio(23); // GPIO 23 has ID 535 internally and is directly next to a GND, 
                                    // see the output of "cat /sys/kernel/debug/gpio" for more information
                                    // on the numbering scheme.
    button.modeSet('input');
    button.pullUpDown(2);
    let lastTick = 0;
    button.notify((level, tick) => {
        if (level === 0 && tick - lastTick > 200_000) {
            console.log('Doorbell pressed!');
            triggerCharacteristic.updateValue(Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS);
            lastTick = tick;
        }
    });
    console.log('GPIO Initialized!');


    console.log("Publishing HomeKit service…");
    accessory.publish({ // Publish should always be the last step!
        username: "FE:ED:BE:EF:42:42", // this value should be customized and unique on your network in case you build multiple accessories (you could run them on the same host)
        pincode: "123-32-123", // you can change this to whatever value you want, this needs to be entered during setup in the Home app
        port: 47128, // remember to open this port if you use a firewall on the Pi Zero
        category: Categories.DOOR, // this is used for UI icons only, door fits best in my opinion
    }).then(() => {
        console.log("Accessory ready!");
    });
});

In the code, GPIO 23 is used as sensing pin listening for the falling edge, it is debounced for 200ms before it triggers (you could also increase this to prevent spam) and then sends the HomeKit event.

Doorbell.service

And finally, a SystemD service file so the HomeKit service starts when you boot the Pi:

[Unit]
Description=Doorbell HomeKit Service
After=syslog.target
After=network.target
# Add the targets below if you use WiFi with dhcp…
#After=wpa_supplicant.service
#After=dhclient.service

[Service]
LimitMEMLOCK=infinity
LimitNOFILE=65535
RestartSec=2s
Type=simple
User=doorbell
Group=doorbell
WorkingDirectory=/home/doorbell/doorbell/
ExecStart=/usr/local/bin/node src/main.js
Restart=always
Environment=USER=doorbell

[Install]
WantedBy=multi-user.target

As you can see in the SystemD file, I am running the service as the “doorbell” user. This user of course has to be created first (adduser doorbell), remember to follow best practises and set a strong password.

Deployment

Of course, you also need to transfer the code to the Pi zero somehow, for example you could either develop directly on there (code will be lost if the system breaks), or you develop using Git and then use the Pi as a remote you push to in order to deploy new code.

I have a GitLab instance running in the network the Pi will run in, so I decided to do automated deployment using GitLab CI. First of all, I have to generate a new SSH Key pair, the public key of which I then add to the authorized_keys files of the root and doorbell user (root is needed to restart the service and configure the GPIO pin), and then I saved the private key in base64 encoded form as a masked and protected secret variable in GitLab CI. Finally, I added this CI file to the Git Repo (you will have to change doorbell.local to the DNS entry of the Pi):

image: debian:latest

stages:
  - deploy

variables:
  JEKYLL_ENV: production
  LC_ALL: C.UTF-8

before_script:
  - apt -yqqqqqqqqq update
  - apt -yqqqqqqqqq upgrade
  - apt -yqqqqqqqqq install rsync openssh-client
  - mkdir -p ~/.ssh
  - echo "$DEPLOY_KEY" | base64 -d > ~/.ssh/id_ed25519
  - chmod 600 ~/.ssh/id_ed25519
  - ssh-keyscan -H doorbell.local >> ~/.ssh/known_hosts

deploy-prod:
  stage: deploy
  script:
    - ssh -tn root@doorbell.local "systemctl enable --now pigpiod.service"
    - ssh -tn root@doorbell.local "systemctl stop doorbell.service"
    - ssh -tn doorbell@doorbell.local "mkdir -p /home/doorbell/doorbell/"
    - rsync -avz --delete ./ doorbell@doorbell.local:/home/doorbell/doorbell/
    - ssh -tn doorbell@doorbell.local "bash -lc 'cd ~/doorbell && npm i'"
    - ssh -tn root@doorbell.local "mv /home/doorbell/doorbell/doorbell.service /lib/systemd/system/doorbell.service"
    - ssh -tn root@doorbell.local "systemctl daemon-reload"
    - ssh -tn root@doorbell.local "systemctl restart doorbell.service"
    - ssh -tn root@doorbell.local "systemctl enable doorbell.service"
  rules:
    - if: '$CI_COMMIT_TAG'
      when: always
    - when: never

This CI file copies files to the Pi, installs dependencies, and restarts the service. Plus it only runs on tagged commits.

So now I am able to work on the code, push every commit, and when I want to update the code running on the doorbell, I just bump the version, add a tag, push it and everything will be done automatically. I even added a way to notified via Grafana Alerts when the service fails, if this sounds interesting for your case too, check out my post about monitoring servers.

HomeKit Setup

I already have an existing HomeKit setup using my AppleTV as a Home Hub, so I only needed to go to the “Add Accessory” screen, then (when the QR scanner view is shown) you have to select “more options”, and the Pi should be already visible there (the QR setup is only really needed when the accessory is not yet connected to the local network), and you can set it up using the pincode property set in the Node.js code. After a short waiting time, the accessory appears in the home view (alongside a notice that it’s not supported, since as a sad single developer I don’t have the resources required to join the MFi program) and is usable.

It works

When I now connect the GPIO I used in the code to a GND pin, (for example by using the doorbell button) I receive a notification on my iPhone: Screenshot of the "doorbell rang" notification on iOS

I hope this post was useful. If you have any suggestions or found errors please let me know!