Compare commits

...

3 Commits

Author SHA1 Message Date
Ettore
e2de0ae1fa Add gate icon and remove gate type 2026-05-14 19:47:15 +02:00
Ettore
db3966a1d7 Add support for Shelly Cloud API 2026-05-14 19:22:17 +02:00
Ettore
46ba26a86d Add support for Shelly Cloud API 2026-05-14 19:22:07 +02:00
11 changed files with 431 additions and 99 deletions

View File

@@ -2,12 +2,13 @@
# Lagomare Gates # 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 ## Features
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date - **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 - **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 - **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 - **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 | | Auth | JWT (HS256) + bcrypt |
| 2FA | TOTP (RFC 6238) via pyotp | | 2FA | TOTP (RFC 6238) via pyotp |
| Credential storage | Fernet symmetric encryption | | Credential storage | Fernet symmetric encryption |
| Gate integration | AVConnect HTTP API | | Gate integration | AVConnect HTTP API / Shelly Cloud API |
| Notifications | Telegram Bot API | | Notifications | Telegram Bot API |
| QR generation | qrcode + Pillow | | QR generation | qrcode + Pillow |
| Frontend | Vanilla JS PWA | | Frontend | Vanilla JS PWA |
@@ -51,8 +52,9 @@ src/
│ ├── stats.py # Access log / statistics (paginated, filtered) │ ├── stats.py # Access log / statistics (paginated, filtered)
│ └── telegram.py # Telegram notification configuration │ └── telegram.py # Telegram notification configuration
├── services/ ├── services/
│ ├── avconnect.py # AVConnect session management and macro execution │ ├── avconnect.py # AVConnect API client
│ ├── gates.py # Gate open orchestration │ ├── gates.py # Gate open orchestration
| |── shelly.py # Shelly Cloud API client
│ └── telegram.py # Telegram Bot API client │ └── telegram.py # Telegram Bot API client
└── static/ # Frontend PWA (index.html, admin.html, JS, CSS) └── static/ # Frontend PWA (index.html, admin.html, JS, CSS)
data/ data/
@@ -111,8 +113,10 @@ data/
| Method | Endpoint | Description | | Method | Endpoint | Description |
|---|---|---| |---|---|---|
| GET | `/api/admin/credentials` | View stored credentials | | GET | `/api/admin/credentials/avconnect` | View stored AVConnect credentials |
| PUT | `/api/admin/credentials` | Create or update 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 | | GET | `/api/admin/credentials/mock` | Get mock mode status |
| PUT | `/api/admin/credentials/mock` | Enable or disable mock mode | | PUT | `/api/admin/credentials/mock` | Enable or disable mock mode |
@@ -217,14 +221,24 @@ The application is then available at:
## AVConnect Integration ## 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) 1. Authenticates with AVConnect using the stored credentials (session is cached in the database)
2. Executes the configured macro for the gate 2. Executes the configured macro for the gate
Credentials (password) are stored encrypted in the database using Fernet symmetric encryption derived from `SECRET_KEY`. 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 ## Keypass QR Codes

View File

@@ -2,7 +2,7 @@ import os
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Boolean, Double, String, Text, create_engine from sqlalchemy import Boolean, Double, String, Text, create_engine, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from core.config import DATA_DIR, DATABASE_URL from core.config import DATA_DIR, DATABASE_URL
@@ -20,8 +20,10 @@ class GateDB(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False) 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
avconnect_macro_id: Mapped[str] = mapped_column(String, nullable=False) # AVConnect macro ID 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
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled' status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
group_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) # display group label group_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) # display group label
lat: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 latitude lat: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 latitude
@@ -38,6 +40,14 @@ class ApiCredential(Base):
mock_avconnect: Mapped[bool] = mapped_column(Boolean, default=False) mock_avconnect: Mapped[bool] = mapped_column(Boolean, default=False)
class ShellyCredential(Base):
__tablename__ = "shelly_credentials"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
server_uri: Mapped[str] = mapped_column(String, nullable=False) # e.g. https://shelly-3.eu.shelly.cloud
auth_key_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted auth key
class Keypass(Base): class Keypass(Base):
__tablename__ = "keypasses" __tablename__ = "keypasses"

View File

