From d37526e39a9f21575102ad471786634704cded65 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Fri, 31 Jul 2026 00:33:13 +0200 Subject: [PATCH 01/10] [pyproject] add gpiozero --- pyproject.toml | 3 ++- uv.lock | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d911fe8..92ba7c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pyscard>=2.3.1", "alembic>=1.18.5", "ruff>=0.16.0", + "gpiozero>=2.0.1.post3", ] [tool.uv.sources] @@ -47,4 +48,4 @@ select = [ # isort "I", ] -ignore = ["B008"] \ No newline at end of file +ignore = ["B008"] diff --git a/uv.lock b/uv.lock index caa56d5..7449981 100644 --- a/uv.lock +++ b/uv.lock @@ -270,6 +270,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorzero" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/688824a06e8c4d04c7d2fd2af2d8da27bed51af20ee5f094154e1d680334/colorzero-2.0.tar.gz", hash = "sha256:e7d5a5c26cd0dc37b164ebefc609f388de24f8593b659191e12d85f8f9d5eb58", size = 25382, upload-time = "2021-03-15T23:42:23.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/a6/ddd0f130e44a7593ac6c55aa93f6e256d2270fd88e9d1b64ab7f22ab8fde/colorzero-2.0-py2.py3-none-any.whl", hash = "sha256:0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8", size = 26573, upload-time = "2021-03-15T23:42:21.757Z" }, +] + [[package]] name = "coverage" version = "7.14.0" @@ -624,6 +636,7 @@ source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "fastapi", extra = ["standard"] }, + { name = "gpiozero" }, { name = "poetry" }, { name = "pwdlib", extra = ["argon2"] }, { name = "pyjwt", extra = ["crypto"] }, @@ -641,6 +654,7 @@ dependencies = [ requires-dist = [ { name = "alembic", specifier = ">=1.18.5" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, + { name = "gpiozero", specifier = ">=2.0.1.post3" }, { name = "poetry", specifier = ">=2.3.4" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" }, @@ -654,6 +668,18 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.38" }, ] +[[package]] +name = "gpiozero" +version = "2.0.1.post3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorzero" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/63/864a3c36842cf7b05808aadf906763a024a1b13f2a4c8144e3163de91255/gpiozero-2.0.1.post3.tar.gz", hash = "sha256:745feab6df463ac2e9de10c67e2dd9f396e668ba4e281e92381d6c460100a8f7", size = 168403, upload-time = "2026-07-27T10:03:32.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/cb/ec5917b454b2e82874769a348dc74cfcec5b447bf1061c827edea9702451/gpiozero-2.0.1.post3-py3-none-any.whl", hash = "sha256:d491734803a9bc6036602e6b8bbd73fb25c33da88de0fcd11a40e890b8bc2d3b", size = 151465, upload-time = "2026-07-27T10:03:31.195Z" }, +] + [[package]] name = "greenlet" version = "3.4.0" -- 2.51.2 From 717ce951a60dd05ec2ff76381ab94017c4848e1c Mon Sep 17 00:00:00 2001 From: ahtlon Date: Fri, 31 Jul 2026 00:36:11 +0200 Subject: [PATCH 02/10] [door] add doorcontroller with mock pins for testing --- app/main.py | 9 +++++- app/services/door.py | 73 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index f8312e9..7910363 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from app.controllers import ( ) from app.services.auth import create_first_user, token_router from app.services.database import create_db_and_tables, get_db_session +from app.services.door import DoorController, init_controller from app.services.scanner import BackgroundScanner from app.services.settings import verify_settings @@ -37,7 +38,13 @@ async def lifespan(app: FastAPI): verify_settings() create_db_and_tables() create_first_user(db=get_db_session()) - + init_controller( + DoorController( + lock_pin=os.getenv("LOCK_PIN", 17), + unlock_pin=os.getenv("UNLOCK_PIN", 18), + mock_factory=os.getenv("MOCK_GPIO", True), + ), + ) if not os.getenv("DISABLE_CARDS"): scanner.start() diff --git a/app/services/door.py b/app/services/door.py index c734ee9..fcc0d4d 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -1,6 +1,9 @@ import logging from datetime import date, datetime, timedelta +from time import sleep +from gpiozero import OutputDevice +from gpiozero.pins.mock import MockFactory from sqlalchemy import exc from sqlmodel import select @@ -9,25 +12,77 @@ from app.services.database import Session, add_and_refresh logger = logging.getLogger(__name__) -doorIsOpen = True -# I think this could also be gpio controlled + # See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional +# TODO: add sensor pin +class DoorController: + def __init__( + self, + lock_pin: str = 17, # connected to 20 on the esp + unlock_pin: str = 18, # connected to 21 on the esp + mock_factory: bool = False, + ): + self._is_open: bool = False + self._lock_pin = lock_pin + self._unlock_pin = unlock_pin + + if mock_factory: + from gpiozero import Device + + Device.pin_factory = MockFactory() + + self._lock = OutputDevice(pin=lock_pin, active_high=False) + self._unlock = OutputDevice(pin=unlock_pin, active_high=False) + + logger.info( + "DoorController started. lock=%s unlock=%s factory=%s", + lock_pin, + unlock_pin, + type(self._lock.pin_factory).__name__, + ) + + def open(self): + self._unlock.on() + sleep(0.4) + self._unlock.off() + self._is_open = True + logger.info("Door unlocked!") + + def close(self): + self._lock.on() + sleep(0.4) + self._lock.off() + self._is_open = False + logger.info("Door locked!") + + def is_open(self): + return self._is_open + + +_contoller: DoorController | None = None + + +def init_controller(ctrl: DoorController): + global _contoller + _contoller = ctrl + + +def get_controller(): + if _contoller is None: + raise RuntimeError("DoorController not initialized.") + return _contoller def openDoor(): - global doorIsOpen - doorIsOpen = True - logger.info("Still needs gpio out") + get_controller().open() def closeDoor(): - global doorIsOpen - doorIsOpen = False - logger.info("Still needs gpio out") + get_controller().close() def isDoorOpen(): - return doorIsOpen + return get_controller().is_open() def decrementOneshot(db: Session, oneshot: OneShotAccess): -- 2.51.2 From b7b07b82da8bd9dd3952ca26ba5e29f3fd8888c8 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Fri, 31 Jul 2026 00:38:40 +0200 Subject: [PATCH 03/10] [door] correct spelling, add status endpoint the door section of the api should "just work" now --- app/controllers/doorManager.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/controllers/doorManager.py b/app/controllers/doorManager.py index 449b964..2d33924 100644 --- a/app/controllers/doorManager.py +++ b/app/controllers/doorManager.py @@ -10,14 +10,21 @@ door_router = APIRouter(prefix="/api/v1/door", tags=["Door"]) @door_router.put("/open") def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): - doorService.opendoor() + doorService.openDoor() @door_router.put("/close") def close_door( db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin) ): - doorService.closedoor() + doorService.closeDoor() + + +@door_router.put("/status") +def is_door_open( + db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin) +): + return doorService.isDoorOpen() @door_router.post("/test") -- 2.51.2 From bad4e8dd1b889cdb0f3c20e8990556c97daaf0b7 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Fri, 31 Jul 2026 01:50:08 +0200 Subject: [PATCH 04/10] [settings] rework settings --- app/services/settings.py | 47 ++++++++++++++++++++++++++++++---------- test/conftest.py | 13 +++++++++-- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/services/settings.py b/app/services/settings.py index 85a31c2..5f84f88 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -1,29 +1,52 @@ import logging import os +from functools import lru_cache logger = logging.getLogger(__name__) -def verify_settings(): - card_envs = [ +@lru_cache(1) +def disable_cards_check(): + if os.environ.get("DISABLE_CARDS", "").lower() in ("1", "true"): + return True + + for env in ( "MIFARE_APP_MASTER_KEY", "MIFARE_ACL_READ_BASE_KEY", "MIFARE_ACL_WRITE_BASE_KEY", - ] - important_envs = ["SECRET_KEY"] - other_envs = ["SQLALCHEMY_DATABASE_PATH"] - for setting in card_envs: - if (setting not in os.environ or setting == "") and not os.getenv( - "DISABLE_CARDS" - ): - raise ValueError( - f"Missing environment variable for scanner start: {setting} \n Run with DISABLE_CARDS env var to disable cards" # noqa: E501 + ): + if not os.getenv(env): + logger.critical( + f"Missing environment variable for scanner start: {env} \n Card scanner and related funcionality is disabled!" # noqa: E501 ) + return True + return False + + +disable_cards = disable_cards_check() + +if not disable_cards: + mifare_app_master_key = os.getenv("MIFARE_APP_MASTER_KEY") + mifare_acl_read_base_key = os.getenv("MIFARE_ACL_READ_BASE_KEY") + mifare_acl_write_base_key = os.getenv("MIFARE_ACL_WRITE_BASE_KEY") + + +def verify_settings(): + important_envs = ["SECRET_KEY"] + other_envs = ["SQLALCHEMY_DATABASE_PATH", "LOCK_PIN", "UNLOCK_PIN", "MOCK_GPIO"] for setting in important_envs: - if setting not in os.environ or setting == "": + if not os.getenv(setting): raise ValueError( f"Missing critical environment variable {setting}. Stopping..." ) for setting in other_envs: if setting not in os.environ: logger.warning(f"Env var {setting} not set. Continuing with defaults.") + + +verify_settings() +secret_key = os.getenv("SECRET_KEY") +sqlalchemy_database_path = os.getenv("SQLALCHEMY_DATABASE_PATH") +lock_pin = os.getenv("LOCK_PIN") +unlock_pin = os.getenv("UNLOCK_PIN") +mock_gpio = os.getenv("MOCK_GPIO") diff --git a/test/conftest.py b/test/conftest.py index 1a4bf40..92ce9f9 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,7 +5,6 @@ from fastapi.testclient import TestClient from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine -from app.main import app from app.model.models import ( AccessAuthorizationDB, Card, @@ -25,6 +24,16 @@ engine = create_engine( ) +@pytest.fixture +def app(): + import os + + os.environ["SECRET_KEY"] = "ff" * 16 + from app.main import app + + return app + + @pytest.fixture(scope="function") def db_session(): """Create a fresh database session for each test.""" @@ -35,7 +44,7 @@ def db_session(): @pytest.fixture(scope="function") -def client(db_session): +def client(app, db_session): """Create a test client with a database session override.""" def override_get_session(): -- 2.51.2 From 158b4302353907cd8cdb0afff9e1326fd8407e7f Mon Sep 17 00:00:00 2001 From: ahtlon Date: Fri, 31 Jul 2026 04:33:18 +0200 Subject: [PATCH 05/10] [settings] fuck it, rework the settings again this time using pydantic-settings as a base - removed all os.getenv calls - removed the secret_key default option - reworked database loading, creating tables - prob. something else also but its 4:30 and i have to sleep --- app/__init__.py | 7 --- app/main.py | 18 ++---- app/services/auth.py | 4 +- app/services/database.py | 32 ++++++---- app/services/door.py | 4 +- app/services/scanner.py | 7 ++- app/services/settings.py | 94 +++++++++++++++++------------ pyproject.toml | 1 + test/conftest.py | 16 ++--- test/test_services/test_database.py | 4 +- uv.lock | 2 + 11 files changed, 100 insertions(+), 89 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index e4c3274..e69de29 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +0,0 @@ -from fastapi import FastAPI - -from .controllers import cardManager, userManager - -app = FastAPI() -app.include_router(userManager.user_router) -app.include_router(cardManager.card_router) diff --git a/app/main.py b/app/main.py index 7910363..210c59b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,10 @@ import logging -import os from contextlib import asynccontextmanager -from dotenv import load_dotenv from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer -load_dotenv() - -# ruff: disable[E402] from app.controllers import ( aaManager, cardManager, @@ -22,9 +17,7 @@ from app.services.auth import create_first_user, token_router from app.services.database import create_db_and_tables, get_db_session from app.services.door import DoorController, init_controller from app.services.scanner import BackgroundScanner -from app.services.settings import verify_settings - -# ruff: enable[E402] +from app.services.settings import settings logger = logging.getLogger(__name__) @@ -35,17 +28,16 @@ logging.basicConfig(level=logging.INFO) @asynccontextmanager async def lifespan(app: FastAPI): - verify_settings() create_db_and_tables() create_first_user(db=get_db_session()) init_controller( DoorController( - lock_pin=os.getenv("LOCK_PIN", 17), - unlock_pin=os.getenv("UNLOCK_PIN", 18), - mock_factory=os.getenv("MOCK_GPIO", True), + lock_pin=settings.lock_pin, + unlock_pin=settings.unlock_pin, + mock_factory=settings.mock_gpio, ), ) - if not os.getenv("DISABLE_CARDS"): + if not settings.disable_cards: scanner.start() logger.info("-" * 63) diff --git a/app/services/auth.py b/app/services/auth.py index 5b54232..d4392db 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,5 +1,4 @@ import logging -import os import secrets import string from datetime import UTC, datetime, timedelta @@ -14,10 +13,11 @@ from sqlmodel import Session, select from app.model.models import Token, TokenData, UserDB from app.services.database import add_and_refresh, get_session +from app.services.settings import settings logger = logging.getLogger(__name__) -SECRET_KEY = os.getenv("SECRET_KEY", default="ff" * 16) +SECRET_KEY = settings.secret_key ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 120 diff --git a/app/services/database.py b/app/services/database.py index f1adadc..7b19034 100644 --- a/app/services/database.py +++ b/app/services/database.py @@ -1,38 +1,48 @@ import logging -from os import getenv, path +from functools import lru_cache +from sqlalchemy import inspect from sqlmodel import Session, SQLModel, create_engine +from app.services.settings import settings + logger = logging.getLogger(__name__) -SQLALCHEMY_DATABASE_PATH = getenv("SQLALCHEMY_DATABASE_PATH", "./gatekeeper.db") -SQLALCHEMY_DATABASE_URL = "sqlite:///" + SQLALCHEMY_DATABASE_PATH -engine = create_engine(SQLALCHEMY_DATABASE_URL) +@lru_cache +def get_engine(): + return create_engine( + settings.sqlalchemy_database_url, connect_args={"check_same_thread": False} + ) def create_db_and_tables(): - if not path.exists(SQLALCHEMY_DATABASE_PATH): - SQLModel.metadata.create_all(engine) + inspector = inspect(get_engine()) + existing_tables = inspector.get_table_names() + if not existing_tables: + SQLModel.metadata.create_all(get_engine()) from alembic.config import Config from alembic import command - alembic_cfg = Config("./alembic.ini") - alembic_cfg.attributes["sqlalchemy.url"] = SQLALCHEMY_DATABASE_URL + alembic_cfg = Config(settings.alembic_config) + alembic_cfg.set_main_option("sqlalchemy.url", str(get_engine().url)) + alembic_cfg.attributes["sqlalchemy.url"] = settings.sqlalchemy_database_url command.stamp(alembic_cfg, "head") logger.info("Database created and tables initialized.") else: - logger.info("Database already exists") + logger.info( + "Database already initialized (%d tables found).", len(existing_tables) + ) def get_session(): - with Session(engine) as db: + with Session(get_engine()) as db: yield db def get_db_session(): - return Session(engine) + return Session(get_engine()) def add_and_refresh(db: Session, obj): diff --git a/app/services/door.py b/app/services/door.py index fcc0d4d..94339c8 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -18,8 +18,8 @@ logger = logging.getLogger(__name__) class DoorController: def __init__( self, - lock_pin: str = 17, # connected to 20 on the esp - unlock_pin: str = 18, # connected to 21 on the esp + lock_pin: int = 17, # connected to 20 on the esp + unlock_pin: int = 18, # connected to 21 on the esp mock_factory: bool = False, ): self._is_open: bool = False diff --git a/app/services/scanner.py b/app/services/scanner.py index 72e056c..b5ef31f 100644 --- a/app/services/scanner.py +++ b/app/services/scanner.py @@ -26,14 +26,15 @@ from smartcard.CardType import AnyCardType from smartcard.Exceptions import CardRequestTimeoutException from app.services.door import checkAccess, openDoor +from app.services.settings import settings logger = logging.getLogger(__name__) # ENV vars load_dotenv() -MIFARE_APP_MASTER_KEY = os.getenv("MIFARE_APP_MASTER_KEY") -MIFARE_ACL_READ_BASE_KEY = os.getenv("MIFARE_ACL_READ_BASE_KEY") -MIFARE_ACL_WRITE_BASE_KEY = os.getenv("MIFARE_ACL_WRITE_BASE_KEY") +MIFARE_APP_MASTER_KEY = settings.mifare_app_master_key +MIFARE_ACL_READ_BASE_KEY = settings.mifare_acl_read_base_key +MIFARE_ACL_WRITE_BASE_KEY = settings.mifare_acl_write_base_key # Constants MIFARE_APP_ID = "DEAFFE" # 7 bytes diff --git a/app/services/settings.py b/app/services/settings.py index 5f84f88..e22c766 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -1,52 +1,70 @@ import logging -import os from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + logger = logging.getLogger(__name__) +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + secret_key: str + sqlalchemy_database_url: str = "sqlite:///./gatekeeper.db" + lock_pin: int = 17 + unlock_pin: int = 18 + mock_gpio: bool = True + alembic_config: str = "./alembic.ini" + + disable_cards: bool = False + mifare_app_master_key: str | None = None + mifare_acl_read_base_key: str | None = None + mifare_acl_write_base_key: str | None = None + + @lru_cache(1) -def disable_cards_check(): - if os.environ.get("DISABLE_CARDS", "").lower() in ("1", "true"): - return True - - for env in ( - "MIFARE_APP_MASTER_KEY", - "MIFARE_ACL_READ_BASE_KEY", - "MIFARE_ACL_WRITE_BASE_KEY", - ): - if not os.getenv(env): +def _create_settings(): + settings = Settings() + if not settings.disable_cards: + missing = [ + name.upper() + for name in ( + "mifare_app_master_key", + "mifare_acl_read_base_key", + "mifare_acl_write_base_key", + ) + if not getattr(settings, name) + ] + if missing: logger.critical( - f"Missing environment variable for scanner start: {env} \n Card scanner and related funcionality is disabled!" # noqa: E501 + "Missing environment variable for scanner start: %s" + "Card scanner and related funcionality is disabled!", + ", ".join(missing), ) - return True - return False + settings.disable_cards = True + return settings -disable_cards = disable_cards_check() +class _SettingsProxy: + _instance: Settings | None = None -if not disable_cards: - mifare_app_master_key = os.getenv("MIFARE_APP_MASTER_KEY") - mifare_acl_read_base_key = os.getenv("MIFARE_ACL_READ_BASE_KEY") - mifare_acl_write_base_key = os.getenv("MIFARE_ACL_WRITE_BASE_KEY") + def _load(self) -> Settings: + if self._instance is None: + self._instance = _create_settings() + return self._instance + + def __getattr__(self, name: str): + return getattr(self._load(), name) + + def __setattr__(self, name: str, value): + if name == "_instance": + super().__setattr__(name, value) + else: + setattr(self._load(), name, value) + + def reset(self) -> None: + self._instance = None + _create_settings.cache_clear() -def verify_settings(): - important_envs = ["SECRET_KEY"] - other_envs = ["SQLALCHEMY_DATABASE_PATH", "LOCK_PIN", "UNLOCK_PIN", "MOCK_GPIO"] - for setting in important_envs: - if not os.getenv(setting): - raise ValueError( - f"Missing critical environment variable {setting}. Stopping..." - ) - for setting in other_envs: - if setting not in os.environ: - logger.warning(f"Env var {setting} not set. Continuing with defaults.") - - -verify_settings() -secret_key = os.getenv("SECRET_KEY") -sqlalchemy_database_path = os.getenv("SQLALCHEMY_DATABASE_PATH") -lock_pin = os.getenv("LOCK_PIN") -unlock_pin = os.getenv("UNLOCK_PIN") -mock_gpio = os.getenv("MOCK_GPIO") +settings = _SettingsProxy() diff --git a/pyproject.toml b/pyproject.toml index 92ba7c0..b26351e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "alembic>=1.18.5", "ruff>=0.16.0", "gpiozero>=2.0.1.post3", + "pydantic-settings>=2.13.1", ] [tool.uv.sources] diff --git a/test/conftest.py b/test/conftest.py index 92ce9f9..8f12ee3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,3 +1,4 @@ +import os from datetime import time import pytest @@ -5,6 +6,9 @@ from fastapi.testclient import TestClient from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine +os.environ["SECRET_KEY"] = "ff" * 16 + +from app.main import app from app.model.models import ( AccessAuthorizationDB, Card, @@ -24,16 +28,6 @@ engine = create_engine( ) -@pytest.fixture -def app(): - import os - - os.environ["SECRET_KEY"] = "ff" * 16 - from app.main import app - - return app - - @pytest.fixture(scope="function") def db_session(): """Create a fresh database session for each test.""" @@ -44,7 +38,7 @@ def db_session(): @pytest.fixture(scope="function") -def client(app, db_session): +def client(db_session): """Create a test client with a database session override.""" def override_get_session(): diff --git a/test/test_services/test_database.py b/test/test_services/test_database.py index 5030922..2b55570 100644 --- a/test/test_services/test_database.py +++ b/test/test_services/test_database.py @@ -9,10 +9,10 @@ def test_create_db_and_tables(): # This is primarily an integration test from sqlalchemy import inspect - from app.services.database import engine + from app.services.database import get_engine create_db_and_tables() - inspector = inspect(engine) + inspector = inspect(get_engine()) # Check that tables exist tables = inspector.get_table_names() diff --git a/uv.lock b/uv.lock index 7449981..85457fa 100644 --- a/uv.lock +++ b/uv.lock @@ -639,6 +639,7 @@ dependencies = [ { name = "gpiozero" }, { name = "poetry" }, { name = "pwdlib", extra = ["argon2"] }, + { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pyscard" }, { name = "pytest" }, @@ -657,6 +658,7 @@ requires-dist = [ { name = "gpiozero", specifier = ">=2.0.1.post3" }, { name = "poetry", specifier = ">=2.3.4" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, + { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" }, { name = "pyscard", specifier = ">=2.3.1" }, { name = "pytest", specifier = ">=9.0.3" }, -- 2.51.2 From 936b78665940fef89109acdee844b28ee234f659 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Sat, 1 Aug 2026 14:45:44 +0200 Subject: [PATCH 06/10] Switch gpiozero(fuck you) for lgpio --- app/services/door.py | 33 ++++++++++++++++++--------------- pyproject.toml | 3 ++- uv.lock | 34 ++++++++-------------------------- 3 files changed, 28 insertions(+), 42 deletions(-) diff --git a/app/services/door.py b/app/services/door.py index 94339c8..c3fb0ee 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -2,8 +2,7 @@ import logging from datetime import date, datetime, timedelta from time import sleep -from gpiozero import OutputDevice -from gpiozero.pins.mock import MockFactory +import lgpio from sqlalchemy import exc from sqlmodel import select @@ -25,33 +24,37 @@ class DoorController: self._is_open: bool = False self._lock_pin = lock_pin self._unlock_pin = unlock_pin + self._mock = mock_factory + self._chip = None - if mock_factory: - from gpiozero import Device - - Device.pin_factory = MockFactory() - - self._lock = OutputDevice(pin=lock_pin, active_high=False) - self._unlock = OutputDevice(pin=unlock_pin, active_high=False) + if not mock_factory: + self._chip = lgpio.gpiochip_open(0) + lgpio.gpio_claim_output(self._chip, unlock_pin, 1) + lgpio.gpio_claim_output(self._chip, lock_pin, 1) logger.info( - "DoorController started. lock=%s unlock=%s factory=%s", + "DoorController started. lock=%s unlock=%s mock=%s", lock_pin, unlock_pin, - type(self._lock.pin_factory).__name__, + mock_factory, ) def open(self): - self._unlock.on() + if self._mock: + self._is_open = True + logger.info("Dor unlocked.[MOCK]") + return + + lgpio.gpio_write(self._chip, self._unlock_pin, 0) sleep(0.4) - self._unlock.off() + lgpio.gpio_write(self._chip, self._unlock_pin, 1) self._is_open = True logger.info("Door unlocked!") def close(self): - self._lock.on() + lgpio.gpio_write(self._chip, self._lock_pin, 0) sleep(0.4) - self._lock.off() + lgpio.gpio_write(self._chip, self._lock_pin, 1) self._is_open = False logger.info("Door locked!") diff --git a/pyproject.toml b/pyproject.toml index b26351e..15bc111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,8 @@ dependencies = [ "pyscard>=2.3.1", "alembic>=1.18.5", "ruff>=0.16.0", - "gpiozero>=2.0.1.post3", "pydantic-settings>=2.13.1", + 'lgpio>=0.2.2.0' # ; sys_platform == "linux" and platform_machine == "aarch64"', ] [tool.uv.sources] @@ -28,6 +28,7 @@ python-desfire = { git = "https://github.com/waza-ari/python-desfire" } [tool.uv.extra-build-dependencies] python-desfire = ["poetry"] "pyscard" = ["setuptools"] +"lgpio" = ["setuptools"] [tool.setuptools] py-modules = ["app"] diff --git a/uv.lock b/uv.lock index 85457fa..1f3154c 100644 --- a/uv.lock +++ b/uv.lock @@ -270,18 +270,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "colorzero" -version = "2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/688824a06e8c4d04c7d2fd2af2d8da27bed51af20ee5f094154e1d680334/colorzero-2.0.tar.gz", hash = "sha256:e7d5a5c26cd0dc37b164ebefc609f388de24f8593b659191e12d85f8f9d5eb58", size = 25382, upload-time = "2021-03-15T23:42:23.261Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/a6/ddd0f130e44a7593ac6c55aa93f6e256d2270fd88e9d1b64ab7f22ab8fde/colorzero-2.0-py2.py3-none-any.whl", hash = "sha256:0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8", size = 26573, upload-time = "2021-03-15T23:42:21.757Z" }, -] - [[package]] name = "coverage" version = "7.14.0" @@ -636,7 +624,7 @@ source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "fastapi", extra = ["standard"] }, - { name = "gpiozero" }, + { name = "lgpio" }, { name = "poetry" }, { name = "pwdlib", extra = ["argon2"] }, { name = "pydantic-settings" }, @@ -655,7 +643,7 @@ dependencies = [ requires-dist = [ { name = "alembic", specifier = ">=1.18.5" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, - { name = "gpiozero", specifier = ">=2.0.1.post3" }, + { name = "lgpio", specifier = ">=0.2.2.0" }, { name = "poetry", specifier = ">=2.3.4" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, @@ -670,18 +658,6 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.38" }, ] -[[package]] -name = "gpiozero" -version = "2.0.1.post3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorzero" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/63/864a3c36842cf7b05808aadf906763a024a1b13f2a4c8144e3163de91255/gpiozero-2.0.1.post3.tar.gz", hash = "sha256:745feab6df463ac2e9de10c67e2dd9f396e668ba4e281e92381d6c460100a8f7", size = 168403, upload-time = "2026-07-27T10:03:32.628Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/cb/ec5917b454b2e82874769a348dc74cfcec5b447bf1061c827edea9702451/gpiozero-2.0.1.post3-py3-none-any.whl", hash = "sha256:d491734803a9bc6036602e6b8bbd73fb25c33da88de0fcd11a40e890b8bc2d3b", size = 151465, upload-time = "2026-07-27T10:03:31.195Z" }, -] - [[package]] name = "greenlet" version = "3.4.0" @@ -870,6 +846,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "lgpio" +version = "0.2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/33/26ec2e8049eaa2f077bf23a12dc61ca559fbfa7bea0516bf263d657ae275/lgpio-0.2.2.0.tar.gz", hash = "sha256:11372e653b200f76a0b3ef8a23a0735c85ec678a9f8550b9893151ed0f863fff", size = 90087, upload-time = "2024-03-29T21:59:55.901Z" } + [[package]] name = "mako" version = "1.3.12" -- 2.51.2 From c9f740b7a3ba752cecdcfb73cee47e3bcf191998 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Sat, 1 Aug 2026 14:46:17 +0200 Subject: [PATCH 07/10] Fix lgpio build... --- flake.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 22e6445..f9ba513 100644 --- a/flake.nix +++ b/flake.nix @@ -65,6 +65,10 @@ buildInputs = (old.buildInputs or []) ++ [ pkgs.pcsclite.dev ]; NIX_CFLAGS_COMPILE = "-I${pkgs.pcsclite.dev}/include/PCSC"; }); + lgpio = prev.lgpio.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.swig ]; + buildInputs = (old.buildInputs or []) ++ [ pkgs.lgpio ]; + }); }) ] ) @@ -93,7 +97,7 @@ UV_NO_SYNC = "1"; UV_PYTHON = pythonSet.python.interpreter; UV_PYTHON_DOWNLOADS = "never"; - LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib"; + LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib:${lib.getLib pkgs.lgpio}/lib"; }; shellHook = '' unset PYTHONPATH -- 2.51.2 From 55072bcc1d52bc2b791c0ee2935064d6c36e23f0 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Sat, 1 Aug 2026 14:47:08 +0200 Subject: [PATCH 08/10] Fix module --- module.nix | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/module.nix b/module.nix index b761f80..aa37337 100644 --- a/module.nix +++ b/module.nix @@ -6,23 +6,42 @@ in options = { services.gatekeeper = { enable = lib.mkEnableOption "Enable the gatekeeper api service."; - dotenv = lib.mkOption { - type = lib.types.path; - description = "The path to a .env file with the keys"; + envFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + description = "The path to a .env file with all the other options"; }; db = lib.mkOption { type = lib.types.path; description = "Where to save the database."; + default = "/var/lib/gatekeeper"; + }; + mockGpio = lib.mkOption { + type = lib.types.str; + default = "True"; + description = "Mock GPIO pins. Has to be a string!"; }; }; }; config = lib.mkIf cfg.enable { users.groups.gatekeeper = {}; + users.groups.gpio = {}; users.users.gatekeeper = { description = "gatekeeper user"; group = "gatekeeper"; + extraGroups = ["gpio"]; isSystemUser = true; }; + services.udev.extraRules = lib.mkBefore '' + KERNEL=="gpiomem", GROUP="gpio", MODE="0660" + SUBSYSTEM=="gpio", KERNEL=="gpiochip*", ACTION=="add", PROGRAM="${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/chgrp gpio /dev/%k && chmod 660 /dev/%k && ${pkgs.coreutils}/bin/chgrp -R gpio /sys/class/gpio && ${pkgs.coreutils}/bin/chmod -R g=u /sys/class/gpio'" + SUBSYSTEM=="gpio", ACTION=="add", PROGRAM="${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/chgrp -R gpio /sys%p && ${pkgs.coreutils}/bin/chmod -R g=u /sys%p'" + ''; + + boot.kernelParams = [ + "iomem=relaxed" # for pigpiod + "strict-devmem=0" + ]; + services.pcscd = { enable = true; plugins = [ pkgs.acsccid ]; @@ -43,6 +62,12 @@ in RestartSec = "20"; StateDirectory = "gatekeeper"; WorkingDirectory = "/var/lib/gatekeeper"; + EnvironmentFile = cfg.envFile; + }; + environment = { + SQLALCHEMY_DATABASE_URL = "sqlite:///${cfg.db}/gatekeeper.db"; + ALEMBIC_CONFIG = "${self}/alembic.ini"; + MOCK_GPIO = cfg.mockGpio; }; }; }; -- 2.51.2 From 73ff546c70df994d5bc1955e2b5afb12679f098d Mon Sep 17 00:00:00 2001 From: ahtlon Date: Sat, 1 Aug 2026 14:47:14 +0200 Subject: [PATCH 09/10] Update readme --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5ebe444..3a7411b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ ## Gatekeeper - Door access system -#### Status: WIP - getting there o.o +#### Status: "WORKING" - Base functionality is there, mayor issues Start prod server `nix run`
Start dev server `nix run .#dev` or `nix run .#dev -- {args}`
Interactive dev with `nix develop`, then sync deps with `uv sync`
+There is a nix module you can use by importing `inputs.gatekeeper.nixosModules.gatekeeper`
Swagger UI @ http://127.0.0.1:8000/api/v1/docs
OpenApi @ http://127.0.0.1:8000/api/v1/openapi.json
@@ -33,17 +34,13 @@ Range: The range of the ACR1552U was much better at over 60mm (almost 70mm if yo #### Issues: + - cards can only unlock, not lock - documentation missing - - raspberry pi image not working - - no door state - - no door operations - - hardcoded secret key in auth.py -> centralise env var loading - i don't like the error handling in the scanner - doesn't pass errors correctly - cors for frontend: https://fastapi.tiangolo.com/tutorial/cors - Load cors from env var or something - BackgroundScanner shouldn't get a single session for the whole lifecycle - input validation maybe - - too many imports - inconsistent logging (request logging?) - rate limiting maybe - pretty sure the controllers are doing too much stuff -- 2.51.2 From 023a704e365d3db90a03497023fa8ac013d25b6f Mon Sep 17 00:00:00 2001 From: ahtlon Date: Sat, 1 Aug 2026 23:27:54 +0200 Subject: [PATCH 10/10] Make scanning the keycard while open close the door --- app/services/scanner.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/services/scanner.py b/app/services/scanner.py index b5ef31f..1b5b23b 100644 --- a/app/services/scanner.py +++ b/app/services/scanner.py @@ -1,5 +1,4 @@ import logging -import os import secrets import threading import time @@ -25,7 +24,7 @@ from smartcard.CardRequest import CardRequest from smartcard.CardType import AnyCardType from smartcard.Exceptions import CardRequestTimeoutException -from app.services.door import checkAccess, openDoor +from app.services.door import checkAccess, closeDoor, isDoorOpen, openDoor from app.services.settings import settings logger = logging.getLogger(__name__) @@ -333,9 +332,13 @@ class BackgroundScanner: time.sleep(5) def _check_db(self, key): - check = checkAccess(key, self.db) - if check: - openDoor() - logger.info("Access granted!") + if isDoorOpen(): + closeDoor() + logger.info("Door closed by key %s", key) else: - logger.error("Access denied!") + check = checkAccess(key, self.db) + if check: + openDoor() + logger.info("Access granted!") + else: + logger.error("Access denied!") -- 2.51.2