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.
- Homebridge-style core:
bridge+ a list ofplatformsin a YAML file. - Auto-discovered plugin system (
@register_platform("name")): drop a module inhbridge/plugins/— no registration boilerplate — or ship an external plugin via thehbridge.platformsentry-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
StatusFaultcharacteristic 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).
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. |
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 |
Home → arm_home |
Home / Stay armed |
1 |
Away → arm_away |
Away armed |
2 |
Night → arm_home or arm_away (see night_arm) |
(no native panel state; settles on Home/Away) |
3 |
Disarm → disarm |
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).
- 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
visonicalarmclient 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.
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.
-
Copy the template and edit it:
cp config.example.yaml config.yaml
-
Generate a unique
app_id(UUID) for this instance:python -c "import uuid; print(uuid.uuid4())" -
Fill in
config.yaml:hostname: your provider's PowerManage server (the same as the app). For the official Visonic-GO app it isvisonic.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.
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 1A2B3CYou 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).
-
Put secrets in environment variables (recommended) instead of the YAML. Copy
.env.exampleto.envand 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"
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 codesource .venv/bin/activate
python -m hbridge config.yaml # add -v for debug loggingThe pairing information will appear in the console. On your iPhone/iPad:
- Open the Home app → + → Add Accessory.
- More options → choose PyBridge.
- Enter the PIN you configured (
pininconfig.yaml).
The accessory will appear as an alarm system with the modes Home, Away, Night and Disarmed.
HomeKit needs two things open on the machine running the bridge:
- The accessory's TCP port (the
portinconfig.yaml, default51826), 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 reloadIf you change
portinconfig.yaml, open that port instead.
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: enabledCheck after rebooting:
docker ps # the container should be "Up"
docker compose logs --since 2mEdit 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 logshomekit-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
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"pip install -e ".[dev]" # runtime + dev tooling
ruff check . # lint
black --check . # format check
mypy hbridge # type check
pytest # unit testsCI (.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.
- 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_stateslist inconfig.yaml(seeconfig.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_statesif a command that the panel executed is otherwise reported as unconfirmed. - Connectivity and unknown-state conditions are surfaced through the accessory's
StatusFaultcharacteristic; 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.