diff --git a/README.md b/README.md index 3e76cab..df22786 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ # Lagomare Gates -A web-based gate access management and control system. Authorized users can remotely open physical car and pedestrian gates via a mobile-friendly PWA. An admin dashboard provides full management of gates, access codes, and users. +A web-based gate access management and control system. Authorized users can remotely open physical gates via a mobile-friendly PWA. An admin dashboard provides full management of gates, access codes, and users. ## Features - **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date -- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) to trigger gate macros +- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) and [Shelly Cloud](https://shelly.cloud) to trigger gate macros/relays +- **Gate icons** — each gate can be assigned any UTF-8 character or emoji as its icon, displayed on the user app button and keypass selector - **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels - **Two-factor authentication (TOTP)** — admins can enable app-based 2FA (Google Authenticator, Authy, etc.) on their account - **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated @@ -26,7 +27,7 @@ A web-based gate access management and control system. Authorized users can remo | Auth | JWT (HS256) + bcrypt | | 2FA | TOTP (RFC 6238) via pyotp | | Credential storage | Fernet symmetric encryption | -| Gate integration | AVConnect HTTP API | +| Gate integration | AVConnect HTTP API / Shelly Cloud API | | Notifications | Telegram Bot API | | QR generation | qrcode + Pillow | | Frontend | Vanilla JS PWA | @@ -51,8 +52,9 @@ src/ │ ├── stats.py # Access log / statistics (paginated, filtered) │ └── telegram.py # Telegram notification configuration ├── services/ -│ ├── avconnect.py # AVConnect session management and macro execution +│ ├── avconnect.py # AVConnect API client │ ├── gates.py # Gate open orchestration +| |── shelly.py # Shelly Cloud API client │ └── telegram.py # Telegram Bot API client └── static/ # Frontend PWA (index.html, admin.html, JS, CSS) data/ @@ -111,8 +113,10 @@ data/ | Method | Endpoint | Description | |---|---|---| -| GET | `/api/admin/credentials` | View stored credentials | -| PUT | `/api/admin/credentials` | Create or update credentials | +| GET | `/api/admin/credentials/avconnect` | View stored AVConnect credentials | +| PUT | `/api/admin/credentials/avconnect` | Create or update AVConnect credentials | +| GET | `/api/admin/credentials/shelly` | View stored Shelly Cloud credentials | +| PUT | `/api/admin/credentials/shelly` | Create or update Shelly Cloud credentials | | GET | `/api/admin/credentials/mock` | Get mock mode status | | PUT | `/api/admin/credentials/mock` | Enable or disable mock mode | @@ -217,14 +221,24 @@ The application is then available at: ## AVConnect Integration -Gates are controlled through the AVConnect platform. Each gate is mapped to an AVConnect *macro ID*. When a gate open request is received, the service: +Gates are controlled through one of two supported API providers. + +### AVConnect + +Each gate is mapped to an AVConnect *macro ID*. When a gate open request is received, the service: 1. Authenticates with AVConnect using the stored credentials (session is cached in the database) 2. Executes the configured macro for the gate Credentials (password) are stored encrypted in the database using Fernet symmetric encryption derived from `SECRET_KEY`. -**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting AVConnect. Useful for testing. +### Shelly Cloud + +Each gate is mapped to a Shelly *device ID*. The service calls the Shelly Cloud API with the stored auth key to activate the device's relay. + +The Shelly server URI and auth key are stored encrypted. Configure them under **Admin → Credentials → Shelly Cloud**. + +**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting any external API. Useful for testing. ## Keypass QR Codes diff --git a/src/core/database.py b/src/core/database.py index 737fd86..0f9a0f5 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -20,7 +20,7 @@ class GateDB(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String, nullable=False) - gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian' + gate_icon: Mapped[str] = mapped_column(String, nullable=False, default="🚪") # any UTF-8 character/emoji api_provider: Mapped[str] = mapped_column(String, nullable=False, default="avconnect") # 'avconnect' | 'shelly' avconnect_macro_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # AVConnect macro ID shelly_device_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Shelly Cloud device ID diff --git a/src/core/schemas.py b/src/core/schemas.py index 4299614..8f885a3 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -80,7 +80,7 @@ class GateResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str - gate_type: str + gate_icon: str api_provider: str avconnect_macro_id: Optional[str] = None shelly_device_id: Optional[str] = None @@ -95,7 +95,7 @@ class GatePublicResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str - gate_type: str + gate_icon: str group_name: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None @@ -103,7 +103,7 @@ class GatePublicResponse(BaseModel): class GateCreate(BaseModel): name: str - gate_type: str # 'car' | 'pedestrian' + gate_icon: str = "🚪" # any UTF-8 character/emoji api_provider: str = "avconnect" # 'avconnect' | 'shelly' avconnect_macro_id: Optional[str] = None shelly_device_id: Optional[str] = None diff --git a/src/routers/credentials.py b/src/routers/credentials.py index d29eba6..b514bde 100644 --- a/src/routers/credentials.py +++ b/src/routers/credentials.py @@ -16,14 +16,14 @@ router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"]) # ── AVConnect credentials ───────────────────────────────────────────────────── -@router.get("", response_model=list[CredentialRead]) +@router.get("/avconnect", response_model=list[CredentialRead]) async def list_credentials( db: Session = Depends(get_db), _: dict = Depends(require_admin) ): return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()] -@router.put("", response_model=CredentialRead) +@router.put("/avconnect", response_model=CredentialRead) async def upsert_credential( req: CredentialUpsert, db: Session = Depends(get_db), diff --git a/src/routers/gates.py b/src/routers/gates.py index 4fe7a5c..606bd71 100644 --- a/src/routers/gates.py +++ b/src/routers/gates.py @@ -36,8 +36,8 @@ def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None def _validate_gate_create(req: GateCreate) -> None: - if req.gate_type not in ("car", "pedestrian"): - raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'") + if not req.gate_icon: + raise HTTPException(400, "gate_icon must not be empty") if req.api_provider not in ("avconnect", "shelly"): raise HTTPException(400, "api_provider must be 'avconnect' or 'shelly'") if req.api_provider == "avconnect" and not req.avconnect_macro_id: diff --git a/src/static/admin.html b/src/static/admin.html index 35bbf95..b657c30 100644 --- a/src/static/admin.html +++ b/src/static/admin.html @@ -78,6 +78,20 @@ margin-bottom: 1rem; } .section-header h3 { font-size: 1rem; font-weight: 700; } + + /* ── Gate icon picker ───────────────────────────────────────────────── */ + .icon-opt { + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: .4rem; + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + padding: .3rem .45rem; + transition: border-color .12s, background .12s; + } + .icon-opt:hover { border-color: var(--primary); } + .icon-opt.selected { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 15%, transparent); }
@@ -166,7 +180,7 @@${esc(deviceId)}