@@ -80,8 +80,10 @@ class GateResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
gate_type: str gate_icon: str
avconnect_macro_id: str api_provider: str
avconnect_macro_id: Optional[str] = None
shelly_device_id: Optional[str] = None
status: str status: str
group_name: Optional[str] = None group_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
@@ -93,7 +95,7 @@ class GatePublicResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
gate_type: str gate_icon: str
group_name: Optional[str] = None group_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
@@ -101,15 +103,17 @@ class GatePublicResponse(BaseModel):
class GateCreate(BaseModel): class GateCreate(BaseModel):
name: str name: str
gate_type: str # 'car' | 'pedestrian' gate_icon: str = "🚪" # any UTF-8 character/emoji
avconnect_macro_id: str api_provider: str = "avconnect" # 'avconnect' | 'shelly'
avconnect_macro_id: Optional[str] = None
shelly_device_id: Optional[str] = None
status: str = "enabled" status: str = "enabled"
group_name: Optional[str] = None group_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
# ── AVConnect Credentials ───────────────────────────────────────────────────── # ── API Credentials ──────────────────────────────────────────────────────────
class CredentialRead(BaseModel): class CredentialRead(BaseModel):
id: int id: int
@@ -121,6 +125,16 @@ class CredentialUpsert(BaseModel):
password: str password: str
class ShellyCredentialRead(BaseModel):
id: int
server_uri: str
class ShellyCredentialUpsert(BaseModel):
server_uri: str
auth_key: str
# ── Admin users ─────────────────────────────────────────────────────────────── # ── Admin users ───────────────────────────────────────────────────────────────
class AdminUserResponse(BaseModel): class AdminUserResponse(BaseModel):

View File

