Compare commits
3 Commits
adcc2b9522
...
e2de0ae1fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2de0ae1fa | ||
|
|
db3966a1d7 | ||
|
|
46ba26a86d |
30
README.md
30
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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from datetime import datetime
|
||||
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 core.config import DATA_DIR, DATABASE_URL
|
||||
@@ -20,12 +20,14 @@ 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'
|
||||
avconnect_macro_id: Mapped[str] = mapped_column(String, nullable=False) # AVConnect macro ID
|
||||
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
|
||||
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
|
||||
lon: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 longitude
|
||||
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
|
||||
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
|
||||
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
|
||||
lon: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 longitude
|
||||
|
||||
|
||||
class ApiCredential(Base):
|
||||
@@ -38,6 +40,14 @@ class ApiCredential(Base):
|
||||
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):
|
||||
__tablename__ = "keypasses"
|
||||
|
||||
|
||||
@@ -80,8 +80,10 @@ class GateResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
gate_type: str
|
||||
avconnect_macro_id: str
|
||||
gate_icon: str
|
||||
api_provider: str
|
||||
avconnect_macro_id: Optional[str] = None
|
||||
shelly_device_id: Optional[str] = None
|
||||
status: str
|
||||
group_name: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
@@ -93,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
|
||||
@@ -101,15 +103,17 @@ class GatePublicResponse(BaseModel):
|
||||
|
||||
class GateCreate(BaseModel):
|
||||
name: str
|
||||
gate_type: str # 'car' | 'pedestrian'
|
||||
avconnect_macro_id: str
|
||||
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
|
||||
status: str = "enabled"
|
||||
group_name: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
|
||||
|
||||
# ── AVConnect Credentials ─────────────────────────────────────────────────────
|
||||
# ── API Credentials ──────────────────────────────────────────────────────────
|
||||
|
||||
class CredentialRead(BaseModel):
|
||||
id: int
|
||||
@@ -121,6 +125,16 @@ class CredentialUpsert(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class ShellyCredentialRead(BaseModel):
|
||||
id: int
|
||||
server_uri: str
|
||||
|
||||
|
||||
class ShellyCredentialUpsert(BaseModel):
|
||||
server_uri: str
|
||||
auth_key: str
|
||||
|
||||
|
||||
# ── Admin users ───────────────────────────────────────────────────────────────
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
|
||||
@@ -5,22 +5,25 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.schemas import CredentialRead, CredentialUpsert
|
||||
from core.schemas import CredentialRead, CredentialUpsert, ShellyCredentialRead, ShellyCredentialUpsert
|
||||
from services.avconnect import AVConnectAPI
|
||||
from services.shelly import ShellyCloudAPI
|
||||
|
||||
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(
|
||||
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),
|
||||
@@ -37,7 +40,7 @@ async def upsert_credential(
|
||||
if cred:
|
||||
cred.username = req.username
|
||||
cred.password_enc = encrypt_secret(req.password)
|
||||
cred.session_id = session_id # reuse the session obtained during validation
|
||||
cred.session_id = session_id
|
||||
else:
|
||||
cred = ApiCredential(
|
||||
username=req.username,
|
||||
@@ -50,7 +53,47 @@ async def upsert_credential(
|
||||
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):
|
||||
enabled: bool
|
||||
@@ -76,7 +119,6 @@ async def set_mock_setting(
|
||||
):
|
||||
cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||
if not cred:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(503, "AVConnect credentials not configured")
|
||||
cred.mock_avconnect = req.enabled
|
||||
db.commit()
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from core.auth import decrypt_secret
|
||||
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.schemas import GateCreate, GatePublicResponse, GateResponse
|
||||
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()
|
||||
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/admin/gates", response_model=list[GateResponse])
|
||||
@@ -50,8 +93,7 @@ async def create_gate(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
if req.gate_type not in ("car", "pedestrian"):
|
||||
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
|
||||
_validate_gate_create(req)
|
||||
gate = GateDB(**req.model_dump())
|
||||
db.add(gate)
|
||||
db.commit()
|
||||
@@ -66,6 +108,7 @@ async def update_gate(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
_validate_gate_create(req)
|
||||
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
|
||||
if not gate:
|
||||
raise HTTPException(404, "Gate not found")
|
||||
@@ -102,21 +145,14 @@ async def admin_open_gate(
|
||||
if gate_db.status != "enabled":
|
||||
raise HTTPException(409, "Gate is disabled")
|
||||
|
||||
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||
if not cred_db:
|
||||
raise HTTPException(503, "AVConnect credentials not configured")
|
||||
# Determine mock mode from AVConnect credential record (applies to all providers)
|
||||
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||
mock = bool(av_cred.mock_avconnect) if av_cred else False
|
||||
|
||||
ip = request.client.host if request.client else None
|
||||
ua = request.headers.get("User-Agent")
|
||||
|
||||
mock = bool(cred_db.mock_avconnect)
|
||||
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,
|
||||
)
|
||||
success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
|
||||
|
||||
db.add(GateAccessLog(
|
||||
timestamp=utcnow(),
|
||||
@@ -130,8 +166,8 @@ async def admin_open_gate(
|
||||
error=error_msg,
|
||||
))
|
||||
|
||||
if new_sid and new_sid != cred_db.session_id:
|
||||
cred_db.session_id = new_sid
|
||||
if new_sid and av_cred and new_sid != av_cred.session_id:
|
||||
av_cred.session_id = new_sid
|
||||
db.commit()
|
||||
|
||||
if not success:
|
||||
@@ -169,25 +205,17 @@ async def open_gate(
|
||||
if not gate_db:
|
||||
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
|
||||
if allowed is not None and gate_id not in allowed:
|
||||
raise HTTPException(403, "This keypass does not have access to this gate")
|
||||
|
||||
mock = bool(cred_db.mock_avconnect)
|
||||
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,
|
||||
)
|
||||
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
|
||||
mock = bool(av_cred.mock_avconnect) if av_cred else False
|
||||
|
||||
ip = request.client.host if request.client else None
|
||||
ua = request.headers.get("User-Agent")
|
||||
|
||||
success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
|
||||
|
||||
db.add(GateAccessLog(
|
||||
timestamp=utcnow(),
|
||||
@@ -201,8 +229,8 @@ async def open_gate(
|
||||
error=error_msg,
|
||||
))
|
||||
|
||||
if new_sid and new_sid != cred_db.session_id:
|
||||
cred_db.session_id = new_sid
|
||||
if new_sid and av_cred and new_sid != av_cred.session_id:
|
||||
av_cred.session_id = new_sid
|
||||
db.commit()
|
||||
|
||||
if not success:
|
||||
@@ -210,5 +238,5 @@ async def open_gate(
|
||||
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)
|
||||
_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}
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
from typing import Optional
|
||||
|
||||
from .avconnect import AVConnectAPI
|
||||
from .shelly import ShellyCloudAPI
|
||||
|
||||
|
||||
def call_open_gate(
|
||||
macro_id: str,
|
||||
username: str,
|
||||
password: str,
|
||||
session_id: Optional[str] = None,
|
||||
api_provider: str,
|
||||
avconnect_macro_id: Optional[str] = None,
|
||||
avconnect_username: 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,
|
||||
) -> 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:
|
||||
return True, None, None
|
||||
|
||||
if api_provider == "shelly":
|
||||
try:
|
||||
assert shelly_server_uri and shelly_auth_key and shelly_device_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:
|
||||
api = AVConnectAPI(username, password, session_id)
|
||||
ok, new_sid = api.exec_gate_macro(macro_id)
|
||||
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:
|
||||
return False, "Gate did not confirm open", new_sid
|
||||
return True, None, new_sid
|
||||
|
||||
59
src/services/shelly.py
Normal file
59
src/services/shelly.py
Normal 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
|
||||
@@ -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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -122,7 +136,7 @@
|
||||
<nav class="tabs">
|
||||
<button class="tab-btn active" data-tab="keypasses">Keypasses</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 admin-only" data-tab="admins">Admins</button>
|
||||
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
|
||||
@@ -166,8 +180,9 @@
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Group</th>
|
||||
<th>Type</th>
|
||||
<th>AVConnect Macro ID</th>
|
||||
<th>Icon</th>
|
||||
<th>Provider</th>
|
||||
<th>Device / Macro ID</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -179,7 +194,10 @@
|
||||
|
||||
<!-- ── Credentials 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">
|
||||
<form id="credentials-form">
|
||||
<div class="field">
|
||||
@@ -195,14 +213,34 @@
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</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>
|
||||
<div class="card" style="max-width:440px">
|
||||
<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>
|
||||
<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" />
|
||||
<span style="font-weight:600">Enable mock AVConnect</span>
|
||||
<span style="font-weight:600">Enable mock mode</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,15 +481,47 @@
|
||||
<datalist id="gate-group-list"></datalist>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gate-type">Type</label>
|
||||
<select id="gate-type">
|
||||
<option value="car">Car</option>
|
||||
<option value="pedestrian">Pedestrian</option>
|
||||
</select>
|
||||
<label>Icon</label>
|
||||
<div style="display:flex;flex-direction:column;gap:.5rem">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.35rem" id="gate-icon-grid">
|
||||
<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>
|
||||
<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 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>
|
||||
<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 class="field">
|
||||
<label for="gate-status">Status</label>
|
||||
|
||||
@@ -203,7 +203,7 @@ async function loadKeypasses() {
|
||||
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";
|
||||
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);
|
||||
}
|
||||
const allGatesCb = document.getElementById("kp-edit-all-gates");
|
||||
@@ -281,7 +281,7 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
||||
for (const g of _allGates) {
|
||||
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.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);
|
||||
}
|
||||
// Reset All gates checkbox
|
||||
@@ -429,13 +429,16 @@ async function loadGates() {
|
||||
const badge = g.status === "enabled"
|
||||
? '<span class="badge badge-green">Enabled</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");
|
||||
tr.innerHTML = `
|
||||
<td>${g.id}</td>
|
||||
<td>${esc(g.name)}</td>
|
||||
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">—</span>'}</td>
|
||||
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
|
||||
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
|
||||
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">\u2014</span>'}</td>
|
||||
<td>${g.gate_icon || ''}</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><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"
|
||||
@@ -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) {
|
||||
document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
|
||||
document.getElementById("gate-edit-id").value = gate ? gate.id : "";
|
||||
document.getElementById("gate-name").value = gate ? gate.name : "";
|
||||
document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : "";
|
||||
document.getElementById("gate-type").value = gate ? gate.gate_type : "car";
|
||||
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : "";
|
||||
_setGateIcon(gate ? (gate.gate_icon || "🚪") : "🚪");
|
||||
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-lat").value = (gate && gate.lat != null) ? gate.lat : "";
|
||||
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");
|
||||
}
|
||||
|
||||
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("gate-cancel").addEventListener("click", () => {
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
const editId = document.getElementById("gate-edit-id").value;
|
||||
const provider = document.getElementById("gate-api-provider").value;
|
||||
const payload = {
|
||||
name: document.getElementById("gate-name").value.trim(),
|
||||
gate_type: document.getElementById("gate-type").value,
|
||||
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(),
|
||||
gate_icon: document.getElementById("gate-icon-input").value.trim() || "\uD83D\uDEAA",
|
||||
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,
|
||||
group_name: document.getElementById("gate-group-name").value.trim() || 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 ───────────────────────────────────────────────────────────────
|
||||
async function loadCredentials() {
|
||||
try {
|
||||
const list = await api("GET", "/api/admin/credentials");
|
||||
const list = await api("GET", "/api/admin/credentials/avconnect");
|
||||
if (list.length) {
|
||||
document.getElementById("cred-username").value = list[0].username;
|
||||
}
|
||||
} 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 {
|
||||
const { enabled } = await api("GET", "/api/admin/credentials/mock");
|
||||
document.getElementById("mock-toggle").checked = enabled;
|
||||
@@ -567,9 +627,30 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api("PUT", "/api/admin/credentials", { username, password });
|
||||
await api("PUT", "/api/admin/credentials/avconnect", { username, password });
|
||||
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) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.classList.remove("hidden");
|
||||
|
||||
@@ -118,7 +118,6 @@ async function updateMap(gates) {
|
||||
const popup = L.popup().setContent(
|
||||
`<div style="min-width:140px">
|
||||
<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}"
|
||||
target="_blank" rel="noopener"
|
||||
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
|
||||
@@ -126,7 +125,7 @@ async function updateMap(gates) {
|
||||
</a>
|
||||
</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)
|
||||
.addTo(_map);
|
||||
_mapMarkers.push(marker);
|
||||
@@ -189,9 +188,9 @@ function renderGates(gates) {
|
||||
groupGrid.className = "gate-group-grid";
|
||||
|
||||
for (const gate of groups.get(key)) {
|
||||
const icon = gate.gate_type === "car" ? "🚘" : "🚶";
|
||||
const icon = gate.gate_icon || '🚪';
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `gate-btn ${gate.gate_type}`;
|
||||
btn.className = "gate-btn";
|
||||
btn.dataset.gateId = gate.id;
|
||||
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
|
||||
btn.addEventListener("click", () => handleOpenGate(btn, gate));
|
||||
|
||||
@@ -105,8 +105,7 @@ label {
|
||||
overflow: hidden;
|
||||
}
|
||||
.gate-btn .icon { font-size: 1.8rem; line-height: 1; }
|
||||
.gate-btn.car { background: var(--primary); color: #fff; }
|
||||
.gate-btn.pedestrian { background: var(--green); color: #fff; }
|
||||
.gate-btn { background: var(--primary); color: #fff; }
|
||||
.gate-btn:not(:disabled):active { transform: scale(.94); }
|
||||
.gate-btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user