diff --git a/.flake8 b/.flake8
index 7b46844..a99d7a4 100644
--- a/.flake8
+++ b/.flake8
@@ -1,6 +1,6 @@
[flake8]
max-line-length = 100
-ignore = E203, E266, E501, W503
+ignore = E203, E266, E501, W503, E731
exclude = .yaml,.json
[pycodestyle]
max_line_length = 100
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
new file mode 100644
index 0000000..c47780b
--- /dev/null
+++ b/.github/workflows/build-image.yml
@@ -0,0 +1,50 @@
+name: Build and Push Docker Image
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+ branches: [main]
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.gitignore b/.gitignore
index a7207bb..6f0012c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -141,3 +141,5 @@ cython_debug/
.envrc
.idea
+
+mm-doorman.json
diff --git a/.isort.cfg b/.isort.cfg
index 3b6e9ad..4ec151a 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -1,2 +1,3 @@
[settings]
-known_third_party = flask,ldap3,requests
+profile = black
+known_third_party = apprise,fastapi,httpx,ldap3,rich,sentry_sdk,websocket
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7539c68..291f70e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,29 +1,45 @@
-default_language_version:
- python: python3.7
-
exclude: ^(migrations|venv)/
repos:
- repo: https://github.com/asottile/seed-isort-config
- rev: v1.9.4
+ rev: v2.2.0
hooks:
- id: seed-isort-config
- - repo: git://github.com/pre-commit/mirrors-isort
- rev: v4.3.21
+ - repo: https://github.com/pycqa/isort
+ rev: 9.0.0a3
hooks:
- id: isort
+ name: isort (python)
+
+ - repo: https://github.com/pycqa/flake8
+ rev: 7.3.0
+ hooks:
+ - id: flake8
+ name: flake8 (python)
- - repo: git://github.com/pre-commit/pre-commit-hooks
- rev: v2.4.0
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
hooks:
- id: check-builtin-literals
- id: trailing-whitespace
- id: end-of-file-fixer
- - id: flake8
- id: check-ast
- repo: https://github.com/ambv/black
- rev: 19.10b0
+ rev: 26.3.1
hooks:
- id: black
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.16.0
+ hooks:
+ - id: mypy
+ additional_dependencies:
+ - fastapi
+ - httpx
+ - apprise
+ - ldap3
+ - websocket-client
+ - rich
+ - sentry-sdk
diff --git a/Dockerfile b/Dockerfile
index 4c30dca..269c74a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,18 @@
-FROM tiangolo/meinheld-gunicorn-flask:python3.9
-COPY ./doorman /app/doorman
-COPY requirements.txt /app
-RUN pip install --upgrade pip
-RUN pip install -r /app/requirements.txt
+FROM python:3.14-alpine3.23
-ENV MODULE_NAME=doorman.app
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+COPY ./doorman ./doorman
+COPY ./websocket_client.py .
+COPY ./main.py .
+
+EXPOSE 5000
+
+# Keep --workers at 1 (uvicorn's default). Each worker spawns its own
+# MemberMatters websocket client; multiple workers would produce duplicate
+# door_access events.
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]
diff --git a/README.md b/README.md
index 9c69732..fb106b8 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,12 @@
# doorman
-A solution for connecting the Fanvil i31s to FreeIPA
+A solution for connecting the Fanvil i31s (or compatible) line of door phones to LDAP for access control.
+Also supports the [MemberMatters](https://github.com/membermatters/MemberMatters) door controller protocol, with optional
+fallback to LDAP.
# Quickstart
Required:
- * python 3.7+
+ * python 3.9+
Suggested:
* [direnv](https://direnv.net/docs/installation.html)
@@ -37,6 +39,26 @@ cp envrc.example .env
# 3. Edit your config to suit
-# 4. Run with:
-docker run -d -p 5000:80 --name=doorman --env-file=.env doorman:latest
+# 4. Run the webserver with:
+docker run -d -p 5000:80 --name=doorman --env-file=.env -v ./mm-doorman.json:/tmp/mm-doorman.json doorman:latest
+
+# 5. For use with Member Matters, run the websocket client:
+docker run -d --name=doorman_ws --env-file=.env -v ./mm-doorman.json:/tmp/mm-doorman.json doorman:latest python3 websocket_client.py
```
+
+# Configure for use with MemberMatters
+
+## Add API key for access control device
+
+1. In the MemberMatters Django Admin (`/admin` URL), navigate to **Access** > **API Key for Access Controlled Devices**, and click **Add**.
+2. Give your connector an unique name less than 50 characters. Set that same name for `DOORMAN_MM_DEVICE_NAME` variable for doorman.
+3. Optionally set an expiration date for the key. Click **Save**.
+4. The API key will be flashed at the top of the screen in an orange box. Use that value for `DOORMAN_MM_API_KEY`.
+
+
+
+## Enable the connector device
+
+1. Run `websocket_client.py`. Doorman will try to register with the server, but the server will disconnect the connector until it is authorized.
+2. In the Django Admin, click on **Access** > **Doors**, then click `New Device (doorman)` (the value in perens will match `DOORMAN_MM_DEVICE_NAME` from earlier).
+3. Check `Is this device authorised to access the system?`, and set any of the option checkboxes according to your setup, and click **Save**.
diff --git a/docs/mm-api-key.png b/docs/mm-api-key.png
new file mode 100644
index 0000000..ceef572
Binary files /dev/null and b/docs/mm-api-key.png differ
diff --git a/doorman/__init__.py b/doorman/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doorman/app.py b/doorman/app.py
index 8eaa120..048a228 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -1,27 +1,68 @@
#!/usr/bin/env python
+import asyncio
import json
+import logging
import os
-import socket
-from functools import wraps
+from contextlib import asynccontextmanager
+from typing import AsyncIterator
+import apprise
+import httpx
import ldap3
-import requests
-from flask import Flask, Response, request
-from requests.auth import HTTPBasicAuth
+import sentry_sdk
+from fastapi import FastAPI, Request
+from fastapi.responses import Response
+from rich.logging import RichHandler
from . import fanvil
+from .ipc import door_access_queue
-app = Flask(__name__)
+logger = logging.getLogger("doorman")
+
+
+def _init_sentry() -> None:
+ """Initialize Sentry if DOORMAN_SENTRY_DSN is set; no-op otherwise.
+
+ The FastAPI integration is auto-enabled when sentry-sdk sees fastapi
+ installed, so we don't need to pass integrations= explicitly. This must
+ run before `app = FastAPI(...)` is constructed.
+ """
+ dsn = os.environ.get("DOORMAN_SENTRY_DSN")
+ if not dsn:
+ return
+ sentry_sdk.init(
+ dsn=dsn,
+ environment=os.environ.get("DOORMAN_SENTRY_ENVIRONMENT"),
+ release=os.environ.get("DOORMAN_SENTRY_RELEASE"),
+ traces_sample_rate=float(
+ os.environ.get("DOORMAN_SENTRY_TRACES_SAMPLE_RATE", "0.1")
+ ),
+ send_default_pii=True,
+ auto_session_tracking=False, # GlitchTip does not support sessions
+ # enable_logs=True, # Opt-in: send logs to GlitchTip (uses disk space)
+ )
+
+
+_init_sentry()
+
+
+def env_bool(s) -> bool:
+ return str(s).lower() in ("1", "t", "true", "y", "yes")
+
+
+LDAP_ENABLE = env_bool(os.environ.get("DOORMAN_LDAP_ENABLE", True))
+MM_ENABLE = env_bool(os.environ.get("DOORMAN_MM_ENABLE", True))
+MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json")
LDAP_SERVER = os.environ.get("DOORMAN_LDAP_SERVER", "localhost")
-LDAP_USE_SSL = os.environ.get("DOORMAN_LDAP_USE_SSL", False)
+LDAP_USE_SSL = env_bool(os.environ.get("DOORMAN_LDAP_USE_SSL", False))
LDAP_BASE_DN = os.environ.get(
"DOORMAN_LDAP_BASE_DN", "cn=users,cn=accounts,dc=pawprint,dc=space"
)
LDAP_USER_DN = os.environ.get("DOORMAN_LDAP_USER_DN")
LDAP_PASS = os.environ.get("DOORMAN_LDAP_PASS")
-FANVIL_SSL = os.environ.get("DOORMAN_FANVIL_SSL", False)
+FANVIL_URL = os.environ.get("DOORMAN_FANVIL_URL", "http://fanvil")
FANVIL_VERIFY_CA = os.environ.get("DOORMAN_FANVIL_CA")
FANVIL_USER = os.environ.get("DOORMAN_FANVIL_USER", "admin")
FANVIL_PASS = os.environ.get("DOORMAN_FANVIL_PASS", "admin")
@@ -29,103 +70,247 @@
DOORBELL_WEBHOOK = os.environ.get("DOORMAN_DOORBELL_WEBHOOK")
ACCESS_PINS = json.loads(os.environ.get("DOORMAN_ACCESS_PINS", "{}"))
SUCCESS_WEBHOOK = os.environ.get("DOORMAN_SUCCESS_WEBHOOK")
+LDAP_APPRISE_URL = os.environ.get("DOORMAN_LDAP_APPRISE_URL")
+DEBUG = env_bool(os.environ.get("DEBUG", False))
+
+HTTP_TIMEOUT = httpx.Timeout(5.0)
+
+
+def setup_logging() -> None:
+ """Attach a RichHandler to the doorman + doorman_client loggers.
+ Called from the FastAPI lifespan and from websocket_client when run
+ standalone, so logs appear regardless of which entrypoint is hosting us.
+ """
+ handler = RichHandler()
+ handler.setFormatter(logging.Formatter("%(message)s", datefmt="[%X]"))
+ level = logging.DEBUG if DEBUG else logging.INFO
+ for name in ("doorman", "doorman_client"):
+ log = logging.getLogger(name)
+ if not any(isinstance(h, RichHandler) for h in log.handlers):
+ log.addHandler(handler)
+ log.setLevel(level)
+ log.propagate = False
-def returns_xml(f):
- @wraps(f)
- def decorated_function(*args, **kwargs):
- r, status = f(*args, **kwargs)
- return Response(r, content_type="application/xml; charset=utf-8", status=status)
- return decorated_function
+def xml_response(status: int) -> Response:
+ body = f'{status}'
+ return Response(
+ content=body,
+ media_type="application/xml; charset=utf-8",
+ status_code=status,
+ )
-def return_code_template(status):
- return f"""{status}"""
+_background_tasks: set[asyncio.Task] = set()
-def open_door(remote_addr):
- if FANVIL_SSL:
- protocol = "https"
- else:
- protocol = "http"
+def _fire_and_forget(coro) -> None:
+ """Schedule a coroutine as a background task with error logging."""
+
+ async def _wrapper():
+ try:
+ await coro
+ except Exception as e:
+ logger.error(f"Background task failed: {e}")
+ sentry_sdk.capture_exception(e)
+
+ task = asyncio.create_task(_wrapper())
+ _background_tasks.add(task)
+ task.add_done_callback(_background_tasks.discard)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncIterator[None]:
+ setup_logging()
+
+ # Single shared HTTP client with explicit timeouts for outbound webhooks.
+ app.state.http = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
+
+ import threading
+
+ from websocket_client import MMAccessClient
+
+ ws_client: dict = {}
+
+ def start_websocket_client() -> None:
+ try:
+ client = MMAccessClient(debug=DEBUG)
+ ws_client["instance"] = client
+ client.run()
+ except Exception as e:
+ logger.exception("MemberMatters websocket client crashed")
+ sentry_sdk.capture_exception(e)
+
+ ws_thread = threading.Thread(
+ target=start_websocket_client, daemon=True, name="mm-ws-client"
+ )
+ ws_thread.start()
try:
- reversed_dns = socket.gethostbyaddr(remote_addr)
- host = reversed_dns[0]
- except Exception:
- app.logger.warn("Unable to get reverse DNS for request host")
- host = remote_addr
+ yield
+ finally:
+ # Close the websocket so run_forever() returns and the pump thread exits.
+ client = ws_client.get("instance")
+ if client is not None:
+ try:
+ client.close()
+ except Exception:
+ logger.exception("Error closing websocket client")
+ await app.state.http.aclose()
+
- # Change code= to match the value in EGS settings > features > calling
- # password (* by default)
- url = f"{protocol}://{host}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*"
- auth = HTTPBasicAuth(FANVIL_USER, FANVIL_PASS)
+app = FastAPI(lifespan=lifespan)
- requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA)
+@app.exception_handler(Exception)
+async def xml_unhandled_exception_handler(request: Request, exc: Exception) -> Response:
+ """Fanvil expects XML for every response; never let a JSON 500 leak out."""
+ logger.exception("Unhandled exception", exc_info=exc)
+ sentry_sdk.capture_exception(exc)
+ return xml_response(500)
-def lookup_card(card_number):
+
+def open_door() -> None:
+ """Trigger the Fanvil door relay via its HTTP API.
+
+ Synchronous on purpose: it is called from the websocket worker thread,
+ which has no event loop. FastAPI handlers should call via asyncio.to_thread.
+ """
+ url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*"
+ verify = FANVIL_VERIFY_CA if FANVIL_VERIFY_CA else False
+ with httpx.Client(verify=verify, timeout=HTTP_TIMEOUT) as client:
+ client.get(url, auth=(FANVIL_USER, FANVIL_PASS))
+
+
+def _lookup_mm(card_number: str) -> bool:
+ with open(MM_DATA_FILE) as f:
+ mm_data = json.load(f)
+ authorized_tags = mm_data.get("tags", [])
+ logger.debug(
+ f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}"
+ )
+ locked_out = mm_data.get("locked_out", False)
+ if locked_out:
+ logger.info("This MemberMatters device was set to locked_out by the server!")
+ return False
+
+ if card_number in authorized_tags:
+ logger.info(f"card_number: {card_number} is authorized by MemberMatters")
+ return True
+
+ logger.debug(f"card_number: {card_number} not authorized by MemberMatters")
+ return False
+
+
+def _notify_ldap(card_number: str, attributes: dict) -> None:
+ """Send a notification when access is granted via LDAP."""
+ if not LDAP_APPRISE_URL:
+ return
+ ap = apprise.Apprise()
+ ap.add(LDAP_APPRISE_URL)
+ cn = attributes.get("cn", "unknown")
+ uid = attributes.get("uid", "unknown")
+ ap.notify(
+ title="Doorman",
+ body=(
+ f"Card {card_number} was granted access via LDAP.\n" f"CN: {cn}, UID: {uid}"
+ ),
+ )
+
+
+def _ldap_search(card_number: str) -> list:
+ """Synchronous LDAP search; call via asyncio.to_thread from async code."""
ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL)
with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn:
- app.logger.info(conn)
+ logger.info(str(conn))
search_filter = f"(&(rfidbadge={card_number})(!(nsAccountLock=TRUE)))"
- app.logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}")
+ logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}")
conn.search(
- LDAP_BASE_DN, search_filter, attributes=["cn", "uid"],
+ LDAP_BASE_DN,
+ search_filter,
+ attributes=["cn", "uid"],
)
- app.logger.info(f"Response: {conn.response}")
-
- if len(conn.response) == 1:
- app.logger.info(f"Card found: {conn.response[0]['attributes']}")
- # TODO: write cn to audit log (influxdb)
- if SUCCESS_WEBHOOK:
- webhook_data = {"_type": "CARD", "card_number": card_number}
- webhook_data.update(conn.response[0]['attributes'])
- try:
- requests.post(SUCCESS_WEBHOOK, webhook_data)
- except requests.RequestException as e:
- app.logger.error("Exception while trying to send success webhook:")
- app.logger.exception(e)
- return True
- else:
- app.logger.info("Card not found")
+ logger.info(f"Response: {conn.response}")
+ return list(conn.response)
+
+
+async def _lookup_ldap(request: Request, card_number: str) -> bool:
+ response = await asyncio.to_thread(_ldap_search, card_number)
+ if len(response) != 1:
+ logger.info("Card not found")
return False
+ attributes = response[0]["attributes"]
+ logger.info(f"Card found: {attributes}")
+ if LDAP_APPRISE_URL:
+ _fire_and_forget(asyncio.to_thread(_notify_ldap, card_number, attributes))
+ if SUCCESS_WEBHOOK:
+ webhook_data = {"_type": "CARD", "card_number": card_number}
+ webhook_data.update(attributes)
+ _fire_and_forget(
+ request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data)
+ )
+ return True
-def lookup_pin(input_value):
+async def lookup_card(request: Request, card_number: str) -> bool:
+ mm_authorized = await asyncio.to_thread(_lookup_mm, card_number)
+ if mm_authorized:
+ # Only notify MemberMatters of access if the card was in its own tag list
+ door_access_queue.put(
+ {
+ "id_number": card_number,
+ "success": True,
+ "method": "rfid",
+ }
+ )
+ return True
+ return await _lookup_ldap(request, card_number)
+
+
+async def lookup_pin(request: Request, input_value: str) -> bool:
for pin in ACCESS_PINS:
if input_value == str(pin):
- app.logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}")
+ logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}")
if SUCCESS_WEBHOOK:
webhook_data = {"_type": "PIN", "cn": ACCESS_PINS[pin]}
- try:
- requests.post(SUCCESS_WEBHOOK, webhook_data)
- except requests.RequestException as e:
- app.logger.error("Exception while trying to send success webhook:")
- app.logger.exception(e)
+ _fire_and_forget(
+ request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data)
+ )
return True
return False
-@app.route("/", methods=["GET", "POST"])
-@returns_xml
-def auth():
+@app.api_route("/", methods=["GET", "POST"])
+async def auth(request: Request) -> Response:
if request.method == "POST":
- input_type, input_value = fanvil.parse_command(request.get_data())
+ body = await request.body()
+ try:
+ input_type, input_value = fanvil.parse_command(body)
+ except fanvil.FanvilParseError as e:
+ logger.warning(f"Failed to parse Fanvil command: {e}")
+ return xml_response(401)
+ success = False
if input_type == fanvil.CARD_ID:
- app.logger.info(f"Got card: {input_value}")
- success = lookup_card(input_value)
+ logger.info(f"Got card: {input_value}")
+ success = await lookup_card(request, input_value)
elif input_type == fanvil.KEYPAD_INPUT:
- app.logger.info(f"Got keypad input: {input_value}")
- success = lookup_pin(input_value)
+ logger.info(f"Got keypad input: {input_value}")
+ success = await lookup_pin(request, input_value)
if success:
- return return_code_template(200), 200
- return return_code_template(401), 401
+ return xml_response(200)
+ return xml_response(401)
-@app.route("/fanvil/doorbell", methods=["GET"])
-@returns_xml
-def doorbell():
- requests.post(DOORBELL_WEBHOOK)
- return return_code_template(200), 200
+@app.get("/fanvil/doorbell")
+async def doorbell(request: Request) -> Response:
+ """Historical function to work around webhook limitations in Home Assistant."""
+ if not DOORBELL_WEBHOOK:
+ return xml_response(200)
+ try:
+ await request.app.state.http.post(DOORBELL_WEBHOOK)
+ except httpx.HTTPError as e:
+ logger.error("Exception while sending doorbell webhook:")
+ logger.exception(e)
+ sentry_sdk.capture_exception(e)
+ return xml_response(200)
diff --git a/doorman/fanvil.py b/doorman/fanvil.py
index 74342f6..c751b19 100644
--- a/doorman/fanvil.py
+++ b/doorman/fanvil.py
@@ -22,13 +22,13 @@ class CommandParseError(FanvilParseError):
pass
-def parse_uri(uri):
+def parse_uri(uri: str) -> tuple[str, str, str]:
command, attr = uri.split(":")
key, val = attr.split("=")
return command, key, val.rstrip("@1")
-def parse_command(xml):
+def parse_command(xml: bytes) -> tuple[str, str]:
"""
Example card read POST payload:
@@ -44,6 +44,8 @@ def parse_command(xml):
"""
root = ET.fromstring(xml)
ei = root.find("ExecuteItem")
+ if ei is None or ei.text is None:
+ raise FanvilParseError("Missing ExecuteItem element or text")
uri = ei.text.split('"')[1]
try:
diff --git a/doorman/ipc.py b/doorman/ipc.py
new file mode 100644
index 0000000..a108435
--- /dev/null
+++ b/doorman/ipc.py
@@ -0,0 +1,12 @@
+"""
+Inter-process communication between the FastAPI server and the websocket client.
+
+Uses a threading.Queue since the websocket client runs in a separate thread.
+Both asyncio (FastAPI) and synchronous (websocket client) code can safely
+interact with threading.Queue.
+"""
+
+import queue
+
+# FastAPI pushes door_access events here; the websocket client consumes them.
+door_access_queue: queue.Queue = queue.Queue()
diff --git a/envrc.example b/envrc.example
index 5687c7c..9454ef3 100644
--- a/envrc.example
+++ b/envrc.example
@@ -1,18 +1,40 @@
-source venv/bin/activate
+# Run with: uvicorn main:app --host 0.0.0.0 --port 5000
-export FLASK_HOST=0.0.0.0
-export FLASK_ENV=development
-export FLASK_APP=doorman.app
+# Websocket connection to Member Matters
+DOORMAN_MM_ENABLE=true
+DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access
+DOORMAN_MM_DEVICE_NAME=frontdoor_test
+DOORMAN_MM_API_KEY=super.secret
-export DOORMAN_LDAP_SERVER=ipa.pawprint.space
-export DOORMAN_LDAP_USE_SSL=1
+# Keep-alive ping interval (seconds). Pong timeout = 3× this value.
+#DOORMAN_MM_PING_INTERVAL=30
+
+# LDAP Configuration
+DOORMAN_LDAP_ENABLE=true
+DOORMAN_LDAP_SERVER=ipa.pawprint.space
+DOORMAN_LDAP_USE_SSL=true
# Service account details for LDAP bind
-export DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space"
-export DOORMAN_LDAP_PASS=foobar
+DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space"
+DOORMAN_LDAP_PASS=foobar
+
+# Optional notification for LDAP lookup. Leave unset to disable.
+# https://appriseit.com/url-builder/
+#DOORMAN_LDAP_APPRISE_URL=
-export DOORMAN_FANVIL_SSL=1
-export DOORMAN_FANVIL_USER=admin
-export DOORMAN_FANVIL_PASS=admin
+DOORMAN_FANVIL_URL=https://fanvil.local
+DOORMAN_FANVIL_USER=admin
+DOORMAN_FANVIL_PASS=admin
# Unset to skip SSL verification
-export DOORMAN_FANVIL_CA=pawprint-ca.crt
+DOORMAN_FANVIL_CA=pawprint-ca.crt
+
+# Sentry error reporting. Leave DSN unset to disable.
+#DOORMAN_SENTRY_DSN=
+#DOORMAN_SENTRY_ENVIRONMENT=production
+#DOORMAN_SENTRY_RELEASE=
+#DOORMAN_SENTRY_TRACES_SAMPLE_RATE=0.1
+
+# Uncomment for .envrc use:
+#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY DOORMAN_MM_PING_INTERVAL
+#export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS
+#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..ae5ed15
--- /dev/null
+++ b/main.py
@@ -0,0 +1,11 @@
+"""
+Unified entrypoint that runs the FastAPI server and the MemberMatters
+websocket client in the same process.
+
+Run with:
+ uvicorn main:app --host 0.0.0.0 --port 5000
+
+The websocket client is started via the FastAPI lifespan handler.
+"""
+
+from doorman.app import app # noqa: F401 — uvicorn imports `app` from this module
diff --git a/requirements.txt b/requirements.txt
index a5e71de..1189f72 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,8 @@
-Flask>=2.2.5
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
ldap3>=2.9.1
-requests~=2.32.3
-#json_logging>=1.2
+httpx>=0.27.0
+apprise>=1.9.0
+websocket-client~=1.9.0
+rich~=14.2.0
+sentry-sdk>=2.0
diff --git a/websocket_client.py b/websocket_client.py
new file mode 100644
index 0000000..cb6460c
--- /dev/null
+++ b/websocket_client.py
@@ -0,0 +1,223 @@
+import json
+import logging
+import os
+import queue
+import threading
+import time
+from enum import Enum
+
+import sentry_sdk
+import websocket
+
+from doorman.app import env_bool, open_door
+from doorman.ipc import door_access_queue
+
+
+class AccessDeviceType(Enum):
+ DOOR = "door"
+ INTERLOCK = "interlock"
+
+ def __str__(self):
+ return f"{self.value}"
+
+
+MM_ACCESS_URL = os.environ.get(
+ "DOORMAN_MM_ACCESS_URL", "ws://membermatters.local/ws/access"
+)
+MM_DEVICE_NAME = os.environ.get("DOORMAN_MM_DEVICE_NAME", "Unnamed")
+MM_DEVICE_TYPE = AccessDeviceType[
+ os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()
+]
+MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json")
+MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset")
+MM_PING_INTERVAL = int(os.environ.get("DOORMAN_MM_PING_INTERVAL", "30"))
+DEBUG = env_bool(os.environ.get("DEBUG", False))
+
+logger = logging.getLogger("doorman_client")
+
+
+class MMAccessClient(websocket.WebSocketApp):
+ def __init__(self, *args, **kwargs):
+ websocket.enableTrace(kwargs.get("debug", False))
+ self.locked_out = False
+ super().__init__(
+ f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}",
+ on_open=self.on_open,
+ on_message=self.on_message,
+ on_error=self.on_error,
+ on_close=self.on_close,
+ )
+
+ def run(self) -> None:
+ # rel's dispatcher installs a SIGINT handler at construction time,
+ # which fails off the main thread. Use run_forever()'s default select
+ # loop and pump work from daemon threads.
+ self._pump_stop = threading.Event()
+ self._last_pong = time.monotonic()
+ pump = threading.Thread(
+ target=self._pump_door_access_queue,
+ daemon=True,
+ name="mm-ws-queue-pump",
+ )
+ keepalive = threading.Thread(
+ target=self._pump_keepalive,
+ daemon=True,
+ name="mm-ws-keepalive",
+ )
+ pump.start()
+ keepalive.start()
+ try:
+ self.run_forever(reconnect=15)
+ finally:
+ self._pump_stop.set()
+
+ def _pump_door_access_queue(self) -> None:
+ """Forward queued door_access events to the server until stopped."""
+ while not self._pump_stop.is_set():
+ try:
+ event = door_access_queue.get(timeout=0.5)
+ except queue.Empty:
+ continue
+ try:
+ self.send_door_access(
+ id_number=event["id_number"],
+ success=event["success"],
+ method=event.get("method", "rfid"),
+ )
+ except Exception as e:
+ logger.exception("Error sending door_access event")
+ sentry_sdk.capture_exception(e)
+
+ def _pump_keepalive(self) -> None:
+ """Send periodic client→server pings; force reconnect on pong timeout."""
+ timeout_seconds = MM_PING_INTERVAL * 3
+ while not self._pump_stop.wait(MM_PING_INTERVAL):
+ sock = self.sock
+ if sock is None or not sock.connected:
+ # Reset so a freshly reopened socket isn't immediately stale.
+ self._last_pong = time.monotonic()
+ continue
+
+ if time.monotonic() - self._last_pong > timeout_seconds:
+ logger.warning(
+ "MemberMatters pong timeout (%ss), forcing reconnect",
+ timeout_seconds,
+ )
+ self._last_pong = time.monotonic()
+ try:
+ self.close()
+ except Exception as e:
+ logger.exception("Error closing socket on pong timeout")
+ sentry_sdk.capture_exception(e)
+ continue
+
+ try:
+ logger.debug("sending keep-alive ping")
+ self.send(json.dumps({"command": "ping"}))
+ except Exception as e:
+ logger.exception("Error sending keep-alive ping")
+ sentry_sdk.capture_exception(e)
+
+ def send_door_access(
+ self, id_number: str, success: bool, method: str = "rfid"
+ ) -> None:
+ """Send a door_access command to the MemberMatters server."""
+ packet = {
+ "command": "door_access",
+ "payload": {
+ "id_number": id_number,
+ "time": str(int(time.time())),
+ "success": success,
+ "method": method,
+ },
+ }
+ logger.info(
+ f"Sending door_access: id={id_number} success={success} method={method}"
+ )
+ self.send(json.dumps(packet))
+
+ def save_tags(self, data: dict) -> None:
+ with open(MM_DATA_FILE, "w") as tags_file:
+ tags_doc = {
+ "tags": data["tags"],
+ "hash": data["hash"],
+ "locked_out": self.locked_out,
+ }
+ json.dump(tags_doc, tags_file)
+
+ def load_tags(self) -> dict:
+ with open(MM_DATA_FILE) as tags_file:
+ data = json.load(tags_file)
+ self.locked_out = data.get("locked_out", False)
+ return data
+
+ def update_device_locked_out(self, locked_out: bool) -> None:
+ logger.info(f"Update device lockout: locked_out = {locked_out}")
+ tags_doc = self.load_tags()
+ self.locked_out = locked_out
+ self.save_tags(tags_doc)
+
+ def parse_command(self, data: dict) -> None:
+ command = data.get("command")
+ if command == "ping":
+ self.send(json.dumps({"command": "pong"}))
+
+ elif command == "pong":
+ self._last_pong = time.monotonic()
+
+ elif command == "reboot":
+ # Not implemented (lol)
+ pass
+
+ elif command == "bump":
+ # Bump the door open. open_door is synchronous so we don't need
+ # to spin up an event loop in this websocket worker thread.
+ try:
+ open_door()
+ except Exception as e:
+ logger.exception("Error opening door from bump command")
+ sentry_sdk.capture_exception(e)
+
+ elif command == "sync":
+ # Save synced tags to a file
+ self.save_tags(data)
+
+ elif command == "update_device_locked_out":
+ self.update_device_locked_out(data.get("locked_out", False))
+
+ def on_message(self, ws, message: str) -> None:
+ logger.debug(f"> {message}")
+ try:
+ data = json.loads(message)
+ self.parse_command(data)
+ except json.decoder.JSONDecodeError as e:
+ logger.exception(e)
+ sentry_sdk.capture_exception(e)
+ return
+
+ def on_error(self, ws, error: Exception) -> None:
+ logger.error(f"[SOCKET ERROR]: {error}")
+ sentry_sdk.capture_exception(error)
+
+ def on_close(self, ws, close_status_code: int, close_msg: str) -> None:
+ logger.info("### closed ###")
+ logger.info(f"Status: {close_status_code}, Message: {close_msg}")
+
+ def on_open(self, data) -> None:
+ self._last_pong = time.monotonic()
+ logger.info(f"Opened connection to {self.url}, sending auth.")
+ # Send auth
+ auth_packet = {
+ "command": "authenticate",
+ "secret_key": MM_API_KEY,
+ }
+ self.send(json.dumps(auth_packet))
+
+
+if __name__ == "__main__":
+ # Standalone mode: run without FastAPI (useful for local testing).
+ from doorman.app import setup_logging
+
+ setup_logging()
+ client = MMAccessClient(debug=DEBUG)
+ client.run()