@@ -5,22 +5,25 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.auth import encrypt_secret from core.auth import encrypt_secret
from core.database import ApiCredential, get_db from core.database import ApiCredential, ShellyCredential, get_db
from core.dependencies import require_admin from core.dependencies import require_admin
from core.schemas import CredentialRead, CredentialUpsert from core.schemas import CredentialRead, CredentialUpsert, ShellyCredentialRead, ShellyCredentialUpsert
from services.avconnect import AVConnectAPI from services.avconnect import AVConnectAPI
from services.shelly import ShellyCloudAPI
router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"]) router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
@router.get("", response_model=list[CredentialRead]) # ── AVConnect credentials ─────────────────────────────────────────────────────
@router.get("/avconnect", response_model=list[CredentialRead])
async def list_credentials( async def list_credentials(
db: Session = Depends(get_db), _: dict = Depends(require_admin) db: Session = Depends(get_db), _: dict = Depends(require_admin)
): ):
return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()] 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( async def upsert_credential(
req: CredentialUpsert, req: CredentialUpsert,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -37,7 +40,7 @@ async def upsert_credential(
if cred: if cred:
cred.username = req.username cred.username = req.username
cred.password_enc = encrypt_secret(req.password) cred.password_enc = encrypt_secret(req.password)
cred.session_id = session_id # reuse the session obtained during validation cred.session_id = session_id
else: else:
cred = ApiCredential( cred = ApiCredential(
username=req.username, username=req.username,
@@ -50,7 +53,47 @@ async def upsert_credential(
return CredentialRead(id=cred.id, username=cred.username) return CredentialRead(id=cred.id, username=cred.username)
# ── Mock AVConnect setting ───────────────────────────────────────────────────── # ── Shelly Cloud credentials ──────────────────────────────────────────────────
@router.get("/shelly", response_model=Optional[ShellyCredentialRead])
async def get_shelly_credential(
db: Session = Depends(get_db), _: dict = Depends(require_admin)
):
cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
if not cred:
return None
return ShellyCredentialRead(id=cred.id, server_uri=cred.server_uri)
@router.put("/shelly", response_model=ShellyCredentialRead)
async def upsert_shelly_credential(
req: ShellyCredentialUpsert,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
try:
ok = ShellyCloudAPI(req.server_uri, req.auth_key).validate_credentials()
except Exception as exc:
raise HTTPException(502, f"Could not reach Shelly Cloud: {exc}")
if not ok:
raise HTTPException(422, "Shelly Cloud rejected these credentials")
cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
if cred:
cred.server_uri = req.server_uri
cred.auth_key_enc = encrypt_secret(req.auth_key)
else:
cred = ShellyCredential(
server_uri=req.server_uri,
auth_key_enc=encrypt_secret(req.auth_key),
)
db.add(cred)
db.commit()
db.refresh(cred)
return ShellyCredentialRead(id=cred.id, server_uri=cred.server_uri)
# ── Mock mode setting ─────────────────────────────────────────────────────────
class MockSettingResponse(BaseModel): class MockSettingResponse(BaseModel):
enabled: bool enabled: bool
@@ -76,7 +119,6 @@ async def set_mock_setting(
): ):
cred: Optional[ApiCredential] = db.query(ApiCredential).first() cred: Optional[ApiCredential] = db.query(ApiCredential).first()
if not cred: if not cred:
from fastapi import HTTPException
raise HTTPException(503, "AVConnect credentials not configured") raise HTTPException(503, "AVConnect credentials not configured")
cred.mock_avconnect = req.enabled cred.mock_avconnect = req.enabled
db.commit() db.commit()

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from core.auth import decrypt_secret from core.auth import decrypt_secret
from core.config import utcnow from core.config import utcnow
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, TelegramConfig, get_db from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, ShellyCredential, TelegramConfig, get_db
from core.dependencies import require_admin, require_manager, require_keypass from core.dependencies import require_admin, require_manager, require_keypass
from core.schemas import GateCreate, GatePublicResponse, GateResponse from core.schemas import GateCreate, GatePublicResponse, GateResponse
from services.gates import call_open_gate from services.gates import call_open_gate
@@ -35,6 +35,49 @@ def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None
).start() ).start()
def _validate_gate_create(req: GateCreate) -> None:
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:
raise HTTPException(400, "avconnect_macro_id is required for AVConnect gates")
if req.api_provider == "shelly" and not req.shelly_device_id:
raise HTTPException(400, "shelly_device_id is required for Shelly gates")
def _do_open_gate(
gate_db: GateDB,
db: Session,
mock: bool,
) -> tuple[bool, Optional[str], Optional[str]]:
"""Dispatch the gate-open call to the appropriate API provider."""
if gate_db.api_provider == "shelly":
shelly_cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
if not shelly_cred:
raise HTTPException(503, "Shelly credentials not configured")
return call_open_gate(
api_provider="shelly",
shelly_device_id=gate_db.shelly_device_id,
shelly_server_uri=shelly_cred.server_uri,
shelly_auth_key=decrypt_secret(shelly_cred.auth_key_enc),
mock=mock,
)
# AVConnect (default)
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
if not av_cred:
raise HTTPException(503, "AVConnect credentials not configured")
return call_open_gate(
api_provider="avconnect",
avconnect_macro_id=gate_db.avconnect_macro_id,
avconnect_username=av_cred.username,
avconnect_password=decrypt_secret(av_cred.password_enc),
avconnect_session_id=av_cred.session_id,
mock=mock,
)
# ── Admin: gate CRUD ────────────────────────────────────────────────────────── # ── Admin: gate CRUD ──────────────────────────────────────────────────────────
@router.get("/api/admin/gates", response_model=list[GateResponse]) @router.get("/api/admin/gates", response_model=list[GateResponse])
@@ -50,8 +93,7 @@ async def create_gate(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
): ):
if req.gate_type not in ("car", "pedestrian"): _validate_gate_create(req)
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
gate = GateDB(**req.model_dump()) gate = GateDB(**req.model_dump())
db.add(gate) db.add(gate)
db.commit() db.commit()
@@ -66,6 +108,7 @@ async def update_gate(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
): ):
_validate_gate_create(req)
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first() gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
if not gate: if not gate:
raise HTTPException(404, "Gate not found") raise HTTPException(404, "Gate not found")
@@ -102,21 +145,14 @@ async def admin_open_gate(
if gate_db.status != "enabled": if gate_db.status != "enabled":
raise HTTPException(409, "Gate is disabled") raise HTTPException(409, "Gate is disabled")
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first() # Determine mock mode from AVConnect credential record (applies to all providers)
if not cred_db: av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
raise HTTPException(503, "AVConnect credentials not configured") mock = bool(av_cred.mock_avconnect) if av_cred else False
ip = request.client.host if request.client else None ip = request.client.host if request.client else None
ua = request.headers.get("User-Agent") ua = request.headers.get("User-Agent")
mock = bool(cred_db.mock_avconnect) success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
success, error_msg, new_sid = call_open_gate(
gate_db.avconnect_macro_id,
cred_db.username,
decrypt_secret(cred_db.password_enc),
cred_db.session_id,
mock=mock,
)
db.add(GateAccessLog( db.add(GateAccessLog(
timestamp=utcnow(), timestamp=utcnow(),
@@ -130,8 +166,8 @@ async def admin_open_gate(
error=error_msg, error=error_msg,
)) ))
if new_sid and new_sid != cred_db.session_id: if new_sid and av_cred and new_sid != av_cred.session_id:
cred_db.session_id = new_sid av_cred.session_id = new_sid
db.commit() db.commit()
if not success: if not success:
@@ -169,25 +205,17 @@ async def open_gate(
if not gate_db: if not gate_db:
raise HTTPException(404, "Gate not found or disabled") raise HTTPException(404, "Gate not found or disabled")
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
if not cred_db:
raise HTTPException(503, "AVConnect credentials not configured")
ip = request.client.host if request.client else None
ua = request.headers.get("User-Agent")
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
if allowed is not None and gate_id not in allowed: if allowed is not None and gate_id not in allowed:
raise HTTPException(403, "This keypass does not have access to this gate") raise HTTPException(403, "This keypass does not have access to this gate")
mock = bool(cred_db.mock_avconnect) av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
success, error_msg, new_sid = call_open_gate( mock = bool(av_cred.mock_avconnect) if av_cred else False
gate_db.avconnect_macro_id,
cred_db.username, ip = request.client.host if request.client else None
decrypt_secret(cred_db.password_enc), ua = request.headers.get("User-Agent")
cred_db.session_id,
mock=mock, success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
)
db.add(GateAccessLog( db.add(GateAccessLog(
timestamp=utcnow(), timestamp=utcnow(),
@@ -201,8 +229,8 @@ async def open_gate(
error=error_msg, error=error_msg,
)) ))
if new_sid and new_sid != cred_db.session_id: if new_sid and av_cred and new_sid != av_cred.session_id:
cred_db.session_id = new_sid av_cred.session_id = new_sid
db.commit() db.commit()
if not success: if not success:
@@ -210,5 +238,5 @@ async def open_gate(
raise HTTPException(502, error_msg or "Gate operation failed") raise HTTPException(502, error_msg or "Gate operation failed")
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip) logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
_notify(db, gate_db.name, f"{_kp.description} ({_kp.code})", ip) _notify(db, gate_db.name, f"{_kp.description}", ip)
return {"success": True, "gate": gate_db.name} return {"success": True, "gate": gate_db.name}

View File

@@ -1,21 +1,37 @@
from typing import Optional from typing import Optional
from .avconnect import AVConnectAPI from .avconnect import AVConnectAPI
from .shelly import ShellyCloudAPI
def call_open_gate( def call_open_gate(
macro_id: str, api_provider: str,
username: str, avconnect_macro_id: Optional[str] = None,
password: str, avconnect_username: Optional[str] = None,
session_id: Optional[str] = None, avconnect_password: Optional[str] = None,
avconnect_session_id: Optional[str] = None,
shelly_device_id: Optional[str] = None,
shelly_server_uri: Optional[str] = None,
shelly_auth_key: Optional[str] = None,
mock: bool = False, mock: bool = False,
) -> tuple[bool, Optional[str], Optional[str]]: ) -> tuple[bool, Optional[str], Optional[str]]:
"""Attempt to open a gate. Returns (success, error_msg, new_session_id).""" """Attempt to open a gate. Returns (success, error_msg, new_avconnect_session_id)."""
if mock: if mock:
return True, None, None return True, None, None
if api_provider == "shelly":
try: try:
api = AVConnectAPI(username, password, session_id) assert shelly_server_uri and shelly_auth_key and shelly_device_id
ok, new_sid = api.exec_gate_macro(macro_id) ShellyCloudAPI(shelly_server_uri, shelly_auth_key).open_gate(shelly_device_id)
return True, None, None
except Exception as e:
return False, str(e), None
# Default: AVConnect
try:
assert avconnect_username and avconnect_password and avconnect_macro_id
api = AVConnectAPI(avconnect_username, avconnect_password, avconnect_session_id)
ok, new_sid = api.exec_gate_macro(avconnect_macro_id)
if not ok: if not ok:
return False, "Gate did not confirm open", new_sid return False, "Gate did not confirm open", new_sid
return True, None, new_sid return True, None, new_sid

59
src/services/shelly.py Normal file
View File

@@ -0,0 +1,59 @@
import logging
import requests
logger = logging.getLogger(__name__)
class ShellyCloudAPI:
"""Shelly Cloud Control API v2 client.
*server_uri* — base URL of your Shelly Cloud server
(e.g. ``https://shelly-3.eu.shelly.cloud``).
*auth_key* — long-lived API key generated in the Shelly Cloud portal.
Reference: https://shelly-api-docs.shelly.cloud/cloud-control-api/communication-v2
"""
def __init__(self, server_uri: str, auth_key: str):
self._server_uri = server_uri.rstrip("/")
self._auth_key = auth_key
def open_gate(self, device_id: str, channel: int = 0) -> None:
"""Send a switch-on command to the device via the v2 API.
Raises on HTTP errors or API-level errors.
"""
url = f"{self._server_uri}/v2/devices/api/set/switch"
params = {"auth_key": self._auth_key}
payload = {"id": device_id, "channel": channel, "on": True}
logger.debug("Shelly v2 open_gate: device_id=%s channel=%d", device_id, channel)
response = requests.post(url, params=params, json=payload, timeout=10)
if not response.ok:
# v2 error body: {"error": "...", "data": {"messages": [...]}}
try:
body = response.json()
error_str = body.get("error", response.text)
messages = body.get("data", {}).get("messages", [])
detail = f"{error_str}: {'; '.join(messages)}" if messages else error_str
except Exception:
detail = response.text
raise Exception(f"Shelly Cloud API error ({response.status_code}): {detail}")
def validate_credentials(self) -> bool:
"""Validate the auth key by issuing a v2 get-state probe.
Any response other than 401 (Unauthorized) is treated as valid auth.
Raises on unexpected network errors.
"""
url = f"{self._server_uri}/v2/devices/api/get"
params = {"auth_key": self._auth_key}
# Send a single dummy id; the server will return an empty/not-found result
# but will authenticate the key first. A 401 means the key is invalid.
payload = {"ids": ["validate"]}
response = requests.post(url, params=params, json=payload, timeout=10)
if response.status_code == 401:
logger.warning("Shelly credentials validation failed: 401 Unauthorized")
return False
logger.debug("Shelly credentials valid (status=%d)", response.status_code)
return True

View File

@@ -78,6 +78,20 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.section-header h3 { font-size: 1rem; font-weight: 700; } .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); }
</style> </style>
</head> </head>
<body> <body>
@@ -122,7 +136,7 @@
<nav class="tabs"> <nav class="tabs">
<button class="tab-btn active" data-tab="keypasses">Keypasses</button> <button class="tab-btn active" data-tab="keypasses">Keypasses</button>
<button class="tab-btn" data-tab="gates">Gates</button> <button class="tab-btn" data-tab="gates">Gates</button>
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</button> <button class="tab-btn admin-only" data-tab="credentials">API Credentials</button>
<button class="tab-btn" data-tab="stats">Statistics</button> <button class="tab-btn" data-tab="stats">Statistics</button>
<button class="tab-btn admin-only" data-tab="admins">Admins</button> <button class="tab-btn admin-only" data-tab="admins">Admins</button>
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button> <button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
@@ -166,8 +180,9 @@
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Group</th> <th>Group</th>
<th>Type</th> <th>Icon</th>
<th>AVConnect Macro ID</th> <th>Provider</th>
<th>Device / Macro ID</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
</tr> </tr>
@@ -179,7 +194,10 @@
<!-- ── Credentials pane ───────────────────────────────────────────── --> <!-- ── Credentials pane ───────────────────────────────────────────── -->
<div id="tab-credentials" class="tab-pane"> <div id="tab-credentials" class="tab-pane">
<h3 style="margin-bottom:1rem">AVConnect Credentials</h3> <h3 style="margin-bottom:1rem">API Credentials</h3>
<!-- AVConnect -->
<h4 style="margin:0 0 .75rem">AVConnect</h4>
<div class="card" style="max-width:440px"> <div class="card" style="max-width:440px">
<form id="credentials-form"> <form id="credentials-form">
<div class="field"> <div class="field">
@@ -195,14 +213,34 @@
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
</form> </form>
</div> </div>
<!-- Shelly Cloud -->
<h4 style="margin:1.5rem 0 .75rem">Shelly Cloud</h4>
<div class="card" style="max-width:440px">
<form id="shelly-credentials-form">
<div class="field">
<label for="shelly-server-uri">Server URI</label>
<input id="shelly-server-uri" type="url" autocomplete="off"
placeholder="e.g. https://shelly-3.eu.shelly.cloud" />
</div>
<div class="field">
<label for="shelly-auth-key">Auth Key</label>
<input id="shelly-auth-key" type="password" autocomplete="new-password"
placeholder="Leave empty to keep current" />
</div>
<p id="shelly-cred-error" class="error-msg hidden"></p>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
<h3 style="margin:1.5rem 0 1rem">Mock Mode</h3> <h3 style="margin:1.5rem 0 1rem">Mock Mode</h3>
<div class="card" style="max-width:440px"> <div class="card" style="max-width:440px">
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1rem"> <p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1rem">
When enabled, gate open requests always succeed without contacting AVConnect. When enabled, gate open requests always succeed without contacting any API.
</p> </p>
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0"> <label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0">
<input type="checkbox" id="mock-toggle" style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" /> <input type="checkbox" id="mock-toggle" style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" />
<span style="font-weight:600">Enable mock AVConnect</span> <span style="font-weight:600">Enable mock mode</span>
</label> </label>
</div> </div>
</div> </div>
@@ -443,15 +481,47 @@
<datalist id="gate-group-list"></datalist> <datalist id="gate-group-list"></datalist>
</div> </div>
<div class="field"> <div class="field">
<label for="gate-type">Type</label> <label>Icon</label>
<select id="gate-type"> <div style="display:flex;flex-direction:column;gap:.5rem">
<option value="car">Car</option> <div style="display:flex;flex-wrap:wrap;gap:.35rem" id="gate-icon-grid">
<option value="pedestrian">Pedestrian</option> <button type="button" class="icon-opt" data-icon="🚘">🚘</button>
</select> <button type="button" class="icon-opt" data-icon="🚗">🚗</button>
<button type="button" class="icon-opt" data-icon="🚙">🚙</button>
<button type="button" class="icon-opt" data-icon="🚕">🚕</button>
<button type="button" class="icon-opt" data-icon="🚌">🚌</button>
<button type="button" class="icon-opt" data-icon="🚛">🚛</button>
<button type="button" class="icon-opt" data-icon="🚲">🚲</button>
<button type="button" class="icon-opt" data-icon="🏍️">🏍️</button>
<button type="button" class="icon-opt" data-icon="🚶">🚶</button>
<button type="button" class="icon-opt" data-icon="🧍">🧍</button>
<button type="button" class="icon-opt" data-icon="🚪">🚪</button>
<button type="button" class="icon-opt" data-icon="⛩️">⛩️</button>
<button type="button" class="icon-opt" data-icon="🏠">🏠</button>
<button type="button" class="icon-opt" data-icon="🏢">🏢</button>
<button type="button" class="icon-opt" data-icon="🔒">🔒</button>
<button type="button" class="icon-opt" data-icon="🔑">🔑</button>
</div>
<div style="display:flex;align-items:center;gap:.6rem">
<span id="gate-icon-preview" style="font-size:1.6rem;line-height:1;min-width:2rem;text-align:center">🚪</span>
<input id="gate-icon-input" type="text" placeholder="Type or paste any character…"
style="flex:1" maxlength="4" autocomplete="off" />
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="gate-api-provider">API Provider</label>
<select id="gate-api-provider">
<option value="avconnect">AVConnect</option>
<option value="shelly">Shelly Cloud</option>
</select>
</div>
<div class="field" id="gate-avconnect-field">
<label for="gate-avconnect-macro-id">AVConnect Macro ID</label> <label for="gate-avconnect-macro-id">AVConnect Macro ID</label>
<input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" required /> <input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" />
</div>
<div class="field" id="gate-shelly-field" style="display:none">
<label for="gate-shelly-device-id">Shelly Device ID</label>
<input id="gate-shelly-device-id" type="text" placeholder="e.g. ab12cd34ef56" />
</div> </div>
<div class="field"> <div class="field">
<label for="gate-status">Status</label> <label for="gate-status">Status</label>

View File

@@ -203,7 +203,7 @@ async function loadKeypasses() {
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : ""; const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : "";
lbl.innerHTML = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`; lbl.innerHTML = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_icon || ''}</span>`;
checksContainer.appendChild(lbl); checksContainer.appendChild(lbl);
} }
const allGatesCb = document.getElementById("kp-edit-all-gates"); const allGatesCb = document.getElementById("kp-edit-all-gates");
@@ -281,7 +281,7 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
for (const g of _allGates) { for (const g of _allGates) {
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
lbl.innerHTML = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`; lbl.innerHTML = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_icon || ''}</span>`;
checksContainer.appendChild(lbl); checksContainer.appendChild(lbl);
} }
// Reset All gates checkbox // Reset All gates checkbox
@@ -429,13 +429,16 @@ async function loadGates() {
const badge = g.status === "enabled" const badge = g.status === "enabled"
? '<span class="badge badge-green">Enabled</span>' ? '<span class="badge badge-green">Enabled</span>'
: '<span class="badge badge-muted">Disabled</span>'; : '<span class="badge badge-muted">Disabled</span>';
const providerLabel = g.api_provider === 'shelly' ? 'Shelly' : 'AVConnect';
const deviceId = g.api_provider === 'shelly' ? (g.shelly_device_id || '') : (g.avconnect_macro_id || '');
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${g.id}</td> <td>${g.id}</td>
<td>${esc(g.name)}</td> <td>${esc(g.name)}</td>
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)"></span>'}</td> <td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">\u2014</span>'}</td>
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td> <td>${g.gate_icon || ''}</td>
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td> <td><span style="font-size:.85em">${esc(providerLabel)}</span></td>
<td><code style="font-size:.85em">${esc(deviceId)}</code></td>
<td>${badge}</td> <td>${badge}</td>
<td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end"> <td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem" ${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
@@ -475,13 +478,40 @@ async function loadGates() {
}); });
} }
function _setGateIcon(icon) {
document.getElementById("gate-icon-input").value = icon;
document.getElementById("gate-icon-preview").textContent = icon;
document.querySelectorAll("#gate-icon-grid .icon-opt").forEach(btn => {
btn.classList.toggle("selected", btn.dataset.icon === icon);
});
}
// Icon grid clicks + manual input sync
document.getElementById("gate-icon-grid").addEventListener("click", e => {
const btn = e.target.closest(".icon-opt");
if (btn) _setGateIcon(btn.dataset.icon);
});
document.getElementById("gate-icon-input").addEventListener("input", e => {
const val = [...e.target.value].slice(0, 2).join(""); // keep at most one composed char
if (val) {
document.getElementById("gate-icon-preview").textContent = val;
document.querySelectorAll("#gate-icon-grid .icon-opt").forEach(btn => {
btn.classList.toggle("selected", btn.dataset.icon === val);
});
}
});
function openGateModal(gate = null) { function openGateModal(gate = null) {
document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate"; document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
document.getElementById("gate-edit-id").value = gate ? gate.id : ""; document.getElementById("gate-edit-id").value = gate ? gate.id : "";
document.getElementById("gate-name").value = gate ? gate.name : ""; document.getElementById("gate-name").value = gate ? gate.name : "";
document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : ""; document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : "";
document.getElementById("gate-type").value = gate ? gate.gate_type : "car"; _setGateIcon(gate ? (gate.gate_icon || "🚪") : "🚪");
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : ""; const provider = gate ? (gate.api_provider || "avconnect") : "avconnect";
document.getElementById("gate-api-provider").value = provider;
document.getElementById("gate-avconnect-macro-id").value = gate ? (gate.avconnect_macro_id || "") : "";
document.getElementById("gate-shelly-device-id").value = gate ? (gate.shelly_device_id || "") : "";
_updateGateProviderFields(provider);
document.getElementById("gate-status").value = gate ? gate.status : "enabled"; document.getElementById("gate-status").value = gate ? gate.status : "enabled";
document.getElementById("gate-lat").value = (gate && gate.lat != null) ? gate.lat : ""; document.getElementById("gate-lat").value = (gate && gate.lat != null) ? gate.lat : "";
document.getElementById("gate-lon").value = (gate && gate.lon != null) ? gate.lon : ""; document.getElementById("gate-lon").value = (gate && gate.lon != null) ? gate.lon : "";
@@ -498,17 +528,41 @@ function openGateModal(gate = null) {
document.getElementById("gate-modal").classList.remove("hidden"); document.getElementById("gate-modal").classList.remove("hidden");
} }
function _updateGateProviderFields(provider) {
const avField = document.getElementById("gate-avconnect-field");
const shellyField = document.getElementById("gate-shelly-field");
const avInput = document.getElementById("gate-avconnect-macro-id");
const shellyInput = document.getElementById("gate-shelly-device-id");
if (provider === "shelly") {
avField.style.display = "none";
shellyField.style.display = "";
avInput.required = false;
shellyInput.required = true;
} else {
avField.style.display = "";
shellyField.style.display = "none";
avInput.required = true;
shellyInput.required = false;
}
}
document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal()); document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal());
document.getElementById("gate-cancel").addEventListener("click", () => { document.getElementById("gate-cancel").addEventListener("click", () => {
document.getElementById("gate-modal").classList.add("hidden"); document.getElementById("gate-modal").classList.add("hidden");
}); });
document.getElementById("gate-api-provider").addEventListener("change", e => {
_updateGateProviderFields(e.target.value);
});
document.getElementById("gate-form").addEventListener("submit", async e => { document.getElementById("gate-form").addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const editId = document.getElementById("gate-edit-id").value; const editId = document.getElementById("gate-edit-id").value;
const provider = document.getElementById("gate-api-provider").value;
const payload = { const payload = {
name: document.getElementById("gate-name").value.trim(), name: document.getElementById("gate-name").value.trim(),
gate_type: document.getElementById("gate-type").value, gate_icon: document.getElementById("gate-icon-input").value.trim() || "\uD83D\uDEAA",
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(), api_provider: provider,
avconnect_macro_id: provider === "avconnect" ? document.getElementById("gate-avconnect-macro-id").value.trim() : null,
shelly_device_id: provider === "shelly" ? document.getElementById("gate-shelly-device-id").value.trim() : null,
status: document.getElementById("gate-status").value, status: document.getElementById("gate-status").value,
group_name: document.getElementById("gate-group-name").value.trim() || null, group_name: document.getElementById("gate-group-name").value.trim() || null,
lat: document.getElementById("gate-lat").value !== "" ? parseFloat(document.getElementById("gate-lat").value) : null, lat: document.getElementById("gate-lat").value !== "" ? parseFloat(document.getElementById("gate-lat").value) : null,
@@ -534,11 +588,17 @@ document.getElementById("gate-form").addEventListener("submit", async e => {
// ── Credentials ─────────────────────────────────────────────────────────────── // ── Credentials ───────────────────────────────────────────────────────────────
async function loadCredentials() { async function loadCredentials() {
try { try {
const list = await api("GET", "/api/admin/credentials"); const list = await api("GET", "/api/admin/credentials/avconnect");
if (list.length) { if (list.length) {
document.getElementById("cred-username").value = list[0].username; document.getElementById("cred-username").value = list[0].username;
} }
} catch { /* no creds yet */ } } catch { /* no creds yet */ }
try {
const shelly = await api("GET", "/api/admin/credentials/shelly");
if (shelly) {
document.getElementById("shelly-server-uri").value = shelly.server_uri;
}
} catch { /* no shelly creds yet */ }
try { try {
const { enabled } = await api("GET", "/api/admin/credentials/mock"); const { enabled } = await api("GET", "/api/admin/credentials/mock");
document.getElementById("mock-toggle").checked = enabled; document.getElementById("mock-toggle").checked = enabled;
@@ -567,9 +627,30 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
return; return;
} }
try { try {
await api("PUT", "/api/admin/credentials", { username, password }); await api("PUT", "/api/admin/credentials/avconnect", { username, password });
document.getElementById("cred-password").value = ""; document.getElementById("cred-password").value = "";
showToast("Credentials saved"); showToast("AVConnect credentials saved");
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove("hidden");
}
});
document.getElementById("shelly-credentials-form").addEventListener("submit", async e => {
e.preventDefault();
const server_uri = document.getElementById("shelly-server-uri").value.trim();
const auth_key = document.getElementById("shelly-auth-key").value;
const errEl = document.getElementById("shelly-cred-error");
errEl.classList.add("hidden");
if (!auth_key) {
errEl.textContent = "Auth key is required.";
errEl.classList.remove("hidden");
return;
}
try {
await api("PUT", "/api/admin/credentials/shelly", { server_uri, auth_key });
document.getElementById("shelly-auth-key").value = "";
showToast("Shelly Cloud credentials saved");
} catch (e) { } catch (e) {
errEl.textContent = e.message; errEl.textContent = e.message;
errEl.classList.remove("hidden"); errEl.classList.remove("hidden");

View File

@@ -118,7 +118,6 @@ async function updateMap(gates) {
const popup = L.popup().setContent( const popup = L.popup().setContent(
`<div style="min-width:140px"> `<div style="min-width:140px">
<strong>${gate.name}</strong><br> <strong>${gate.name}</strong><br>
<em style="font-size:.85em;color:#666">${gate.gate_type === "car" ? "Car gate" : "Pedestrian gate"}</em><br>
<a href="https://www.google.com/maps/dir/?api=1&destination=${gate.lat},${gate.lon}" <a href="https://www.google.com/maps/dir/?api=1&destination=${gate.lat},${gate.lon}"
target="_blank" rel="noopener" target="_blank" rel="noopener"
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline"> style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
@@ -126,7 +125,7 @@ async function updateMap(gates) {
</a> </a>
</div>` </div>`
); );
const marker = L.marker([gate.lat, gate.lon], { icon: _gateIcon(gate.gate_type) }) const marker = L.marker([gate.lat, gate.lon], { icon: _gateIcon() })
.bindPopup(popup) .bindPopup(popup)
.addTo(_map); .addTo(_map);
_mapMarkers.push(marker); _mapMarkers.push(marker);
@@ -189,9 +188,9 @@ function renderGates(gates) {
groupGrid.className = "gate-group-grid"; groupGrid.className = "gate-group-grid";
for (const gate of groups.get(key)) { for (const gate of groups.get(key)) {
const icon = gate.gate_type === "car" ? "🚘" : "🚶"; const icon = gate.gate_icon || '🚪';
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.className = `gate-btn ${gate.gate_type}`; btn.className = "gate-btn";
btn.dataset.gateId = gate.id; btn.dataset.gateId = gate.id;
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`; btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
btn.addEventListener("click", () => handleOpenGate(btn, gate)); btn.addEventListener("click", () => handleOpenGate(btn, gate));

View File

@@ -105,8 +105,7 @@ label {
overflow: hidden; overflow: hidden;
} }
.gate-btn .icon { font-size: 1.8rem; line-height: 1; } .gate-btn .icon { font-size: 1.8rem; line-height: 1; }
.gate-btn.car { background: var(--primary); color: #fff; } .gate-btn { background: var(--primary); color: #fff; }
.gate-btn.pedestrian { background: var(--green); color: #fff; }
.gate-btn:not(:disabled):active { transform: scale(.94); } .gate-btn:not(:disabled):active { transform: scale(.94); }
.gate-btn:disabled { opacity: .55; cursor: not-allowed; } .gate-btn:disabled { opacity: .55; cursor: not-allowed; }