A small web app that opens your office door from a phone or laptop by sending an unlock command to a ZKTeco access device over the local network. Tapping the button fires the same door relay that a successful fingerprint match does.
It serves a passcode login, then a single "Open Door" button with live device status.
Phone / laptop browser ---> this web app ---> ZKTeco device ---> door relay
(HTTP) (LAN, port 4370 via pyzk)
The app runs on any always-on machine on the same network as the device (a Raspberry Pi, a mini PC, or a spare computer). It connects to the device using the pyzk library and calls the unlock command.
- Python 3.9 or newer
- A machine on the same local network as the ZKTeco device
- The device's IP address, port, and (if set) communication password
- The device must have its door relay wired to the lock (the same wiring that already lets a fingerprint open the door)
# 1. Create and activate a virtual environment
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. Create your config
cp .env.example .env
# 4. Generate a secret key and paste it into .env as SECRET_KEY
python -c "import secrets; print(secrets.token_hex(32))"
# 5. Edit .env: set ACCESS_PASSCODE, DEVICE_IP, and any device passwordAll settings live in .env. See .env.example for the full annotated list.
The device settings (IP, port, password, timeout, UDP/ping, auto-discovery,
discovery subnets, door-open seconds) can also be set live from the Device
panel in the app (footer → Device); those overrides are saved to
device_config.json (gitignored) and take effect without editing .env or
restarting. The panel also has Scan (find the device on the network) and
Test (check the connection) buttons. The values below are the defaults the
app falls back to:
| Variable | What it is |
|---|---|
SECRET_KEY |
Random key that signs login cookies. Generate one. |
ACCESS_PASSCODE |
The passcode your team types to log in. |
DEVICE_IP |
The device's IP on your network. |
DEVICE_PORT |
Device port, usually 4370. |
DEVICE_PASSWORD |
Device communication password, 0 if none. |
DOOR_OPEN_SECONDS |
How long the lock stays released per tap. |
TRUST_PROXY |
False by default. Set True only behind a trusted reverse proxy. |
SESSION_COOKIE_SECURE |
False for HTTP LAN. Set True when fronted by HTTPS. |
For quick local testing:
python app.pyThen open http://<machine-ip>:8080 on a phone or laptop on the same network.
See DEPLOY.md for the full production guide (architecture, systemd, HTTPS proxy, Windows, firewall, secrets). In short:
pip install -r requirements.txt
gunicorn -c gunicorn.conf.py app:app # Linux
# or on Windows:
waitress-serve --listen=0.0.0.0:8080 --threads=8 app:appOne worker, multiple threads. In-memory state (rate-limit, status, discovery) and the config files are per-process, so run a single worker; the
gthreadworker's threads keep a live camera stream from blocking the door.gunicorn.conf.pyis configured this way. Adeploy/door-opener.servicesystemd unit and adeploy/Caddyfile(HTTPS) are included.
It's a LAN appliance. The server must be on the same network as the device/camera, one instance per site. Don't expose it to the public internet; use a VPN or the HTTPS-proxy mode in DEPLOY.md.
| Route | Method | Purpose |
|---|---|---|
/healthz |
GET | Liveness probe (no auth, no device I/O) |
/login |
GET/POST | Passcode login |
/ |
GET | The open-door page (requires login) |
/open |
POST | Sends the unlock command (CSRF protected) |
/status |
GET | Checks whether the device is reachable |
/logout |
POST | Ends the session |
/device/config |
GET/POST | Read / save device settings (POST is CSRF protected) |
/device/scan |
POST | Scan the network for the device (CSRF protected) |
/device/test |
POST | Test the connection to the device (CSRF protected) |
/camera/config |
GET/POST | Read / save camera settings (POST is CSRF protected) |
/camera/snapshot |
GET | One JPEG frame (proxied from the camera) |
/camera/stream |
GET | Live MJPEG feed (proxied / transcoded) |
The open-door page shows a live camera panel above the button. Click Connect
to add a network camera; settings are saved to camera_config.json (gitignored)
and persist across restarts. Three modes:
| Mode | URL example | Needs |
|---|---|---|
snapshot |
http://192.168.1.50/snapshot.jpg |
nothing - universal, polled ~1/sec |
mjpeg |
http://192.168.1.50/video.mjpg |
nothing - live HTTP MJPEG |
rtsp |
rtsp://user:pass@192.168.1.50:554/stream1 |
ffmpeg on PATH - transcoded to live MJPEG |
The feed is proxied through the app, so the camera's credentials never reach the
browser and the camera stays on the LAN. Optional username/password are stored
in camera_config.json and sent to the camera using basic or digest auth.
Notes:
- For RTSP, install ffmpeg and ensure it's on
PATH(the form warns if missing). - Snapshot mode works with almost any IP camera (Hikvision ISAPI picture URL,
Dahua
/cgi-bin/snapshot.cgi, etc.) and is the lightest option. - The camera URL host must be a private/LAN address unless
CAMERA_ALLOW_PUBLIC=True(an SSRF guard, since the URL is user-supplied). CAMERA_SNAPSHOT_INTERVAL_MScontrols the refresh rate of snapshot mode.
- This app should run on your local network only. Do not forward a port on your router to expose it to the open internet.
- To open the door while away from the office, connect to the office network with a VPN first (for example WireGuard or Tailscale), then open the app as if you were on site. This keeps the door opener off the public internet.
- Logins are protected by a shared passcode, signed session cookies, a CSRF token on the open command, a per-IP open cooldown, and login-attempt throttling. These are sensible for a LAN, not a substitute for keeping the app off the public internet.
- Rate limiting keys on the socket peer IP (
request.remote_addr). TheX-Forwarded-Forheader is ignored unlessTRUST_PROXY=True, so a client cannot spoof it to bypass throttling. SetTRUST_PROXY=Trueonly when the app sits behind a single trusted reverse proxy that sets that header. - If you front the app with HTTPS (a TLS proxy or VPN gateway), set
SESSION_COOKIE_SECURE=Trueso the session cookie is only sent over TLS. - Responses send
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy, and aContent-Security-Policyto limit clickjacking and content-sniffing. - Never commit
.env. It is already gitignored.
-
Device unreachable / connection hangs: confirm the machine can reach the device IP (
ping <DEVICE_IP>or check the router). If ping is blocked but the device is up, keepDEVICE_OMMIT_PING=True. -
Connects then fails: try
DEVICE_FORCE_UDP=Truein.env. Some firmware prefers UDP. -
Wrong / unknown IP: check the device under Menu > Comm > Ethernet, or let the app find it. Run the bundled scanner from the project root:
python zk_door.py
It scans the subnets in
DEVICE_DISCOVERY_SUBNETS(default192.168.0and192.168.1) and prints the device's IP. Set that asDEVICE_IP, or setDEVICE_AUTODISCOVER=Trueto let the running app discover and cache it automatically. Discovery only works when the machine running this app is on the same subnet as the device - if the device is on192.168.0.xand your machine is on192.168.1.xwith no route between them, no scan can reach it; run the app on a host that shares the device's network. -
Separate controller box: if the unit you touch is only a reader and a separate controller (for example a ZKAccess C3 or inBio) drives the lock, this app talks to the wrong box. In that case the unlock call goes to the controller instead, using a different library (
pyzkaccessorzkaccess-c3). Tell me and I will adaptzk_door.pyfor that hardware.
door-opener/
├── app.py # Flask app: routes, login, rate limiting
├── config.py # Loads settings from .env
├── zk_door.py # Talks to the ZKTeco device (open_door, ping_device, discovery)
├── device_store.py # Runtime device settings (in-app Device panel) over .env
├── camera.py # Network camera config + snapshot/stream proxy
├── templates/
│ ├── login.html # Passcode login screen
│ └── index.html # Open-door button + live status
├── requirements.txt
├── .env.example # Copy to .env and fill in
├── .gitignore
└── README.md