Skip to content

Repository files navigation

homekit-bridge

CI License: MIT

A minimal, extensible HomeKit bridge written in Python, inspired by Homebridge. It uses HAP-python to speak Apple's HomeKit protocol (HAP) and a plugin system (platforms) to integrate devices that don't support HomeKit natively.

It includes a plugin for the Visonic PowerMaster alarm through the cloud API (the same one used by the Visonic-GO / BWA app), so it works wherever the official app does. It is developed and tested against a PowerMaster-10 with a PowerLink 3 module; other PowerMaster models (PM-30/33/360) use the same API and are likely compatible but have not been confirmed.

Features

  • Homebridge-style core: bridge + a list of platforms in a YAML file.
  • Auto-discovered plugin system (@register_platform("name")): drop a module in hbridge/plugins/ — no registration boilerplate — or ship an external plugin via the hbridge.platforms entry-point group.
  • Visonic plugin: a Security System accessory (Home / Away / Night / Disarmed) with two-way sync, command confirmation via the panel's process token, periodic polling, a StatusFault characteristic reflecting connectivity, and automatic reconnection with bounded backoff.
  • Secrets via environment variables (${VAR} and ${VAR:-default}) to avoid writing them to disk.
  • Fully typed (py.typed), linted (ruff), formatted (black), type-checked (mypy) and unit-tested (pytest).

HomeKit concepts and terminology

If you're new to HomeKit/HAP, these are the concepts this project builds on. See Adding your own plugins for how they map to code.

Term What it means here
HAP HomeKit Accessory Protocol — Apple's protocol for talking to accessories. Implemented by the HAP-python library this bridge is built on.
mDNS / Bonjour Zero-config LAN discovery. Your iPhone finds the bridge over multicast DNS, which is why Docker needs host networking (see Firewall).
Bridge A single accessory that exposes many other accessories to HomeKit. You pair the bridge once and every accessory it carries appears in the Home app.
Accessory One device in the Home app (here: the alarm). Has an identity (name, manufacturer, serial, firmware) and one or more services.
Service A functional unit of an accessory (e.g. SecuritySystem, AccessoryInformation).
Characteristic A single readable/writable value of a service (e.g. SecuritySystemTargetState, StatusFault). State sync happens at this level.
Platform This project's plugin concept: a class registered with @register_platform("name") that reads its config and produces one or more accessories. Homebridge users will recognise the term.
Pairing / setup PIN The xxx-xx-xxx code you enter once in the Home app to establish the encrypted pairing. Pairing state is then persisted (accessory.state) so it survives restarts.

Visonic ↔ HomeKit mapping

The alarm is exposed as a HomeKit SecuritySystem service. HomeKit uses two integer characteristics — the target the user requests and the current state the panel reports — plus a StatusFault flag:

HomeKit value SecuritySystemTargetState (requested) SecuritySystemCurrentState (reported)
0 Homearm_home Home / Stay armed
1 Awayarm_away Away armed
2 Nightarm_home or arm_away (see night_arm) (no native panel state; settles on Home/Away)
3 Disarmdisarm Disarmed
4 (n/a — not a target) Alarm triggered (matched via triggered_states)

StatusFault is set to 1 (general fault) whenever the panel is unreachable, reports "not connected", or returns an unmapped state. HomeKit always requires a current-state value, so the last known state is retained and the fault flag signals that it may be stale (and potentially unsafe to trust).

Requirements

  • Python 3.10+ (tested on 3.10–3.12 in CI).
  • A Visonic-GO / BWA app account already set up with your panel.
  • Details from your provider: server hostname, panel serial and master code.
  • The visonicalarm client library, pinned to the 3.12.x series. The plugin classifies the library's exception types (for retry/fatal/throttle handling) against that release's API; installing a different major/minor outside the lock file may change those shapes and is untested. Use the provided lock file (requirements.lock) or the packaged dependency range.

Important: this approach uses the Visonic cloud, so it requires your PowerLink module to be connected to the internet. You don't need to touch any wiring.

Installation

git clone <this-repo> homekit-bridge   # or copy the folder
cd homekit-bridge

python3 -m venv .venv
source .venv/bin/activate
pip install -e .

This installs the hbridge package and its hbridge console script. You can start the bridge with either hbridge config.yaml or python -m hbridge config.yaml.

Configuration

  1. Copy the template and edit it:

    cp config.example.yaml config.yaml
  2. Generate a unique app_id (UUID) for this instance:

    python -c "import uuid; print(uuid.uuid4())"
  3. Fill in config.yaml:

    • hostname: your provider's PowerManage server (the same as the app). For the official Visonic-GO app it is visonic.tycomonitor.com.
    • app_id: the generated UUID.
    • panel_serial: your panel's hexadecimal serial.
    • user_email / user_password: the app credentials.
    • user_code: the panel master code.

