A secure web interface for controlling smart door openers via Home Assistant — modern glass-morphism UI with visual keypad, per-user PINs, audio feedback, battery monitoring, and comprehensive brute-force protection.
DoorOpener provides a web-based keypad interface to remotely open doors connected to Home Assistant. Users enter their personal PIN on a visual keypad and the system securely communicates with Home Assistant to trigger the door opener.
- Visual keypad — 3×4 keypad interface with auto-submit
- Per-user PINs — individual PINs for each user with JSON-based user management
- Audio feedback — success chimes and failure sounds
- Battery monitoring — real-time battery level for Zigbee devices
- Multi-layer security — rate limiting and IP blocking (see Security)
- Admin UI — user management and log viewer
- Test mode — safe development without triggering the actual door
- Entity support — Home Assistant
switch,lock,input_boolean, andbuttonentities
- In Home Assistant go to Settings → Add-ons → Add-on Store → ⋮ → Repositories.
- Paste the repository URL:
https://github.com/leonardpitzu/DoorOpener - Find DoorOpener in the store and click Install.
- Configure
entity_idandadmin_passwordin the add-on options. - Click Start — the panel appears in the sidebar with a door icon.
When running as an add-on you can leave
ha_urlandha_tokenempty — the Supervisor API token is used automatically.
A Dockerfile.dev is provided for local testing outside Home Assistant:
docker build -f Dockerfile.dev -t dooropener:dev .
cp options.json.example options.json # edit as needed
docker run -d \
-v $(pwd)/options.json:/app/options.json:ro \
-v $(pwd)/users.json:/app/users.json:rw \
-v $(pwd)/logs:/app/logs \
-p 6532:6532 dooropener:devOr via Compose:
docker compose up -dAll application settings live in a single options.json file. Copy the example and edit:
cp options.json.example options.json{
"ha_url": "http://homeassistant.local:8123",
"ha_token": "your_long_lived_access_token_here",
"entity_id": "switch.dooropener_zigbee",
"battery_entity": "sensor.dooropener_zigbee_battery",
"port": 6532,
"test_mode": false,
"admin_password": "change_me_please",
"max_attempts": 5,
"block_time_minutes": 5,
"max_global_attempts_per_hour": 50,
"session_max_attempts": 3,
"secret_key": "",
"session_cookie_secure": false,
"ha_cert_pem": ""
}| Key | Description | Default |
|---|---|---|
ha_url |
Home Assistant base URL | (required) |
ha_token |
Long-lived access token (HA → Profile → Security) | (required) |
entity_id |
Entity to trigger (switch.*, lock.*, input_boolean.*, or button.*) |
(required) |
battery_entity |
Battery sensor entity for monitoring | auto-derived |
port |
Web server port (overridden by DOOROPENER_PORT env var) |
6532 |
test_mode |
When true, simulates door actions without calling HA |
false |
admin_password |
Password for the admin dashboard | (required) |
max_attempts |
Failed PIN attempts per IP before blocking | 5 |
block_time_minutes |
Block duration in minutes | 5 |
max_global_attempts_per_hour |
Global rate limit across all clients | 50 |
session_max_attempts |
Failed attempts per browser session before blocking | 3 |
secret_key |
Flask secret key (leave empty + set FLASK_SECRET_KEY env var instead) |
"" |
session_cookie_secure |
Set true when running behind HTTPS |
false |
ha_cert_pem |
Inline PEM certificate for self-signed HA (see below) | "" |
# Port (optional, overrides options.json)
DOOROPENER_PORT=6532
# Timezone
TZ=Europe/Amsterdam
# Container user/group mapping (linuxserver-style)
PUID=1000
PGID=1000
UMASK=002
# Flask session secret (recommended for production)
FLASK_SECRET_KEY=please-change-me
# Set true when behind HTTPS reverse proxy
SESSION_COOKIE_SECURE=trueConfiguration priority: environment variables > options.json defaults.
The image supports PUID, PGID, and UMASK to avoid host-side chown. On startup the entrypoint aligns the runtime user/group to those IDs, ensures /app/logs is writable, then drops privileges via gosu.
| Log | Location |
|---|---|
| Door access / audit log | /app/logs/log.txt (bind-mount ./logs:/app/logs) |
| Gunicorn / access log | Container stdout/stderr (docker logs dooropener) |
If your Home Assistant uses a self-signed certificate, paste the PEM certificate directly into the ha_cert_pem option.
In options.json:
{
"ha_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIFTjCCA...\n-----END CERTIFICATE-----"
}In the HA add-on YAML config (Settings → Add-ons → DoorOpener → Configuration):
ha_cert_pem: |-
-----BEGIN CERTIFICATE-----
MIIFTjCCAzagAwIBAgIIAQ3X7arBz...
-----END CERTIFICATE-----The certificate is written to a temporary file at startup and used for all Home Assistant API calls.
Note: The hostname in
ha_urlmust match a Subject Alternative Name in the certificate.
- Access interface — visit
http://localhost:6532. - Enter PIN — use the visual keypad (4–8 digit PIN).
- Auto-submit — the door opens automatically when a valid-length PIN is entered.
- Admin access — click the gear icon for the admin dashboard.
| Layer | Description |
|---|---|
| IP rate limiting | Blocks an IP after max_attempts failures for block_time_minutes |
| Session rate limiting | Blocks a browser session after session_max_attempts failures |
| Global rate limiting | Caps total failed attempts system-wide to max_global_attempts_per_hour |
| Progressive delay info | Exponential back-off metadata (1 s → 16 s) returned to the client |
| Persistent cookie block | Block state survives page reloads via a session cookie |
| Audit logging | Every attempt logged with timestamp, IP, session, user, and result |
| Input validation | PIN format and request body validated before processing |
| Security headers | CSP, X-Frame-Options DENY, HSTS referral, no-sniff, etc. |
| Bot detection | Obvious bot/crawler/spider user-agents are rejected |
Every response includes:
Content-Security-Policy— strict self-only policyX-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 1; mode=blockReferrer-Policy: strict-origin-when-cross-originPermissions-Policy— geolocation, camera, microphone disabledCache-Control: no-store
| Method | Path | Description |
|---|---|---|
GET |
/ |
Main keypad interface |
POST |
/open-door |
Validate PIN and trigger door entity |
GET |
/battery |
Battery level JSON ({"level": 85}) |
GET |
/admin |
Admin dashboard |
POST |
/admin/auth |
Admin login |
GET |
/admin/check-auth |
Check admin session |
POST |
/admin/logout |
Admin logout |
GET |
/admin/logs |
Retrieve audit logs (admin auth required) |
POST |
/admin/logs/clear |
Clear logs (admin auth required) |
GET |
/admin/users |
List users (admin auth required) |
POST |
/admin/users |
Create user (admin auth required) |
PUT |
/admin/users/<name> |
Update user (admin auth required) |
DELETE |
/admin/users/<name> |
Delete user (admin auth required) |
┌────────────────────────────────────────────────────┐
│ app.py │
│ Flask routes & request handling │
├──────────────┬──────────────┬──────────────────────┤
│ config.py │ security.py │ ha_client.py │
│ options.json │ RateLimiter │ HAClient │
│ loader │ headers │ (requests.Session) │
│ timezone │ validation │ trigger / battery │
├──────────────┴──────────────┴──────────────────────┤
│ users_store.py │
│ JSON-based user management │
└────────────────────────────────────────────────────┘
| Module | Responsibility |
|---|---|
dooropener/config.py |
Loads options.json, exposes all settings as module attributes, timezone handling via zoneinfo |
dooropener/security.py |
RateLimiter class (IP / session / global), security headers, bot detection, PIN validation |
dooropener/ha_client.py |
HAClient class wrapping requests.Session — dispatches switch/lock/input_boolean/button services |
dooropener/users_store.py |
Atomic JSON-based user CRUD with usage tracking |
dooropener/app.py |
Flask app, route handlers, audit logging |
- Python 3.12+
- An
options.jsonfile (copy fromoptions.json.example)
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
cp options.json.example options.json # edit as needed
python app.pypytest --tb=short -qWith coverage:
pytest --cov=./ --cov-report=term-missing --cov-fail-under=75ruff check . # lint
bandit -r . -x tests # security scanSet "test_mode": true in options.json to test the interface without actually triggering the door in Home Assistant.
The CI pipeline (.github/workflows/ci.yml) runs on every push and PR:
| Job | Tool | Purpose |
|---|---|---|
| Tests | pytest + pytest-cov | Unit/integration tests, 75 % coverage gate |
| Lint | ruff | Code style and import checks |
| Security | bandit | Static security analysis |
| Docker | docker build | Smoke-test the dev container image |
Weekly pull requests for dependency updates are configured via .github/dependabot.yml:
- pip dependencies — every Monday
- GitHub Actions versions — every Monday
This project is licensed under the MIT License — see the LICENSE file for details.