Discover the panel serial automatically

If you don't know the serial (panel_serial), run the included utility: with your email and password it lists the panels on your account and shows their serials.

source .venv/bin/activate

# Provide the email and app-id on the CLI; the password (and, later, the master
# code) are read from the environment or prompted for securely — never pass
# secrets as command-line arguments, where they leak into shell history and the
# process list.
export VISONIC_PASSWORD='YourPassword'
python tools/discover.py \
    --email you@email.com --app-id <your-uuid>

# Detail + state of a specific panel (requires the master code):
export VISONIC_USER_CODE='1234'
python tools/discover.py \
    --email you@email.com --app-id <your-uuid> \
    --panel-serial 1A2B3C

You can also set VISONIC_EMAIL, VISONIC_APP_ID and VISONIC_HOSTNAME in the environment to omit the corresponding flags. If a required secret is neither supplied nor present in the environment, the script prompts for it securely. The default hostname is already visonic.tycomonitor.com (Visonic-GO).

  1. Put secrets in environment variables (recommended) instead of the YAML. Copy .env.example to .env and fill it in (Docker reads it automatically), or export them in your shell for a direct run:

    cp .env.example .env      # then edit .env
    # or, for a one-off shell:
    export VISONIC_EMAIL="you@email.com"
    export VISONIC_PASSWORD="your-password"
    export VISONIC_USER_CODE="1234"

Running with Docker (recommended if you'll host more services)

HomeKit discovers the bridge via mDNS/Bonjour, which needs the bridge and your iPhone to share a broadcast domain. The simplest way to guarantee that is host networking, so docker-compose.yml uses network_mode: host (with the default bridge network the iPhone will NOT see the bridge unless you add an mDNS reflector and forward the port — more moving parts than most setups warrant).

Requirements: Docker Engine + the Compose plugin.

# Install Docker (if you don't have it) on a Debian/Ubuntu-based host. The
# official convenience script is shown for reference, but piping a remote
# script straight into a shell is a supply-chain risk: prefer your distro's
# packages or the vendor instructions at https://docs.docker.com/engine/install/
# and inspect the script first if you do use it.
curl -fsSL https://get.docker.com -o get-docker.sh   # then review it
sh get-docker.sh
sudo usermod -aG docker $USER   # re-login afterwards

cd ~/homekit-bridge
# You need: config.yaml and .env (the pairing state lives in a named volume)
docker compose up -d --build      # build and start in the background
docker compose logs -f            # view logs (pairing info)

The pairing state is stored in the hbridge-data named volume (mounted at /app/data/accessory.state), so it survives docker compose down/up and updates. A named volume is used instead of a host bind mount so it inherits the image's non-root ownership and stays writable by the unprivileged process.

The provided docker-compose.yml is hardened: the container runs as a non-root user with a read-only root filesystem (only the hbridge-data volume and /tmp are writable), all Linux capabilities dropped, no-new-privileges, a HEALTHCHECK, and bounded log rotation. If you change port in config.yaml, update HB_HEALTHCHECK_PORT in the compose environment: to match.

Common operations:

docker compose restart            # restart
docker compose down               # stop the container (keeps the hbridge-data volume)
docker compose up -d --build      # update after changing the code

Running directly (venv, no Docker)

source .venv/bin/activate
python -m hbridge config.yaml          # add -v for debug logging

The pairing information will appear in the console. On your iPhone/iPad:

  1. Open the Home app → +Add Accessory.
  2. More options → choose PyBridge.
  3. Enter the PIN you configured (pin in config.yaml).

The accessory will appear as an alarm system with the modes Home, Away, Night and Disarmed.

Firewall (important)

HomeKit needs two things open on the machine running the bridge:

  • The accessory's TCP port (the port in config.yaml, default 51826), used for pairing and control.
  • mDNS/Bonjour on UDP 5353, so the iPhone can discover the bridge.

If you have an active firewall (e.g. ufw on Ubuntu/Debian) and pairing fails with "Unable to add accessory. Accessory not reachable", a firewall blocking the port is a common cause (though not the only one — see the troubleshooting notes below). Open it like this:

sudo ufw status                 # check whether it's active
sudo ufw allow 51826/tcp        # accessory port (adjust if you changed 'port')
sudo ufw allow 5353/udp         # mDNS / discovery
sudo ufw reload

If you change port in config.yaml, open that port instead.

Auto-start on boot

With Docker (recommended)

docker-compose.yml already includes restart: unless-stopped, so the container restarts by itself after a machine reboot. You only need to make sure the Docker daemon itself starts on boot:

sudo systemctl enable --now docker
sudo systemctl is-enabled docker      # should say: enabled

Check after rebooting:

docker ps                             # the container should be "Up"
docker compose logs --since 2m

With systemd (direct execution, no Docker)

Edit systemd/homekit-bridge.service with your paths/user (keep the install under a system path such as /opt; the unit sets ProtectHome=true, which makes a home-directory checkout inaccessible) and then:

sudo cp systemd/homekit-bridge.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now homekit-bridge
journalctl -u homekit-bridge -f        # view logs

Project structure

homekit-bridge/
├── hbridge/
│   ├── __init__.py          # package metadata (__version__, ...)
│   ├── __main__.py          # entry point (python -m hbridge / hbridge)
│   ├── _compat.py           # asyncio child-watcher shim for HAP-python
│   ├── bridge.py            # builds the AccessoryDriver + Bridge
│   ├── config.py            # loads YAML, validates, expands ${ENV}
│   ├── healthcheck.py       # container HEALTHCHECK helper
│   ├── plugin.py            # platform registry and base class
│   ├── py.typed             # PEP 561 typing marker
│   └── plugins/
│       ├── __init__.py      # auto-discovery of built-in/external plugins
│       └── visonic_alarm.py # cloud client + SecuritySystem accessory
├── tools/discover.py        # helper to discover panel serials
├── tests/                   # pytest suite
├── .github/workflows/ci.yml # lint, type-check, tests, docker build
├── Dockerfile               # multi-stage, non-root, healthcheck
├── docker-compose.yml       # hardened service definition
├── systemd/homekit-bridge.service
├── config.example.yaml
├── .env.example
├── .dockerignore            # keeps build context minimal
├── pyproject.toml           # packaging + tooling config
├── requirements.txt         # direct pins (source for the lock)
├── requirements.lock        # fully-resolved, hash-locked deps (Docker build)
├── build-requirements.in    # build-backend pins (source for its lock)
├── build-requirements.lock  # hash-locked build backend (Docker build)
├── CHANGELOG.md · CONTRIBUTING.md · SECURITY.md · LICENSE
└── README.md

Adding your own plugins

Create a module in hbridge/plugins/ with a class that inherits from Platform. A Platform reads its config entry and returns HomeKit accessories (see HomeKit concepts and terminology). Built-in plugins are auto-discovered — simply defining the class with the decorator is enough; there is no __init__.py to edit.

from ..plugin import Platform, register_platform


@register_platform("my_plugin")
class MyPlatform(Platform):
    def accessories(self):
        # return a list of HAP-python Accessory objects
        return [...]

    def teardown(self):  # optional cleanup hook, called on shutdown
        ...

Then reference it by name under platforms: in your config.yaml.

To distribute a plugin as a separate package, advertise it in the hbridge.platforms entry-point group and it will be discovered on startup:

# pyproject.toml of your external plugin
[project.entry-points."hbridge.platforms"]
my_plugin = "my_package.my_module"

Development

pip install -e ".[dev]"     # runtime + dev tooling

ruff check .                      # lint
black --check .                   # format check
mypy hbridge                      # type check
pytest                            # unit tests

CI (.github/workflows/ci.yml) runs the same checks across Python 3.10–3.12, additionally runs the test suite against the hash-locked runtime dependencies (requirements.lock, the exact versions the Docker image ships), and builds the Docker image on pushes to main/master, on every pull request, and on manual dispatch.

Notes and limitations

  • State is obtained by polling (poll_interval); there are no push notifications, so after arming/disarming outside HomeKit it may take a few seconds to be reflected.
  • HomeKit's Night mode has no direct Visonic equivalent; it is mapped to Home or Away according to night_arm.
  • The panel's "triggered/alarm" tokens vary by firmware. If a real alarm is not reflected as Triggered in HomeKit, override the detected tokens with the triggered_states list in config.yaml (see config.example.yaml).
  • Arm/disarm commands are confirmed by polling the panel's process status. The terminal success/failure tokens also vary by firmware; override them via process_success_states / process_failure_states if a command that the panel executed is otherwise reported as unconfirmed.
  • Connectivity and unknown-state conditions are surfaced through the accessory's StatusFault characteristic; the last known state is retained alongside it (HomeKit always needs a current value), so the fault flag signals that the displayed state may be stale (and potentially unsafe).
  • It depends on the availability of your alarm provider's cloud.

About

A Homebridge-style HomeKit bridge in Python, exposing a Visonic PowerMaster alarm to Apple HomeKit.

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages